Browse Source

refactor(frontend): extract genVlessLink to lib/xray/inbound-link

Second link generator. genVlessLink builds the
vless://<uuid>@<host>:<port>?<query>#<remark> share URL from a typed
Inbound + client args, dispatching on streamSettings.network for the
network-specific knobs and on streamSettings.security for the
TLS/Reality knobs. Three param-style helpers move alongside the obj-
style ones already in this file:

  - applyXhttpExtraToParams — writes path/host/mode/x_padding_bytes and
    the JSON extra blob into URLSearchParams
  - applyFinalMaskToParams — writes the fm payload when shareable
  - applyExternalProxyTLSParams — overrides sni/fp/alpn when an external
    proxy entry is supplied and security is tls

A vless-tcp-reality fixture lands alongside the existing vless-ws-tls
one, so the parity test now exercises both security branches.

Discovered a latent legacy bug while writing parity: the old class
stored realitySettings.serverNames as a comma-joined string and gated
SNI on `!ObjectUtil.isArrEmpty(serverNames)`, which always returns true
for strings — so SNI was never written into Reality share URLs.
Existing clients rely on the omission (they pull SNI from
realitySettings.target instead). We preserve the omission here to keep
this extraction byte-stable; an inline comment marks the spot for a
separate intentional fix.

Suite: 70 tests across 8 files; typecheck + lint clean.
MHSanaei 22 hours ago
parent
commit
79c076ee11

+ 147 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -1,6 +1,7 @@
 import { Base64 } from '@/utils';
 import { Base64 } from '@/utils';
 
 
 import type { Inbound } from '@/schemas/api/inbound';
 import type { Inbound } from '@/schemas/api/inbound';
+import type { VlessClient } from '@/schemas/protocols/inbound/vless';
 import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
 import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
 import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
 import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
 import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
 import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
@@ -224,3 +225,149 @@ export function genVmessLink(input: GenVmessLinkInput): string {
 
 
   return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
   return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
 }
 }
