Kaynağa Gözat

feat(sub): modern xray JSON format with unified finalmask editor (#4912)

* feat(sub): add finalmask support to JSON subscriptions

* feat(sub): modern xray JSON format with unified finalmask editor

Drop the legacy JSON subscription format entirely and always emit the
modern xray shape:

- Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/
  shadowsocks; hysteria was already flat.
- Express fragment/noise via streamSettings.finalmask instead of the
  legacy direct_out freedom dialer + dialerProxy sockopt.

The global finalmask (tcp/udp masks + quicParams) is stored as a single
setting (subJsonFinalMask) and merged into every generated stream,
replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams
settings.

Reuse the existing FinalMaskForm (used by inbound/outbound) for the
settings UI via a small bridge component; add a showAll prop so all
TCP/UDP/QUIC sections render for the global case. This supersedes the
hand-rolled Fragment/Noises/quicParams tabs with the full mask editor
(all mask types).

Note: this is a breaking change — JSON subscriptions now require a
recent xray client on the consumer side.

* fix

---------

Co-authored-by: biohazardous-man <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
biohazardous-man 7 saat önce
ebeveyn
işleme
97f88fb1a9

+ 1 - 1
frontend/public/openapi.json

@@ -5791,7 +5791,7 @@
         "tags": [
           "Subscription Server"
         ],
-        "summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
+        "summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
         "operationId": "get_clashPath_subid",
         "parameters": [
           {

+ 2 - 4
frontend/src/generated/types.ts

@@ -44,9 +44,8 @@ export interface AllSetting {
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subJsonEnable: boolean;
-  subJsonFragment: string;
+  subJsonFinalMask: string;
   subJsonMux: string;
-  subJsonNoises: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
@@ -133,9 +132,8 @@ export interface AllSettingView {
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subJsonEnable: boolean;
-  subJsonFragment: string;
+  subJsonFinalMask: string;
   subJsonMux: string;
-  subJsonNoises: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;

+ 2 - 4
frontend/src/generated/zod.ts

@@ -46,9 +46,8 @@ export const AllSettingSchema = z.object({
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subJsonEnable: z.boolean(),
-  subJsonFragment: z.string(),
+  subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
-  subJsonNoises: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
@@ -136,9 +135,8 @@ export const AllSettingViewSchema = z.object({
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subJsonEnable: z.boolean(),
-  subJsonFragment: z.string(),
+  subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
-  subJsonNoises: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),

+ 15 - 21
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
 import { RandomUtil } from '@/utils';
 import { OutboundProtocols } from '@/schemas/primitives';
 
-// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
-// paths under `name`; the parent modal owns the Form instance.
-//
-// Naming convention inside Form.List: AntD prefixes Form.Item `name`
-// with the Form.List's own `name`. So Form.Items inside the render
-// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
-// Form.Lists also use relative names. Using absolute paths here would
-// double up the prefix and silently route reads/writes to the wrong
-// storage path.
-
 export interface FinalMaskFormProps {
   name: NamePath;
   network: string;
   protocol: string;
   form: FormInstance;
+  // When true, all sections (TCP / UDP / QUIC) are shown regardless of
+  // network/protocol. Used by the global sub-JSON finalmask editor where
+  // the masks apply to every stream rather than one specific transport.
+  showAll?: boolean;
 }
 
 const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
@@ -99,12 +93,12 @@ function defaultUdpHop(): Record<string, unknown> {
   return { ports: '20000-50000', interval: '5-10' };
 }
 
-export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
+export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
-  const showTcp = TCP_NETWORKS.includes(network);
-  const showUdp = isHysteria || network === 'kcp';
-  const showQuic = isHysteria || network === 'xhttp';
+  const showTcp = showAll || TCP_NETWORKS.includes(network);
+  const showUdp = showAll || isHysteria || network === 'kcp';
+  const showQuic = showAll || isHysteria || network === 'xhttp';
   const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
   const hasQuicParams = quicParams != null;
 
@@ -392,13 +386,13 @@ function UdpMaskItem({
   const options = isHysteria
     ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
     : [
-        { value: 'mkcp-legacy', label: 'mKCP Legacy' },
-        { value: 'xdns', label: 'xDNS' },
-        { value: 'xicmp', label: 'xICMP' },
-        { value: 'realm', label: 'Realm' },
-        { value: 'header-custom', label: 'Header Custom' },
-        { value: 'noise', label: 'Noise' },
-      ];
+      { value: 'mkcp-legacy', label: 'mKCP Legacy' },
+      { value: 'xdns', label: 'xDNS' },
+      { value: 'xicmp', label: 'xICMP' },
+      { value: 'realm', label: 'Realm' },
+      { value: 'header-custom', label: 'Header Custom' },
+      { value: 'noise', label: 'Noise' },
+    ];
 
   return (
     <div>

+ 1 - 2
frontend/src/models/setting.ts

@@ -57,10 +57,9 @@ export class AllSetting {
   subClashURI = '';
   subClashEnableRouting = false;
   subClashRules = '';
-  subJsonFragment = '';
-  subJsonNoises = '';
   subJsonMux = '';
   subJsonRules = '';
+  subJsonFinalMask = '';
 
   timeLocation = 'Local';
 

+ 55 - 0
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx

@@ -0,0 +1,55 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form } from 'antd';
+
+import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
+
+interface SubJsonFinalMaskFormProps {
+  value: string;
+  onChange: (next: string) => void;
+}
+
+function hasValue(v: unknown): boolean {
+  if (v == null) return false;
+  if (Array.isArray(v)) return v.some(hasValue);
+  if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
+  if (typeof v === 'string') return v.length > 0;
+  return true;
+}
+
+function parseFinalMask(raw: string): FinalMaskStreamSettings {
+  try {
+    if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
+  } catch {
+    return { tcp: [], udp: [] };
+  }
+  return { tcp: [], udp: [] };
+}
+
+export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
+  const [form] = Form.useForm();
+  const [initial] = useState(() => parseFinalMask(value));
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+
+  const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
+
+  useEffect(() => {
+    if (finalmask === undefined) return;
+    const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
+    if (next !== value) onChangeRef.current(next);
+  }, [finalmask, value]);
+
+  return (
+    <Form
+      form={form}
+      layout="horizontal"
+      labelCol={{ flex: '160px' }}
+      wrapperCol={{ flex: 'auto' }}
+      colon={false}
+      initialValues={{ finalmask: initial }}
+    >
+      <FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
+    </Form>
+  );
+}

+ 9 - 147
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -1,8 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
-  Button,
-  Card,
   Input,
   InputNumber,
   Select,
@@ -10,19 +8,17 @@ import {
   Tabs,
 } from 'antd';
 import {
-  DeleteOutlined,
   PartitionOutlined,
-  PlusOutlined,
-  ScissorOutlined,
+  RocketOutlined,
   SendOutlined,
   SettingOutlined,
-  ThunderboltOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
+import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
 import './SubscriptionFormatsTab.css';
 
 interface SubscriptionFormatsTabProps {
@@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
-const DEFAULT_FRAGMENT = {
-  packets: 'tlshello',
-  length: '100-200',
-  interval: '10-20',
-  maxSplit: '300-400',
-};
-const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
-  { type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
-];
 const DEFAULT_MUX = {
   enabled: true,
   concurrency: 8,
@@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
 
-  const fragment = allSetting.subJsonFragment !== '';
-  const noisesEnabled = allSetting.subJsonNoises !== '';
   const muxEnabled = allSetting.subJsonMux !== '';
   const directEnabled = allSetting.subJsonRules !== '';
 
-  const fragmentObj = useMemo(
-    () => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
-    [allSetting.subJsonFragment, fragment],
-  );
-
-  function setFragmentEnabled(v: boolean) {
-    updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
-  }
-
-  function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
-    if (value === '') return;
-    const next = { ...fragmentObj, [key]: value };
-    updateSetting({ subJsonFragment: JSON.stringify(next) });
-  }
-
-  const noisesArray = useMemo(
-    () => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
-    [allSetting.subJsonNoises, noisesEnabled],
-  );
-
-  function setNoisesEnabled(v: boolean) {
-    updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
-  }
-
-  function setNoisesArray(next: typeof DEFAULT_NOISES) {
-    if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
-  }
-
-  function addNoise() {
-    setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
-  }
-
-  function removeNoise(index: number) {
-    const next = [...noisesArray];
-    next.splice(index, 1);
-    setNoisesArray(next);
-  }
-
-  function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
-    const next = [...noisesArray];
-    next[index] = { ...next[index], [field]: value };
-    setNoisesArray(next);
-  }
-
   const muxObj = useMemo(
     () => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
     [allSetting.subJsonMux, muxEnabled],
@@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
       },
       {
         key: '2',
-        label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
+        label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
         children: (
           <>
-            <SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
-              <Switch checked={fragment} onChange={setFragmentEnabled} />
-            </SettingListItem>
-            {fragment && (
-              <div className="format-settings">
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
-                  <Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
-                    onChange={(e) => setFragmentField('packets', e.target.value)} />
-                </SettingListItem>
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
-                  <Input value={fragmentObj.length} placeholder="100-200"
-                    onChange={(e) => setFragmentField('length', e.target.value)} />
-                </SettingListItem>
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
-                  <Input value={fragmentObj.interval} placeholder="10-20"
-                    onChange={(e) => setFragmentField('interval', e.target.value)} />
-                </SettingListItem>
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
-                  <Input value={fragmentObj.maxSplit} placeholder="300-400"
-                    onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
-                </SettingListItem>
-              </div>
-            )}
+            <SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
+            <SubJsonFinalMaskForm
+              value={allSetting.subJsonFinalMask}
+              onChange={(v) => updateSetting({ subJsonFinalMask: v })}
+            />
           </>
         ),
       },
       {
         key: '3',
-        label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
-        children: (
-          <>
-            <SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
-              <Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
-            </SettingListItem>
-            {noisesEnabled && (
-              <div className="format-settings-list">
-                {noisesArray.map((noise, index) => (
-                  <Card
-                    key={index}
-                    size="small"
-                    className="noise-card"
-                    title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
-                    extra={noisesArray.length > 1 ? (
-                      <Button
-                        size="small"
-                        danger
-                        icon={<DeleteOutlined />}
-                        aria-label={t('delete')}
-                        onClick={() => removeNoise(index)}
-                      />
-                    ) : null}
-                    styles={{ body: { padding: 0 } }}
-                  >
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
-                      <Select
-                        value={noise.type}
-                        style={{ width: '100%' }}
-                        onChange={(v) => updateNoiseField(index, 'type', v)}
-                        options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
-                      />
-                    </SettingListItem>
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
-                      <Input value={noise.packet} placeholder="5-10"
-                        onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
-                    </SettingListItem>
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
-                      <Input value={noise.delay} placeholder="10-20"
-                        onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
-                    </SettingListItem>
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
-                      <Select
-                        value={noise.applyTo}
-                        style={{ width: '100%' }}
-                        onChange={(v) => updateNoiseField(index, 'applyTo', v)}
-                        options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
-                      />
-                    </SettingListItem>
-                  </Card>
-                ))}
-                <Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
-                  {t('pages.settings.subFormats.addNoise')}
-                </Button>
-              </div>
-            )}
-          </>
-        ),
-      },
-      {
-        key: '4',
         label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
         children: (
           <>
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
         ),
       },
       {
-        key: '5',
+        key: '4',
         label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
         children: (
           <>

+ 1 - 2
frontend/src/schemas/setting.ts

@@ -61,10 +61,9 @@ export const AllSettingSchema = z.object({
   subClashURI: z.string().optional(),
   subClashEnableRouting: z.boolean().optional(),
   subClashRules: z.string().optional(),
-  subJsonFragment: z.string().optional(),
-  subJsonNoises: z.string().optional(),
   subJsonMux: z.string().optional(),
   subJsonRules: z.string().optional(),
+  subJsonFinalMask: z.string().optional(),
   timeLocation: z.string().optional(),
   ldapEnable: z.boolean().optional(),
   ldapHost: z.string().optional(),

+ 6 - 11
sub/sub.go

@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubUpdates = "10"
 	}
 
-	SubJsonFragment, err := s.settingService.GetSubJsonFragment()
-	if err != nil {
-		SubJsonFragment = ""
-	}
-
-	SubJsonNoises, err := s.settingService.GetSubJsonNoises()
-	if err != nil {
-		SubJsonNoises = ""
-	}
-
 	SubJsonMux, err := s.settingService.GetSubJsonMux()
 	if err != nil {
 		SubJsonMux = ""
@@ -140,6 +130,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubJsonRules = ""
 	}
 
+	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
+	if err != nil {
+		SubJsonFinalMask = ""
+	}
+
 	SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
 	if err != nil {
 		SubClashEnableRouting = false
@@ -236,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	s.sub = NewSUBController(
 		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
-		SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
+		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 
 	return engine, nil

+ 2 - 3
sub/subController.go

@@ -62,10 +62,9 @@ func NewSUBController(
 	showInfo bool,
 	rModel string,
 	update string,
-	jsonFragment string,
-	jsonNoise string,
 	jsonMux string,
 	jsonRules string,
+	jsonFinalMask string,
 	clashEnableRouting bool,
 	clashRules string,
 	subTitle string,
@@ -92,7 +91,7 @@ func NewSUBController(
 		updateInterval:   update,
 
 		subService:      sub,
-		subJsonService:  NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
 		subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
 	}
 	a.initRouter(g)

+ 78 - 70
sub/subJsonService.go

@@ -21,7 +21,7 @@ var defaultJson string
 type SubJsonService struct {
 	configJson       map[string]any
 	defaultOutbounds []json_util.RawMessage
-	fragmentOrNoises bool
+	finalMask        string
 	mux              string
 
 	inboundService service.InboundService
@@ -29,7 +29,7 @@ type SubJsonService struct {
 }
 
 // NewSubJsonService creates a new JSON subscription service with the given configuration.
-func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
+func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
 	var configJson map[string]any
 	var defaultOutbounds []json_util.RawMessage
 	json.Unmarshal([]byte(defaultJson), &configJson)
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
 		}
 	}
 
-	fragmentOrNoises := false
-	if fragment != "" || noises != "" {
-		fragmentOrNoises = true
-		defaultOutboundsSettings := map[string]any{
-			"domainStrategy": "UseIP",
-			"redirect":       "",
-		}
-
-		if fragment != "" {
-			defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
-		}
-
-		if noises != "" {
-			defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
-		}
-
-		defaultDirectOutbound := map[string]any{
-			"protocol": "freedom",
-			"settings": defaultOutboundsSettings,
-			"tag":      "direct_out",
-		}
-		jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", "  ")
-		defaultOutbounds = append(defaultOutbounds, jsonBytes)
-	}
-
 	if rules != "" {
 		var newRules []any
 		routing, _ := configJson["routing"].(map[string]any)
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
 	return &SubJsonService{
 		configJson:       configJson,
 		defaultOutbounds: defaultOutbounds,
-		fragmentOrNoises: fragmentOrNoises,
+		finalMask:        finalMask,
 		mux:              mux,
 		SubService:       subService,
 	}
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
 	}
 	delete(streamSettings, "sockopt")
 
-	if s.fragmentOrNoises {
-		streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
+	if s.finalMask != "" {
+		s.applyGlobalFinalMask(streamSettings)
 	}
 
 	// remove proxy protocol
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
 	return streamSettings
 }
 
+func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
+	var fm map[string]any
+	if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
+		return
+	}
+	merged := mergeFinalMask(streamSettings["finalmask"], fm)
+	if len(merged) > 0 {
+		streamSettings["finalmask"] = merged
+	}
+}
+
 func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
 	netSettings, ok := setting.(map[string]any)
 	if ok {
@@ -307,17 +293,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
 
 func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
 	outbound := Outbound{}
-	usersData := make([]UserVnext, 1)
-
-	usersData[0].ID = client.ID
-	usersData[0].Email = client.Email
-	usersData[0].Security = client.Security
-	vnextData := make([]VnextSetting, 1)
-	vnextData[0] = VnextSetting{
-		Address: inbound.Listen,
-		Port:    inbound.Port,
-		Users:   usersData,
-	}
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
@@ -325,8 +300,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 		outbound.Mux = json_util.RawMessage(s.mux)
 	}
 	outbound.StreamSettings = streamSettings
+
+	security := client.Security
+	if security == "" {
+		security = "auto"
+	}
 	outbound.Settings = map[string]any{
-		"vnext": vnextData,
+		"address":  inbound.Listen,
+		"port":     inbound.Port,
+		"id":       client.ID,
+		"security": security,
+		"level":    8,
 	}
 
 	result, _ := json.MarshalIndent(outbound, "", "  ")
@@ -347,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 	json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 	encryption, _ := inboundSettings["encryption"].(string)
 
-	user := map[string]any{
+	settings := map[string]any{
+		"address":    inbound.Listen,
+		"port":       inbound.Port,
 		"id":         client.ID,
-		"level":      8,
 		"encryption": encryption,
+		"level":      8,
 	}
 	if client.Flow != "" {
-		user["flow"] = client.Flow
-	}
-
-	vnext := map[string]any{
-		"address": inbound.Listen,
-		"port":    inbound.Port,
-		"users":   []any{user},
-	}
-
-	outbound.Settings = map[string]any{
-		"vnext": []any{vnext},
+		settings["flow"] = client.Flow
 	}
+	outbound.Settings = settings
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result
 }
@@ -400,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 		outbound.Mux = json_util.RawMessage(s.mux)
 	}
 	outbound.StreamSettings = streamSettings
-	outbound.Settings = map[string]any{
-		"servers": serverData,
+
+	settings := map[string]any{
+		"address":  serverData[0].Address,
+		"port":     serverData[0].Port,
+		"password": serverData[0].Password,
+		"level":    8,
 	}
+	if inbound.Protocol == model.Shadowsocks {
+		settings["method"] = serverData[0].Method
+	}
+	outbound.Settings = settings
 
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result
@@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 	newStream["hysteriaSettings"] = outHyStream
 
 	if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
-		newStream["finalmask"] = finalmask
+		newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
 	}
 
 	newStream["network"] = "hysteria"
@@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 	return result
 }
 
+func mergeFinalMask(base any, extra map[string]any) map[string]any {
+	merged := map[string]any{}
+	if baseMap, ok := base.(map[string]any); ok {
+		for key, value := range baseMap {
+			switch key {
+			case "tcp", "udp":
+				if masks, ok := value.([]any); ok {
+					merged[key] = append([]any(nil), masks...)
+				}
+			default:
+				merged[key] = value
+			}
+		}
+	}
+
+	for key, value := range extra {
+		switch key {
+		case "tcp", "udp":
+			baseMasks, _ := merged[key].([]any)
+			extraMasks, _ := value.([]any)
+			if len(extraMasks) > 0 {
+				merged[key] = append(baseMasks, extraMasks...)
+			}
+		case "quicParams":
+			if _, exists := merged[key]; !exists {
+				merged[key] = value
+			}
+		default:
+			merged[key] = value
+		}
+	}
+
+	return merged
+}
+
 type Outbound struct {
 	Protocol       string               `json:"protocol"`
 	Tag            string               `json:"tag"`
@@ -462,18 +482,6 @@ type Outbound struct {
 	Settings       map[string]any       `json:"settings,omitempty"`
 }
 
-type VnextSetting struct {
-	Address string      `json:"address"`
-	Port    int         `json:"port"`
-	Users   []UserVnext `json:"users"`
-}
-
-type UserVnext struct {
-	ID       string `json:"id"`
-	Email    string `json:"email,omitempty"`
-	Security string `json:"security,omitempty"`
-}
-
 type ServerSetting struct {
 	Password string `json:"password"`
 	Level    int    `json:"level"`

+ 148 - 0
sub/subJsonService_test.go

@@ -0,0 +1,148 @@
+package sub
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func hasDirectOutOutbound(svc *SubJsonService) bool {
+	for _, raw := range svc.defaultOutbounds {
+		var outbound map[string]any
+		if err := json.Unmarshal(raw, &outbound); err != nil {
+			continue
+		}
+		if outbound["tag"] == "direct_out" {
+			return true
+		}
+	}
+	return false
+}
+
+func outboundSettings(t *testing.T, raw []byte) map[string]any {
+	t.Helper()
+	var parsed map[string]any
+	if err := json.Unmarshal(raw, &parsed); err != nil {
+		t.Fatalf("failed to unmarshal outbound: %v", err)
+	}
+	settings, _ := parsed["settings"].(map[string]any)
+	if settings == nil {
+		t.Fatal("outbound has no settings")
+	}
+	return settings
+}
+
+func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
+	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
+	svc := NewSubJsonService("", "", finalMask, nil)
+
+	if hasDirectOutOutbound(svc) {
+		t.Fatal("direct_out outbound must never be emitted")
+	}
+
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	if _, ok := stream["sockopt"]; ok {
+		t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
+	}
+
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	if finalmask == nil {
+		t.Fatal("streamSettings is missing finalmask")
+	}
+
+	tcp, _ := finalmask["tcp"].([]any)
+	if len(tcp) != 1 {
+		t.Fatalf("tcp masks len = %d, want 1", len(tcp))
+	}
+	if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
+		t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
+	}
+
+	udp, _ := finalmask["udp"].([]any)
+	if len(udp) != 1 {
+		t.Fatalf("udp masks len = %d, want 1", len(udp))
+	}
+
+	quic, _ := finalmask["quicParams"].(map[string]any)
+	if quic == nil || quic["congestion"] != "bbr" {
+		t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
+	}
+}
+
+func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
+	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
+	svc := NewSubJsonService("", "", finalMask, nil)
+
+	stream := svc.streamData(`{
+		"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
+		"finalmask":{"tcp":[{"type":"sudoku"}]}
+	}`)
+
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	tcp, _ := finalmask["tcp"].([]any)
+	if len(tcp) != 2 {
+		t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
+	}
+	a, _ := tcp[0].(map[string]any)
+	b, _ := tcp[1].(map[string]any)
+	if a["type"] != "sudoku" || b["type"] != "fragment" {
+		t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
+	}
+}
+
+func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	if _, ok := stream["finalmask"]; ok {
+		t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
+	}
+	if _, ok := stream["sockopt"]; ok {
+		t.Fatal("legacy direct_out sockopt must never be set")
+	}
+}
+
+func TestSubJsonServiceVlessFlattened(t *testing.T) {
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
+	if _, ok := settings["vnext"]; ok {
+		t.Fatal("vless outbound must not use vnext")
+	}
+	if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
+		t.Fatalf("flat vless settings wrong: %#v", settings)
+	}
+}
+
+func TestSubJsonServiceVmessFlattened(t *testing.T) {
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
+	client := model.Client{ID: "uuid-2"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
+	if _, ok := settings["vnext"]; ok {
+		t.Fatal("vmess outbound must not use vnext")
+	}
+	if settings["id"] != "uuid-2" || settings["security"] != "auto" {
+		t.Fatalf("flat vmess settings wrong: %#v", settings)
+	}
+}
+
+func TestSubJsonServiceServerFlattened(t *testing.T) {
+	trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
+	client := model.Client{Password: "p4ss"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
+	if _, ok := settings["servers"]; ok {
+		t.Fatal("trojan outbound must not use servers array")
+	}
+	if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
+		t.Fatalf("flat trojan settings wrong: %#v", settings)
+	}
+
+	ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
+	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
+	if ssSettings["method"] != "aes-256-gcm" {
+		t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
+	}
+}

+ 1 - 2
web/entity/entity.go

@@ -85,10 +85,9 @@ type AllSetting struct {
 	SubClashURI                 string `json:"subClashURI" form:"subClashURI"`                                 // Clash/Mihomo subscription server URI
 	SubClashEnableRouting       bool   `json:"subClashEnableRouting" form:"subClashEnableRouting"`             // Enable global routing rules for Clash/Mihomo
 	SubClashRules               string `json:"subClashRules" form:"subClashRules"`                             // Clash/Mihomo global routing rules
-	SubJsonFragment             string `json:"subJsonFragment" form:"subJsonFragment"`                         // JSON subscription fragment configuration
-	SubJsonNoises               string `json:"subJsonNoises" form:"subJsonNoises"`                             // JSON subscription noise configuration
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
+	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
 
 	// LDAP settings
 	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`

+ 5 - 10
web/service/setting.go

@@ -81,10 +81,9 @@ var defaultValueMap = map[string]string{
 	"subClashURI":                 "",
 	"subClashEnableRouting":       "false",
 	"subClashRules":               "",
-	"subJsonFragment":             "",
-	"subJsonNoises":               "",
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
+	"subJsonFinalMask":            "",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
 	"nord":                        "",
@@ -668,14 +667,6 @@ func (s *SettingService) GetSubClashRules() (string, error) {
 	return s.getString("subClashRules")
 }
 
-func (s *SettingService) GetSubJsonFragment() (string, error) {
-	return s.getString("subJsonFragment")
-}
-
-func (s *SettingService) GetSubJsonNoises() (string, error) {
-	return s.getString("subJsonNoises")
-}
-
 func (s *SettingService) GetSubJsonMux() (string, error) {
 	return s.getString("subJsonMux")
 }
@@ -684,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
 	return s.getString("subJsonRules")
 }
 
+func (s *SettingService) GetSubJsonFinalMask() (string, error) {
+	return s.getString("subJsonFinalMask")
+}
+
 func (s *SettingService) GetDatepicker() (string, error) {
 	return s.getString("datepicker")
 }

+ 2 - 0
web/translation/ar-EG.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "حد IP الافتراضي"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
         "packets": "الحزم",
         "length": "الطول",
         "interval": "الفاصل",

+ 2 - 0
web/translation/en-US.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Default IP limit"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
         "packets": "Packets",
         "length": "Length",
         "interval": "Interval",

+ 2 - 0
web/translation/es-ES.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Límite IP por defecto"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
         "packets": "Paquetes",
         "length": "Longitud",
         "interval": "Intervalo",

+ 2 - 0
web/translation/fa-IR.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "محدودیت IP پیش‌فرض"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "ماسک‌های finalmask ایکس‌ری (TCP/UDP) و تنظیمات QUIC که داخل همه‌ی stream های اشتراک JSON تزریق می‌شوند. به نسخه‌ی جدید هسته‌ی xray در کلاینت نیاز دارد.",
         "packets": "بسته‌ها",
         "length": "طول",
         "interval": "بازه",

+ 2 - 0
web/translation/id-ID.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Batas IP default"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
         "packets": "Paket",
         "length": "Panjang",
         "interval": "Interval",

+ 2 - 0
web/translation/ja-JP.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "デフォルト IP 制限"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
         "packets": "パケット",
         "length": "長さ",
         "interval": "間隔",

+ 2 - 0
web/translation/pt-BR.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Limite de IP padrão"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
         "packets": "Pacotes",
         "length": "Comprimento",
         "interval": "Intervalo",

+ 2 - 0
web/translation/ru-RU.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Лимит IP по умолчанию"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
         "packets": "Пакеты",
         "length": "Длина",
         "interval": "Интервал",

+ 2 - 0
web/translation/tr-TR.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Varsayılan IP limiti"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Her JSON abonelik akışına eklenen xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir xray istemcisi gerektirir.",
         "packets": "Paketler",
         "length": "Uzunluk",
         "interval": "Aralık",

+ 2 - 0
web/translation/uk-UA.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Ліміт IP за замовч."
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
         "packets": "Пакети",
         "length": "Довжина",
         "interval": "Інтервал",

+ 2 - 0
web/translation/vi-VN.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "Giới hạn IP mặc định"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
         "packets": "Gói",
         "length": "Độ dài",
         "interval": "Khoảng",

+ 2 - 0
web/translation/zh-CN.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "默认 IP 限制"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
         "packets": "数据包",
         "length": "长度",
         "interval": "间隔",

+ 2 - 0
web/translation/zh-TW.json

@@ -1074,6 +1074,8 @@
         "defaultIpLimit": "預設 IP 限制"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
         "packets": "封包",
         "length": "長度",
         "interval": "間隔",