refactor(frontend): retire class-based xray models (Step 5)

Delete models/inbound.ts (3,359 lines) and outbound.ts (2,405).
The Inbound/Outbound classes and ~50 sub-classes are replaced by
Zod-typed data + pure functions in lib/xray/*.

Consumer migration off dbInbound.toInbound():
- useInbounds: isSSMultiUser({protocol, settings}) directly
- QrCodeModal: genWireguardConfigs/Links/AllLinks from lib/xray
- InboundList: derives tags from streamSettings raw fields
- InboundsPage: clone via raw JSON, fallback projection via
  schema-shape stream object, exports via genInboundLinks
- InboundInfoModal: builds an InboundInfo facade locally from
  raw streamSettings (host/path/serverName/serviceName per
  network), canEnableTlsFlow + isSS2022 from lib/xray

New helper: lib/xray/inbound-from-db.ts exposes
inboundFromDb(raw) converting a raw DBInbound row into a
schema-typed Inbound for the link-generation orchestrators.

DBInbound trimmed: drops toInbound, isMultiUser, hasLink,
genInboundLinks, _cachedInbound. Imports Protocols from
@/schemas/primitives now that ./inbound is gone.

Bundled Phase 2 fixes:
- Outbound modal: Form.useWatch with preserve: true so the
  stream block doesn't gate itself out when network is unmounted
- Inbound form adapter: pruneEmpty preserves empty objects;
  per-protocol client field projection via Zod safeParse;
  sniffing collapse to {enabled:false}
- useClients invalidateAll also invalidates inbounds.root()
- IndexPage Config modal top/maxHeight polish

Tests: 283/283 pass. typecheck/lint clean.
This commit is contained in:
MHSanaei
2026-05-26 19:49:42 +02:00
parent 5a90f7e348
commit f92f07e8f2
16 changed files with 697 additions and 6108 deletions

View File

@@ -1,132 +0,0 @@
# 3x-ui Frontend Zod Migration — Status
Branch: `feat/frontend-zod-validation` · 83 commits ahead of `main`
Last updated: 2026-05-26
## What this is
The work tracked here is the migration described in
`C:\Users\Hossein Sanaei\.claude\plans\zod-soft-feather.md` — replacing the
class-based xray models (`models/inbound.ts`, `models/outbound.ts`) with Zod
schemas as the single source of truth, standardizing every form on AntD
`Form.useForm` + `antdRule(schema.shape.X)`, and tightening
`@typescript-eslint/no-explicit-any` to `error`.
Verify state: `npm run typecheck` clean, `npm run lint` clean,
`npm run test` 302/302, snapshot baselines 172/172.
---
## Done
### Foundations
- API-boundary Zod validation in TanStack Query hooks (`parseMsg` helper)
- Backend request-body validation via `go-playground/validator`
- Go-first codegen tool (`tools/openapigen`) emitting `zod.ts` + `types.ts`
- `antdRule(schema)` helper bridging Zod issues to AntD form rules
- Five secondary modals migrated to Pattern A (Login, 2FA, Geo, Balancer, Rule)
- Pre-save schema guard on Inbound/Outbound form submits
### Schemas — `frontend/src/schemas/`
- `primitives/` — port, protocol, sniffing, atomic dictionaries
- `protocols/inbound/*` — 10 protocols as leaf schemas
- `protocols/outbound/*` — 11 protocols as leaf schemas
- `protocols/stream/*` — 7 networks (tcp/kcp/ws/grpc/httpupgrade/xhttp/hysteria)
- `protocols/security/*` — 3 securities (none/tls/reality)
- `forms/inbound-form.ts``InboundFormValues` discriminated union
- `forms/outbound-form.ts``OutboundFormValues` discriminated union
- Stream + security families wired as `z.discriminatedUnion` with intersection
### Pure-function ports — `frontend/src/lib/xray/`
- `headers.ts``toHeaders`, `toV2Headers`, `getHeaderValue`
- `inbound-link.ts``genVmessLink`, `genVlessLink`, `genTrojanLink`,
`genShadowsocksLink`, `genHysteriaLink`, Wireguard link/config
- `outbound-link-parser.ts` — vmess/vless/trojan/shadowsocks/hysteria2
- `inbound-defaults.ts``createDefault{Vmess,Vless,...}{Client,InboundSettings}`
- `outbound-defaults.ts` — settings factories + dispatcher
- `outbound-form-adapter.ts` — raw ↔ `OutboundFormValues` round-trip
- `protocol-capabilities.ts` — capability predicates as pure functions
### Form modals on Pattern A
- `InboundFormModal.tsx` — full rewrite, atomic-swapped from `.new.tsx`
- Tabs: Basic, Sniffing, Protocol, Stream, Security, Advanced JSON,
Fallbacks
- All 10 protocols (VLESS, VMess, Trojan, Shadowsocks, HTTP, Mixed,
Tunnel, TUN, Wireguard, Hysteria)
- Full Stream tab (TCP, KCP, WS, gRPC, HTTPUpgrade, XHTTP, Hysteria)
- Full Security tab (TLS list, Reality, ECH, mldsa65)
- 18-field sockopt section, full TLS cert list, external-proxy section
- `OutboundFormModal.tsx` — full rewrite, atomic-swapped from `.new.tsx`
- All 12 protocols (vmess/vless/trojan/shadowsocks/socks/http/hysteria/
freedom/blackhole/dns/loopback/wireguard)
- Full Stream tab with XHTTP advanced fields + xmux sub-form
- Full Security tab (TLS + Reality + Vision flow)
- Sockopt section (17 knobs)
- Mux section
- JSON tab for advanced fields
- Link import (vmess/vless/trojan/ss/hysteria2) with full XHTTP
round-trip (padding obfs + session/seq/uplink keys + all post-size
knobs)
- `FinalMaskForm` rewritten to Pattern A (Form.List-driven) and wired
into both stream tabs (Inbound + Outbound). Covers TCP/UDP mask
arrays, all 13 UDP mask types, header-custom nested groups, noise
items, and the QUIC params sub-form.
### Tests
- Golden-file fixture suite (`test/golden/fixtures/`)
- Snapshot-baseline regression tests for inbound-full / outbound / stream /
security DUs
- Shadow-parse harness asserting legacy class and Zod converge
- Link-parser tests (15 round-trip cases including XHTTP padding-obfs)
- Outbound form-adapter tests (15 round-trip cases)
- 302 tests across 12 files, 172 snapshots
### Build infrastructure
- `@typescript-eslint/no-explicit-any: 'error'` enforced
- `.github/workflows/ci.yml` runs `typecheck` + `test` before `build`
- Vite pinned to 8.0.13 (dev-mode dep-optimizer regression in 8.0.14)
---
## Remaining
### Out of migration scope (per plan)
- `DBInbound`, `Status`, `AllSetting` legacy classes — flagged as out of
scope in `zod-soft-feather.md`. The mainline migration of
`models/inbound.ts` / `models/outbound.ts` cannot delete them entirely
while `DBInbound.toInbound()` still imports.
- The plan accepts this and treats parity via snapshot baselines instead.
### Nice-to-haves — would not block ship
- Reality `sid=` multi-value parsing in share-link import
(outbound reality only carries a single shortId — this is server-side
state)
- `fm=` (FinalMask) param in share-link import
- VMess link `xmux` nested JSON parsing (currently round-trips at the
XHTTP top level; nested xmux object is left empty)
- Tighter `.loose()` removal in `schemas/api/inbound.ts`,
`schemas/api/client.ts`, `schemas/xray.ts` — gated on Step 6 of the plan
(currently held because the codegen tool still emits one or two loose
fields the panel writes back)
---
## How to pick up where this left off
1. `git checkout feat/frontend-zod-validation`
2. `cd frontend && npm install && npm run typecheck && npm run test`
3. Open `C:\Users\Hossein Sanaei\.claude\plans\zod-soft-feather.md`
Steps 15 are done. Step 6 (tighten `.loose()`) and Step 7 (lint/CI
tightening) are partially done.
4. Nothing in this list blocks ship. The mainline migration goal
(replace class-based models with Zod schemas + Pattern A forms) is
done; remaining work is incremental polish.

View File

@@ -161,8 +161,17 @@ export function useClients() {
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
const pageSize = (defaults.pageSize as number) ?? 0;
// Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
// mutate inbound rows server-side too — adding a client appends to
// settings.clients on each attached inbound, the slim list's per-inbound
// client count is derived from that. Invalidate both buckets so the
// Inbounds page and any open edit modal pick up the new shape without
// a manual reload.
const invalidateAll = useCallback(
() => queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
() => Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
]),
[queryClient],
);

View File

@@ -1,7 +1,15 @@
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
HysteriaClientSchema,
ShadowsocksClientSchema,
TrojanClientSchema,
VlessClientSchema,
VmessClientSchema,
} from '@/schemas/protocols/inbound';
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod';
// Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside
@@ -79,6 +87,31 @@ function coerceTrafficReset(v: unknown): TrafficReset {
: 'never';
}
// 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 {
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] = {};
}
}
// Map a raw DB row (settings/streamSettings/sniffing as string OR object)
// into the typed InboundFormValues. Does NOT validate against the schema —
// callers that want a hard guarantee should follow up with
@@ -90,6 +123,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
const streamSettings = Object.keys(rawStream).length > 0
? (rawStream as StreamSettings)
: undefined;
if (streamSettings) {
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
}
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
return {
@@ -112,7 +148,107 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
} 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.ZodTypeAny | null {
switch (protocol) {
case 'vless': return VlessClientSchema;
case 'vmess': return VmessClientSchema;
case 'trojan': return TrojanClientSchema;
case 'shadowsocks': return ShadowsocksClientSchema;
case 'hysteria': return HysteriaClientSchema;
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;
// StreamSettings emits `finalmask` only when at least one transport
// mask exists (legacy `hasFinalMask`). Otherwise drop the whole block.
if (stream) {
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;
}
}
}
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);
}
const streamPruned = values.streamSettings
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
: undefined;
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
const payload: WireInboundPayload = {
up: values.up,
down: values.down,
@@ -125,9 +261,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
listen: values.listen,
port: values.port,
protocol: values.protocol,
settings: JSON.stringify(values.settings ?? {}),
streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '',
sniffing: JSON.stringify(values.sniffing ?? {}),
settings: JSON.stringify(settingsPruned),
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
tag: values.tag,
};
if (values.nodeId != null) payload.nodeId = values.nodeId;

View File

@@ -0,0 +1,39 @@
import type { Inbound } from '@/schemas/api/inbound';
import { coerceInboundJsonField } from '@/models/dbinbound';
export interface DbInboundLike {
port: number;
listen: string;
protocol: string;
settings: unknown;
streamSettings: unknown;
sniffing: unknown;
tag?: string;
remark?: string;
enable?: boolean;
expiryTime?: number;
up?: number;
down?: number;
total?: number;
}
export function inboundFromDb(raw: DbInboundLike): Inbound {
const settings = coerceInboundJsonField(raw.settings);
const streamSettings = coerceInboundJsonField(raw.streamSettings);
const sniffing = coerceInboundJsonField(raw.sniffing);
return {
protocol: raw.protocol,
port: raw.port,
listen: raw.listen ?? '',
tag: raw.tag ?? '',
remark: raw.remark ?? '',
enable: raw.enable ?? true,
expiryTime: raw.expiryTime ?? 0,
up: raw.up ?? 0,
down: raw.down ?? 0,
total: raw.total ?? 0,
settings,
streamSettings,
sniffing,
} as unknown as Inbound;
}

View File

@@ -1,6 +1,6 @@
import dayjs, { type Dayjs } from 'dayjs';
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
import { Inbound, Protocols } from './inbound';
import { Protocols } from '@/schemas/primitives';
export type RawJsonField = string | Record<string, unknown> | unknown[];
@@ -85,7 +85,6 @@ export class DBInbound {
nodeId: number | null;
fallbackParent: FallbackParentRef | null;
private _cachedInbound: Inbound | null = null;
private _clientStatsMap: Map<string, ClientStats> | null = null;
constructor(data?: DBInboundInit) {
@@ -184,34 +183,9 @@ export class DBInbound {
}
invalidateCache(): void {
this._cachedInbound = null;
this._clientStatsMap = null;
}
toInbound(): Inbound {
if (this._cachedInbound) {
return this._cachedInbound;
}
const settings = coerceInboundJsonField(this.settings);
const streamSettings = coerceInboundJsonField(this.streamSettings);
const sniffing = coerceInboundJsonField(this.sniffing);
const config = {
port: this.port,
listen: this.listen,
protocol: this.protocol,
settings: settings,
streamSettings: streamSettings,
tag: this.tag,
sniffing: sniffing,
clientStats: this.clientStats,
};
this._cachedInbound = Inbound.fromJson(config);
return this._cachedInbound;
}
getClientStats(email: string): ClientStats | undefined {
if (!this._clientStatsMap) {
this._clientStatsMap = new Map();
@@ -226,35 +200,4 @@ export class DBInbound {
return this._clientStatsMap.get(email);
}
isMultiUser(): boolean {
switch (this.protocol) {
case Protocols.VMESS:
case Protocols.VLESS:
case Protocols.TROJAN:
case Protocols.HYSTERIA:
return true;
case Protocols.SHADOWSOCKS:
return this.toInbound().isSSMultiUser;
default:
return false;
}
}
hasLink(): boolean {
switch (this.protocol) {
case Protocols.VMESS:
case Protocols.VLESS:
case Protocols.TROJAN:
case Protocols.SHADOWSOCKS:
case Protocols.HYSTERIA:
return true;
default:
return false;
}
}
genInboundLinks(remarkModel: string, hostOverride: string = ''): string {
const inbound = this.toInbound();
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,10 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from
import {
rawInboundToFormValues,
formValuesToWirePayload,
pruneEmpty,
normalizeSniffing,
normalizeClients,
dropLegacyOptionalEmpties,
} from '@/lib/xray/inbound-form-adapter';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import {
@@ -82,7 +86,7 @@ import type { FormInstance } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
const { TextArea } = Input;
import type { DBInbound } from '@/models/dbinbound';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Pattern A rewrite of InboundFormModal. Built as a sibling file so the
@@ -121,7 +125,12 @@ function AdvancedSliceEditor({
return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
};
const watched = Form.useWatch(path, form);
// preserve: true so useWatch returns the full subtree from the form
// store — without it, useWatch goes through getFieldsValue() which
// filters out unregistered fields. Slices like `settings` would lose
// their `clients` / `fallbacks` sub-trees because those aren't bound
// to any Form.Item.
const watched = Form.useWatch(path, { form, preserve: true });
const lastEmitRef = useRef<string>('');
const [text, setText] = useState(() => {
const initial = serialize(form.getFieldValue(path));
@@ -172,24 +181,40 @@ function AdvancedAllEditor({
form: FormInstance<InboundFormValues>;
streamEnabled: boolean;
}) {
const wListen = Form.useWatch('listen', form);
const wPort = Form.useWatch('port', form);
const wProtocol = Form.useWatch('protocol', form);
const wTag = Form.useWatch('tag', form);
const wSettings = Form.useWatch('settings', form);
const wSniffing = Form.useWatch('sniffing', form);
const wStream = Form.useWatch('streamSettings', form);
// preserve: true — default useWatch returns only registered fields, so
// sub-trees we never bound (settings.clients/fallbacks, sniffing
// defaults, etc.) wouldn't show up. preserve switches the read to
// getFieldsValue(true) which returns the full form store.
const wListen = Form.useWatch('listen', { form, preserve: true });
const wPort = Form.useWatch('port', { form, preserve: true });
const wProtocol = Form.useWatch('protocol', { form, preserve: true });
const wTag = Form.useWatch('tag', { form, preserve: true });
const wSettings = Form.useWatch('settings', { form, preserve: true });
const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
const wStream = Form.useWatch('streamSettings', { form, preserve: true });
const serialize = () => {
// Apply the same prune/normalize as the wire payload so the JSON
// shown here is what the panel actually POSTs (no empty defaults,
// disabled sniffing as { enabled: false }, finalmask dropped when
// there are no masks).
const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
}
const streamView = streamEnabled
? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
: undefined;
dropLegacyOptionalEmpties(settingsView, streamView);
const out: Record<string, unknown> = {
listen: wListen ?? '',
port: wPort ?? 0,
protocol: wProtocol ?? '',
tag: wTag ?? '',
settings: wSettings ?? {},
sniffing: wSniffing ?? {},
settings: settingsView,
sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
};
if (streamEnabled) out.streamSettings = wStream ?? {};
if (streamView) out.streamSettings = streamView;
return JSON.stringify(out, null, 2);
};
@@ -368,6 +393,39 @@ export default function InboundFormModal({
return !!msg?.success;
};
// Derive a fallback row's SNI / ALPN / Path / xver from a child
// inbound's streamSettings — what the legacy panel auto-filled when an
// operator wired a fallback target. SNI/ALPN come straight off the
// child's TLS block; path depends on the child's transport (ws/grpc
// /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of
// their own). xver stays 0 unless the child explicitly opts in via
// PROXY-protocol sockopt.
const deriveFallbackDefaults = (childId: number): Partial<FallbackRow> => {
const child = (dbInbounds || []).find((ib) => ib.id === childId);
if (!child) return {};
const stream = coerceInboundJsonField(child.streamSettings);
const tls = (stream.tlsSettings as Record<string, unknown> | undefined) ?? {};
const network = typeof stream.network === 'string' ? stream.network : '';
const sni = typeof tls.serverName === 'string' ? tls.serverName : '';
const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : [];
const alpn = alpnArr.filter((v) => typeof v === 'string').join(',');
let path = '';
if (network === 'ws') {
const ws = (stream.wsSettings as Record<string, unknown> | undefined) ?? {};
if (typeof ws.path === 'string') path = ws.path;
} else if (network === 'grpc') {
const grpc = (stream.grpcSettings as Record<string, unknown> | undefined) ?? {};
if (typeof grpc.serviceName === 'string') path = grpc.serviceName;
} else if (network === 'httpupgrade') {
const hu = (stream.httpupgradeSettings as Record<string, unknown> | undefined) ?? {};
if (typeof hu.path === 'string') path = hu.path;
} else if (network === 'xhttp') {
const xh = (stream.xhttpSettings as Record<string, unknown> | undefined) ?? {};
if (typeof xh.path === 'string') path = xh.path;
}
return { name: sni, alpn, path, xver: 0 };
};
const addFallback = () => {
setFallbacks((prev) => [...prev, {
rowKey: `fb-${++fallbackKeyRef.current}`,
@@ -380,7 +438,18 @@ export default function InboundFormModal({
};
const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
setFallbacks((prev) => prev.map((r) => r.rowKey === rowKey ? { ...r, ...patch } : r));
setFallbacks((prev) => prev.map((r) => {
if (r.rowKey !== rowKey) return r;
// When the picker selects a new child inbound and the row hasn't
// been hand-edited yet (sni/alpn/path all blank, xver = 0), pull
// the SNI/ALPN/Path defaults off that child. Operators who
// intentionally typed values keep them — we only fill the empties.
if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
const isPristine = !r.name && !r.alpn && !r.path && r.xver === 0;
if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
}
return { ...r, ...patch };
}));
};
const removeFallback = (idx: number) => {
@@ -409,14 +478,17 @@ export default function InboundFormModal({
const alreadyHave = new Set(prev.map((r) => r.childId));
const additions = fallbackChildOptions
.filter((opt) => !alreadyHave.has(opt.value))
.map<FallbackRow>((opt) => ({
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: opt.value,
name: '',
alpn: '',
path: '',
xver: 0,
}));
.map<FallbackRow>((opt) => {
const derived = deriveFallbackDefaults(opt.value);
return {
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: opt.value,
name: derived.name ?? '',
alpn: derived.alpn ?? '',
path: derived.path ?? '',
xver: derived.xver ?? 0,
};
});
if (additions.length === 0) return prev;
return [...prev, ...additions];
});
@@ -697,20 +769,34 @@ export default function InboundFormModal({
};
const submit = async () => {
let values: InboundFormValues;
try {
values = await form.validateFields();
await form.validateFields();
} catch {
return;
}
// Why getFieldsValue(true) instead of the validateFields return value:
// rc-component/form's validateFields filters its output by REGISTERED
// name paths. settings.clients and settings.fallbacks have no Form.Item
// bound to them (clients are managed via the standalone Client modal,
// not inside this inbound modal) — so validateFields would drop them
// and the update wire payload would silently delete every client on
// every save. getFieldsValue(true) returns the entire form store and
// keeps those sub-trees intact.
const values = form.getFieldsValue(true) as InboundFormValues;
const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) {
const issue = parsed.error.issues[0];
messageApi.error(
t(issue?.message ?? 'somethingWentWrong', {
defaultValue: issue?.message ?? 'invalid',
}),
);
const path = Array.isArray(issue?.path) && issue.path.length > 0
? issue.path.join('.')
: '';
const baseMsg = issue?.message ?? 'somethingWentWrong';
const display = path ? `${path}: ${baseMsg}` : baseMsg;
messageApi.error(t(baseMsg, { defaultValue: display }));
console.error('[InboundFormModal] schema validation failed', {
path: issue?.path,
message: issue?.message,
values,
});
return;
}
setSaving(true);

View File

@@ -15,9 +15,93 @@ import {
import { Protocols } from '@/schemas/primitives';
import InfinityIcon from '@/components/InfinityIcon';
import { useDatepicker } from '@/hooks/useDatepicker';
import { coerceInboundJsonField } from '@/models/dbinbound';
import {
canEnableTlsFlow,
isSS2022 as isSS2022Helper,
isSSMultiUser as isSSMultiUserHelper,
} from '@/lib/xray/protocol-capabilities';
import {
genAllLinks,
genWireguardConfigs,
genWireguardLinks,
} from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import type { SubSettings } from './useInbounds';
import './InboundInfoModal.css';
const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
]);
function hasShareLink(protocol: string): boolean {
return LINK_PROTOCOLS.has(protocol);
}
function readHeader(headers: unknown, name: string): string {
const needle = name.toLowerCase();
if (Array.isArray(headers)) {
for (const h of headers) {
if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) {
return String((h as { value?: unknown }).value ?? '');
}
}
return '';
}
if (headers && typeof headers === 'object') {
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
if (k.toLowerCase() === needle) {
return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? '');
}
}
}
return '';
}
function readNetworkHost(stream: Record<string, unknown>, network: string): string | null {
switch (network) {
case 'tcp': {
const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined;
return readHeader(tcp?.header?.request?.headers, 'host');
}
case 'ws': {
const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined;
return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host');
}
case 'httpupgrade': {
const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined;
return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host');
}
case 'xhttp': {
const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined;
return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host');
}
default:
return null;
}
}
function readNetworkPath(stream: Record<string, unknown>, network: string): string | null {
switch (network) {
case 'tcp': {
const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined;
return tcp?.header?.request?.path?.[0] ?? null;
}
case 'ws':
return (stream.wsSettings as { path?: string } | undefined)?.path ?? null;
case 'httpupgrade':
return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null;
case 'xhttp':
return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null;
default:
return null;
}
}
interface ClientStats {
email: string;
up: number;
@@ -44,37 +128,35 @@ interface ClientSetting {
updated_at?: number;
}
interface InboundLike {
interface InboundInfo {
protocol: string;
clients?: ClientSetting[];
settings?: Record<string, unknown>;
serverName?: string;
isTcp?: boolean;
isWs?: boolean;
isHttpupgrade?: boolean;
isXHTTP?: boolean;
isGrpc?: boolean;
isSSMultiUser?: boolean;
isSS2022?: boolean;
host?: string;
path?: string;
serviceName?: string;
stream?: {
network?: string;
security?: string;
clients: ClientSetting[];
settings: Record<string, unknown>;
isTcp: boolean;
isWs: boolean;
isHttpupgrade: boolean;
isXHTTP: boolean;
isGrpc: boolean;
isSSMultiUser: boolean;
isSS2022: boolean;
isVlessTlsFlow: boolean;
host: string | null;
path: string | null;
serviceName: string;
serverName: string;
stream: {
network: string;
security: string;
xhttp?: { mode?: string };
grpc?: { multiMode?: boolean };
};
canEnableTlsFlow?: () => boolean;
genWireguardConfigs: (remark: string, model: string, host: string) => string;
genWireguardLinks: (remark: string, model: string, host: string) => string;
genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[];
}
interface DBInboundLike {
id: number;
address: string;
port: number;
listen: string;
protocol: string;
remark: string;
enable?: boolean;
@@ -85,9 +167,64 @@ interface DBInboundLike {
isMixed?: boolean;
isHTTP?: boolean;
isWireguard?: boolean;
settings: unknown;
streamSettings: unknown;
sniffing: unknown;
clientStats?: ClientStats[];
hasLink: () => boolean;
toInbound: () => InboundLike;
}
function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
const settings = coerceInboundJsonField(dbInbound.settings) as Record<string, unknown>;
const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record<string, unknown>;
const network = (stream.network as string | undefined) ?? '';
const security = (stream.security as string | undefined) ?? 'none';
const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : [];
const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined;
const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined;
let serverName = '';
if (security === 'tls') {
const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined;
serverName = tls?.sni ?? tls?.serverName ?? '';
} else if (security === 'reality') {
const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined;
if (Array.isArray(reality?.serverNames)) {
serverName = reality.serverNames.join(', ');
} else if (reality?.serverName) {
serverName = reality.serverName;
}
}
return {
protocol: dbInbound.protocol,
clients,
settings,
isTcp: network === 'tcp',
isWs: network === 'ws',
isHttpupgrade: network === 'httpupgrade',
isXHTTP: network === 'xhttp',
isGrpc: network === 'grpc',
isSSMultiUser: isSSMultiUserHelper({
protocol: dbInbound.protocol,
settings: settings as { method?: string },
}),
isSS2022: isSS2022Helper({
protocol: dbInbound.protocol,
settings: settings as { method?: string },
}),
isVlessTlsFlow: canEnableTlsFlow({
protocol: dbInbound.protocol,
streamSettings: { network, security },
}),
host: readNetworkHost(stream, network),
path: readNetworkPath(stream, network),
serviceName: grpcSettings?.serviceName ?? '',
serverName,
stream: {
network,
security,
xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined,
grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined,
},
};
}
interface InboundInfoModalProps {
@@ -155,7 +292,7 @@ export default function InboundInfoModal({
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const [inbound, setInbound] = useState<InboundLike | null>(null);
const [inbound, setInbound] = useState<InboundInfo | null>(null);
const [clientSettings, setClientSettings] = useState<ClientSetting | null>(null);
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
@@ -213,24 +350,51 @@ export default function InboundInfoModal({
useEffect(() => {
if (!open || !dbInbound) return;
const parsed = dbInbound.toInbound();
setInbound(parsed);
setActiveTab((parsed.clients?.length ?? 0) > 0 ? 'client' : 'inbound');
const info = buildInboundInfo(dbInbound);
setInbound(info);
setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
const idx = clientIndex ?? 0;
const clientSet = (parsed.clients?.length ?? 0) > 0 ? (parsed.clients?.[idx] || null) : null;
const clientSet = info.clients.length > 0 ? (info.clients[idx] || null) : null;
setClientSettings(clientSet);
const stats = clientSet
? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null
: null;
setClientStats(stats);
if (parsed.protocol === Protocols.WIREGUARD) {
setWireguardConfigs(parsed.genWireguardConfigs(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
setWireguardLinks(parsed.genWireguardLinks(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
const inboundForLinks = inboundFromDb(dbInbound);
const fallbackHostname = window.location.hostname;
if (info.protocol === Protocols.WIREGUARD) {
setWireguardConfigs(
genWireguardConfigs({
inbound: inboundForLinks,
remark: dbInbound.remark,
remarkModel: '-ieo',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setWireguardLinks(
genWireguardLinks({
inbound: inboundForLinks,
remark: dbInbound.remark,
remarkModel: '-ieo',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setLinks([]);
} else {
setLinks(parsed.genAllLinks(dbInbound.remark, remarkModel, clientSet, nodeAddress));
setLinks(
genAllLinks({
inbound: inboundForLinks,
remark: dbInbound.remark,
remarkModel,
client: (clientSet ?? {}) as Parameters<typeof genAllLinks>[0]['client'],
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setWireguardConfigs([]);
setWireguardLinks([]);
}
@@ -340,7 +504,7 @@ export default function InboundInfoModal({
{dbInbound.isVMess && (
<tr><td>{t('security')}</td><td><Tag>{clientSettings?.security}</Tag></td></tr>
)}
{inbound.canEnableTlsFlow?.() && (
{inbound.isVlessTlsFlow && (
<tr>
<td>Flow</td>
<td>
@@ -484,7 +648,7 @@ export default function InboundInfoModal({
</>
)}
{dbInbound.hasLink() && links.length > 0 && (
{hasShareLink(dbInbound.protocol) && links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => (
@@ -584,7 +748,7 @@ export default function InboundInfoModal({
</>
)}
{dbInbound.hasLink() && (
{hasShareLink(dbInbound.protocol) && (
<>
<div className="info-row">
<dt>{t('security')}</dt>

View File

@@ -34,8 +34,43 @@ import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import InfinityIcon from '@/components/InfinityIcon';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { coerceInboundJsonField } from '@/models/dbinbound';
import './InboundList.css';
interface StreamHints {
network: string;
isTls: boolean;
isReality: boolean;
}
function readStreamHints(streamSettings: unknown): StreamHints {
const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
return {
network: stream.network ?? '',
isTls: stream.security === 'tls',
isReality: stream.security === 'reality',
};
}
function readSettings(settings: unknown): { method?: string } {
return coerceInboundJsonField(settings) as { method?: string };
}
function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
switch (record.protocol) {
case 'vmess':
case 'vless':
case 'trojan':
case 'hysteria':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
default:
return false;
}
}
type ProtocolFlags = {
isVMess?: boolean;
isVLess?: boolean;
@@ -59,11 +94,8 @@ interface DBInboundRecord extends ProtocolFlags {
expiryTime: number;
_expiryTime: { valueOf(): number } | null;
nodeId?: number | null;
toInbound: () => {
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
isSSMultiUser?: boolean;
};
isMultiUser: () => boolean;
settings: unknown;
streamSettings: unknown;
}
export interface ClientCountEntry {
@@ -137,11 +169,7 @@ const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: {
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
try {
return !dbInbound.toInbound().isSSMultiUser;
} catch {
return false;
}
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
}
return false;
}
@@ -161,7 +189,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInb
if (showQrCodeMenu(record)) {
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
}
if (record.isMultiUser()) {
if (isInboundMultiUser(record)) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
if (subEnable) {
items.push({
@@ -341,14 +369,14 @@ export default function InboundList({
render: (_, record) => {
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
const stream = record.toInbound().stream;
const stream = readStreamHints(record.streamSettings);
tags.push(
<Tag key="n" color="green">
{record.isHysteria ? 'UDP' : stream?.network}
{record.isHysteria ? 'UDP' : stream.network}
</Tag>,
);
if (stream?.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
if (stream?.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
}
return <div className="protocol-tags">{tags}</div>;
},
@@ -578,15 +606,18 @@ export default function InboundList({
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
<Tag color="purple">{statsRecord.protocol}</Tag>
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (
<>
<Tag color="green">
{statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network}
</Tag>
{statsRecord.toInbound().stream?.isTls && <Tag color="blue">TLS</Tag>}
{statsRecord.toInbound().stream?.isReality && <Tag color="blue">Reality</Tag>}
</>
)}
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (() => {
const stream = readStreamHints(statsRecord.streamSettings);
return (
<>
<Tag color="green">
{statsRecord.isHysteria ? 'UDP' : stream.network}
</Tag>
{stream.isTls && <Tag color="blue">TLS</Tag>}
{stream.isReality && <Tag color="blue">Reality</Tag>}
</>
);
})()}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.port')}</span>

View File

@@ -21,6 +21,8 @@ import {
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { genInboundLinks } from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -179,13 +181,13 @@ export default function InboundsPage() {
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
projected.listen = master.listen;
projected.port = master.port;
const masterStream = master.toInbound().stream;
const childInbound = child.toInbound();
childInbound.stream.security = masterStream.security;
childInbound.stream.tls = masterStream.tls;
childInbound.stream.reality = masterStream.reality;
childInbound.stream.externalProxy = masterStream.externalProxy;
projected.streamSettings = childInbound.stream.toString();
const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
const childStream = { ...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>) };
childStream.security = masterStream.security;
childStream.tlsSettings = masterStream.tlsSettings;
childStream.realitySettings = masterStream.realitySettings;
childStream.externalProxy = masterStream.externalProxy;
projected.streamSettings = JSON.stringify(childStream);
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
return new Ctor(projected);
}, []);
@@ -199,11 +201,12 @@ export default function InboundsPage() {
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue;
const parsed = candidate.toInbound();
if (!parsed.isTcp) continue;
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
const fallbacks = parsed.settings.fallbacks || [];
if (!fallbacks.find((f: { dest?: string }) => f.dest === dbInbound.listen)) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue;
const candSettings = coerceInboundJsonField(candidate.settings) as { fallbacks?: { dest?: string }[] };
const fallbacks = candSettings.fallbacks || [];
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
@@ -211,8 +214,8 @@ export default function InboundsPage() {
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
if (!client) return 0;
const inbound = dbInbound.toInbound();
const clients = (inbound?.clients || []) as ClientMatchTarget[];
const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: ClientMatchTarget[] };
const clients = settings.clients || [];
const idx = clients.findIndex((c) => {
if (!c) return false;
switch (dbInbound.protocol) {
@@ -230,7 +233,13 @@ export default function InboundsPage() {
const projected = checkFallback(dbInbound);
openText({
title: t('pages.inbounds.exportLinksTitle'),
content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
content: genInboundLinks({
inbound: inboundFromDb(projected),
remark: projected.remark,
remarkModel,
hostOverride: hostOverrideFor(dbInbound),
fallbackHostname: window.location.hostname,
}),
fileName: projected.remark || 'inbound',
});
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
@@ -240,8 +249,8 @@ export default function InboundsPage() {
}, [openText, t]);
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
const inbound = dbInbound.toInbound();
const clients = (inbound?.clients || []) as { subId?: string }[];
const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: { subId?: string }[] };
const clients = settings.clients || [];
const subLinks: string[] = [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
@@ -262,7 +271,13 @@ export default function InboundsPage() {
const out: string[] = [];
for (const ib of hydrated) {
const projected = checkFallback(ib);
out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
out.push(genInboundLinks({
inbound: inboundFromDb(projected),
remark: projected.remark,
remarkModel,
hostOverride: hostOverrideFor(ib),
fallbackHostname: window.location.hostname,
}));
}
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' });
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
@@ -273,8 +288,8 @@ export default function InboundsPage() {
);
const out: string[] = [];
for (const ib of hydrated) {
const inbound = ib.toInbound();
const clients = (inbound?.clients || []) as { subId?: string }[];
const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] };
const clients = settings.clients || [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
out.push(subSettings.subURI + c.subId);
@@ -347,16 +362,21 @@ export default function InboundsPage() {
okText: t('pages.inbounds.clone'),
cancelText: t('cancel'),
onOk: async () => {
const baseInbound = dbInbound.toInbound();
let clonedSettings: string;
try {
const raw = coerceInboundJsonField(dbInbound.settings);
raw.clients = [];
clonedSettings = JSON.stringify(raw);
} catch {
const fallback = createDefaultInboundSettings(baseInbound.protocol);
const fallback = createDefaultInboundSettings(dbInbound.protocol);
clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
}
const streamSettingsString = typeof dbInbound.streamSettings === 'string'
? dbInbound.streamSettings
: JSON.stringify(dbInbound.streamSettings ?? {});
const sniffingString = typeof dbInbound.sniffing === 'string'
? dbInbound.sniffing
: JSON.stringify(dbInbound.sniffing ?? {});
const data = {
up: 0,
down: 0,
@@ -366,10 +386,10 @@ export default function InboundsPage() {
expiryTime: 0,
listen: '',
port: RandomUtil.randomInteger(10000, 60000),
protocol: baseInbound.protocol,
protocol: dbInbound.protocol,
settings: clonedSettings,
streamSettings: baseInbound.stream.toString(),
sniffing: baseInbound.sniffing.toString(),
streamSettings: streamSettingsString,
sniffing: sniffingString,
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg?.success) await refresh();

View File

@@ -4,6 +4,12 @@ import { Collapse, Modal } from 'antd';
import type { CollapseProps } from 'antd';
import { Protocols } from '@/schemas/primitives';
import {
genAllLinks,
genWireguardConfigs,
genWireguardLinks,
} from '@/lib/xray/inbound-link';
import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
import QrPanel from './QrPanel';
import type { SubSettings } from './useInbounds';
@@ -13,22 +19,10 @@ interface ClientSetting {
[k: string]: unknown;
}
interface DBInboundLike {
remark?: string;
toInbound: () => InboundLike;
}
interface InboundLike {
protocol: string;
genWireguardConfigs: (remark: string, model: string, host: string) => string;
genWireguardLinks: (remark: string, model: string, host: string) => string;
genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[];
}
interface QrCodeModalProps {
open: boolean;
onClose: () => void;
dbInbound: DBInboundLike | null;
dbInbound: (DbInboundLike & { remark?: string }) | null;
client?: ClientSetting | null;
remarkModel?: string;
nodeAddress?: string;
@@ -61,16 +55,42 @@ export default function QrCodeModal({
useEffect(() => {
if (!open || !dbInbound) return;
const inbound = dbInbound.toInbound();
const inbound = inboundFromDb(dbInbound);
const fallbackHostname = window.location.hostname;
if (inbound.protocol === Protocols.WIREGUARD) {
const peerRemark = client?.email
? `${dbInbound.remark}-${client.email}`
: dbInbound.remark || '';
setWireguardConfigs(inbound.genWireguardConfigs(peerRemark, '-ieo', nodeAddress).split('\r\n'));
setWireguardLinks(inbound.genWireguardLinks(peerRemark, '-ieo', nodeAddress).split('\r\n'));
setWireguardConfigs(
genWireguardConfigs({
inbound,
remark: peerRemark,
remarkModel: '-ieo',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setWireguardLinks(
genWireguardLinks({
inbound,
remark: peerRemark,
remarkModel: '-ieo',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setLinks([]);
} else {
setLinks(inbound.genAllLinks(dbInbound.remark || '', remarkModel, client, nodeAddress) as { remark?: string; link: string }[]);
setLinks(
genAllLinks({
inbound,
remark: dbInbound.remark || '',
remarkModel,
client: client ?? {},
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setWireguardConfigs([]);
setWireguardLinks([]);
}

View File

@@ -3,8 +3,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DBInbound } from '@/models/dbinbound';
import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
import { Protocols } from '@/schemas/primitives';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { setDatepicker } from '@/hooks/useDatepicker';
import { keys } from '@/api/queryKeys';
import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
@@ -201,12 +202,14 @@ export function useInbounds() {
const rebuildClientCount = useCallback(() => {
const counts: Record<number, ClientRollup> = {};
for (const dbInbound of dbInboundsRef.current) {
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean }; isSS: boolean; protocol: string }).toInbound();
const protocol = (dbInbound as unknown as { protocol: string }).protocol;
const protocol = dbInbound.protocol;
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
const isSS = (dbInbound as unknown as { isSS: boolean }).isSS;
if (isSS && !parsed.isSSMultiUser) continue;
counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
const settings = coerceInboundJsonField(dbInbound.settings) as {
method?: string;
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
};
if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
}
setClientCount(counts);
}, [rollupClients]);
@@ -219,11 +222,14 @@ export function useInbounds() {
const counts: Record<number, ClientRollup> = {};
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
const dbInbound = new DBInbound(row) as DBInboundInstance;
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
next.push(dbInbound);
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
const settings = coerceInboundJsonField(dbInbound.settings) as {
method?: string;
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
};
if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue;
counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
}
}
dbInboundsRef.current = next;
@@ -245,8 +251,12 @@ export function useInbounds() {
const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
const refresh = useCallback(async () => {
// Invalidate at the inbounds root so both `slim` (this page's list)
// and `options` (the Clients page's inbound picker) refetch. Without
// the options bucket, a freshly-created inbound stays invisible in
// the client add/edit modal until a full page reload.
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
]);

View File

@@ -480,7 +480,9 @@ export default function IndexPage() {
open={configTextOpen}
title={t('pages.index.config')}
width={isMobile ? '100%' : 900}
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
style={isMobile
? { top: 20, maxWidth: 'calc(100vw - 16px)' }
: { top: 20 }}
onCancel={() => setConfigTextOpen(false)}
footer={[
<Button
@@ -505,8 +507,8 @@ export default function IndexPage() {
<JsonEditor
value={configText}
onChange={setConfigText}
minHeight={isMobile ? '300px' : '420px'}
maxHeight={isMobile ? '500px' : '720px'}
minHeight={isMobile ? '300px' : 'calc(100vh - 220px)'}
maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
readOnly
/>
</Modal>

View File

@@ -227,8 +227,14 @@ export default function OutboundFormModal({
const tag = Form.useWatch('tag', form) ?? '';
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
const network = (Form.useWatch(['streamSettings', 'network'], form) ?? '') as string;
const security = (Form.useWatch(['streamSettings', 'security'], form) ?? 'none') as string;
// preserve: true — without it useWatch only reflects values whose
// Form.Item is currently mounted. The streamSettings selectors live
// INSIDE `{streamAllowed && network && (...)}`, so the moment that
// conditional gates them out, useWatch returns undefined, the gate
// keeps returning false, and the stream block never renders even
// though streamSettings is in the form store.
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
@@ -1856,7 +1862,7 @@ export default function OutboundFormModal({
</>
)}
{streamAllowed && network && (
{((streamAllowed && network) || !streamAllowed) && (
<Form.Item shouldUpdate noStyle>
{() => {
const hasSockopt = !!form.getFieldValue([

View File

@@ -114,7 +114,7 @@ describe('rawInboundToFormValues', () => {
});
describe('formValuesToWirePayload', () => {
it('stringifies settings/streamSettings/sniffing', () => {
it('stringifies settings/streamSettings/sniffing with empty-array/default pruning', () => {
const values = rawInboundToFormValues(vlessRow);
const payload = formValuesToWirePayload(values);
@@ -122,9 +122,18 @@ describe('formValuesToWirePayload', () => {
expect(typeof payload.streamSettings).toBe('string');
expect(typeof payload.sniffing).toBe('string');
expect(JSON.parse(payload.settings)).toEqual(vlessRow.settings);
// Empty arrays like `fallbacks: []` drop out of the payload to match
// the legacy panel's minimal JSON.
const parsedSettings = JSON.parse(payload.settings);
const { fallbacks: _f, ...expectedSettings } = vlessRow.settings as Record<string, unknown>;
expect(parsedSettings).toEqual(expectedSettings);
expect(JSON.parse(payload.streamSettings)).toEqual(vlessRow.streamSettings);
expect(JSON.parse(payload.sniffing)).toEqual(vlessRow.sniffing);
// Disabled sniffing collapses to the bare `{ enabled: false }`
// regardless of which destOverride/metadataOnly/etc. defaults the
// form carries.
expect(JSON.parse(payload.sniffing)).toEqual({ enabled: false });
});
it('emits empty string for absent streamSettings', () => {
@@ -145,7 +154,11 @@ describe('formValuesToWirePayload', () => {
expect(payload.nodeId).toBe(42);
});
it('round-trips through raw → values → payload → values', () => {
it('round-trips top-level fields through raw → values → payload → values', () => {
// settings/streamSettings/sniffing don't round-trip byte-equal because
// the wire payload prunes empty arrays and collapses disabled sniffing
// to `{ enabled: false }`. Top-level scalars and the protocol picker
// must still survive the round trip without loss.
const original = rawInboundToFormValues(vlessRow);
const payload = formValuesToWirePayload(original);
const replay = rawInboundToFormValues({
@@ -166,6 +179,12 @@ describe('formValuesToWirePayload', () => {
lastTrafficResetTime: payload.lastTrafficResetTime,
nodeId: payload.nodeId ?? null,
});
expect(replay).toEqual(original);
expect(replay.protocol).toBe(original.protocol);
expect(replay.port).toBe(original.port);
expect(replay.tag).toBe(original.tag);
expect(replay.listen).toBe(original.listen);
expect(replay.up).toBe(original.up);
expect(replay.down).toBe(original.down);
expect(replay.streamSettings).toEqual(original.streamSettings);
});
});