mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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.
This commit is contained in:
parent
f4a07121a9
commit
08fca9ed66
26 changed files with 276 additions and 445 deletions
|
|
@ -42,9 +42,8 @@ export interface AllSetting {
|
||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFragment: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
subJsonNoises: string;
|
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
|
|
@ -129,9 +128,8 @@ export interface AllSettingView {
|
||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFragment: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
subJsonNoises: string;
|
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,8 @@ export const AllSettingSchema = z.object({
|
||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFragment: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
subJsonNoises: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
|
|
@ -132,9 +131,8 @@ export const AllSettingViewSchema = z.object({
|
||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFragment: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
subJsonNoises: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
|
||||||
import { RandomUtil } from '@/utils';
|
import { RandomUtil } from '@/utils';
|
||||||
import { OutboundProtocols } from '@/schemas/primitives';
|
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 {
|
export interface FinalMaskFormProps {
|
||||||
name: NamePath;
|
name: NamePath;
|
||||||
network: string;
|
network: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
form: FormInstance;
|
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'];
|
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' };
|
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 base = asPath(name);
|
||||||
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
||||||
const showTcp = TCP_NETWORKS.includes(network);
|
const showTcp = showAll || TCP_NETWORKS.includes(network);
|
||||||
const showUdp = isHysteria || network === 'kcp';
|
const showUdp = showAll || isHysteria || network === 'kcp';
|
||||||
const showQuic = isHysteria || network === 'xhttp';
|
const showQuic = showAll || isHysteria || network === 'xhttp';
|
||||||
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
|
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
|
||||||
const hasQuicParams = quicParams != null;
|
const hasQuicParams = quicParams != null;
|
||||||
|
|
||||||
|
|
@ -392,13 +386,13 @@ function UdpMaskItem({
|
||||||
const options = isHysteria
|
const options = isHysteria
|
||||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||||
: [
|
: [
|
||||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||||
{ value: 'xdns', label: 'xDNS' },
|
{ value: 'xdns', label: 'xDNS' },
|
||||||
{ value: 'xicmp', label: 'xICMP' },
|
{ value: 'xicmp', label: 'xICMP' },
|
||||||
{ value: 'realm', label: 'Realm' },
|
{ value: 'realm', label: 'Realm' },
|
||||||
{ value: 'header-custom', label: 'Header Custom' },
|
{ value: 'header-custom', label: 'Header Custom' },
|
||||||
{ value: 'noise', label: 'Noise' },
|
{ value: 'noise', label: 'Noise' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,9 @@ export class AllSetting {
|
||||||
subURI = '';
|
subURI = '';
|
||||||
subJsonURI = '';
|
subJsonURI = '';
|
||||||
subClashURI = '';
|
subClashURI = '';
|
||||||
subJsonFragment = '';
|
|
||||||
subJsonNoises = '';
|
|
||||||
subJsonMux = '';
|
subJsonMux = '';
|
||||||
subJsonRules = '';
|
subJsonRules = '';
|
||||||
|
subJsonFinalMask = '';
|
||||||
|
|
||||||
timeLocation = 'Local';
|
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 { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -10,19 +8,17 @@ import {
|
||||||
Tabs,
|
Tabs,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
|
||||||
PartitionOutlined,
|
PartitionOutlined,
|
||||||
PlusOutlined,
|
RocketOutlined,
|
||||||
ScissorOutlined,
|
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ThunderboltOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath, normalizePath } from './uriPath';
|
import { sanitizePath, normalizePath } from './uriPath';
|
||||||
|
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
|
||||||
import './SubscriptionFormatsTab.css';
|
import './SubscriptionFormatsTab.css';
|
||||||
|
|
||||||
interface SubscriptionFormatsTabProps {
|
interface SubscriptionFormatsTabProps {
|
||||||
|
|
@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
|
||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
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 = {
|
const DEFAULT_MUX = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
concurrency: 8,
|
concurrency: 8,
|
||||||
|
|
@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
const fragment = allSetting.subJsonFragment !== '';
|
|
||||||
const noisesEnabled = allSetting.subJsonNoises !== '';
|
|
||||||
const muxEnabled = allSetting.subJsonMux !== '';
|
const muxEnabled = allSetting.subJsonMux !== '';
|
||||||
const directEnabled = allSetting.subJsonRules !== '';
|
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(
|
const muxObj = useMemo(
|
||||||
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
|
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
|
||||||
[allSetting.subJsonMux, muxEnabled],
|
[allSetting.subJsonMux, muxEnabled],
|
||||||
|
|
@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '2',
|
key: '2',
|
||||||
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
|
label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
|
||||||
<Switch checked={fragment} onChange={setFragmentEnabled} />
|
<SubJsonFinalMaskForm
|
||||||
</SettingListItem>
|
value={allSetting.subJsonFinalMask}
|
||||||
{fragment && (
|
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '3',
|
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),
|
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
|
|
@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '5',
|
key: '4',
|
||||||
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,9 @@ export const AllSettingSchema = z.object({
|
||||||
subURI: z.string().optional(),
|
subURI: z.string().optional(),
|
||||||
subJsonURI: z.string().optional(),
|
subJsonURI: z.string().optional(),
|
||||||
subClashURI: z.string().optional(),
|
subClashURI: z.string().optional(),
|
||||||
subJsonFragment: z.string().optional(),
|
|
||||||
subJsonNoises: z.string().optional(),
|
|
||||||
subJsonMux: z.string().optional(),
|
subJsonMux: z.string().optional(),
|
||||||
subJsonRules: z.string().optional(),
|
subJsonRules: z.string().optional(),
|
||||||
|
subJsonFinalMask: z.string().optional(),
|
||||||
timeLocation: z.string().optional(),
|
timeLocation: z.string().optional(),
|
||||||
ldapEnable: z.boolean().optional(),
|
ldapEnable: z.boolean().optional(),
|
||||||
ldapHost: z.string().optional(),
|
ldapHost: z.string().optional(),
|
||||||
|
|
|
||||||
17
sub/sub.go
17
sub/sub.go
|
|
@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
SubUpdates = "10"
|
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()
|
SubJsonMux, err := s.settingService.GetSubJsonMux()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SubJsonMux = ""
|
SubJsonMux = ""
|
||||||
|
|
@ -140,6 +130,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
SubJsonRules = ""
|
SubJsonRules = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||||
|
if err != nil {
|
||||||
|
SubJsonFinalMask = ""
|
||||||
|
}
|
||||||
|
|
||||||
SubTitle, err := s.settingService.GetSubTitle()
|
SubTitle, err := s.settingService.GetSubTitle()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SubTitle = ""
|
SubTitle = ""
|
||||||
|
|
@ -226,7 +221,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
|
|
||||||
s.sub = NewSUBController(
|
s.sub = NewSUBController(
|
||||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubTitle, SubSupportUrl,
|
||||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||||
|
|
||||||
return engine, nil
|
return engine, nil
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,9 @@ func NewSUBController(
|
||||||
showInfo bool,
|
showInfo bool,
|
||||||
rModel string,
|
rModel string,
|
||||||
update string,
|
update string,
|
||||||
jsonFragment string,
|
|
||||||
jsonNoise string,
|
|
||||||
jsonMux string,
|
jsonMux string,
|
||||||
jsonRules string,
|
jsonRules string,
|
||||||
|
jsonFinalMask string,
|
||||||
subTitle string,
|
subTitle string,
|
||||||
subSupportUrl string,
|
subSupportUrl string,
|
||||||
subProfileUrl string,
|
subProfileUrl string,
|
||||||
|
|
@ -90,7 +89,7 @@ func NewSUBController(
|
||||||
updateInterval: update,
|
updateInterval: update,
|
||||||
|
|
||||||
subService: sub,
|
subService: sub,
|
||||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
||||||
subClashService: NewSubClashService(sub),
|
subClashService: NewSubClashService(sub),
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ var defaultJson string
|
||||||
type SubJsonService struct {
|
type SubJsonService struct {
|
||||||
configJson map[string]any
|
configJson map[string]any
|
||||||
defaultOutbounds []json_util.RawMessage
|
defaultOutbounds []json_util.RawMessage
|
||||||
fragmentOrNoises bool
|
finalMask string
|
||||||
fragment string
|
|
||||||
noises string
|
|
||||||
mux string
|
mux string
|
||||||
|
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
|
@ -31,7 +29,7 @@ type SubJsonService struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
// 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 configJson map[string]any
|
||||||
var defaultOutbounds []json_util.RawMessage
|
var defaultOutbounds []json_util.RawMessage
|
||||||
json.Unmarshal([]byte(defaultJson), &configJson)
|
json.Unmarshal([]byte(defaultJson), &configJson)
|
||||||
|
|
@ -42,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 != "" {
|
if rules != "" {
|
||||||
var newRules []any
|
var newRules []any
|
||||||
routing, _ := configJson["routing"].(map[string]any)
|
routing, _ := configJson["routing"].(map[string]any)
|
||||||
|
|
@ -80,9 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||||
return &SubJsonService{
|
return &SubJsonService{
|
||||||
configJson: configJson,
|
configJson: configJson,
|
||||||
defaultOutbounds: defaultOutbounds,
|
defaultOutbounds: defaultOutbounds,
|
||||||
fragmentOrNoises: fragmentOrNoises,
|
finalMask: finalMask,
|
||||||
fragment: fragment,
|
|
||||||
noises: noises,
|
|
||||||
mux: mux,
|
mux: mux,
|
||||||
SubService: subService,
|
SubService: subService,
|
||||||
}
|
}
|
||||||
|
|
@ -234,9 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||||
}
|
}
|
||||||
delete(streamSettings, "sockopt")
|
delete(streamSettings, "sockopt")
|
||||||
|
|
||||||
if s.fragmentOrNoises {
|
if s.finalMask != "" {
|
||||||
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
|
s.applyGlobalFinalMask(streamSettings)
|
||||||
s.applySubJsonFinalMask(streamSettings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove proxy protocol
|
// remove proxy protocol
|
||||||
|
|
@ -260,102 +230,15 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||||
return streamSettings
|
return streamSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubJsonService) applySubJsonFinalMask(streamSettings map[string]any) {
|
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
|
||||||
finalmask, _ := streamSettings["finalmask"].(map[string]any)
|
var fm map[string]any
|
||||||
if finalmask == nil {
|
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
|
||||||
finalmask = map[string]any{}
|
return
|
||||||
}
|
}
|
||||||
|
merged := mergeFinalMask(streamSettings["finalmask"], fm)
|
||||||
changed := false
|
if len(merged) > 0 {
|
||||||
if tcpMask, ok := buildSubJsonFragmentFinalMask(s.fragment); ok {
|
streamSettings["finalmask"] = merged
|
||||||
tcpMasks, _ := finalmask["tcp"].([]any)
|
|
||||||
finalmask["tcp"] = append(tcpMasks, tcpMask)
|
|
||||||
changed = true
|
|
||||||
}
|
}
|
||||||
if udpMask, ok := buildSubJsonNoisesFinalMask(s.noises); ok {
|
|
||||||
udpMasks, _ := finalmask["udp"].([]any)
|
|
||||||
finalmask["udp"] = append(udpMasks, udpMask)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
streamSettings["finalmask"] = finalmask
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSubJsonFragmentFinalMask(fragment string) (map[string]any, bool) {
|
|
||||||
if fragment == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var settings map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(fragment), &settings); err != nil || len(settings) == 0 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if interval, ok := settings["interval"]; ok {
|
|
||||||
if _, hasDelay := settings["delay"]; !hasDelay {
|
|
||||||
settings["delay"] = interval
|
|
||||||
}
|
|
||||||
delete(settings, "interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
"type": "fragment",
|
|
||||||
"settings": settings,
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSubJsonNoisesFinalMask(noises string) (map[string]any, bool) {
|
|
||||||
if noises == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var rawNoises []map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(noises), &rawNoises); err != nil || len(rawNoises) == 0 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
noiseItems := make([]any, 0, len(rawNoises))
|
|
||||||
for _, rawNoise := range rawNoises {
|
|
||||||
item := map[string]any{}
|
|
||||||
noiseType, _ := rawNoise["type"].(string)
|
|
||||||
packet, hasPacket := rawNoise["packet"]
|
|
||||||
|
|
||||||
if noiseType == "rand" {
|
|
||||||
if !hasPacket {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item["rand"] = packet
|
|
||||||
} else if hasPacket {
|
|
||||||
if noiseType != "" {
|
|
||||||
item["type"] = noiseType
|
|
||||||
}
|
|
||||||
item["packet"] = packet
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if delay, ok := rawNoise["delay"]; ok {
|
|
||||||
item["delay"] = delay
|
|
||||||
}
|
|
||||||
if randRange, ok := rawNoise["randRange"]; ok {
|
|
||||||
item["randRange"] = randRange
|
|
||||||
}
|
|
||||||
|
|
||||||
noiseItems = append(noiseItems, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(noiseItems) == 0 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
"type": "noise",
|
|
||||||
"settings": map[string]any{
|
|
||||||
"noise": noiseItems,
|
|
||||||
},
|
|
||||||
}, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
||||||
|
|
@ -410,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 {
|
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||||
outbound := Outbound{}
|
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.Protocol = string(inbound.Protocol)
|
||||||
outbound.Tag = "proxy"
|
outbound.Tag = "proxy"
|
||||||
|
|
@ -428,8 +300,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
||||||
outbound.Mux = json_util.RawMessage(s.mux)
|
outbound.Mux = json_util.RawMessage(s.mux)
|
||||||
}
|
}
|
||||||
outbound.StreamSettings = streamSettings
|
outbound.StreamSettings = streamSettings
|
||||||
|
|
||||||
|
security := client.Security
|
||||||
|
if security == "" {
|
||||||
|
security = "auto"
|
||||||
|
}
|
||||||
outbound.Settings = map[string]any{
|
outbound.Settings = map[string]any{
|
||||||
"vnext": vnextData,
|
"address": inbound.Listen,
|
||||||
|
"port": inbound.Port,
|
||||||
|
"id": client.ID,
|
||||||
|
"security": security,
|
||||||
|
"level": 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
|
|
@ -450,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
||||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
encryption, _ := inboundSettings["encryption"].(string)
|
encryption, _ := inboundSettings["encryption"].(string)
|
||||||
|
|
||||||
user := map[string]any{
|
settings := map[string]any{
|
||||||
|
"address": inbound.Listen,
|
||||||
|
"port": inbound.Port,
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
"level": 8,
|
|
||||||
"encryption": encryption,
|
"encryption": encryption,
|
||||||
|
"level": 8,
|
||||||
}
|
}
|
||||||
if client.Flow != "" {
|
if client.Flow != "" {
|
||||||
user["flow"] = client.Flow
|
settings["flow"] = client.Flow
|
||||||
}
|
|
||||||
|
|
||||||
vnext := map[string]any{
|
|
||||||
"address": inbound.Listen,
|
|
||||||
"port": inbound.Port,
|
|
||||||
"users": []any{user},
|
|
||||||
}
|
|
||||||
|
|
||||||
outbound.Settings = map[string]any{
|
|
||||||
"vnext": []any{vnext},
|
|
||||||
}
|
}
|
||||||
|
outbound.Settings = settings
|
||||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -503,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
||||||
outbound.Mux = json_util.RawMessage(s.mux)
|
outbound.Mux = json_util.RawMessage(s.mux)
|
||||||
}
|
}
|
||||||
outbound.StreamSettings = streamSettings
|
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, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
|
|
@ -600,18 +482,6 @@ type Outbound struct {
|
||||||
Settings map[string]any `json:"settings,omitempty"`
|
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 {
|
type ServerSetting struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,47 @@ package sub
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubJsonServiceKeepsDirectOutAndAddsFinalMask(t *testing.T) {
|
func hasDirectOutOutbound(svc *SubJsonService) bool {
|
||||||
fragment := `{"packets":"1-3","length":"100-200","interval":"10-20","maxSplit":"100-200"}`
|
for _, raw := range svc.defaultOutbounds {
|
||||||
noises := `[{"type":"rand","packet":"10-20","delay":"10-16","applyTo":"ip"},{"type":"base64","packet":"SGVsbG8=","delay":"5"}]`
|
var outbound map[string]any
|
||||||
svc := NewSubJsonService(fragment, noises, "", "", nil)
|
if err := json.Unmarshal(raw, &outbound); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if outbound["tag"] == "direct_out" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var directOut map[string]any
|
func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
||||||
if err := json.Unmarshal(svc.defaultOutbounds[len(svc.defaultOutbounds)-1], &directOut); err != nil {
|
t.Helper()
|
||||||
t.Fatalf("failed to unmarshal compatibility direct_out: %v", err)
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal outbound: %v", err)
|
||||||
}
|
}
|
||||||
if directOut["tag"] != "direct_out" {
|
settings, _ := parsed["settings"].(map[string]any)
|
||||||
t.Fatalf("direct_out tag = %v, want direct_out", directOut["tag"])
|
if settings == nil {
|
||||||
|
t.Fatal("outbound has no settings")
|
||||||
}
|
}
|
||||||
directSettings, _ := directOut["settings"].(map[string]any)
|
return settings
|
||||||
if _, ok := directSettings["fragment"]; !ok {
|
}
|
||||||
t.Fatal("compatibility direct_out is missing freedom fragment")
|
|
||||||
}
|
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||||
if _, ok := directSettings["noises"]; !ok {
|
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"}}`
|
||||||
t.Fatal("compatibility direct_out is missing freedom noises")
|
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"}}}`)
|
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||||
if _, ok := stream["sockopt"]; !ok {
|
if _, ok := stream["sockopt"]; ok {
|
||||||
t.Fatal("streamSettings is missing direct_out sockopt compatibility path")
|
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||||
|
|
@ -35,72 +51,98 @@ func TestSubJsonServiceKeepsDirectOutAndAddsFinalMask(t *testing.T) {
|
||||||
t.Fatal("streamSettings is missing finalmask")
|
t.Fatal("streamSettings is missing finalmask")
|
||||||
}
|
}
|
||||||
|
|
||||||
tcpMasks, _ := finalmask["tcp"].([]any)
|
tcp, _ := finalmask["tcp"].([]any)
|
||||||
if len(tcpMasks) != 1 {
|
if len(tcp) != 1 {
|
||||||
t.Fatalf("finalmask tcp masks len = %d, want 1", len(tcpMasks))
|
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
||||||
}
|
}
|
||||||
fragmentMask, _ := tcpMasks[0].(map[string]any)
|
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
||||||
if fragmentMask["type"] != "fragment" {
|
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
||||||
t.Fatalf("tcp mask type = %v, want fragment", fragmentMask["type"])
|
|
||||||
}
|
|
||||||
fragmentSettings, _ := fragmentMask["settings"].(map[string]any)
|
|
||||||
if fragmentSettings["delay"] != "10-20" {
|
|
||||||
t.Fatalf("fragment delay = %v, want 10-20", fragmentSettings["delay"])
|
|
||||||
}
|
|
||||||
if _, ok := fragmentSettings["interval"]; ok {
|
|
||||||
t.Fatal("finalmask fragment should use delay, not interval")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
udpMasks, _ := finalmask["udp"].([]any)
|
udp, _ := finalmask["udp"].([]any)
|
||||||
if len(udpMasks) != 1 {
|
if len(udp) != 1 {
|
||||||
t.Fatalf("finalmask udp masks len = %d, want 1", len(udpMasks))
|
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
||||||
}
|
}
|
||||||
noiseMask, _ := udpMasks[0].(map[string]any)
|
|
||||||
if noiseMask["type"] != "noise" {
|
quic, _ := finalmask["quicParams"].(map[string]any)
|
||||||
t.Fatalf("udp mask type = %v, want noise", noiseMask["type"])
|
if quic == nil || quic["congestion"] != "bbr" {
|
||||||
}
|
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
||||||
noiseSettings, _ := noiseMask["settings"].(map[string]any)
|
|
||||||
noiseItems, _ := noiseSettings["noise"].([]any)
|
|
||||||
if len(noiseItems) != 2 {
|
|
||||||
t.Fatalf("noise items len = %d, want 2", len(noiseItems))
|
|
||||||
}
|
|
||||||
randItem, _ := noiseItems[0].(map[string]any)
|
|
||||||
if randItem["rand"] != "10-20" {
|
|
||||||
t.Fatalf("rand noise item rand = %v, want 10-20", randItem["rand"])
|
|
||||||
}
|
|
||||||
if _, ok := randItem["applyTo"]; ok {
|
|
||||||
t.Fatal("finalmask noise should not carry freedom noises applyTo")
|
|
||||||
}
|
|
||||||
packetItem, _ := noiseItems[1].(map[string]any)
|
|
||||||
if packetItem["type"] != "base64" || packetItem["packet"] != "SGVsbG8=" {
|
|
||||||
t.Fatalf("packet noise item = %#v, want base64 packet", packetItem)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubJsonServiceAppendsFinalMaskToExistingMasks(t *testing.T) {
|
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||||
fragment := `{"packets":"tlshello","length":"100-200","interval":"0"}`
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
||||||
svc := NewSubJsonService(fragment, "", "", "", nil)
|
svc := NewSubJsonService("", "", finalMask, nil)
|
||||||
|
|
||||||
stream := svc.streamData(`{
|
stream := svc.streamData(`{
|
||||||
"network":"tcp",
|
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||||
"security":"none",
|
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
||||||
"tcpSettings":{"header":{"type":"none"}},
|
|
||||||
"finalmask":{"tcp":[{"type":"sudoku"}],"udp":[{"type":"salamander","settings":{"password":"secret"}}]}
|
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||||
tcpMasks, _ := finalmask["tcp"].([]any)
|
tcp, _ := finalmask["tcp"].([]any)
|
||||||
if len(tcpMasks) != 2 {
|
if len(tcp) != 2 {
|
||||||
t.Fatalf("finalmask tcp masks len = %d, want 2", len(tcpMasks))
|
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
|
||||||
}
|
}
|
||||||
firstTCP, _ := tcpMasks[0].(map[string]any)
|
a, _ := tcp[0].(map[string]any)
|
||||||
secondTCP, _ := tcpMasks[1].(map[string]any)
|
b, _ := tcp[1].(map[string]any)
|
||||||
if firstTCP["type"] != "sudoku" || secondTCP["type"] != "fragment" {
|
if a["type"] != "sudoku" || b["type"] != "fragment" {
|
||||||
t.Fatalf("tcp masks = %#v, want existing mask followed by subscription fragment", tcpMasks)
|
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
udpMasks, _ := finalmask["udp"].([]any)
|
|
||||||
if len(udpMasks) != 1 {
|
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||||
t.Fatalf("finalmask udp masks len = %d, want existing udp mask preserved", len(udpMasks))
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,9 @@ type AllSetting struct {
|
||||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||||
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||||
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||||
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
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||||
|
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
||||||
|
|
||||||
// LDAP settings
|
// LDAP settings
|
||||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,9 @@ var defaultValueMap = map[string]string{
|
||||||
"subClashEnable": "false",
|
"subClashEnable": "false",
|
||||||
"subClashPath": "/clash/",
|
"subClashPath": "/clash/",
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
"subJsonFragment": "",
|
|
||||||
"subJsonNoises": "",
|
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
|
"subJsonFinalMask": "",
|
||||||
"datepicker": "gregorian",
|
"datepicker": "gregorian",
|
||||||
"warp": "",
|
"warp": "",
|
||||||
"nord": "",
|
"nord": "",
|
||||||
|
|
@ -658,14 +657,6 @@ func (s *SettingService) GetSubClashURI() (string, error) {
|
||||||
return s.getString("subClashURI")
|
return s.getString("subClashURI")
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func (s *SettingService) GetSubJsonMux() (string, error) {
|
||||||
return s.getString("subJsonMux")
|
return s.getString("subJsonMux")
|
||||||
}
|
}
|
||||||
|
|
@ -674,6 +665,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
|
||||||
return s.getString("subJsonRules")
|
return s.getString("subJsonRules")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||||
|
return s.getString("subJsonFinalMask")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetDatepicker() (string, error) {
|
func (s *SettingService) GetDatepicker() (string, error) {
|
||||||
return s.getString("datepicker")
|
return s.getString("datepicker")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "حد IP الافتراضي"
|
"defaultIpLimit": "حد IP الافتراضي"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
|
||||||
"packets": "الحزم",
|
"packets": "الحزم",
|
||||||
"length": "الطول",
|
"length": "الطول",
|
||||||
"interval": "الفاصل",
|
"interval": "الفاصل",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Default IP limit"
|
"defaultIpLimit": "Default IP limit"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Packets",
|
||||||
"length": "Length",
|
"length": "Length",
|
||||||
"interval": "Interval",
|
"interval": "Interval",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Límite IP por defecto"
|
"defaultIpLimit": "Límite IP por defecto"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Paquetes",
|
||||||
"length": "Longitud",
|
"length": "Longitud",
|
||||||
"interval": "Intervalo",
|
"interval": "Intervalo",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "محدودیت IP پیشفرض"
|
"defaultIpLimit": "محدودیت IP پیشفرض"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "ماسکهای finalmask ایکسری (TCP/UDP) و تنظیمات QUIC که داخل همهی stream های اشتراک JSON تزریق میشوند. به نسخهی جدید هستهی xray در کلاینت نیاز دارد.",
|
||||||
"packets": "بستهها",
|
"packets": "بستهها",
|
||||||
"length": "طول",
|
"length": "طول",
|
||||||
"interval": "بازه",
|
"interval": "بازه",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Batas IP default"
|
"defaultIpLimit": "Batas IP default"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Paket",
|
||||||
"length": "Panjang",
|
"length": "Panjang",
|
||||||
"interval": "Interval",
|
"interval": "Interval",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "デフォルト IP 制限"
|
"defaultIpLimit": "デフォルト IP 制限"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
|
||||||
"packets": "パケット",
|
"packets": "パケット",
|
||||||
"length": "長さ",
|
"length": "長さ",
|
||||||
"interval": "間隔",
|
"interval": "間隔",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Limite de IP padrão"
|
"defaultIpLimit": "Limite de IP padrão"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Pacotes",
|
||||||
"length": "Comprimento",
|
"length": "Comprimento",
|
||||||
"interval": "Intervalo",
|
"interval": "Intervalo",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Лимит IP по умолчанию"
|
"defaultIpLimit": "Лимит IP по умолчанию"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
|
||||||
"packets": "Пакеты",
|
"packets": "Пакеты",
|
||||||
"length": "Длина",
|
"length": "Длина",
|
||||||
"interval": "Интервал",
|
"interval": "Интервал",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Varsayılan IP limiti"
|
"defaultIpLimit": "Varsayılan IP limiti"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Paketler",
|
||||||
"length": "Uzunluk",
|
"length": "Uzunluk",
|
||||||
"interval": "Aralık",
|
"interval": "Aralık",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Ліміт IP за замовч."
|
"defaultIpLimit": "Ліміт IP за замовч."
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
|
||||||
"packets": "Пакети",
|
"packets": "Пакети",
|
||||||
"length": "Довжина",
|
"length": "Довжина",
|
||||||
"interval": "Інтервал",
|
"interval": "Інтервал",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "Giới hạn IP mặc định"
|
"defaultIpLimit": "Giới hạn IP mặc định"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Gói",
|
||||||
"length": "Độ dài",
|
"length": "Độ dài",
|
||||||
"interval": "Khoảng",
|
"interval": "Khoảng",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "默认 IP 限制"
|
"defaultIpLimit": "默认 IP 限制"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
|
||||||
"packets": "数据包",
|
"packets": "数据包",
|
||||||
"length": "长度",
|
"length": "长度",
|
||||||
"interval": "间隔",
|
"interval": "间隔",
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,8 @@
|
||||||
"defaultIpLimit": "預設 IP 限制"
|
"defaultIpLimit": "預設 IP 限制"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
|
||||||
"packets": "封包",
|
"packets": "封包",
|
||||||
"length": "長度",
|
"length": "長度",
|
||||||
"interval": "間隔",
|
"interval": "間隔",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue