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.
This commit is contained in:
MHSanaei 2026-05-26 00:27:11 +02:00
parent 1e2845306c
commit a7ca8c5b10
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 431 additions and 2 deletions

View file

@ -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 };

View file

@ -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,
}
`;

View file

@ -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": ""
}
}
}
}

View file

@ -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
}
}

View file

@ -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);