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 (#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:
parent
f947fbd6c6
commit
97f88fb1a9
27 changed files with 352 additions and 277 deletions
|
|
@ -5791,7 +5791,7 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"Subscription Server"
|
"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",
|
"operationId": "get_clashPath_subid",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,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;
|
||||||
|
|
@ -133,9 +132,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;
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,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(),
|
||||||
|
|
@ -136,9 +135,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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,9 @@ export class AllSetting {
|
||||||
subClashURI = '';
|
subClashURI = '';
|
||||||
subClashEnableRouting = false;
|
subClashEnableRouting = false;
|
||||||
subClashRules = '';
|
subClashRules = '';
|
||||||
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: (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,9 @@ export const AllSettingSchema = z.object({
|
||||||
subClashURI: z.string().optional(),
|
subClashURI: z.string().optional(),
|
||||||
subClashEnableRouting: z.boolean().optional(),
|
subClashEnableRouting: z.boolean().optional(),
|
||||||
subClashRules: z.string().optional(),
|
subClashRules: 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 = ""
|
||||||
|
}
|
||||||
|
|
||||||
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SubClashEnableRouting = false
|
SubClashEnableRouting = false
|
||||||
|
|
@ -236,7 +231,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, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
|
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, 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,
|
||||||
clashEnableRouting bool,
|
clashEnableRouting bool,
|
||||||
clashRules string,
|
clashRules string,
|
||||||
subTitle string,
|
subTitle string,
|
||||||
|
|
@ -92,7 +91,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(clashEnableRouting, clashRules, sub),
|
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +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
|
||||||
mux string
|
mux string
|
||||||
|
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
|
@ -29,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)
|
||||||
|
|
@ -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 != "" {
|
if rules != "" {
|
||||||
var newRules []any
|
var newRules []any
|
||||||
routing, _ := configJson["routing"].(map[string]any)
|
routing, _ := configJson["routing"].(map[string]any)
|
||||||
|
|
@ -78,7 +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,
|
||||||
mux: mux,
|
mux: mux,
|
||||||
SubService: subService,
|
SubService: subService,
|
||||||
}
|
}
|
||||||
|
|
@ -230,8 +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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove proxy protocol
|
// remove proxy protocol
|
||||||
|
|
@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||||
return streamSettings
|
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 {
|
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
||||||
netSettings, ok := setting.(map[string]any)
|
netSettings, ok := setting.(map[string]any)
|
||||||
if ok {
|
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 {
|
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"
|
||||||
|
|
@ -325,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, "", " ")
|
||||||
|
|
@ -347,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{
|
||||||
"id": client.ID,
|
|
||||||
"level": 8,
|
|
||||||
"encryption": encryption,
|
|
||||||
}
|
|
||||||
if client.Flow != "" {
|
|
||||||
user["flow"] = client.Flow
|
|
||||||
}
|
|
||||||
|
|
||||||
vnext := map[string]any{
|
|
||||||
"address": inbound.Listen,
|
"address": inbound.Listen,
|
||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
"users": []any{user},
|
"id": client.ID,
|
||||||
|
"encryption": encryption,
|
||||||
|
"level": 8,
|
||||||
}
|
}
|
||||||
|
if client.Flow != "" {
|
||||||
outbound.Settings = map[string]any{
|
settings["flow"] = client.Flow
|
||||||
"vnext": []any{vnext},
|
|
||||||
}
|
}
|
||||||
|
outbound.Settings = settings
|
||||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -400,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
|
||||||
|
|
@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||||
newStream["hysteriaSettings"] = outHyStream
|
newStream["hysteriaSettings"] = outHyStream
|
||||||
|
|
||||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||||
newStream["finalmask"] = finalmask
|
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
||||||
}
|
}
|
||||||
|
|
||||||
newStream["network"] = "hysteria"
|
newStream["network"] = "hysteria"
|
||||||
|
|
@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||||
return result
|
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 {
|
type Outbound struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
|
|
@ -462,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"`
|
||||||
|
|
|
||||||
148
sub/subJsonService_test.go
Normal file
148
sub/subJsonService_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,10 +85,9 @@ type AllSetting struct {
|
||||||
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
|
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
|
||||||
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
|
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
|
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"`
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,9 @@ var defaultValueMap = map[string]string{
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
"subClashEnableRouting": "false",
|
"subClashEnableRouting": "false",
|
||||||
"subClashRules": "",
|
"subClashRules": "",
|
||||||
"subJsonFragment": "",
|
|
||||||
"subJsonNoises": "",
|
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
|
"subJsonFinalMask": "",
|
||||||
"datepicker": "gregorian",
|
"datepicker": "gregorian",
|
||||||
"warp": "",
|
"warp": "",
|
||||||
"nord": "",
|
"nord": "",
|
||||||
|
|
@ -668,14 +667,6 @@ func (s *SettingService) GetSubClashRules() (string, error) {
|
||||||
return s.getString("subClashRules")
|
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) {
|
func (s *SettingService) GetSubJsonMux() (string, error) {
|
||||||
return s.getString("subJsonMux")
|
return s.getString("subJsonMux")
|
||||||
}
|
}
|
||||||
|
|
@ -684,6 +675,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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,8 @@
|
||||||
"defaultIpLimit": "حد IP الافتراضي"
|
"defaultIpLimit": "حد IP الافتراضي"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
|
||||||
"packets": "الحزم",
|
"packets": "الحزم",
|
||||||
"length": "الطول",
|
"length": "الطول",
|
||||||
"interval": "الفاصل",
|
"interval": "الفاصل",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,8 @@
|
||||||
"defaultIpLimit": "محدودیت IP پیشفرض"
|
"defaultIpLimit": "محدودیت IP پیشفرض"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "ماسکهای finalmask ایکسری (TCP/UDP) و تنظیمات QUIC که داخل همهی stream های اشتراک JSON تزریق میشوند. به نسخهی جدید هستهی xray در کلاینت نیاز دارد.",
|
||||||
"packets": "بستهها",
|
"packets": "بستهها",
|
||||||
"length": "طول",
|
"length": "طول",
|
||||||
"interval": "بازه",
|
"interval": "بازه",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,8 @@
|
||||||
"defaultIpLimit": "デフォルト IP 制限"
|
"defaultIpLimit": "デフォルト IP 制限"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
|
||||||
"packets": "パケット",
|
"packets": "パケット",
|
||||||
"length": "長さ",
|
"length": "長さ",
|
||||||
"interval": "間隔",
|
"interval": "間隔",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,8 @@
|
||||||
"defaultIpLimit": "Лимит IP по умолчанию"
|
"defaultIpLimit": "Лимит IP по умолчанию"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
|
||||||
"packets": "Пакеты",
|
"packets": "Пакеты",
|
||||||
"length": "Длина",
|
"length": "Длина",
|
||||||
"interval": "Интервал",
|
"interval": "Интервал",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,8 @@
|
||||||
"defaultIpLimit": "Ліміт IP за замовч."
|
"defaultIpLimit": "Ліміт IP за замовч."
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
|
||||||
"packets": "Пакети",
|
"packets": "Пакети",
|
||||||
"length": "Довжина",
|
"length": "Довжина",
|
||||||
"interval": "Інтервал",
|
"interval": "Інтервал",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,8 @@
|
||||||
"defaultIpLimit": "默认 IP 限制"
|
"defaultIpLimit": "默认 IP 限制"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
|
||||||
"packets": "数据包",
|
"packets": "数据包",
|
||||||
"length": "长度",
|
"length": "长度",
|
||||||
"interval": "间隔",
|
"interval": "间隔",
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,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