Files
l-ui/frontend/src/lib/xray/inbound-form-adapter.ts
T
Muse f6d652019d P9(3): per-inbound forwarding module (amo-style form, dynamic egress compile)
Design as decided 2026-10-21 (replaces the raw-JSON egress editor idea):
the forwarding form lives on the inbound, and the entry agent's egress
entries are compiled from it on every config pull — never snapshotted.

- model: Inbound.Forwarding (JSON text) via AutoMigrate; panel-side
  metadata only, never reaches xray itself
- service/forwarding.go: ForwardingSpec (mode direct/forward; landing
  managed|manual), save-time validation (managed target must exist and
  be a SOCKS-family landing — the fork's SK5 inbound is 'mixed'; no
  self-forward; manual endpoint/protocol sanity), live resolution
  (managed: concrete listen IP wins, else hosting node's address;
  credentials from the target's first account), and the compiler that
  appends one socks/http outbound (tag fwd-<inboundId>) plus a prepended
  inboundTag rule per forwarding inbound
- buildXrayConfig agent path: compile runs after applyAgentEgress, so
  entries ride on the live template (or stored egress override) and
  template edits keep flowing; unresolvable entries skip with a warning
  instead of failing the whole config
- UpdateInbound's field-merge now carries Forwarding (its absence was
  caught by the smoke: adds worked, updates silently kept the old spec)
- GET /panel/api/inbounds/forwardTargets: managed-landing candidates
  (id/remark/tag/listen/port/node name) — credentials never exposed
- frontend: new Forwarding tab in the inbound form (mode, landing type,
  managed picker, manual endpoint with link import incl. base64 form),
  adapter round-trips the spec as a JSON string like settings; en/zh
  strings; generated API types refreshed

Acceptance: unit tests for parse/validate/resolve/compile incl. the
dynamic-recompile proof (template edit + spec edit both reflected), and
an end-to-end smoke (real binary, two agent nodes, all inbound writes
in browser form-urlencoded format): 22/22 PASS — see P9-REPORT.md.
2026-10-21 17:40:42 +00:00

409 lines
15 KiB
TypeScript

