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:
MHSanaei 2026-06-04 23:16:43 +02:00
parent f4a07121a9
commit 08fca9ed66
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
26 changed files with 276 additions and 445 deletions

View file

@ -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;

View file

@ -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(),

View file

@ -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>

View file

@ -55,10 +55,9 @@ export class AllSetting {
subURI = ''; subURI = '';
subJsonURI = ''; subJsonURI = '';
subClashURI = ''; subClashURI = '';
subJsonFragment = '';
subJsonNoises = '';
subJsonMux = ''; subJsonMux = '';
subJsonRules = ''; subJsonRules = '';
subJsonFinalMask = '';
timeLocation = 'Local'; timeLocation = 'Local';

View 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>
);
}

View file

@ -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: (
<> <>

View file

@ -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(),

View file

@ -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

View file

@ -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)

View file

@ -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"`

View file

@ -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)
} }
} }

View file

@ -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"`

View file

@ -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")
} }

View file

@ -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": "الفاصل",

View file

@ -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",

View file

@ -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",

View file

@ -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": "بازه",

View file

@ -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",

View file

@ -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": "間隔",

View file

@ -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",

View file

@ -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": "Интервал",

View file

@ -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",

View file

@ -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": "Інтервал",

View file

@ -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",

View file

@ -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": "间隔",

View file

@ -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": "間隔",