Selaa lähdekoodia

refactor(frontend): extract genTrojanLink + genShadowsocksLink to lib/xray

Third and fourth link generators. genTrojanLink mirrors genVlessLink's
shape (URLSearchParams + network/security branches + remark hash) minus
the encryption/flow VLESS-isms. genShadowsocksLink shares the same query
construction but base64-encodes the userinfo portion as method:password
or method:settingsPw:clientPw depending on whether SS-2022 is in
single-user or multi-user mode.

Three reusable helpers move out of the per-protocol functions:
  - writeNetworkParams: the per-network switch that all param-style
    links share (tcp http header / kcp mtu+tti / ws path+host /
    grpc serviceName+authority / httpupgrade / xhttp extras)
  - writeTlsParams: fingerprint/alpn/ech/sni
  - writeRealityParams: pbk/sid/spx/pqv (preserves the SNI-omission
    legacy parity quirk noted in the genVlessLink commit)

genVmessLink stays with its inline switch — it builds a JSON obj instead
of URLSearchParams and has per-network quirks (kcp emits mtu+tti at
the obj root, grpc maps multiMode to obj.type='multi') that don't
factor cleanly through the shared writer.

Two new full-inbound fixtures (trojan-ws-tls, shadowsocks-tcp-2022)
plus matching parity tests bring the suite to 74 tests across 8 files;
typecheck + lint clean.
MHSanaei 23 tuntia sitten
vanhempi
sitoutus
1e2845306c

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

@@ -371,3 +371,173 @@ export function genVlessLink(input: GenVlessLinkInput): string {
   url.hash = encodeURIComponent(remark);
   return url.toString();
 }
+
+// Shared network-branch writer used by trojan + shadowsocks links.
+// VLESS and VMess don't call this because they have minor per-protocol
+// quirks inline (vmess maps `multi` differently into obj.type; vless sets
+// encryption=none up-front).
+function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+  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);
+  }
+}
+
+function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+  if (stream.security !== 'tls') return;
+  const tls = stream.tlsSettings;
+  params.set('fp', tls.settings.fingerprint);
+  params.set('alpn', tls.alpn.join(','));
+  if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
+  if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+}
+
+// Reality query-string writer shared by VLESS and Trojan. Preserves the
+// legacy SNI-omission quirk (see genVlessLink for the full story).
+function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+  if (stream.security !== 'reality') return;
+  const reality = stream.realitySettings;
+  params.set('pbk', reality.settings.publicKey);
+  params.set('fp', reality.settings.fingerprint);
+  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);
+}
+
+export interface GenTrojanLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientPassword: string;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// Trojan share link: trojan://<password>@<host>:<port>?<query>#<remark>.
+// Same query-string shape as VLESS minus the `encryption` and `flow`
+// fields. Returns '' if the inbound isn't trojan.
+export function genTrojanLink(input: GenTrojanLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientPassword,
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'trojan') return '';
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+
+  const security = forceTls === 'same' ? stream.security : forceTls;
+  const params = new URLSearchParams();
+  params.set('type', stream.network);
+
+  writeNetworkParams(stream, params);
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  if (security === 'tls') {
+    params.set('security', 'tls');
+    writeTlsParams(stream, params);
+    applyExternalProxyTLSParams(externalProxy, params, security);
+  } else if (security === 'reality') {
+    params.set('security', 'reality');
+    writeRealityParams(stream, params);
+  } else {
+    params.set('security', 'none');
+  }
+
+  const url = new URL(`trojan://${clientPassword}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+export interface GenShadowsocksLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientPassword?: string;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// Shadowsocks 2022 share link. The userinfo portion is base64(method:pw)
+// for single-user and base64(method:settingsPw:clientPw) for multi-user
+// 2022-blake3. Legacy SS (non-2022) leaves the password out of the
+// userinfo entirely — matches the legacy class's password-array logic.
+// Note: legacy `isSSMultiUser` returns true for everything except
+// 2022-blake3-chacha20-poly1305 (a curious classification, but we
+// preserve it for byte-stable parity).
+export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientPassword = '',
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'shadowsocks') return '';
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+  const settings = inbound.settings;
+
+  const security = forceTls === 'same' ? stream.security : forceTls;
+  const params = new URLSearchParams();
+  params.set('type', stream.network);
+
+  writeNetworkParams(stream, params);
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  if (security === 'tls') {
+    params.set('security', 'tls');
+    writeTlsParams(stream, params);
+    applyExternalProxyTLSParams(externalProxy, params, security);
+  }
+
+  const isSS2022 = settings.method.substring(0, 4) === '2022';
+  const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
+  const passwords: string[] = [];
+  if (isSS2022) passwords.push(settings.password);
+  if (isSSMultiUser) passwords.push(clientPassword);
+
+  const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
+  const url = new URL(`ss://${userinfo}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}

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

@@ -1,5 +1,148 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 17,
+  "listen": "",
+  "port": 8388,
+  "protocol": "shadowsocks",
+  "remark": "frank-ss-tcp-2022",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "method": "",
+        "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==",
+        "reset": 0,
+        "subId": "ss-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "ivCheck": false,
+    "method": "2022-blake3-aes-256-gcm",
+    "network": "tcp,udp",
+    "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "security": "none",
+    "tcpSettings": {
+      "header": {
+        "type": "none",
+      },
+    },
+  },
+  "tag": "inbound-ss-2022",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 13,
+  "listen": "",
+  "port": 443,
+  "protocol": "trojan",
+  "remark": "eve-trojan-ws-tls",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "password": "trojan-test-pw-XYZ",
+        "reset": 0,
+        "subId": "trj-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "fallbacks": [],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "ws",
+    "security": "tls",
+    "tlsSettings": {
+      "alpn": [
+        "h2",
+        "http/1.1",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/trojan.crt",
+          "keyFile": "/etc/ssl/private/trojan.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "trojan.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+      },
+    },
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "headers": {},
+      "heartbeatPeriod": 0,
+      "host": "trojan.example.test",
+      "path": "/trojan",
+    },
+  },
+  "tag": "inbound-trojan-ws",
+  "total": 0,
+  "up": 0,
+}
+`;
+
 exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`] = `
 {
   "down": 0,

+ 49 - 0
frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json

@@ -0,0 +1,49 @@
+{
+  "id": 17,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "frank-ss-tcp-2022",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 8388,
+  "tag": "inbound-ss-2022",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "shadowsocks",
+  "settings": {
+    "method": "2022-blake3-aes-256-gcm",
+    "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
+    "network": "tcp,udp",
+    "clients": [
+      {
+        "method": "",
+        "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "ss-001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "ivCheck": false
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {
+      "header": { "type": "none" }
+    },
+    "security": "none"
+  }
+}

+ 73 - 0
frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json

@@ -0,0 +1,73 @@
+{
+  "id": 13,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "eve-trojan-ws-tls",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 443,
+  "tag": "inbound-trojan-ws",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "trojan",
+  "settings": {
+    "clients": [
+      {
+        "password": "trojan-test-pw-XYZ",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "trj-001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "fallbacks": []
+  },
+  "streamSettings": {
+    "network": "ws",
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "path": "/trojan",
+      "host": "trojan.example.test",
+      "headers": {},
+      "heartbeatPeriod": 0
+    },
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "trojan.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/trojan.crt",
+          "keyFile": "/etc/ssl/private/trojan.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h2", "http/1.1"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

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

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