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.
This commit is contained in:
MHSanaei
2026-05-26 14:14:53 +02:00
parent 9f84859ff6
commit 2f1a146f45
2 changed files with 82 additions and 13 deletions

View File

@@ -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<string, unknown>;
@@ -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;

View File

@@ -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<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
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<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
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