import {
ForwardingSpecSchema,
type ForwardingSpec,
type InboundFormValues,
type ShareAddrStrategy,
type TrafficReset,
} from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
AmneziawgClientSchema,
HysteriaClientSchema,
MtprotoClientSchema,
ShadowsocksClientSchema,
TrojanClientSchema,
VlessClientSchema,
VmessClientSchema,
WireguardClientSchema,
} from '@/schemas/protocols/inbound';
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
// Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside
// InboundFormModal. No dependency on the legacy Inbound/DBInbound
// classes — the modal hands the raw row in, takes typed values out, and
// on submit calls formValuesToWirePayload() to get a payload ready to
// POST to /panel/api/inbounds/add or /update/:id.
export interface RawInboundRow {
port?: number;
listen?: string;
protocol?: string;
tag?: string;
settings?: unknown;
streamSettings?: unknown;
sniffing?: unknown;
up?: number;
down?: number;
total?: number;
remark?: string;
enable?: boolean;
expiryTime?: number;
trafficReset?: string;
trafficResetDay?: number;
lastTrafficResetTime?: number;
nodeId?: number | null;
forwarding?: unknown;
shareAddrStrategy?: string;
shareAddr?: string;
subSortIndex?: number;
disableFlow?: boolean;
clientStats?: unknown;
}
// The wire payload — settings/streamSettings/sniffing arrive as JSON
// strings, mirroring what the Go endpoints expect (xray-core wants the
// nested config slices as strings to round-trip through its loader).
export interface WireInboundPayload {
up: number;
down: number;
total: number;
remark: string;
enable: boolean;
expiryTime: number;
trafficReset: TrafficReset;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
protocol: string;
settings: string;
streamSettings: string;
sniffing: string;
tag: string;
clientStats?: unknown;
nodeId?: number | null;
forwarding: string;
shareAddrStrategy: ShareAddrStrategy;
shareAddr: string;
subSortIndex: number;
disableFlow: boolean;
}
function coerceJsonObject(value: unknown): Record<string, unknown> {
if (value == null) return {};
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== 'string') return {};
const trimmed = value.trim();
if (trimmed === '') return {};
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
const SHARE_ADDR_STRATEGIES: ShareAddrStrategy[] = ['node', 'listen', 'custom'];
function coerceTrafficReset(v: unknown): TrafficReset {
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
? (v as TrafficReset)
: 'never';
}
function coerceForwarding(v: unknown): ForwardingSpec {
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
}
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
? (v as ShareAddrStrategy)
: 'node';
}
// Network values that map to a required `${network}Settings` key in
// NetworkSettingsSchema. Older saved inbounds may be missing the per-
// network sub-object (the legacy panel sometimes emitted streamSettings
// without it, and an earlier panel-side prune wrongly stripped empty
// `tcpSettings: {}` out of the wire payload). Reseat an empty object
// here so InboundFormSchema.safeParse doesn't blow up at edit time.
const NETWORK_SETTINGS_KEY: Record<string, string> = {
tcp: 'tcpSettings',
kcp: 'kcpSettings',
ws: 'wsSettings',
grpc: 'grpcSettings',
httpupgrade: 'httpupgradeSettings',
xhttp: 'xhttpSettings',
hysteria: 'hysteriaSettings',
};
function healStreamNetworkKey(stream: Record<string, unknown>): void {
if (typeof stream.method === 'string' && stream.method !== '') {
stream.network = stream.method;
}
delete stream.method;
const network = typeof stream.network === 'string' ? stream.network : '';
const key = NETWORK_SETTINGS_KEY[network];
if (!key) return;
if (stream[key] == null || typeof stream[key] !== 'object') {
stream[key] = {};
}
}
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
return Array.isArray(tls?.certificates) ? (tls.certificates as Record<string, unknown>[]) : [];
}
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
for (const c of tlsCerts(stream)) {
if (typeof c.useFile === 'boolean') continue;
const hasFile = !!c.certificateFile || !!c.keyFile;
const hasInline =
(Array.isArray(c.certificate) && c.certificate.length > 0) ||
(Array.isArray(c.key) && c.key.length > 0);
c.useFile = hasFile || !hasInline;
}
}
function stripTlsCertUseFile(stream: Record<string, unknown>): void {
for (const c of tlsCerts(stream)) delete c.useFile;
}
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
const rawStream = coerceJsonObject(row.streamSettings);
const streamSettings =
Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
if (streamSettings) {
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
const streamRecord = streamSettings as unknown as Record<string, unknown>;
const xh = streamRecord.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const parsed = XHttpStreamSettingsSchema.safeParse(xh);
const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
streamRecord.xhttpSettings = xhttp;
const xmux = xhttp.xmux;
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
xhttp.enableXmux = true;
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
}
}
const so = streamRecord.sockopt;
if (so && typeof so === 'object' && !Array.isArray(so)) {
const parsed = SockoptStreamSettingsSchema.safeParse(so);
if (parsed.success) {
streamRecord.sockopt = { ...(so as Record<string, unknown>), ...parsed.data };
}
}
}
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
return {
remark: row.remark ?? '',
enable: row.enable ?? true,
port: row.port ?? 0,
listen: row.listen ?? '',
tag: row.tag ?? '',
expiryTime: row.expiryTime ?? 0,
sniffing,
streamSettings,
up: row.up ?? 0,
down: row.down ?? 0,
total: row.total ?? 0,
trafficReset: coerceTrafficReset(row.trafficReset),
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
forwarding: coerceForwarding(row.forwarding),
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
disableFlow: row.disableFlow ?? false,
protocol,
settings,
} as InboundFormValues;
}
// Recursively strip undefined leaves from the wire payload. Empty arrays
// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
// shells like `tcpSettings: {}` so xray-core picks up its built-in
// defaults, and stripping them led the FE to lose required-but-empty
// arrays (vless clients, wireguard peers, etc.) which the Go side then
// serialized back as `null`. Primitive values (including 0, false, '')
// are kept verbatim.
export function pruneEmpty(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(pruneEmpty);
}
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const p = pruneEmpty(v);
if (p === undefined) continue;
out[k] = p;
}
return out;
}
return value;
}
// Per-protocol client field whitelist — the Zod schemas in
// schemas/protocols/inbound/<proto>.ts define which keys a given
// protocol's clients accept on the wire. When a global client is created
// the panel may persist cross-protocol fields on the same row (`auth` for
// hysteria, `password` for trojan, `security` for vmess, etc.); rendering
// those inside a vless inbound's settings.clients is confusing and rides
// dead weight in the wire payload. Parsing through the protocol's schema
// gives us the canonical projection.
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
switch (protocol) {
case 'vless':
return VlessClientSchema;
case 'vmess':
return VmessClientSchema;
case 'trojan':
return TrojanClientSchema;
case 'shadowsocks':
return ShadowsocksClientSchema;
case 'hysteria':
return HysteriaClientSchema;
case 'wireguard':
return WireguardClientSchema;
case 'mtproto':
return MtprotoClientSchema;
case 'amneziawg':
return AmneziawgClientSchema;
default:
return null;
}
}
export function normalizeClients(protocol: string, clients: unknown): unknown {
const schema = clientSchemaForProtocol(protocol);
if (!schema || !Array.isArray(clients)) return clients;
return clients.map((c) => {
const parsed = schema.safeParse(c);
return parsed.success ? parsed.data : c;
});
}
// Sniffing normalizer matching the legacy Sniffing.toJson(): when
// disabled the payload is the bare `{ enabled: false }` regardless of
// what the form holds; when enabled, only non-default fields ride.
export function normalizeSniffing(s: Sniffing | undefined): Record<string, unknown> {
if (!s || !s.enabled) return { enabled: false };
const out: Record<string, unknown> = {
enabled: true,
destOverride: s.destOverride,
};
if (s.metadataOnly) out.metadataOnly = true;
if (s.routeOnly) out.routeOnly = true;
if (s.ipsExcluded?.length) out.ipsExcluded = s.ipsExcluded;
if (s.domainsExcluded?.length) out.domainsExcluded = s.domainsExcluded;
return out;
}
// Drops cosmetic empty-array keys that legacy XrayCommonClass.toJson()
// explicitly skipped (fallbacks/finalmask). Mutates the pruned settings
// objects in place; called AFTER pruneEmpty so we can lean on the
// already-shallow shape.
export function dropLegacyOptionalEmpties(
settings: Record<string, unknown>,
stream: Record<string, unknown> | undefined,
): void {
// VLESS/Trojan emit `fallbacks` only when non-empty.
const fb = settings.fallbacks;
if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
if (stream) {
// StreamSettings emits `finalmask` only when at least one transport
// mask exists (legacy `hasFinalMask`). Drop the whole block when all
// sub-fields are empty; otherwise drop only the empty sub-arrays so
// the wire payload doesn't carry a stray `"tcp": []` next to a
// populated UDP mask list (and vice versa).
const fm = stream.finalmask as
| { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown }
| undefined;
if (fm && typeof fm === 'object') {
const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
const hasQuic = fm.quicParams != null;
if (!hasTcp && !hasUdp && !hasQuic) {
delete stream.finalmask;
} else {
if (!hasTcp) delete fm.tcp;
if (!hasUdp) delete fm.udp;
}
}
// Hysteria's per-client auth lives in settings.clients[*].auth; the
// streamSettings.hysteriaSettings.auth slot is a holdover from older
// hysteria builds and serves no purpose on the inbound side, so an
// empty value shouldn't ride along in the JSON payload.
const hs = stream.hysteriaSettings as { auth?: string } | undefined;
if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
delete hs.auth;
}
}
}
export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
}
let streamPruned = values.streamSettings
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
: undefined;
if (streamPruned) {
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
stripTlsCertUseFile(streamPruned);
}
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
const payload: WireInboundPayload = {
up: values.up,
down: values.down,
total: values.total,
remark: values.remark,
enable: values.enable,
expiryTime: values.expiryTime,
trafficReset: values.trafficReset,
trafficResetDay: values.trafficResetDay,
lastTrafficResetTime: values.lastTrafficResetTime,
listen: values.listen,
port: values.port,
protocol: values.protocol,
settings: JSON.stringify(settingsPruned),
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
// mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
// rather than the default { enabled: false } so the row carries no sniffing.
sniffing: canEnableSniffing({ protocol: values.protocol })
? JSON.stringify(normalizeSniffing(values.sniffing))
: '',
tag: values.tag,
shareAddrStrategy: values.shareAddrStrategy,
shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex,
disableFlow: values.disableFlow,
// Forwarding rides as a JSON string like settings; direct mode stores
// an empty string so the row carries no forwarding residue.
forwarding:
values.forwarding && values.forwarding.mode === 'forward'
? JSON.stringify(values.forwarding)
: '',
};
// nodeId is always emitted (null = local panel): the update endpoint
// distinguishes an explicit null — "move the inbound back to local" —
// from an omitted key, which keeps the stored assignment untouched.
payload.nodeId = values.nodeId ?? null;
return payload;
}