diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 8785d4cd..bd1ed0ed 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -40,6 +40,30 @@ function asBool(s: string | null): boolean | undefined { } function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void { + // Precedence from lowest to highest: stream-init default → + // x_padding_bytes snake_case alias → extra JSON payload → + // explicit camelCase URL param. Apply in that order so each tier + // overwrites the previous when present. + const padBytesAlt = params.get('x_padding_bytes'); + if (padBytesAlt !== null && padBytesAlt !== '') { + xhttp.xPaddingBytes = padBytesAlt; + } + // The inbound link bundles advanced xhttp knobs into `extra=`. + // Decode and merge so re-importing a share link round-trips the full + // xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.). + const extra = params.get('extra'); + if (extra) { + try { + const parsed = JSON.parse(extra) as Record; + applyXhttpStringFromJson(xhttp, parsed); + if (parsed.headers && typeof parsed.headers === 'object') { + xhttp.headers = parsed.headers; + } + } catch { + // malformed extra — silently ignore, the panel can still operate + // on the rest of the link + } + } for (const k of XHTTP_STRING_KEYS) { const v = params.get(k); if (v !== null && v !== '') xhttp[k] = v; @@ -156,6 +180,22 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void { } } +// The inbound link emits the entire finalmask object as a JSON-encoded +// `fm` query param. Decode and attach to streamSettings so udpHop / +// quicParams / tcp+udp masks round-trip on outbound import. +function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { + const fm = params.get('fm'); + if (!fm) return; + try { + const parsed = JSON.parse(fm) as Record; + if (parsed && typeof parsed === 'object') { + stream.finalmask = parsed; + } + } catch { + // malformed fm — leave streamSettings.finalmask absent + } +} + function applySecurityParams(stream: Raw, params: URLSearchParams): void { if (stream.security === 'tls') { const tls = stream.tlsSettings as Raw; @@ -263,6 +303,7 @@ export function parseVlessLink(link: string): Raw | null { const stream = buildStream(network, security); applyTransportParams(stream, params); applySecurityParams(stream, params); + applyFinalMaskParam(stream, params); return { protocol: 'vless', tag: decodeRemark(url), @@ -289,6 +330,7 @@ export function parseTrojanLink(link: string): Raw | null { const stream = buildStream(network, security); applyTransportParams(stream, params); applySecurityParams(stream, params); + applyFinalMaskParam(stream, params); return { protocol: 'trojan', tag: decodeRemark(url), diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index b6647ca6..1b5b5578 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -239,6 +239,73 @@ describe('parseHysteria2Link', () => { }); }); +describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { + it('round-trips a real inbound-generated link with extra+fm+reality+xhttp', () => { + // Real user-reported link — bundled xhttp knobs via `extra` JSON, + // full finalmask via `fm` JSON, reality auth, snake_case + // x_padding_bytes alias. All three parse-paths must combine. + const link = 'vless://b622ac2f-f155-47db-a3b2-b64e8d7f6342@localhost:37723?' + + 'encryption=none&' + + 'extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMinPostsIntervalMs%22%3A%2230%22%2C%22xPaddingBytes%22%3A%22100-1000%22%7D&' + + 'fm=%7B%22quicParams%22%3A%7B%22congestion%22%3A%22bbr%22%2C%22maxIdleTimeout%22%3A30%2C%22udpHop%22%3A%7B%22interval%22%3A%225-10%22%2C%22ports%22%3A%2220000-50000%22%7D%7D%7D&' + + 'fp=chrome&host=&mode=auto&path=%2F&' + + 'pbk=nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o&' + + 'security=reality&sid=14ebccc4d3&sni=aws.amazon.com&' + + 'spx=%2F97L2FjycXEwrE67&type=xhttp&x_padding_bytes=100-1000' + + '#sda-8ud3us6rt'; + const parsed = parseVlessLink(link); + expect(parsed).not.toBeNull(); + expect(parsed!.tag).toBe('sda-8ud3us6rt'); + + const stream = parsed!.streamSettings as Record; + expect(stream.network).toBe('xhttp'); + expect(stream.security).toBe('reality'); + + const xhttp = stream.xhttpSettings as Record; + expect(xhttp.xPaddingBytes).toBe('100-1000'); + expect(xhttp.scMaxEachPostBytes).toBe('1000000'); + expect(xhttp.scMinPostsIntervalMs).toBe('30'); + + const reality = stream.realitySettings as Record; + expect(reality.publicKey).toBe('nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o'); + expect(reality.shortId).toBe('14ebccc4d3'); + expect(reality.spiderX).toBe('/97L2FjycXEwrE67'); + expect(reality.serverName).toBe('aws.amazon.com'); + + const finalmask = stream.finalmask as Record; + expect(finalmask).toBeDefined(); + const quicParams = finalmask.quicParams as Record; + expect(quicParams.congestion).toBe('bbr'); + expect(quicParams.maxIdleTimeout).toBe(30); + expect((quicParams.udpHop as Record).interval).toBe('5-10'); + expect((quicParams.udpHop as Record).ports).toBe('20000-50000'); + }); + + it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => { + const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto&x_padding_bytes=200-2000#t'; + const parsed = parseVlessLink(link); + const xhttp = (parsed!.streamSettings as Record).xhttpSettings as Record; + expect(xhttp.xPaddingBytes).toBe('200-2000'); + }); + + it('extra takes precedence — camelCase wins over snake_case alias', () => { + const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' + + '&xPaddingBytes=900-9000&x_padding_bytes=100-1000#t'; + const parsed = parseVlessLink(link); + const xhttp = (parsed!.streamSettings as Record).xhttpSettings as Record; + expect(xhttp.xPaddingBytes).toBe('900-9000'); + }); + + it('ignores malformed extra JSON without breaking the rest of the link', () => { + const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' + + '&extra=not-json&fp=chrome#t'; + const parsed = parseVlessLink(link); + expect(parsed).not.toBeNull(); + const stream = parsed!.streamSettings as Record; + expect((stream.xhttpSettings as Record).mode).toBe('auto'); + }); +}); + describe('parseOutboundLink dispatcher', () => { it('dispatches vmess via base64 JSON', () => { const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' };