Răsfoiți Sursa

refactor(frontend): extract genHysteriaLink + Wireguard link/config to lib/xray

Fifth and sixth link generators. genHysteriaLink builds the v1/v2
share URL (scheme picked from settings.version), copying TLS knobs into
the query, surfacing the salamander obfs password from
finalmask.udp[type=salamander] when present, and writing the broader
finalmask payload under `fm` like the other links.

Legacy parity note: the old genHysteriaLink read
stream.tls.settings.allowInsecure, which isn't a field on
TlsStreamSettings.Settings — the guard always evaluated false and the
`insecure` param never made it into the URL. We omit it here to stay
byte-stable.

genWireguardLink and genWireguardConfig take a typed
WireguardInboundSettings + peer index and:

  - link: wireguard://<peerPriv>@host:port?publickey=&address=&mtu=#remark
  - config: the .conf text WireGuard clients consume directly

Both derive the server pubKey from settings.secretKey via
Wireguard.generateKeypair at call time — Zod stores only secretKey on
the wire (pubKey is computed). The Wireguard utility is pure JS (X25519
over Float64Array), so it runs fine under node + the window polyfill we
added with the vmess extraction.

Two new full-inbound fixtures (hysteria-v1-tls, wireguard-server) plus
matching parity tests bring the suite to 78 tests across 8 files;
typecheck + lint clean.

Hysteria2 (protocol literal) parity stays deferred — the legacy
class has no HYSTERIA2 dispatch case, so it can't round-trip a
hysteria2 fixture without a protocol remap. Same trick the shadow
harness uses; revisit in the orchestrator commit.
MHSanaei 23 ore în urmă
părinte
comite
a7ca8c5b10

+ 138 - 1
frontend/src/lib/xray/inbound-link.ts

@@ -1,8 +1,12 @@
-import { Base64 } from '@/utils';
+import { Base64, Wireguard } from '@/utils';
 
 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 {
+  WireguardInboundPeer,
+  WireguardInboundSettings,
+} from '@/schemas/protocols/inbound/wireguard';
 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';
@@ -541,3 +545,136 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
   url.hash = encodeURIComponent(remark);
   return url.toString();
 }