+
+// Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
+// legacy applyXhttpExtraToParams / applyFinalMaskToParams /
+// applyExternalProxyTLSParams but write to a URLSearchParams instance
+// directly. Number values get coerced via .toString() on set — same as
+// what URLSearchParams does internally so the resulting URL bytes match.
+
+function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
+  if (!xhttp) return;
+  params.set('path', xhttp.path);
+  const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
+  params.set('host', host);
+  params.set('mode', xhttp.mode);
+  if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+    params.set('x_padding_bytes', xhttp.xPaddingBytes);
+  }
+  const extra = buildXhttpExtra(xhttp);
+  if (extra) params.set('extra', JSON.stringify(extra));
+}
+
+function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
+  const payload = serializeFinalMask(finalmask);
+  if (payload.length > 0) params.set('fm', payload);
+}
+
+function applyExternalProxyTLSParams(
+  externalProxy: ExternalProxyEntry | null | undefined,
+  params: URLSearchParams,
+  security: string,
+): void {
+  if (!externalProxy || security !== 'tls') return;
+  const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
+  if (sni && sni.length > 0) params.set('sni', sni);
+  if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
+  const alpn = externalProxyAlpn(externalProxy.alpn);
+  if (alpn.length > 0) params.set('alpn', alpn);
+}
+
+export interface GenVlessLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientId: string;
+  flow?: VlessClient['flow'];
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
+// query carries network type, encryption, network-specific knobs, and
+// security-specific knobs (TLS fingerprint/alpn/sni or Reality
+// pbk/sid/spx). Returns '' if the inbound isn't vless.
+export function genVlessLink(input: GenVlessLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientId,
+    flow = '',
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'vless') return '';
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+
+  const security = forceTls === 'same' ? stream.security : forceTls;
+  const params = new URLSearchParams();
+  params.set('type', stream.network);
+  params.set('encryption', inbound.settings.encryption);
+
+  if (stream.network === 'tcp') {
+    const tcp = stream.tcpSettings;
+    if (tcp.header?.type === 'http') {
+      const request = tcp.header.request;
+      if (request) {
+        params.set('path', request.path.join(','));
+        const host = getHeaderValue(request.headers, 'host');
+        if (host) params.set('host', host);
+        params.set('headerType', 'http');
+      }
+    }
+  } else if (stream.network === 'kcp') {
+    const kcp = stream.kcpSettings;
+    params.set('mtu', String(kcp.mtu));
+    params.set('tti', String(kcp.tti));
+  } else if (stream.network === 'ws') {
+    const ws = stream.wsSettings;
+    params.set('path', ws.path);
+    params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
+  } else if (stream.network === 'grpc') {
+    const grpc = stream.grpcSettings;
+    params.set('serviceName', grpc.serviceName);
+    params.set('authority', grpc.authority);
+    if (grpc.multiMode) params.set('mode', 'multi');
+  } else if (stream.network === 'httpupgrade') {
+    const hu = stream.httpupgradeSettings;
+    params.set('path', hu.path);
+    params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
+  } else if (stream.network === 'xhttp') {
+    applyXhttpExtraToParams(stream.xhttpSettings, params);
+  }
+
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  if (security === 'tls') {
+    params.set('security', 'tls');
+    if (stream.security === 'tls') {
+      const tls = stream.tlsSettings;
+      params.set('fp', tls.settings.fingerprint);
+      params.set('alpn', tls.alpn.join(','));
+      if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+      if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
+      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
+    }
+    applyExternalProxyTLSParams(externalProxy, params, security);
+  } else if (security === 'reality') {
+    params.set('security', 'reality');
+    if (stream.security === 'reality') {
+      const reality = stream.realitySettings;
+      params.set('pbk', reality.settings.publicKey);
+      params.set('fp', reality.settings.fingerprint);
+      // Legacy parity quirk: the old class stored realitySettings.serverNames
+      // as a comma-joined string and gated SNI on `!ObjectUtil.isArrEmpty(s)`
+      // — which returns true for any string, so SNI was never written into
+      // Reality share links. Existing deployed clients rely on receiving
+      // the SNI from realitySettings.target instead; we keep the omission
+      // here so this extraction stays byte-stable with the legacy URL.
+      // Fixing the bug is a separate intentional commit.
+      if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
+      if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
+      if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
+      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
+    }
+  } else {
+    params.set('security', 'none');
+  }
+
+  const url = new URL(`vless://${clientId}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}

+ 83 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -1,5 +1,88 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 
+exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 9,
+  "listen": "",
+  "port": 443,
+  "protocol": "vless",
+  "remark": "dave-vless-tcp-reality",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "flow": "xtls-rprx-vision",
+        "id": "22222222-3333-4444-9555-666666666666",
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "vless-reality-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": [],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "realitySettings": {
+      "maxClientVer": "",
+      "maxTimediff": 0,
+      "minClientVer": "",
+      "mldsa65Seed": "",
+      "privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
+      "serverNames": [
+        "yahoo.com",
+        "www.yahoo.com",
+      ],
+      "settings": {
+        "fingerprint": "chrome",
+        "mldsa65Verify": "",
+        "publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
+        "serverName": "",
+        "spiderX": "/",
+      },
+      "shortIds": [
+        "a3f1",
+        "b8c2",
+      ],
+      "show": false,
+      "target": "yahoo.com:443",
+      "xver": 0,
+    },
+    "security": "reality",
+    "tcpSettings": {
+      "header": {
+        "type": "none",
+      },
+    },
+  },
+  "tag": "inbound-vless-reality",
+  "total": 0,
+  "up": 0,
+}
+`;
+
 exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
 exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
 {
 {
   "down": 0,
   "down": 0,

+ 67 - 0
frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json

@@ -0,0 +1,67 @@
+{
+  "id": 9,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "dave-vless-tcp-reality",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 443,
+  "tag": "inbound-vless-reality",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "vless",
+  "settings": {
+    "clients": [
+      {
+        "id": "22222222-3333-4444-9555-666666666666",
+        "email": "[email protected]",
+        "flow": "xtls-rprx-vision",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "vless-reality-001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": []
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {
+      "header": { "type": "none" }
+    },
+    "security": "reality",
+    "realitySettings": {
+      "show": false,
+      "xver": 0,
+      "target": "yahoo.com:443",
+      "serverNames": ["yahoo.com", "www.yahoo.com"],
+      "privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
+      "minClientVer": "",
+      "maxClientVer": "",
+      "maxTimediff": 0,
+      "shortIds": ["a3f1", "b8c2"],
+      "mldsa65Seed": "",
+      "settings": {
+        "publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
+        "fingerprint": "chrome",
+        "serverName": "",
+        "spiderX": "/",
+        "mldsa65Verify": ""
+      }
+    }
+  }
+}

+ 34 - 1
frontend/src/test/inbound-link.test.ts

@@ -1,7 +1,7 @@
 /// <reference types="vite/client" />
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
-import { genVmessLink } from '@/lib/xray/inbound-link';
+import { genVlessLink, genVmessLink } from '@/lib/xray/inbound-link';
 import { Inbound as LegacyInbound } from '@/models/inbound';
 import { Inbound as LegacyInbound } from '@/models/inbound';
 import { InboundSchema } from '@/schemas/api/inbound';
 import { InboundSchema } from '@/schemas/api/inbound';
 
 
@@ -63,3 +63,36 @@ describe('genVmessLink parity', () => {
     });
     });
   }
   }
 });
 });
+
+describe('genVlessLink parity', () => {
+  const fixtures = fixturesForProtocol('vless');
+  expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
+
+  for (const [name, raw] of fixtures) {
+    it(`${name}: matches legacy Inbound.genVLESSLink`, () => {
+      const typed = InboundSchema.parse(raw);
+      const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } }).settings;
+      const client = settings.clients[0];
+
+      const address = 'example.test';
+      const port = typed.port;
+      const remark = 'parity-test';
+
+      const newLink = genVlessLink({
+        inbound: typed,
+        address,
+        port,
+        forceTls: 'same',
+        remark,
+        clientId: client.id,
+        flow: client.flow as never,
+        externalProxy: null,
+      });
+
+      const legacy = LegacyInbound.fromJson(raw);
+      const legacyLink = legacy.genVLESSLink(address, port, 'same', remark, client.id, client.flow, null);
+
+      expect(newLink).toBe(legacyLink);
+    });
+  }
+});