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