+
+export interface GenHysteriaLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  remark?: string;
+  clientAuth: string;
+}
+
+// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
+// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
+// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
+// password from finalmask.udp[type=salamander] when present; the broader
+// finalmask payload still rides under `fm` like the other links.
+//
+// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
+// which isn't a field on TlsStreamSettings.Settings — the guard is always
+// false. We omit the `insecure` param here to stay byte-stable.
+export function genHysteriaLink(input: GenHysteriaLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    remark = '',
+    clientAuth,
+  } = input;
+
+  if (inbound.protocol !== 'hysteria' && inbound.protocol !== 'hysteria2') return '';
+  const stream = inbound.streamSettings;
+  if (!stream || stream.security !== 'tls') return '';
+
+  const settings = inbound.settings;
+  const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
+
+  const params = new URLSearchParams();
+  params.set('security', 'tls');
+  const tls = stream.tlsSettings;
+  if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
+  if (tls.alpn.length > 0) 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);
+
+  const udpMasks = stream.finalmask?.udp;
+  if (Array.isArray(udpMasks)) {
+    const salamander = udpMasks.find((m) => m?.type === 'salamander');
+    const obfsPassword = salamander?.settings?.password;
+    if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
+      params.set('obfs', 'salamander');
+      params.set('obfs-password', obfsPassword);
+    }
+  }
+
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+export interface GenWireguardLinkInput {
+  settings: WireguardInboundSettings;
+  address: string;
+  port: number;
+  remark?: string;
+  peerIndex: number;
+}
+
+// Wireguard share link: wireguard://<peerPrivKey>@<host>:<port>
+//   ?publickey=<serverPub>&address=<peerAllowedIP>&mtu=<mtu>#<remark>
+// pubKey is derived from the server's secretKey via Wireguard.generateKeypair
+// at call time (Zod's schema stores secretKey only — pubKey isn't on the
+// wire). Returns '' when the peer index is out of bounds.
+export function genWireguardLink(input: GenWireguardLinkInput): string {
+  const { settings, address, port, remark = '', peerIndex } = input;
+  const peer = settings.peers[peerIndex];
+  if (!peer) return '';
+
+  const url = new URL(`wireguard://${address}:${port}`);
+  url.username = peer.privateKey ?? '';
+
+  const pubKey = settings.secretKey.length > 0
+    ? Wireguard.generateKeypair(settings.secretKey).publicKey
+    : '';
+  if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
+  if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
+    url.searchParams.set('address', peer.allowedIPs[0]);
+  }
+  if (typeof settings.mtu === 'number' && settings.mtu > 0) {
+    url.searchParams.set('mtu', String(settings.mtu));
+  }
+
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+// Plain-text WireGuard client config (.conf format). Mirrors the legacy
+// getWireguardTxt — same DNS defaults (1.1.1.1, 1.0.0.1), MTU optional,
+// presharedKey + keepAlive only emitted when present on the peer. The
+// final newline structure follows the legacy: no newline after Endpoint,
+// optional preSharedKey appended with leading \n, keepAlive appended
+// with leading \n AND trailing \n.
+export function genWireguardConfig(input: GenWireguardLinkInput): string {
+  const { settings, address, port, remark = '', peerIndex } = input;
+  const peer = settings.peers[peerIndex];
+  if (!peer) return '';
+
+  const pubKey = settings.secretKey.length > 0
+    ? Wireguard.generateKeypair(settings.secretKey).publicKey
+    : '';
+
+  let txt = `[Interface]\n`;
+  txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
+  txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
+  txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
+  if (typeof settings.mtu === 'number' && settings.mtu > 0) {
+    txt += `MTU = ${settings.mtu}\n`;
+  }
+  txt += `\n# ${remark}\n`;
+  txt += `[Peer]\n`;
+  txt += `PublicKey = ${pubKey}\n`;
+  txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
+  txt += `Endpoint = ${address}:${port}`;
+  if (peer.preSharedKey && peer.preSharedKey.length > 0) {
+    txt += `\nPresharedKey = ${peer.preSharedKey}`;
+  }
+  if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
+    txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
+  }
+  return txt;
+}
+
+export type { WireguardInboundPeer };

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

@@ -1,5 +1,82 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 21,
+  "listen": "",
+  "port": 36715,
+  "protocol": "hysteria",
+  "remark": "gina-hysteria-v1",
+  "settings": {
+    "clients": [
+      {
+        "auth": "hyst-v1-auth-XYZ",
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "hy1-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "version": 1,
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": false,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "security": "tls",
+    "tcpSettings": {},
+    "tlsSettings": {
+      "alpn": [
+        "h3",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/hysteria.crt",
+          "keyFile": "/etc/ssl/private/hysteria.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "hysteria.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+      },
+    },
+  },
+  "tag": "inbound-hysteria-v1",
+  "total": 0,
+  "up": 0,
+}
+`;
+
 exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably 1`] = `
 {
   "down": 0,
@@ -394,3 +471,47 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
   "up": 0,
 }
 `;
