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.
This commit is contained in:
MHSanaei
2026-05-26 14:27:43 +02:00
parent 2f1a146f45
commit 34590dc327
2 changed files with 103 additions and 15 deletions

View File

@@ -17,6 +17,55 @@ import { Base64 } from '@/utils';
type Raw = Record<string, unknown>;
// 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<string, unknown>): 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;

View File

@@ -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<string, unknown>).xhttpSettings as Record<string, unknown>;
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<string, unknown>).xhttpSettings as Record<string, unknown>;
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', () => {