Browse Source

fix(frontend): outbound link parser handles extra/fm/x_padding_bytes (B20)

User-reported vless share link with full xhttp + reality + finalmask
config failed to round-trip on outbound import. The inbound link
generator emits three payloads the outbound parser was ignoring:

1. `extra=<json>` — bundles advanced xhttp knobs (xPaddingBytes,
   scMaxEachPostBytes, scMinPostsIntervalMs, padding-obfs keys,
   etc.). applyXhttpStringFromParams now JSON.parses this and
   merges the fields into xhttpSettings via the same JSON-branch
   logic used by vmess.

2. `x_padding_bytes=<range>` — snake_case alias the inbound emits
   alongside the camelCase form. Now applied before camelCase so
   explicit `xPaddingBytes` URL params still win.

3. `fm=<json>` — full finalmask object including quicParams.udpHop
   and tcp/udp mask arrays. New applyFinalMaskParam attaches the
   decoded object to streamSettings.finalmask. Wired into both
   parseVlessLink and parseTrojanLink.

Tests:
- Real B20 link parses with xhttp + reality + finalmask all populated
- Precedence: camelCase URL > extra JSON > snake_case alias > default
- Malformed extra JSON falls through without crashing the parser

300/300 pass.
MHSanaei 1 day ago
parent
commit
f910bfbcda

+ 42 - 0
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=<json>`.
+  // 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<string, unknown>;
+      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<string, unknown>;
+    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),

+ 67 - 0
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<string, unknown>;
+    expect(stream.network).toBe('xhttp');
+    expect(stream.security).toBe('reality');
+
+    const xhttp = stream.xhttpSettings as Record<string, unknown>;
+    expect(xhttp.xPaddingBytes).toBe('100-1000');
+    expect(xhttp.scMaxEachPostBytes).toBe('1000000');
+    expect(xhttp.scMinPostsIntervalMs).toBe('30');
+
+    const reality = stream.realitySettings as Record<string, unknown>;
+    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<string, unknown>;
+    expect(finalmask).toBeDefined();
+    const quicParams = finalmask.quicParams as Record<string, unknown>;
+    expect(quicParams.congestion).toBe('bbr');
+    expect(quicParams.maxIdleTimeout).toBe(30);
+    expect((quicParams.udpHop as Record<string, unknown>).interval).toBe('5-10');
+    expect((quicParams.udpHop as Record<string, unknown>).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<string, unknown>).xhttpSettings as Record<string, unknown>;
+    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<string, unknown>).xhttpSettings as Record<string, unknown>;
+    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<string, unknown>;
+    expect((stream.xhttpSettings as Record<string, unknown>).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' };