瀏覽代碼

refactor(frontend): extract genVmessLink to lib/xray/inbound-link.ts

First link generator to leave the class hierarchy. genVmessLink takes a
typed Inbound + client args and returns the base64-encoded vmess://
URL. Internal helpers (buildXhttpExtra, applyXhttpExtraToObj,
applyFinalMaskToObj, applyExternalProxyTLSObj, serializeFinalMask,
hasShareableFinalMaskValue, externalProxyAlpn) port across from
XrayCommonClass — same logic, rewritten to read the Zod schemas'
Record<string, string> headers instead of the legacy HeaderEntry[].

Parity test (inbound-link.test.ts) loads each vmess fixture in
golden/fixtures/inbound-full, parses it with InboundSchema for the new
pure fn AND constructs LegacyInbound.fromJson(raw) for the class method,
then asserts the URLs match byte-for-byte. Drift between the two impls
fails here before the call sites in pages/inbounds/* get swapped.

Adds a small test setup file that aliases globalThis.window to globalThis
so Base64.encode's window.btoa works under Node — keeps the test env at
'node' and avoids pulling jsdom as a new dep.

A first vmess-tcp-tls full-inbound fixture pins the round-trip path.

Suite: 67 tests across 8 files; typecheck + lint clean. Five more link
generators (vless/trojan/ss/hysteria/wireguard) plus the orchestrator
(toShareLink, genAllLinks) follow in subsequent turns.
MHSanaei 23 小時之前
父節點
當前提交
24c5c80bc3

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

@@ -0,0 +1,226 @@
+import { Base64 } from '@/utils';
+
+import type { Inbound } from '@/schemas/api/inbound';
+import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
+import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
+import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
+import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
+
+import { getHeaderValue } from './headers';
+
+// Share-link generators. Each per-protocol fn takes a typed inbound plus
+// client overrides and returns a URL (or '' when the protocol doesn't
+// support shareable links). The helpers below were previously static
+// methods on the Inbound class; extracting them removes the
+// XrayCommonClass dependency and lets these run against Zod-parsed data
+// directly.
+
+type ForceTls = 'same' | 'tls' | 'none';
+
+// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
+// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
+function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
+  return getHeaderValue(xhttp?.headers, 'host');
+}
+
+// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
+// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
+// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
+// reading the share link wouldn't honor them. Mirrors the legacy
+// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
+function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
+  if (!xhttp) return null;
+  const extra: Record<string, unknown> = {};
+
+  if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+    extra.xPaddingBytes = xhttp.xPaddingBytes;
+  }
+  if (xhttp.xPaddingObfsMode === true) {
+    extra.xPaddingObfsMode = true;
+    for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
+      const v = xhttp[k];
+      if (typeof v === 'string' && v.length > 0) extra[k] = v;
+    }
+  }
+
+  const stringFields = [
+    'uplinkHTTPMethod',
+    'sessionPlacement',
+    'sessionKey',
+    'seqPlacement',
+    'seqKey',
+    'uplinkDataPlacement',
+    'uplinkDataKey',
+    'scMaxEachPostBytes',
+  ] as const;
+  for (const k of stringFields) {
+    const v = xhttp[k];
+    if (typeof v === 'string' && v.length > 0) extra[k] = v;
+  }
+
+  // Headers on the wire are a record; emit them as a map upstream's
+  // SplitHTTPConfig.headers expects, dropping Host (already on the URL).
+  if (xhttp.headers && Object.keys(xhttp.headers).length > 0) {
+    const headersMap: Record<string, string> = {};
+    for (const [name, value] of Object.entries(xhttp.headers)) {
+      if (name.toLowerCase() === 'host') continue;
+      headersMap[name] = value;
+    }
+    if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
+  }
+
+  return Object.keys(extra).length > 0 ? extra : null;
+}
+
+function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
+  if (!xhttp) return;
+  if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+    obj.x_padding_bytes = xhttp.xPaddingBytes;
+  }
+  const extra = buildXhttpExtra(xhttp);
+  if (!extra) return;
+  for (const [k, v] of Object.entries(extra)) obj[k] = v;
+}
+
+// Recursively checks whether a finalmask payload has any non-empty
+// content. Empty arrays / empty objects / empty strings all return false;
+// any truthy primitive returns true. Used to decide whether the link
+// should carry an `fm` blob at all.
+function hasShareableFinalMaskValue(value: unknown): boolean {
+  if (value == null) return false;
+  if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue);
+  if (typeof value === 'object') {
+    return Object.values(value as Record<string, unknown>).some(hasShareableFinalMaskValue);
+  }
+  if (typeof value === 'string') return value.length > 0;
+  return true;
+}
+
+function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
+  if (!finalmask) return '';
+  return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : '';
+}
+
+function applyFinalMaskToObj(
+  finalmask: FinalMaskStreamSettings | undefined,
+  obj: Record<string, unknown>,
+): void {
+  const payload = serializeFinalMask(finalmask);
+  if (payload.length > 0) obj.fm = payload;
+}
+
+function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
+  if (Array.isArray(value)) return value.filter(Boolean).join(',');
+  return '';
+}
+
+function applyExternalProxyTLSObj(
+  externalProxy: ExternalProxyEntry | null | undefined,
+  obj: Record<string, unknown>,
+  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) obj.sni = sni;
+  if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
+  const alpn = externalProxyAlpn(externalProxy.alpn);
+  if (alpn.length > 0) obj.alpn = alpn;
+}
+
+export interface GenVmessLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientId: string;
+  security?: VmessSecurity;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// VMess share link: `vmess://` followed by base64-encoded JSON. The JSON
+// schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound
+// is not vmess so dispatcher code can fall through cleanly.
+export function genVmessLink(input: GenVmessLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientId,
+    security,
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'vmess') return '';
+
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+
+  const tls = forceTls === 'same' ? stream.security : forceTls;
+  const obj: Record<string, unknown> = {
+    v: '2',
+    ps: remark,
+    add: address,
+    port,
+    id: clientId,
+    scy: security,
+    net: stream.network,
+    tls,
+  };
+
+  if (stream.network === 'tcp') {
+    const tcp = stream.tcpSettings;
+    const header = tcp.header;
+    if (header) {
+      obj.type = header.type;
+      if (header.type === 'http') {
+        const request = header.request;
+        if (request) {
+          obj.path = request.path.join(',');
+          const host = getHeaderValue(request.headers, 'host');
+          if (host) obj.host = host;
+        }
+      }
+    } else {
+      obj.type = 'none';
+    }
+  } else if (stream.network === 'kcp') {
+    const kcp = stream.kcpSettings;
+    obj.mtu = kcp.mtu;
+    obj.tti = kcp.tti;
+  } else if (stream.network === 'ws') {
+    const ws = stream.wsSettings;
+    obj.path = ws.path;
+    obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host');
+  } else if (stream.network === 'grpc') {
+    const grpc = stream.grpcSettings;
+    obj.path = grpc.serviceName;
+    obj.authority = grpc.authority;
+    if (grpc.multiMode) obj.type = 'multi';
+  } else if (stream.network === 'httpupgrade') {
+    const hu = stream.httpupgradeSettings;
+    obj.path = hu.path;
+    obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host');
+  } else if (stream.network === 'xhttp') {
+    const xhttp = stream.xhttpSettings;
+    obj.path = xhttp.path;
+    obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
+    obj.type = xhttp.mode;
+    applyXhttpExtraToObj(xhttp, obj);
+  }
+
+  applyFinalMaskToObj(stream.finalmask, obj);
+
+  if (tls === 'tls' && stream.security === 'tls') {
+    const tlsSettings = stream.tlsSettings;
+    if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
+    if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
+    if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
+  }
+
+  applyExternalProxyTLSObj(externalProxy, obj, tls);
+
+  return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
+}

+ 69 - 0
frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json

@@ -0,0 +1,69 @@
+{
+  "id": 7,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "carol-vmess-tcp-tls",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 8443,
+  "tag": "inbound-vmess-1",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "vmess",
+  "settings": {
+    "clients": [
+      {
+        "id": "11111111-2222-4333-8444-555555555555",
+        "security": "auto",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "vmess-001",
+        "comment": "",
+        "reset": 0
+      }
+    ]
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {
+      "header": { "type": "none" }
+    },
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "vmess.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/vmess.crt",
+          "keyFile": "/etc/ssl/private/vmess.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h2", "http/1.1"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

+ 65 - 0
frontend/src/test/inbound-link.test.ts

@@ -0,0 +1,65 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { genVmessLink } from '@/lib/xray/inbound-link';
+import { Inbound as LegacyInbound } from '@/models/inbound';
+import { InboundSchema } from '@/schemas/api/inbound';
+
+// Parity harness for the share-link extraction. For each full inbound
+// fixture matching the protocol under test, we:
+//   1. Parse with the Zod InboundSchema -> typed input for the new pure fn
+//   2. Construct the legacy Inbound class via Inbound.fromJson(fixture)
+//   3. Call both link generators with matching args
+//   4. Assert the URLs match byte-for-byte
+// Drift between the new pure fn and the legacy class method fails the
+// test here, before the call sites in pages/ get swapped.
+
+const fullFixtures = import.meta.glob<unknown>(
+  './golden/fixtures/inbound-full/*.json',
+  { eager: true, import: 'default' },
+);
+
+function fixtureName(path: string): string {
+  const file = path.split('/').pop() ?? path;
+  return file.replace(/\.json$/, '');
+}
+
+function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
+  return Object.entries(fullFixtures)
+    .filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
+    .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
+    .sort(([a], [b]) => a.localeCompare(b));
+}
+
+describe('genVmessLink parity', () => {
+  const fixtures = fixturesForProtocol('vmess');
+  expect(fixtures.length, 'need at least one vmess full-inbound fixture').toBeGreaterThan(0);
+
+  for (const [name, raw] of fixtures) {
+    it(`${name}: matches legacy Inbound.genVmessLink`, () => {
+      const typed = InboundSchema.parse(raw);
+      const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } }).settings;
+      const client = settings.clients[0];
+
+      const address = 'example.test';
+      const port = typed.port;
+      const remark = 'parity-test';
+
+      const newLink = genVmessLink({
+        inbound: typed,
+        address,
+        port,
+        forceTls: 'same',
+        remark,
+        clientId: client.id,
+        security: client.security as never,
+        externalProxy: null,
+      });
+
+      const legacy = LegacyInbound.fromJson(raw);
+      const legacyLink = legacy.genVmessLink(address, port, 'same', remark, client.id, client.security, null);
+
+      expect(newLink).toBe(legacyLink);
+    });
+  }
+});

+ 8 - 0
frontend/src/test/setup.ts

@@ -0,0 +1,8 @@
+// Vitest setup. The frontend's Base64 utility (used by link generators)
+// reaches for `window.btoa` directly. Node 16+ ships btoa/atob on
+// globalThis, so we just alias `window` to `globalThis` instead of
+// pulling in jsdom — keeps the test env light and avoids a new dep.
+
+if (typeof globalThis.window === 'undefined') {
+  (globalThis as unknown as { window: typeof globalThis }).window = globalThis;
+}

+ 1 - 0
frontend/vitest.config.ts

@@ -12,5 +12,6 @@ export default defineConfig({
     include: ['src/test/**/*.test.ts'],
     environment: 'node',
     globals: false,
+    setupFiles: ['./src/test/setup.ts'],
   },
 });