+
+exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 25,
+  "listen": "",
+  "port": 51820,
+  "protocol": "wireguard",
+  "remark": "wg-server",
+  "settings": {
+    "mtu": 1420,
+    "noKernelTun": false,
+    "peers": [
+      {
+        "allowedIPs": [
+          "10.0.0.2/32",
+        ],
+        "keepAlive": 25,
+        "privateKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+        "publicKey": "DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=",
+      },
+    ],
+    "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": false,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "tag": "inbound-wg-1",
+  "total": 0,
+  "up": 0,
+}
+`;

+ 67 - 0
frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json

@@ -0,0 +1,67 @@
+{
+  "id": 21,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "gina-hysteria-v1",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 36715,
+  "tag": "inbound-hysteria-v1",
+  "sniffing": {
+    "enabled": false,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "hysteria",
+  "settings": {
+    "version": 1,
+    "clients": [
+      {
+        "auth": "hyst-v1-auth-XYZ",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "hy1-001",
+        "comment": "",
+        "reset": 0
+      }
+    ]
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {},
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "hysteria.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/hysteria.crt",
+          "keyFile": "/etc/ssl/private/hysteria.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h3"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

+ 34 - 0
frontend/src/test/golden/fixtures/inbound-full/wireguard-server.json

@@ -0,0 +1,34 @@
+{
+  "id": 25,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "wg-server",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 51820,
+  "tag": "inbound-wg-1",
+  "sniffing": {
+    "enabled": false,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "wireguard",
+  "settings": {
+    "mtu": 1420,
+    "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+    "peers": [
+      {
+        "privateKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+        "publicKey": "DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=",
+        "allowedIPs": ["10.0.0.2/32"],
+        "keepAlive": 25
+      }
+    ],
+    "noKernelTun": false
+  }
+}

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

@@ -1,9 +1,18 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
-import { genShadowsocksLink, genTrojanLink, genVlessLink, genVmessLink } from '@/lib/xray/inbound-link';
+import {
+  genHysteriaLink,
+  genShadowsocksLink,
+  genTrojanLink,
+  genVlessLink,
+  genVmessLink,
+  genWireguardConfig,
+  genWireguardLink,
+} from '@/lib/xray/inbound-link';
 import { Inbound as LegacyInbound } from '@/models/inbound';
 import { InboundSchema } from '@/schemas/api/inbound';
+import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
 
 // Parity harness for the share-link extraction. For each full inbound
 // fixture matching the protocol under test, we:
@@ -129,6 +138,67 @@ describe('genTrojanLink parity', () => {
   }
 });
 
+describe('genHysteriaLink parity', () => {
+  const fixtures = fixturesForProtocol('hysteria');
+  expect(fixtures.length, 'need at least one hysteria full-inbound fixture').toBeGreaterThan(0);
+
+  for (const [name, raw] of fixtures) {
+    it(`${name}: matches legacy Inbound.genHysteriaLink`, () => {
+      const typed = InboundSchema.parse(raw);
+      const settings = (raw as { settings: { clients: Array<{ auth: string }> } }).settings;
+      const client = settings.clients[0];
+
+      const address = 'example.test';
+      const port = typed.port;
+      const remark = 'parity-test';
+
+      const newLink = genHysteriaLink({
+        inbound: typed,
+        address,
+        port,
+        remark,
+        clientAuth: client.auth,
+      });
+
+      const legacy = LegacyInbound.fromJson(raw);
+      const legacyLink = legacy.genHysteriaLink(address, port, remark, client.auth);
+
+      expect(newLink).toBe(legacyLink);
+    });
+  }
+});
+
+describe('genWireguardLink + genWireguardConfig parity', () => {
+  const fixtures = fixturesForProtocol('wireguard');
+  expect(fixtures.length, 'need at least one wireguard full-inbound fixture').toBeGreaterThan(0);
+
+  for (const [name, raw] of fixtures) {
+    it(`${name}: matches legacy getWireguardLink + getWireguardTxt`, () => {
+      const typed = InboundSchema.parse(raw);
+      if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
+      // InboundSchema is an intersection of two DUs, so TS can't auto-narrow
+      // `settings` from `protocol`. The runtime guard above is the real
+      // check; this cast just helps the type checker.
+      const settings = typed.settings as WireguardInboundSettings;
+
+      const address = 'wg.example.test';
+      const port = typed.port;
+      const remark = 'wg-peer-1';
+      const peerIndex = 0;
+
+      const newLink = genWireguardLink({ settings, address, port, remark, peerIndex });
+      const newConfig = genWireguardConfig({ settings, address, port, remark, peerIndex });
+
+      const legacy = LegacyInbound.fromJson(raw);
+      const legacyLink = legacy.getWireguardLink(address, port, remark, peerIndex);
+      const legacyConfig = legacy.getWireguardTxt(address, port, remark, peerIndex);
+
+      expect(newLink).toBe(legacyLink);
+      expect(newConfig).toBe(legacyConfig);
+    });
+  }
+});
+
 describe('genShadowsocksLink parity', () => {
   const fixtures = fixturesForProtocol('shadowsocks');
   expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);