From 34590dc327e855886e2735e84e3c755c7444ec59 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 14:27:43 +0200 Subject: [PATCH] feat(frontend): round-trip XHTTP padding-obfs + remaining advanced knobs Extract the XHTTP key-mapping into typed string/number/bool key arrays applied by both the URL query-param branch and the vmess JSON branch. The parser now covers xPaddingObfsMode + xPaddingKey/Header/Placement/ Method, sessionKey/seqKey/uplinkData{Placement,Key}, noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes, and uplinkHTTPMethod alongside the previous five XHTTP fields. Two new round-trip tests cover the padding-obfs surface on both link forms. --- frontend/src/lib/xray/outbound-link-parser.ts | 66 ++++++++++++++----- .../src/test/outbound-link-parser.test.ts | 52 +++++++++++++++ 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 62be762b..fff79025 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -17,6 +17,55 @@ import { Base64 } from '@/utils'; type Raw = Record; +// XHTTP knob keys grouped by wire type. Used by both the URL query-param +// (vless/trojan) branch and the vmess JSON branch to consistently pull +// the same set of advanced fields when present. Keep order ~stable to +// match the schema's authoring order so diffs read naturally. +const XHTTP_STRING_KEYS = [ + 'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', + 'xPaddingMethod', 'sessionPlacement', 'sessionKey', 'seqPlacement', + 'seqKey', 'uplinkDataPlacement', 'uplinkDataKey', 'scMaxEachPostBytes', + 'scMinPostsIntervalMs', 'scStreamUpServerSecs', 'uplinkHTTPMethod', +] as const; +const XHTTP_NUMBER_KEYS = [ + 'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize', +] as const; +const XHTTP_BOOL_KEYS = [ + 'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader', +] as const; + +function asBool(s: string | null): boolean | undefined { + if (s === null) return undefined; + return s === 'true' || s === '1'; +} + +function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void { + for (const k of XHTTP_STRING_KEYS) { + const v = params.get(k); + if (v !== null && v !== '') xhttp[k] = v; + } + for (const k of XHTTP_NUMBER_KEYS) { + const v = params.get(k); + if (v !== null && v !== '') xhttp[k] = Number(v) || 0; + } + for (const k of XHTTP_BOOL_KEYS) { + const v = params.get(k); + if (v !== null && v !== '') xhttp[k] = asBool(v); + } +} + +function applyXhttpStringFromJson(xhttp: Raw, json: Record): void { + for (const k of XHTTP_STRING_KEYS) { + if (typeof json[k] === 'string') xhttp[k] = json[k]; + } + for (const k of XHTTP_NUMBER_KEYS) { + if (typeof json[k] === 'number') xhttp[k] = json[k]; + } + for (const k of XHTTP_BOOL_KEYS) { + if (typeof json[k] === 'boolean') xhttp[k] = json[k]; + } +} + function buildStream(network: string, security: string): Raw { const stream: Raw = { network, security }; switch (network) { @@ -87,16 +136,7 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void { xhttp.host = host; xhttp.path = path; if (params.get('mode')) xhttp.mode = params.get('mode'); - const xPad = params.get('xPaddingBytes'); - if (xPad) xhttp.xPaddingBytes = xPad; - const scMax = params.get('scMaxEachPostBytes'); - if (scMax) xhttp.scMaxEachPostBytes = scMax; - const scMin = params.get('scMinPostsIntervalMs'); - if (scMin) xhttp.scMinPostsIntervalMs = scMin; - const upChunk = params.get('uplinkChunkSize'); - if (upChunk) xhttp.uplinkChunkSize = Number(upChunk) || 0; - const noGrpc = params.get('noGRPCHeader'); - if (noGrpc) xhttp.noGRPCHeader = noGrpc === 'true' || noGrpc === '1'; + applyXhttpStringFromParams(xhttp, params); break; } case 'tcp': @@ -174,11 +214,7 @@ export function parseVmessLink(link: string): Raw | null { xhttp.host = json.host ?? ''; xhttp.path = json.path ?? '/'; if (json.mode) xhttp.mode = json.mode; - if (typeof json.xPaddingBytes === 'string') xhttp.xPaddingBytes = json.xPaddingBytes; - if (typeof json.scMaxEachPostBytes === 'string') xhttp.scMaxEachPostBytes = json.scMaxEachPostBytes; - if (typeof json.scMinPostsIntervalMs === 'string') xhttp.scMinPostsIntervalMs = json.scMinPostsIntervalMs; - if (typeof json.uplinkChunkSize === 'number') xhttp.uplinkChunkSize = json.uplinkChunkSize; - if (typeof json.noGRPCHeader === 'boolean') xhttp.noGRPCHeader = json.noGRPCHeader; + applyXhttpStringFromJson(xhttp, json); } if (security === 'tls') { const tls = stream.tlsSettings as Raw; diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 00d26793..b6647ca6 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -75,6 +75,36 @@ describe('parseVmessLink — XHTTP advanced fields', () => { expect(xhttp.uplinkChunkSize).toBe(8192); expect(xhttp.noGRPCHeader).toBe(true); }); + + it('round-trips xhttp padding-obfs knobs from the vmess JSON', () => { + const json = { + v: '2', ps: 'imported-pad', add: '1.2.3.4', port: 443, + id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto', + net: 'xhttp', host: 'edge.example', path: '/sp', + xPaddingObfsMode: true, + xPaddingKey: 'secret-key', + xPaddingHeader: 'X-Pad', + xPaddingPlacement: 'header', + xPaddingMethod: 'random', + sessionKey: 'X-Session', + seqKey: 'X-Seq', + noSSEHeader: true, + scMaxBufferedPosts: 50, + tls: 'tls', + }; + const link = `vmess://${Base64.encode(JSON.stringify(json))}`; + const out = parseVmessLink(link); + const xhttp = (out?.streamSettings as Record).xhttpSettings as Record; + expect(xhttp.xPaddingObfsMode).toBe(true); + expect(xhttp.xPaddingKey).toBe('secret-key'); + expect(xhttp.xPaddingHeader).toBe('X-Pad'); + expect(xhttp.xPaddingPlacement).toBe('header'); + expect(xhttp.xPaddingMethod).toBe('random'); + expect(xhttp.sessionKey).toBe('X-Session'); + expect(xhttp.seqKey).toBe('X-Seq'); + expect(xhttp.noSSEHeader).toBe(true); + expect(xhttp.scMaxBufferedPosts).toBe(50); + }); }); describe('parseVlessLink — XHTTP advanced fields', () => { @@ -97,6 +127,28 @@ describe('parseVlessLink — XHTTP advanced fields', () => { expect(xhttp.uplinkChunkSize).toBe(8192); expect(xhttp.noGRPCHeader).toBe(true); }); + + it('round-trips xhttp padding-obfs knobs from URL query params', () => { + const link + = 'vless://uuid@srv.example:443' + + '?type=xhttp&security=tls&host=edge.example&path=%2Fsp' + + '&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad' + + '&xPaddingPlacement=header&xPaddingMethod=random' + + '&sessionKey=X-Session&seqKey=X-Seq&noSSEHeader=true' + + '&scMaxBufferedPosts=50' + + '#imported-pad'; + const out = parseVlessLink(link); + const xhttp = (out?.streamSettings as Record).xhttpSettings as Record; + expect(xhttp.xPaddingObfsMode).toBe(true); + expect(xhttp.xPaddingKey).toBe('secret-key'); + expect(xhttp.xPaddingHeader).toBe('X-Pad'); + expect(xhttp.xPaddingPlacement).toBe('header'); + expect(xhttp.xPaddingMethod).toBe('random'); + expect(xhttp.sessionKey).toBe('X-Session'); + expect(xhttp.seqKey).toBe('X-Seq'); + expect(xhttp.noSSEHeader).toBe(true); + expect(xhttp.scMaxBufferedPosts).toBe(50); + }); }); describe('parseVlessLink', () => {