refactor(frontend): extract share-link orchestrator to lib/xray/inbound-link

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

View file

@ -678,3 +678,247 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
} }
export type { WireguardInboundPeer }; 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<string, string> = { 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');
}

View file

@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest';
import { import {
genHysteriaLink, genHysteriaLink,
genInboundLinks,
genShadowsocksLink, genShadowsocksLink,
genTrojanLink, genTrojanLink,
genVlessLink, genVlessLink,
genVmessLink, genVmessLink,
genWireguardConfig, genWireguardConfig,
genWireguardLink, genWireguardLink,
resolveAddr,
} from '@/lib/xray/inbound-link'; } from '@/lib/xray/inbound-link';
import { Inbound as LegacyInbound } from '@/models/inbound'; import { Inbound as LegacyInbound } from '@/models/inbound';
import { InboundSchema } from '@/schemas/api/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<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
.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', () => { describe('genShadowsocksLink parity', () => {
const fixtures = fixturesForProtocol('shadowsocks'); const fixtures = fixturesForProtocol('shadowsocks');
expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0); expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);