mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
1e2845306c
commit
a7ca8c5b10
5 changed files with 431 additions and 2 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue