From 08fca9ed6600357f0b1c3bb1216567ecba86586b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 23:16:43 +0200 Subject: [PATCH] feat(sub): modern xray JSON format with unified finalmask editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/generated/types.ts | 6 +- frontend/src/generated/zod.ts | 6 +- .../xray/forms/transport/FinalMaskForm.tsx | 36 ++- frontend/src/models/setting.ts | 3 +- .../pages/settings/SubJsonFinalMaskForm.tsx | 55 +++++ .../pages/settings/SubscriptionFormatsTab.tsx | 156 +------------ frontend/src/schemas/setting.ts | 3 +- sub/sub.go | 17 +- sub/subController.go | 5 +- sub/subJsonService.go | 206 ++++-------------- sub/subJsonService_test.go | 184 ++++++++++------ web/entity/entity.go | 3 +- web/service/setting.go | 15 +- web/translation/ar-EG.json | 2 + web/translation/en-US.json | 2 + web/translation/es-ES.json | 2 + web/translation/fa-IR.json | 2 + web/translation/id-ID.json | 2 + web/translation/ja-JP.json | 2 + web/translation/pt-BR.json | 2 + web/translation/ru-RU.json | 2 + web/translation/tr-TR.json | 2 + web/translation/uk-UA.json | 2 + web/translation/vi-VN.json | 2 + web/translation/zh-CN.json | 2 + web/translation/zh-TW.json | 2 + 26 files changed, 276 insertions(+), 445 deletions(-) create mode 100644 frontend/src/pages/settings/SubJsonFinalMaskForm.tsx diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 23da8193..342771b9 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -42,9 +42,8 @@ export interface AllSetting { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; @@ -129,9 +128,8 @@ export interface AllSettingView { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e64c26f9..dac5acdc 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -44,9 +44,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(), @@ -132,9 +131,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(), diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index f400620f..457f9a85 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/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 { 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 (
diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index fcbe1ec1..f9c25828 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -55,10 +55,9 @@ export class AllSetting { subURI = ''; subJsonURI = ''; subClashURI = ''; - subJsonFragment = ''; - subJsonNoises = ''; subJsonMux = ''; subJsonRules = ''; + subJsonFinalMask = ''; timeLocation = 'Local'; diff --git a/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx new file mode 100644 index 00000000..09f588a7 --- /dev/null +++ b/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).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 ( +
+ + + ); +} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index 7cfe45f1..14c9aafe 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/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) => 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(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT), - [allSetting.subJsonFragment, fragment], - ); - - function setFragmentEnabled(v: boolean) { - updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' }); - } - - function setFragmentField(key: K, value: string) { - if (value === '') return; - const next = { ...fragmentObj, [key]: value }; - updateSetting({ subJsonFragment: JSON.stringify(next) }); - } - - const noisesArray = useMemo( - () => (noisesEnabled ? readJson(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(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), [allSetting.subJsonMux, muxEnabled], @@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su }, { key: '2', - label: catTabLabel(, t('pages.settings.fragment'), isMobile), + label: catTabLabel(, t('pages.settings.subFormats.finalMask'), isMobile), children: ( <> - - - - {fragment && ( -
- - setFragmentField('packets', e.target.value)} /> - - - setFragmentField('length', e.target.value)} /> - - - setFragmentField('interval', e.target.value)} /> - - - setFragmentField('maxSplit', e.target.value)} /> - -
- )} + + updateSetting({ subJsonFinalMask: v })} + /> ), }, { key: '3', - label: catTabLabel(, t('pages.settings.subFormats.noises'), isMobile), - children: ( - <> - - - - {noisesEnabled && ( -
- {noisesArray.map((noise, index) => ( - 1 ? ( -