From 0442be5078fa5f9f6d80e80ac629a7c788469593 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 22:14:38 +0200 Subject: [PATCH] feat(frontend): align finalmask + sockopt with xray docs, add golden fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema fixes per https://xtls.github.io/config/transports/finalmask.html and https://xtls.github.io/config/transports/sockopt.html: finalmask: - QuicCongestionSchema: remove non-doc 'cubic', keep reno/bbr/brutal/force-brutal - Add BbrProfileSchema (conservative/standard/aggressive) and bbrProfile field - brutalUp/brutalDown: number -> string per docs (units like '60 mbps') - Tighten ranges: maxIdleTimeout 4-120, keepAlivePeriod 2-60, maxIncomingStreams min 8 - UdpMaskTypeSchema: add missing 'sudoku' - udpHop.interval stays as preprocessed string-range per intentional B19 divergence sockopt: - tcpFastOpen: boolean -> union(boolean, number) per docs (number tunes queue size) - mark: drop min(0) (can be any int) - domainStrategy default: 'UseIP' -> 'AsIs' per docs - tcpKeepAlive Interval/Idle defaults: 0/300 -> 45/45 per docs (outbound) - Add AddressPortStrategySchema enum (7 values) + addressPortStrategy field - Add HappyEyeballsSchema (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry) - Add CustomSockoptSchema (system/type/level/opt/value) + customSockopt array Bug fixes: - options.ts: Address_Port_Strategy values were lowercase ('srvportonly'); xray-core requires camelCase ('SrvPortOnly'). Fixed all 6 entries. - OutboundFormModal: domainStrategy Select was mistakenly populated from ADDRESS_PORT_STRATEGY_OPTIONS; now uses DOMAIN_STRATEGY_OPTION. - OutboundFormModal: inline sockopt defaults (hardcoded {acceptProxyProtocol: false, domainStrategy: 'UseIP', ...}) replaced with SockoptStreamSettingsSchema.parse({}) so schema is the single source. Form additions (both InboundFormModal + OutboundFormModal): - Address+port strategy Select - Happy Eyeballs Switch + sub-form (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry) - Custom sockopt Form.List (system/type/level/opt/value) - FinalMaskForm: BBR Profile Select (visible when congestion='bbr'), Brutal Up/Down placeholders updated to string format Golden fixtures (8 new + 4 xhttp extras): - finalmask/{tcp-mask, udp-mask, quic-params, combined}.json — cover all TCP mask types, 7 UDP mask types including new sudoku, full QUIC params shape - sockopt/{defaults, tcp-tuning, tproxy, full}.json — full sockopt knobs - stream/xhttp-{basic, extra-padding, extra-placement, extra-tuning}.json — cover the extra-blob fields bundled into share-link extra= Tests now at 312 (up from 300); typecheck/lint clean. --- frontend/src/components/FinalMaskForm.tsx | 19 +- .../src/pages/inbounds/InboundFormModal.tsx | 116 +++++++++++- frontend/src/pages/xray/OutboundFormModal.tsx | 140 +++++++++++--- frontend/src/schemas/primitives/options.ts | 12 +- .../src/schemas/protocols/stream/finalmask.ts | 17 +- .../src/schemas/protocols/stream/sockopt.ts | 51 +++-- .../test/__snapshots__/finalmask.test.ts.snap | 174 ++++++++++++++++++ .../test/__snapshots__/sockopt.test.ts.snap | 100 ++++++++++ .../test/__snapshots__/stream.test.ts.snap | 147 +++++++++++++++ frontend/src/test/finalmask.test.ts | 26 +++ .../golden/fixtures/finalmask/combined.json | 15 ++ .../fixtures/finalmask/quic-params.json | 16 ++ .../golden/fixtures/finalmask/tcp-mask.json | 30 +++ .../golden/fixtures/finalmask/udp-mask.json | 29 +++ .../golden/fixtures/sockopt/defaults.json | 1 + .../test/golden/fixtures/sockopt/full.json | 19 ++ .../golden/fixtures/sockopt/tcp-tuning.json | 10 + .../test/golden/fixtures/sockopt/tproxy.json | 7 + .../golden/fixtures/stream/xhttp-basic.json | 8 + .../fixtures/stream/xhttp-extra-padding.json | 14 ++ .../stream/xhttp-extra-placement.json | 14 ++ .../fixtures/stream/xhttp-extra-tuning.json | 29 +++ frontend/src/test/sockopt.test.ts | 26 +++ 23 files changed, 966 insertions(+), 54 deletions(-) create mode 100644 frontend/src/test/__snapshots__/finalmask.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/sockopt.test.ts.snap create mode 100644 frontend/src/test/finalmask.test.ts create mode 100644 frontend/src/test/golden/fixtures/finalmask/combined.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/quic-params.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/tcp-mask.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/udp-mask.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/defaults.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/full.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/tcp-tuning.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/tproxy.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-basic.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json create mode 100644 frontend/src/test/sockopt.test.ts diff --git a/frontend/src/components/FinalMaskForm.tsx b/frontend/src/components/FinalMaskForm.tsx index 1251d19a..23f2f02b 100644 --- a/frontend/src/components/FinalMaskForm.tsx +++ b/frontend/src/components/FinalMaskForm.tsx @@ -83,8 +83,6 @@ function defaultQuicParams(): Record { return { congestion: 'bbr', debug: false, - brutalUp: 0, - brutalDown: 0, maxIdleTimeout: 30, keepAlivePeriod: 10, disablePathMTUDiscovery: false, @@ -680,6 +678,19 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI ]} /> + {congestion === 'bbr' && ( + + + - + )} diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index a5bf707b..b96f25ef 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -56,6 +56,7 @@ import { import { antdRule } from '@/utils/zodForm'; import { ALPN_OPTION, + Address_Port_Strategy, DOMAIN_STRATEGY_OPTION, Protocols, SNIFFING_OPTION, @@ -65,7 +66,10 @@ import { USAGE_OPTION, UTLS_FINGERPRINT, } from '@/schemas/primitives'; -import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; +import { + HappyEyeballsSchema, + SockoptStreamSettingsSchema, +} from '@/schemas/protocols/stream/sockopt'; import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; @@ -2260,6 +2264,116 @@ export default function InboundFormModal({ X-Client-IP + + + + + {({ getFieldValue, setFieldValue }) => { + const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']); + const hasHe = he != null; + return ( + <> + + { + setFieldValue( + ['streamSettings', 'sockopt', 'happyEyeballs'], + v ? HappyEyeballsSchema.parse({}) : undefined, + ); + }} + /> + + {hasHe && ( + <> + + + + + + + + + + + + + + )} + + ); + }} + + + {(fields, { add, remove }) => ( + <> + + + + {fields.map((field) => ( + + + + + + + + + + + + + + + + ))} + + )} + )} diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx index eae3724c..c12cfd6f 100644 --- a/frontend/src/pages/xray/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/OutboundFormModal.tsx @@ -37,6 +37,7 @@ import { ALPN_OPTION, Address_Port_Strategy, DNSRuleActions, + DOMAIN_STRATEGY_OPTION, MODE_OPTION, OutboundDomainStrategies, OutboundProtocols as Protocols, @@ -47,6 +48,10 @@ import { UTLS_FINGERPRINT, WireguardDomainStrategy, } from '@/schemas/primitives'; +import { + HappyEyeballsSchema, + SockoptStreamSettingsSchema, +} from '@/schemas/protocols/stream/sockopt'; import { canEnableReality, canEnableStream, @@ -1897,27 +1902,7 @@ export default function OutboundFormModal({ onChange={(checked) => { form.setFieldValue( ['streamSettings', 'sockopt'], - checked - ? { - acceptProxyProtocol: false, - tcpFastOpen: false, - mark: 0, - tproxy: 'off', - tcpMptcp: false, - penetrate: false, - domainStrategy: 'UseIP', - tcpMaxSeg: 1440, - dialerProxy: '', - tcpKeepAliveInterval: 0, - tcpKeepAliveIdle: 300, - tcpUserTimeout: 10000, - tcpcongestion: 'bbr', - V6Only: false, - tcpWindowClamp: 600, - interfaceName: '', - trustedXForwardedFor: [], - } - : undefined, + checked ? SockoptStreamSettingsSchema.parse({}) : undefined, ); }} /> @@ -1935,9 +1920,18 @@ export default function OutboundFormModal({ name={['streamSettings', 'sockopt', 'domainStrategy']} > + + + {() => { + const he = form.getFieldValue([ + 'streamSettings', 'sockopt', 'happyEyeballs', + ]); + const hasHe = he != null; + return ( + <> + + { + form.setFieldValue( + ['streamSettings', 'sockopt', 'happyEyeballs'], + v ? HappyEyeballsSchema.parse({}) : undefined, + ); + }} + /> + + {hasHe && ( + <> + + + + + + + + + + + + + + )} + + ); + }} + + + {(fields, { add, remove }) => ( + <> + + + + {fields.map((field) => ( + + + + + + + + + + + + + + + + ))} + + )} + )} diff --git a/frontend/src/schemas/primitives/options.ts b/frontend/src/schemas/primitives/options.ts index daccc4c1..79cce3c5 100644 --- a/frontend/src/schemas/primitives/options.ts +++ b/frontend/src/schemas/primitives/options.ts @@ -51,12 +51,12 @@ export const WireguardDomainStrategy = Object.freeze([ export const Address_Port_Strategy = Object.freeze({ NONE: 'none', - SrvPortOnly: 'srvportonly', - SrvAddressOnly: 'srvaddressonly', - SrvPortAndAddress: 'srvportandaddress', - TxtPortOnly: 'txtportonly', - TxtAddressOnly: 'txtaddressonly', - TxtPortAndAddress: 'txtportandaddress', + SRV_PORT_ONLY: 'SrvPortOnly', + SRV_ADDRESS_ONLY: 'SrvAddressOnly', + SRV_PORT_AND_ADDRESS: 'SrvPortAndAddress', + TXT_PORT_ONLY: 'TxtPortOnly', + TXT_ADDRESS_ONLY: 'TxtAddressOnly', + TXT_PORT_AND_ADDRESS: 'TxtPortAndAddress', }); export const DNSRuleActions = Object.freeze(['direct', 'drop', 'reject', 'hijack'] as const); diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 83d06808..4a054801 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -33,6 +33,7 @@ export const UdpMaskTypeSchema = z.enum([ 'xdns', 'xicmp', 'noise', + 'sudoku', ]); export type UdpMaskType = z.infer; @@ -42,9 +43,12 @@ export const UdpMaskSchema = z.object({ }); export type UdpMask = z.infer; -export const QuicCongestionSchema = z.enum(['bbr', 'cubic', 'reno', 'brutal', 'force-brutal']); +export const QuicCongestionSchema = z.enum(['reno', 'bbr', 'brutal', 'force-brutal']); export type QuicCongestion = z.infer; +export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']); +export type BbrProfile = z.infer; + // udpHop randomizes the QUIC port between a range every `interval` seconds // to dodge port-based blocking. Both fields are dash-range strings on the // wire (e.g. '20000-50000', '5-10'). preprocess coerces legacy DB rows @@ -62,18 +66,19 @@ export type QuicUdpHop = z.infer; export const QuicParamsSchema = z.object({ congestion: QuicCongestionSchema.default('bbr'), + bbrProfile: BbrProfileSchema.optional(), debug: z.boolean().optional(), - brutalUp: z.number().int().min(0).optional(), - brutalDown: z.number().int().min(0).optional(), + brutalUp: z.string().optional(), + brutalDown: z.string().optional(), udpHop: QuicUdpHopSchema.optional(), initStreamReceiveWindow: z.number().int().min(0).optional(), maxStreamReceiveWindow: z.number().int().min(0).optional(), initConnectionReceiveWindow: z.number().int().min(0).optional(), maxConnectionReceiveWindow: z.number().int().min(0).optional(), - maxIdleTimeout: z.number().int().min(0).optional(), - keepAlivePeriod: z.number().int().min(0).optional(), + maxIdleTimeout: z.number().int().min(4).max(120).optional(), + keepAlivePeriod: z.number().int().min(2).max(60).optional(), disablePathMTUDiscovery: z.boolean().optional(), - maxIncomingStreams: z.number().int().min(0).optional(), + maxIncomingStreams: z.number().int().min(8).optional(), }); export type QuicParams = z.infer; diff --git a/frontend/src/schemas/protocols/stream/sockopt.ts b/frontend/src/schemas/protocols/stream/sockopt.ts index 30f3a0a1..1268ab43 100644 --- a/frontend/src/schemas/protocols/stream/sockopt.ts +++ b/frontend/src/schemas/protocols/stream/sockopt.ts @@ -21,33 +21,54 @@ export type TcpCongestion = z.infer; export const TproxyModeSchema = z.enum(['off', 'redirect', 'tproxy']); export type TproxyMode = z.infer; -// Sockopt knobs are an orthogonal layer on streamSettings — they tune -// the underlying socket (TCP keepalive, TFO, mark, tproxy, dialer proxy, -// IPv6-only, MPTCP). The wire field is `interface` (single word) but the -// panel class names it `interfaceName` internally to avoid the JS -// reserved keyword. We use `interfaceName` here too and document the -// renames; serializers writing back to wire must rename. -// -// trustedXForwardedFor is omitted from the wire payload when empty -// (legacy toJson() filters it); our default([]) lets parsing succeed but -// the shadow canonicalize step treats [] and absence as equivalent. +export const AddressPortStrategySchema = z.enum([ + 'none', + 'SrvPortOnly', + 'SrvAddressOnly', + 'SrvPortAndAddress', + 'TxtPortOnly', + 'TxtAddressOnly', + 'TxtPortAndAddress', +]); +export type AddressPortStrategy = z.infer; + +export const HappyEyeballsSchema = z.object({ + tryDelayMs: z.number().int().min(0).default(0), + prioritizeIPv6: z.boolean().default(false), + interleave: z.number().int().min(1).default(1), + maxConcurrentTry: z.number().int().min(0).default(4), +}); +export type HappyEyeballs = z.infer; + +export const CustomSockoptSchema = z.object({ + system: z.enum(['linux', 'windows', 'darwin']).optional(), + type: z.enum(['int', 'str']), + level: z.string().default('6'), + opt: z.string(), + value: z.union([z.string(), z.number()]), +}); +export type CustomSockopt = z.infer; + export const SockoptStreamSettingsSchema = z.object({ acceptProxyProtocol: z.boolean().default(false), - tcpFastOpen: z.boolean().default(false), - mark: z.number().int().min(0).default(0), + tcpFastOpen: z.union([z.boolean(), z.number().int()]).default(false), + mark: z.number().int().default(0), tproxy: TproxyModeSchema.default('off'), tcpMptcp: z.boolean().default(false), penetrate: z.boolean().default(false), - domainStrategy: SockoptDomainStrategySchema.default('UseIP'), + domainStrategy: SockoptDomainStrategySchema.default('AsIs'), tcpMaxSeg: z.number().int().min(0).default(1440), dialerProxy: z.string().default(''), - tcpKeepAliveInterval: z.number().int().min(0).default(0), - tcpKeepAliveIdle: z.number().int().min(0).default(300), + tcpKeepAliveInterval: z.number().int().min(0).default(45), + tcpKeepAliveIdle: z.number().int().min(0).default(45), tcpUserTimeout: z.number().int().min(0).default(10000), tcpcongestion: TcpCongestionSchema.default('bbr'), V6Only: z.boolean().default(false), tcpWindowClamp: z.number().int().min(0).default(600), interfaceName: z.string().default(''), trustedXForwardedFor: z.array(z.string()).default([]), + addressPortStrategy: AddressPortStrategySchema.default('none'), + happyEyeballs: HappyEyeballsSchema.optional(), + customSockopt: z.array(CustomSockoptSchema).default([]), }); export type SockoptStreamSettings = z.infer; diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap new file mode 100644 index 00000000..72545d5e --- /dev/null +++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap @@ -0,0 +1,174 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`] = ` +{ + "quicParams": { + "brutalDown": "200 mbps", + "brutalUp": "100 mbps", + "congestion": "brutal", + "udpHop": { + "interval": "5-10", + "ports": "10000-20000", + }, + }, + "tcp": [ + { + "settings": { + "packets": "1-3", + }, + "type": "fragment", + }, + ], + "udp": [ + { + "settings": { + "password": "swordfish", + }, + "type": "salamander", + }, + { + "type": "header-wireguard", + }, + ], +} +`; + +exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably 1`] = ` +{ + "quicParams": { + "bbrProfile": "standard", + "congestion": "bbr", + "debug": false, + "disablePathMTUDiscovery": false, + "initConnectionReceiveWindow": 20971520, + "initStreamReceiveWindow": 8388608, + "keepAlivePeriod": 10, + "maxConnectionReceiveWindow": 20971520, + "maxIdleTimeout": 30, + "maxIncomingStreams": 1024, + "maxStreamReceiveWindow": 8388608, + "udpHop": { + "interval": "5-10", + "ports": "20000-50000", + }, + }, + "tcp": [], + "udp": [], +} +`; + +exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = ` +{ + "tcp": [ + { + "settings": { + "delay": "5-10", + "length": "10-20", + "maxSplit": "0", + "packets": "1-3", + }, + "type": "fragment", + }, + { + "type": "sudoku", + }, + { + "settings": { + "clients": [ + [ + { + "delay": 0, + "packet": [ + "GET / HTTP/1.1", + ], + "type": "str", + }, + ], + ], + "errors": [], + "servers": [ + [ + { + "delay": 0, + "packet": [ + "HTTP/1.1 200 OK", + ], + "type": "str", + }, + ], + ], + }, + "type": "header-custom", + }, + ], + "udp": [], +} +`; + +exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = ` +{ + "tcp": [], + "udp": [ + { + "settings": { + "password": "swordfish", + }, + "type": "salamander", + }, + { + "settings": { + "password": "abcdef0123456789", + }, + "type": "mkcp-aes128gcm", + }, + { + "settings": { + "domain": "cloudflare.com", + }, + "type": "header-dns", + }, + { + "type": "header-wireguard", + }, + { + "settings": { + "noise": [ + { + "delay": "10-16", + "rand": "10-20", + "type": "rand", + }, + { + "delay": "5", + "packet": [ + "ping", + ], + "type": "str", + }, + ], + "reset": "60", + }, + "type": "noise", + }, + { + "settings": { + "domains": [ + "example.com:txt", + "example.org:a", + ], + "resolvers": [ + "example.com:txt+udp://1.1.1.1:53", + ], + }, + "type": "xdns", + }, + { + "settings": { + "id": 0, + "listenIp": "0.0.0.0", + }, + "type": "xicmp", + }, + ], +} +`; diff --git a/frontend/src/test/__snapshots__/sockopt.test.ts.snap b/frontend/src/test/__snapshots__/sockopt.test.ts.snap new file mode 100644 index 00000000..3c83f8e7 --- /dev/null +++ b/frontend/src/test/__snapshots__/sockopt.test.ts.snap @@ -0,0 +1,100 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`] = ` +{ + "V6Only": false, + "acceptProxyProtocol": false, + "addressPortStrategy": "none", + "customSockopt": [], + "dialerProxy": "", + "domainStrategy": "AsIs", + "interfaceName": "", + "mark": 0, + "penetrate": false, + "tcpFastOpen": false, + "tcpKeepAliveIdle": 45, + "tcpKeepAliveInterval": 45, + "tcpMaxSeg": 1440, + "tcpMptcp": false, + "tcpUserTimeout": 10000, + "tcpWindowClamp": 600, + "tcpcongestion": "bbr", + "tproxy": "off", + "trustedXForwardedFor": [], +} +`; + +exports[`SockoptStreamSettingsSchema fixtures > parses full byte-stably 1`] = ` +{ + "V6Only": false, + "acceptProxyProtocol": true, + "addressPortStrategy": "none", + "customSockopt": [], + "dialerProxy": "out-proxy-tag", + "domainStrategy": "UseIP", + "interfaceName": "eth0", + "mark": 100, + "penetrate": false, + "tcpFastOpen": true, + "tcpKeepAliveIdle": 300, + "tcpKeepAliveInterval": 15, + "tcpMaxSeg": 1440, + "tcpMptcp": true, + "tcpUserTimeout": 10000, + "tcpWindowClamp": 600, + "tcpcongestion": "cubic", + "tproxy": "redirect", + "trustedXForwardedFor": [ + "10.0.0.0/8", + "192.168.0.0/16", + ], +} +`; + +exports[`SockoptStreamSettingsSchema fixtures > parses tcp-tuning byte-stably 1`] = ` +{ + "V6Only": false, + "acceptProxyProtocol": false, + "addressPortStrategy": "none", + "customSockopt": [], + "dialerProxy": "", + "domainStrategy": "AsIs", + "interfaceName": "", + "mark": 0, + "penetrate": false, + "tcpFastOpen": true, + "tcpKeepAliveIdle": 120, + "tcpKeepAliveInterval": 30, + "tcpMaxSeg": 1440, + "tcpMptcp": true, + "tcpUserTimeout": 5000, + "tcpWindowClamp": 600, + "tcpcongestion": "bbr", + "tproxy": "off", + "trustedXForwardedFor": [], +} +`; + +exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] = ` +{ + "V6Only": false, + "acceptProxyProtocol": false, + "addressPortStrategy": "none", + "customSockopt": [], + "dialerProxy": "", + "domainStrategy": "ForceIPv4", + "interfaceName": "", + "mark": 255, + "penetrate": true, + "tcpFastOpen": false, + "tcpKeepAliveIdle": 45, + "tcpKeepAliveInterval": 45, + "tcpMaxSeg": 1440, + "tcpMptcp": false, + "tcpUserTimeout": 10000, + "tcpWindowClamp": 600, + "tcpcongestion": "bbr", + "tproxy": "tproxy", + "trustedXForwardedFor": [], +} +`; diff --git a/frontend/src/test/__snapshots__/stream.test.ts.snap b/frontend/src/test/__snapshots__/stream.test.ts.snap index 616152af..67817b6e 100644 --- a/frontend/src/test/__snapshots__/stream.test.ts.snap +++ b/frontend/src/test/__snapshots__/stream.test.ts.snap @@ -32,3 +32,150 @@ exports[`NetworkSettingsSchema fixtures > parses ws-default byte-stably 1`] = ` }, } `; + +exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = ` +{ + "network": "xhttp", + "xhttpSettings": { + "enableXmux": false, + "headers": {}, + "host": "edge.example.test", + "mode": "auto", + "noGRPCHeader": false, + "noSSEHeader": false, + "path": "/sp", + "scMaxBufferedPosts": 30, + "scMaxEachPostBytes": "1000000", + "scMinPostsIntervalMs": "30", + "scStreamUpServerSecs": "20-80", + "seqKey": "", + "seqPlacement": "", + "serverMaxHeaderBytes": 0, + "sessionKey": "", + "sessionPlacement": "", + "uplinkChunkSize": 0, + "uplinkDataKey": "", + "uplinkDataPlacement": "", + "uplinkHTTPMethod": "", + "xPaddingBytes": "100-1000", + "xPaddingHeader": "", + "xPaddingKey": "", + "xPaddingMethod": "", + "xPaddingObfsMode": false, + "xPaddingPlacement": "", + }, +} +`; + +exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably 1`] = ` +{ + "network": "xhttp", + "xhttpSettings": { + "enableXmux": false, + "headers": {}, + "host": "edge.example.test", + "mode": "stream-up", + "noGRPCHeader": false, + "noSSEHeader": false, + "path": "/sp", + "scMaxBufferedPosts": 30, + "scMaxEachPostBytes": "1000000", + "scMinPostsIntervalMs": "30", + "scStreamUpServerSecs": "20-80", + "seqKey": "", + "seqPlacement": "", + "serverMaxHeaderBytes": 0, + "sessionKey": "", + "sessionPlacement": "", + "uplinkChunkSize": 0, + "uplinkDataKey": "", + "uplinkDataPlacement": "", + "uplinkHTTPMethod": "", + "xPaddingBytes": "500-1500", + "xPaddingHeader": "X-Pad", + "xPaddingKey": "secret-key", + "xPaddingMethod": "random", + "xPaddingObfsMode": true, + "xPaddingPlacement": "header", + }, +} +`; + +exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stably 1`] = ` +{ + "network": "xhttp", + "xhttpSettings": { + "enableXmux": false, + "headers": {}, + "host": "edge.example.test", + "mode": "auto", + "noGRPCHeader": false, + "noSSEHeader": false, + "path": "/sp", + "scMaxBufferedPosts": 30, + "scMaxEachPostBytes": "1000000", + "scMinPostsIntervalMs": "30", + "scStreamUpServerSecs": "20-80", + "seqKey": "X-Seq", + "seqPlacement": "cookie", + "serverMaxHeaderBytes": 0, + "sessionKey": "X-Session", + "sessionPlacement": "header", + "uplinkChunkSize": 0, + "uplinkDataKey": "u", + "uplinkDataPlacement": "query", + "uplinkHTTPMethod": "", + "xPaddingBytes": "100-1000", + "xPaddingHeader": "", + "xPaddingKey": "", + "xPaddingMethod": "", + "xPaddingObfsMode": false, + "xPaddingPlacement": "", + }, +} +`; + +exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably 1`] = ` +{ + "network": "xhttp", + "xhttpSettings": { + "enableXmux": false, + "headers": { + "X-Forwarded-For": "10.0.0.1", + "X-Real-IP": "1.2.3.4", + }, + "host": "edge.example.test", + "mode": "packet-up", + "noGRPCHeader": true, + "noSSEHeader": true, + "path": "/sp", + "scMaxBufferedPosts": 50, + "scMaxEachPostBytes": "2000000", + "scMinPostsIntervalMs": "60", + "scStreamUpServerSecs": "30-90", + "seqKey": "", + "seqPlacement": "", + "serverMaxHeaderBytes": 16384, + "sessionKey": "", + "sessionPlacement": "", + "uplinkChunkSize": 8192, + "uplinkDataKey": "", + "uplinkDataPlacement": "", + "uplinkHTTPMethod": "PUT", + "xPaddingBytes": "100-1000", + "xPaddingHeader": "", + "xPaddingKey": "", + "xPaddingMethod": "", + "xPaddingObfsMode": false, + "xPaddingPlacement": "", + "xmux": { + "cMaxReuseTimes": 0, + "hKeepAlivePeriod": 30, + "hMaxRequestTimes": "600-900", + "hMaxReusableSecs": "1800-3000", + "maxConcurrency": "16-32", + "maxConnections": 4, + }, + }, +} +`; diff --git a/frontend/src/test/finalmask.test.ts b/frontend/src/test/finalmask.test.ts new file mode 100644 index 00000000..de2d5169 --- /dev/null +++ b/frontend/src/test/finalmask.test.ts @@ -0,0 +1,26 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream'; + +const fixtures = import.meta.glob( + './golden/fixtures/finalmask/*.json', + { eager: true, import: 'default' }, +); + +function fixtureName(path: string): string { + const file = path.split('/').pop() ?? path; + return file.replace(/\.json$/, ''); +} + +describe('FinalMaskStreamSettingsSchema fixtures', () => { + const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b)); + expect(entries.length, 'expected at least one fixture under golden/fixtures/finalmask').toBeGreaterThan(0); + + for (const [path, raw] of entries) { + it(`parses ${fixtureName(path)} byte-stably`, () => { + const parsed = FinalMaskStreamSettingsSchema.parse(raw); + expect(parsed).toMatchSnapshot(); + }); + } +}); diff --git a/frontend/src/test/golden/fixtures/finalmask/combined.json b/frontend/src/test/golden/fixtures/finalmask/combined.json new file mode 100644 index 00000000..13b3be41 --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/combined.json @@ -0,0 +1,15 @@ +{ + "tcp": [ + { "type": "fragment", "settings": { "packets": "1-3" } } + ], + "udp": [ + { "type": "salamander", "settings": { "password": "swordfish" } }, + { "type": "header-wireguard" } + ], + "quicParams": { + "congestion": "brutal", + "brutalUp": "100 mbps", + "brutalDown": "200 mbps", + "udpHop": { "ports": "10000-20000", "interval": "5-10" } + } +} diff --git a/frontend/src/test/golden/fixtures/finalmask/quic-params.json b/frontend/src/test/golden/fixtures/finalmask/quic-params.json new file mode 100644 index 00000000..0700210a --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/quic-params.json @@ -0,0 +1,16 @@ +{ + "quicParams": { + "congestion": "bbr", + "bbrProfile": "standard", + "debug": false, + "udpHop": { "ports": "20000-50000", "interval": "5-10" }, + "initStreamReceiveWindow": 8388608, + "maxStreamReceiveWindow": 8388608, + "initConnectionReceiveWindow": 20971520, + "maxConnectionReceiveWindow": 20971520, + "maxIdleTimeout": 30, + "keepAlivePeriod": 10, + "disablePathMTUDiscovery": false, + "maxIncomingStreams": 1024 + } +} diff --git a/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json b/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json new file mode 100644 index 00000000..18ab3111 --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json @@ -0,0 +1,30 @@ +{ + "tcp": [ + { + "type": "fragment", + "settings": { + "packets": "1-3", + "length": "10-20", + "delay": "5-10", + "maxSplit": "0" + } + }, + { "type": "sudoku" }, + { + "type": "header-custom", + "settings": { + "clients": [ + [ + { "type": "str", "packet": ["GET / HTTP/1.1"], "delay": 0 } + ] + ], + "servers": [ + [ + { "type": "str", "packet": ["HTTP/1.1 200 OK"], "delay": 0 } + ] + ], + "errors": [] + } + } + ] +} diff --git a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json new file mode 100644 index 00000000..770827cc --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json @@ -0,0 +1,29 @@ +{ + "udp": [ + { "type": "salamander", "settings": { "password": "swordfish" } }, + { "type": "mkcp-aes128gcm", "settings": { "password": "abcdef0123456789" } }, + { "type": "header-dns", "settings": { "domain": "cloudflare.com" } }, + { "type": "header-wireguard" }, + { + "type": "noise", + "settings": { + "reset": "60", + "noise": [ + { "type": "rand", "rand": "10-20", "delay": "10-16" }, + { "type": "str", "packet": ["ping"], "delay": "5" } + ] + } + }, + { + "type": "xdns", + "settings": { + "domains": ["example.com:txt", "example.org:a"], + "resolvers": ["example.com:txt+udp://1.1.1.1:53"] + } + }, + { + "type": "xicmp", + "settings": { "listenIp": "0.0.0.0", "id": 0 } + } + ] +} diff --git a/frontend/src/test/golden/fixtures/sockopt/defaults.json b/frontend/src/test/golden/fixtures/sockopt/defaults.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/frontend/src/test/golden/fixtures/sockopt/defaults.json @@ -0,0 +1 @@ +{} diff --git a/frontend/src/test/golden/fixtures/sockopt/full.json b/frontend/src/test/golden/fixtures/sockopt/full.json new file mode 100644 index 00000000..a1b3c93a --- /dev/null +++ b/frontend/src/test/golden/fixtures/sockopt/full.json @@ -0,0 +1,19 @@ +{ + "acceptProxyProtocol": true, + "tcpFastOpen": true, + "mark": 100, + "tproxy": "redirect", + "tcpMptcp": true, + "penetrate": false, + "domainStrategy": "UseIP", + "tcpMaxSeg": 1440, + "dialerProxy": "out-proxy-tag", + "tcpKeepAliveInterval": 15, + "tcpKeepAliveIdle": 300, + "tcpUserTimeout": 10000, + "tcpcongestion": "cubic", + "V6Only": false, + "tcpWindowClamp": 600, + "interfaceName": "eth0", + "trustedXForwardedFor": ["10.0.0.0/8", "192.168.0.0/16"] +} diff --git a/frontend/src/test/golden/fixtures/sockopt/tcp-tuning.json b/frontend/src/test/golden/fixtures/sockopt/tcp-tuning.json new file mode 100644 index 00000000..45be5270 --- /dev/null +++ b/frontend/src/test/golden/fixtures/sockopt/tcp-tuning.json @@ -0,0 +1,10 @@ +{ + "tcpFastOpen": true, + "tcpcongestion": "bbr", + "tcpKeepAliveInterval": 30, + "tcpKeepAliveIdle": 120, + "tcpUserTimeout": 5000, + "tcpMaxSeg": 1440, + "tcpWindowClamp": 600, + "tcpMptcp": true +} diff --git a/frontend/src/test/golden/fixtures/sockopt/tproxy.json b/frontend/src/test/golden/fixtures/sockopt/tproxy.json new file mode 100644 index 00000000..547e3e6c --- /dev/null +++ b/frontend/src/test/golden/fixtures/sockopt/tproxy.json @@ -0,0 +1,7 @@ +{ + "tproxy": "tproxy", + "mark": 255, + "domainStrategy": "ForceIPv4", + "V6Only": false, + "penetrate": true +} diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-basic.json b/frontend/src/test/golden/fixtures/stream/xhttp-basic.json new file mode 100644 index 00000000..6769dab8 --- /dev/null +++ b/frontend/src/test/golden/fixtures/stream/xhttp-basic.json @@ -0,0 +1,8 @@ +{ + "network": "xhttp", + "xhttpSettings": { + "path": "/sp", + "host": "edge.example.test", + "mode": "auto" + } +} diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json b/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json new file mode 100644 index 00000000..29b1de86 --- /dev/null +++ b/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json @@ -0,0 +1,14 @@ +{ + "network": "xhttp", + "xhttpSettings": { + "path": "/sp", + "host": "edge.example.test", + "mode": "stream-up", + "xPaddingBytes": "500-1500", + "xPaddingObfsMode": true, + "xPaddingKey": "secret-key", + "xPaddingHeader": "X-Pad", + "xPaddingPlacement": "header", + "xPaddingMethod": "random" + } +} diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json b/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json new file mode 100644 index 00000000..26a9181b --- /dev/null +++ b/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json @@ -0,0 +1,14 @@ +{ + "network": "xhttp", + "xhttpSettings": { + "path": "/sp", + "host": "edge.example.test", + "mode": "auto", + "sessionPlacement": "header", + "sessionKey": "X-Session", + "seqPlacement": "cookie", + "seqKey": "X-Seq", + "uplinkDataPlacement": "query", + "uplinkDataKey": "u" + } +} diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json b/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json new file mode 100644 index 00000000..cbaf4a7c --- /dev/null +++ b/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json @@ -0,0 +1,29 @@ +{ + "network": "xhttp", + "xhttpSettings": { + "path": "/sp", + "host": "edge.example.test", + "mode": "packet-up", + "uplinkHTTPMethod": "PUT", + "scMaxEachPostBytes": "2000000", + "scMaxBufferedPosts": 50, + "scStreamUpServerSecs": "30-90", + "scMinPostsIntervalMs": "60", + "noSSEHeader": true, + "serverMaxHeaderBytes": 16384, + "uplinkChunkSize": 8192, + "noGRPCHeader": true, + "headers": { + "X-Real-IP": "1.2.3.4", + "X-Forwarded-For": "10.0.0.1" + }, + "xmux": { + "maxConcurrency": "16-32", + "maxConnections": 4, + "cMaxReuseTimes": 0, + "hMaxRequestTimes": "600-900", + "hMaxReusableSecs": "1800-3000", + "hKeepAlivePeriod": 30 + } + } +} diff --git a/frontend/src/test/sockopt.test.ts b/frontend/src/test/sockopt.test.ts new file mode 100644 index 00000000..db096bf2 --- /dev/null +++ b/frontend/src/test/sockopt.test.ts @@ -0,0 +1,26 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream'; + +const fixtures = import.meta.glob( + './golden/fixtures/sockopt/*.json', + { eager: true, import: 'default' }, +); + +function fixtureName(path: string): string { + const file = path.split('/').pop() ?? path; + return file.replace(/\.json$/, ''); +} + +describe('SockoptStreamSettingsSchema fixtures', () => { + const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b)); + expect(entries.length, 'expected at least one fixture under golden/fixtures/sockopt').toBeGreaterThan(0); + + for (const [path, raw] of entries) { + it(`parses ${fixtureName(path)} byte-stably`, () => { + const parsed = SockoptStreamSettingsSchema.parse(raw); + expect(parsed).toMatchSnapshot(); + }); + } +});