Răsfoiți Sursa

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.
MHSanaei 22 ore în urmă
părinte
comite
5d07185438
2 a modificat fișierele cu 323 adăugiri și 0 ștergeri
  1. 244 0
      frontend/src/lib/xray/inbound-link.ts
  2. 79 0
      frontend/src/test/inbound-link.test.ts

+ 244 - 0
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<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');
+}

+ 79 - 0
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<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', () => {
   const fixtures = fixturesForProtocol('shadowsocks');
   expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);