diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index d7e8c1e3..06220159 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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://@:?#. +// 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://@: +// ?publickey=&address=&mtu=# +// 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 }; diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index d27a7293..274faf5d 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/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": "gina@example.test", + "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, +} +`; diff --git a/frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json b/frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json new file mode 100644 index 00000000..3b00b966 --- /dev/null +++ b/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": "gina@example.test", + "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": "" + } + } + } +} diff --git a/frontend/src/test/golden/fixtures/inbound-full/wireguard-server.json b/frontend/src/test/golden/fixtures/inbound-full/wireguard-server.json new file mode 100644 index 00000000..04bb977c --- /dev/null +++ b/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 + } +} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 2f3169d9..63143c5c 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -1,9 +1,18 @@ /// 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);