From 2f1a146f454098f8cf6e4335256a3c0822017bbe Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 14:14:53 +0200 Subject: [PATCH] feat(frontend): round-trip XHTTP advanced fields in outbound link parser Pick up xPaddingBytes, scMaxEachPostBytes, scMinPostsIntervalMs, uplinkChunkSize, and noGRPCHeader from both vmess:// JSON and the URL query-param parsers (vless/trojan). The advanced xmux/padding-obfs/ reality-shortId knobs still wait on a follow-up; this slice unblocks the common case where a phone-issued xhttp link carries non-default padding or post sizes. --- frontend/src/lib/xray/outbound-link-parser.ts | 45 ++++++++++++----- .../src/test/outbound-link-parser.test.ts | 50 +++++++++++++++++++ 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 0326774b..62be762b 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -7,12 +7,13 @@ import { Base64 } from '@/utils'; // // Scope: address + port + auth + remark, plus the network/security // fields the common vmess:// / vless:// links carry as query params. -// Advanced transport fields (xmux, padding obfs, hysteria udphop, -// reality short IDs, etc.) are not parsed — the user finishes them -// in the form after import. This is intentional: a focused parser -// keeps the surface small; the legacy Outbound.fromLink was ~250 -// lines of dense edge-case handling we don't need to replicate -// verbatim for the common phone-to-panel workflow. +// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes, +// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when +// present in either the JSON or URL params. xmux, reality shortIds, +// padding obfs key/header/placement, hysteria udphop are still left +// to the user to fill in after import — the legacy Outbound.fromLink +// was ~250 lines of dense edge-case handling we don't need to +// replicate verbatim for the common phone-to-panel workflow. type Raw = Record; @@ -81,11 +82,23 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void { (stream.httpupgradeSettings as Raw).host = host; (stream.httpupgradeSettings as Raw).path = path; break; - case 'xhttp': - (stream.xhttpSettings as Raw).host = host; - (stream.xhttpSettings as Raw).path = path; - if (params.get('mode')) (stream.xhttpSettings as Raw).mode = params.get('mode'); + case 'xhttp': { + const xhttp = stream.xhttpSettings as Raw; + 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'; break; + } case 'tcp': // vless/trojan TCP HTTP camouflage rides on header=http+host+path if (params.get('headerType') === 'http' || params.get('type') === 'http') { @@ -157,9 +170,15 @@ export function parseVmessLink(link: string): Raw | null { (stream.httpupgradeSettings as Raw).host = json.host ?? ''; (stream.httpupgradeSettings as Raw).path = json.path ?? '/'; } else if (network === 'xhttp') { - (stream.xhttpSettings as Raw).host = json.host ?? ''; - (stream.xhttpSettings as Raw).path = json.path ?? '/'; - if (json.mode) (stream.xhttpSettings as Raw).mode = json.mode; + const xhttp = stream.xhttpSettings as Raw; + 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; } 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 24ccfeca..00d26793 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -49,6 +49,56 @@ describe('parseVmessLink', () => { }); }); +describe('parseVmessLink — XHTTP advanced fields', () => { + it('round-trips xhttp knobs from the vmess JSON', () => { + const json = { + v: '2', ps: 'imported-xhttp', add: '1.2.3.4', port: 443, + id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto', + net: 'xhttp', host: 'edge.example', path: '/sp', mode: 'stream-up', + xPaddingBytes: '500-1500', + scMaxEachPostBytes: '2000000', + scMinPostsIntervalMs: '60', + uplinkChunkSize: 8192, + noGRPCHeader: true, + tls: 'tls', sni: 'edge.example', + }; + const link = `vmess://${Base64.encode(JSON.stringify(json))}`; + const out = parseVmessLink(link); + const stream = out?.streamSettings as Record; + const xhttp = stream.xhttpSettings as Record; + expect(xhttp.host).toBe('edge.example'); + expect(xhttp.path).toBe('/sp'); + expect(xhttp.mode).toBe('stream-up'); + expect(xhttp.xPaddingBytes).toBe('500-1500'); + expect(xhttp.scMaxEachPostBytes).toBe('2000000'); + expect(xhttp.scMinPostsIntervalMs).toBe('60'); + expect(xhttp.uplinkChunkSize).toBe(8192); + expect(xhttp.noGRPCHeader).toBe(true); + }); +}); + +describe('parseVlessLink — XHTTP advanced fields', () => { + it('round-trips xhttp knobs from URL query params', () => { + const link + = 'vless://uuid@srv.example:443' + + '?type=xhttp&security=tls&host=edge.example&path=%2Fsp&mode=stream-up' + + '&xPaddingBytes=500-1500&scMaxEachPostBytes=2000000' + + '&scMinPostsIntervalMs=60&uplinkChunkSize=8192&noGRPCHeader=true' + + '#imported-xhttp'; + const out = parseVlessLink(link); + const stream = out?.streamSettings as Record; + const xhttp = stream.xhttpSettings as Record; + expect(xhttp.host).toBe('edge.example'); + expect(xhttp.path).toBe('/sp'); + expect(xhttp.mode).toBe('stream-up'); + expect(xhttp.xPaddingBytes).toBe('500-1500'); + expect(xhttp.scMaxEachPostBytes).toBe('2000000'); + expect(xhttp.scMinPostsIntervalMs).toBe('60'); + expect(xhttp.uplinkChunkSize).toBe(8192); + expect(xhttp.noGRPCHeader).toBe(true); + }); +}); + describe('parseVlessLink', () => { it('parses a vless:// link with reality', () => { const link