From 5d07185438d86d5954e2c89921528642a50dc055 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 00:31:25 +0200 Subject: [PATCH] refactor(frontend): extract share-link orchestrator to lib/xray/inbound-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last slice of Step 3d. Five orchestrator exports compose the per- protocol generators into the public surface the panel consumes: - resolveAddr(inbound, hostOverride, fallbackHostname): picks the address that goes into share/sub URLs. Browser `location.hostname` is no longer a hidden dependency — callers pass it in (or any other fallback they want). - getInboundClients(inbound): protocol-aware clients accessor. Mirrors the legacy `Inbound.clients` getter, including the SS quirk where 2022-blake3-chacha20 single-user inbounds report null (no client loop) and everything else returns the clients array. - genLink: per-protocol dispatcher matching legacy Inbound.genLink. - genAllLinks: per-client fanout. Builds the remarkModel-formatted remark (separator + 'i'/'e'/'o' field picker) and iterates streamSettings.externalProxy when present. - genInboundLinks: top-level \r\n-joined link block. Loops per client for clientful protocols, single-shots SS for non-multi-user, and delegates to genWireguardConfigs for wireguard. Returns '' for http/mixed/tunnel (no share URL at all). Plus genWireguardLinks / genWireguardConfigs fanouts which iterate peers and append index-suffixed remarks. Parity test exercises every full-inbound fixture against legacy Inbound.genInboundLinks. Skips hysteria2 (no legacy dispatch case; that bridge belongs in a separate intentional commit alongside the form modal swap). Suite: 89 tests across 8 files; typecheck + lint clean. Next: Step 4 form modal migrations. Forms can now drop `new Inbound.Settings.getSettings(protocol)` in favor of the createDefault*InboundSettings factories, and InboundsPage clone can swap to genInboundLinks. Models/ deletion follows in Step 5 once all call sites are off the class. --- frontend/src/lib/xray/inbound-link.ts | 244 +++++++++++++++++++++++++ frontend/src/test/inbound-link.test.ts | 79 ++++++++ 2 files changed, 323 insertions(+) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 06220159..4a1560b9 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -678,3 +678,247 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string { } export type { WireguardInboundPeer }; + +// Orchestrators. +// resolveAddr picks the host that goes into share/sub links. Order: +// 1. hostOverride (caller supplies node address for node-managed inbounds) +// 2. inbound's bind listen (when explicit, not 0.0.0.0) +// 3. fallbackHostname (caller-supplied — typically window.location.hostname +// in the browser; tests pass a fixed value) +export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string { + if (hostOverride.length > 0) return hostOverride; + if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen; + return fallbackHostname; +} + +// Returns the client array for protocols that have one. SS returns its +// clients only in 2022-blake3 multi-user mode (matches the legacy +// `this.clients` getter, which used isSSMultiUser to gate). Returns null +// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without- +// clients, and any protocol without a clients array. +type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string }; + +export function getInboundClients(inbound: Inbound): ClientShape[] | null { + switch (inbound.protocol) { + case 'vmess': + return (inbound.settings.clients ?? []) as ClientShape[]; + case 'vless': + return (inbound.settings.clients ?? []) as ClientShape[]; + case 'trojan': + return (inbound.settings.clients ?? []) as ClientShape[]; + case 'hysteria': + case 'hysteria2': + return (inbound.settings.clients ?? []) as ClientShape[]; + case 'shadowsocks': { + const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305'; + return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null; + } + default: + return null; + } +} + +export interface GenLinkInput { + inbound: Inbound; + address: string; + port?: number; + forceTls?: ForceTls; + remark?: string; + client: ClientShape; + externalProxy?: ExternalProxyEntry | null; +} + +// Per-protocol dispatcher matching the legacy `genLink` switch. Returns +// '' for protocols that don't have client-based share links (wireguard +// goes through genWireguardLinks/Configs separately, http/mixed/tunnel +// don't have share URLs). +export function genLink(input: GenLinkInput): string { + const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input; + switch (inbound.protocol) { + case 'vmess': + return genVmessLink({ + inbound, address, port, forceTls, remark, + clientId: client.id ?? '', + security: client.security, + externalProxy, + }); + case 'vless': + return genVlessLink({ + inbound, address, port, forceTls, remark, + clientId: client.id ?? '', + flow: client.flow, + externalProxy, + }); + case 'shadowsocks': { + const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305'; + return genShadowsocksLink({ + inbound, address, port, forceTls, remark, + clientPassword: isMultiUser ? (client.password ?? '') : '', + externalProxy, + }); + } + case 'trojan': + return genTrojanLink({ + inbound, address, port, forceTls, remark, + clientPassword: client.password ?? '', + externalProxy, + }); + case 'hysteria': + case 'hysteria2': + return genHysteriaLink({ + inbound, address, port, remark, + clientAuth: client.auth ?? '', + }); + default: + return ''; + } +} + +export interface GenAllLinksEntry { + remark: string; + link: string; +} + +export interface GenAllLinksInput { + inbound: Inbound; + remark?: string; + remarkModel?: string; + client: ClientShape; + hostOverride?: string; + fallbackHostname: string; +} + +// Fans out a single client's link per externalProxy entry, or just one +// link when there are no external proxies. remarkModel is a 4-char +// string: first char is the separator, remaining chars pick which +// pieces to compose into the per-link remark — 'i' = inbound remark, +// 'e' = client email, 'o' = externalProxy remark. Defaults to '-ieo' +// (dash-separated, inbound + email + proxy). +export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] { + const { + inbound, + remark = '', + remarkModel = '-ieo', + client, + hostOverride = '', + fallbackHostname, + } = input; + + const addr = resolveAddr(inbound, hostOverride, fallbackHostname); + const port = inbound.port; + const separationChar = remarkModel.charAt(0); + const orderChars = remarkModel.slice(1); + const email = client.email ?? ''; + + const composeRemark = (proxyRemark: string): string => { + const orders: Record = { i: remark, e: email, o: proxyRemark }; + return orderChars.split('') + .map((c) => orders[c] ?? '') + .filter((x) => x.length > 0) + .join(separationChar); + }; + + const externals = inbound.streamSettings?.externalProxy; + if (!externals || externals.length === 0) { + const r = composeRemark(''); + return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }]; + } + return externals.map((ep) => { + const r = composeRemark(ep.remark); + return { + remark: r, + link: genLink({ + inbound, + address: ep.dest, + port: ep.port, + forceTls: ep.forceTls, + remark: r, + client, + externalProxy: ep, + }), + }; + }); +} + +export interface GenInboundLinksInput { + inbound: Inbound; + remark?: string; + remarkModel?: string; + hostOverride?: string; + fallbackHostname: string; +} + +// Top-level entrypoint that produces the full \r\n-joined block a user +// pastes into a client. Iterates per-client for protocols with clients, +// falls back to a single SS link for single-user 2022-blake3-chacha20, +// and emits per-peer .conf blocks for wireguard. Returns '' for the +// other clientless protocols (http, mixed, tunnel). +export function genInboundLinks(input: GenInboundLinksInput): string { + const { + inbound, + remark = '', + remarkModel = '-ieo', + hostOverride = '', + fallbackHostname, + } = input; + const addr = resolveAddr(inbound, hostOverride, fallbackHostname); + const clients = getInboundClients(inbound); + if (clients) { + const links: string[] = []; + for (const client of clients) { + const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname }); + for (const e of entries) links.push(e.link); + } + return links.join('\r\n'); + } + if (inbound.protocol === 'shadowsocks') { + return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark }); + } + if (inbound.protocol === 'wireguard') { + return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname }); + } + return ''; +} + +// Per-peer wireguard fanout. Each peer gets its own link (or .conf +// block) with an index-suffixed remark, joined by \r\n. Matches the +// legacy genWireguardLinks / genWireguardConfigs exactly. +export interface GenWireguardFanoutInput { + inbound: Inbound; + remark?: string; + remarkModel?: string; + hostOverride?: string; + fallbackHostname: string; +} + +export function genWireguardLinks(input: GenWireguardFanoutInput): string { + const { inbound, remark = '', remarkModel = '-ieo', hostOverride = '', fallbackHostname } = input; + if (inbound.protocol !== 'wireguard') return ''; + const addr = resolveAddr(inbound, hostOverride, fallbackHostname); + const sep = remarkModel.charAt(0); + return inbound.settings.peers + .map((_p, i) => genWireguardLink({ + settings: inbound.settings as WireguardInboundSettings, + address: addr, + port: inbound.port, + remark: `${remark}${sep}${i + 1}`, + peerIndex: i, + })) + .join('\r\n'); +} + +export function genWireguardConfigs(input: GenWireguardFanoutInput): string { + const { inbound, remark = '', remarkModel = '-ieo', hostOverride = '', fallbackHostname } = input; + if (inbound.protocol !== 'wireguard') return ''; + const addr = resolveAddr(inbound, hostOverride, fallbackHostname); + const sep = remarkModel.charAt(0); + return inbound.settings.peers + .map((_p, i) => genWireguardConfig({ + settings: inbound.settings as WireguardInboundSettings, + address: addr, + port: inbound.port, + remark: `${remark}${sep}${i + 1}`, + peerIndex: i, + })) + .join('\r\n'); +} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 63143c5c..b45590ee 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'; import { genHysteriaLink, + genInboundLinks, genShadowsocksLink, genTrojanLink, genVlessLink, genVmessLink, genWireguardConfig, genWireguardLink, + resolveAddr, } from '@/lib/xray/inbound-link'; import { Inbound as LegacyInbound } from '@/models/inbound'; import { InboundSchema } from '@/schemas/api/inbound'; @@ -199,6 +201,83 @@ describe('genWireguardLink + genWireguardConfig parity', () => { } }); +describe('resolveAddr precedence', () => { + const baseInbound = { + listen: '', + port: 443, + protocol: 'vless' as const, + }; + + it('prefers hostOverride over listen and fallback', () => { + expect(resolveAddr( + { ...baseInbound, listen: '10.0.0.1' } as never, + 'cdn.example.test', + 'fallback.test', + )).toBe('cdn.example.test'); + }); + + it('uses listen when override is empty and listen is explicit', () => { + expect(resolveAddr( + { ...baseInbound, listen: '10.0.0.1' } as never, + '', + 'fallback.test', + )).toBe('10.0.0.1'); + }); + + it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => { + expect(resolveAddr( + { ...baseInbound, listen: '0.0.0.0' } as never, + '', + 'fallback.test', + )).toBe('fallback.test'); + }); + + it('falls through to fallbackHostname when listen is empty', () => { + expect(resolveAddr( + baseInbound as never, + '', + 'fallback.test', + )).toBe('fallback.test'); + }); +}); + +describe('genInboundLinks orchestrator parity', () => { + // Every full-inbound fixture should produce the same \r\n-joined link + // block as the legacy Inbound.genInboundLinks. Pass hostOverride + // explicitly so neither pipeline reaches for location.hostname. + const fixtures = Object.entries(fullFixtures) + .map(([path, raw]): [string, Record] => [fixtureName(path), raw as Record]) + .sort(([a], [b]) => a.localeCompare(b)); + + for (const [name, raw] of fixtures) { + const protocol = (raw as { protocol?: string }).protocol; + // Skip protocols the legacy class can't dispatch (hysteria2 has no + // dispatch case; getSettings(protocol) returns null and crashes + // genHysteriaLink). Orchestrator-level parity covers the others. + if (protocol === 'hysteria2') continue; + + it(`${name}: matches legacy Inbound.genInboundLinks`, () => { + const typed = InboundSchema.parse(raw); + + const remark = 'parity-test'; + const hostOverride = 'override.test'; + const fallbackHostname = 'fallback.test'; + + const newBlock = genInboundLinks({ + inbound: typed, + remark, + hostOverride, + fallbackHostname, + }); + + const legacy = LegacyInbound.fromJson(raw); + const legacyBlock = legacy.genInboundLinks(remark, '-ieo', hostOverride); + + expect(newBlock).toBe(legacyBlock); + }); + } +}); + describe('genShadowsocksLink parity', () => { const fixtures = fixturesForProtocol('shadowsocks'); expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);