mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 21:34:33 +00:00
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 <biohazardous-man@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -57,10 +57,9 @@ export class AllSetting {
|
||||
subClashURI = '';
|
||||
subClashEnableRouting = false;
|
||||
subClashRules = '';
|
||||
subJsonFragment = '';
|
||||
subJsonNoises = '';
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
subJsonFinalMask = '';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
|
||||
55
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
Normal file
55
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
<>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user