mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
5a90f7e348
commit
f92f07e8f2
16 changed files with 697 additions and 6108 deletions
|
|
@ -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 1–5 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.
|
|
||||||
|
|
@ -161,8 +161,17 @@ export function useClients() {
|
||||||
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
||||||
const pageSize = (defaults.pageSize as number) ?? 0;
|
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(
|
const invalidateAll = useCallback(
|
||||||
() => queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
() => Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
|
]),
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
|
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
|
||||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
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 { StreamSettings } from '@/schemas/api/inbound';
|
||||||
import type { Sniffing } from '@/schemas/primitives';
|
import type { Sniffing } from '@/schemas/primitives';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
// Plain-data adapter between the panel's stored inbound row shape and
|
// Plain-data adapter between the panel's stored inbound row shape and
|
||||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||||
|
|
@ -79,6 +87,31 @@ function coerceTrafficReset(v: unknown): TrafficReset {
|
||||||
: 'never';
|
: '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)
|
// Map a raw DB row (settings/streamSettings/sniffing as string OR object)
|
||||||
// into the typed InboundFormValues. Does NOT validate against the schema —
|
// into the typed InboundFormValues. Does NOT validate against the schema —
|
||||||
// callers that want a hard guarantee should follow up with
|
// 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
|
const streamSettings = Object.keys(rawStream).length > 0
|
||||||
? (rawStream as StreamSettings)
|
? (rawStream as StreamSettings)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (streamSettings) {
|
||||||
|
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -112,7 +148,107 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||||
} as 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 {
|
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 = {
|
const payload: WireInboundPayload = {
|
||||||
up: values.up,
|
up: values.up,
|
||||||
down: values.down,
|
down: values.down,
|
||||||
|
|
@ -125,9 +261,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||||
listen: values.listen,
|
listen: values.listen,
|
||||||
port: values.port,
|
port: values.port,
|
||||||
protocol: values.protocol,
|
protocol: values.protocol,
|
||||||
settings: JSON.stringify(values.settings ?? {}),
|
settings: JSON.stringify(settingsPruned),
|
||||||
streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '',
|
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
|
||||||
sniffing: JSON.stringify(values.sniffing ?? {}),
|
sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
|
||||||
tag: values.tag,
|
tag: values.tag,
|
||||||
};
|
};
|
||||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||||
|
|
|
||||||
39
frontend/src/lib/xray/inbound-from-db.ts
Normal file
39
frontend/src/lib/xray/inbound-from-db.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||||
import { Inbound, Protocols } from './inbound';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
|
|
||||||
export type RawJsonField = string | Record<string, unknown> | unknown[];
|
export type RawJsonField = string | Record<string, unknown> | unknown[];
|
||||||
|
|
||||||
|
|
@ -85,7 +85,6 @@ export class DBInbound {
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
|
|
||||||
private _cachedInbound: Inbound | null = null;
|
|
||||||
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||||
|
|
||||||
constructor(data?: DBInboundInit) {
|
constructor(data?: DBInboundInit) {
|
||||||
|
|
@ -184,34 +183,9 @@ export class DBInbound {
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateCache(): void {
|
invalidateCache(): void {
|
||||||
this._cachedInbound = null;
|
|
||||||
this._clientStatsMap = 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 {
|
getClientStats(email: string): ClientStats | undefined {
|
||||||
if (!this._clientStatsMap) {
|
if (!this._clientStatsMap) {
|
||||||
this._clientStatsMap = new Map();
|
this._clientStatsMap = new Map();
|
||||||
|
|
@ -226,35 +200,4 @@ export class DBInbound {
|
||||||
return this._clientStatsMap.get(email);
|
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
|
|
@ -33,6 +33,10 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from
|
||||||
import {
|
import {
|
||||||
rawInboundToFormValues,
|
rawInboundToFormValues,
|
||||||
formValuesToWirePayload,
|
formValuesToWirePayload,
|
||||||
|
pruneEmpty,
|
||||||
|
normalizeSniffing,
|
||||||
|
normalizeClients,
|
||||||
|
dropLegacyOptionalEmpties,
|
||||||
} from '@/lib/xray/inbound-form-adapter';
|
} from '@/lib/xray/inbound-form-adapter';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
||||||
import {
|
import {
|
||||||
|
|
@ -82,7 +86,7 @@ import type { FormInstance } from 'antd';
|
||||||
import type { NamePath } from 'antd/es/form/interface';
|
import type { NamePath } from 'antd/es/form/interface';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
import type { DBInbound } from '@/models/dbinbound';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
|
||||||
// Pattern A rewrite of InboundFormModal. Built as a sibling file so the
|
// 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);
|
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 lastEmitRef = useRef<string>('');
|
||||||
const [text, setText] = useState(() => {
|
const [text, setText] = useState(() => {
|
||||||
const initial = serialize(form.getFieldValue(path));
|
const initial = serialize(form.getFieldValue(path));
|
||||||
|
|
@ -172,24 +181,40 @@ function AdvancedAllEditor({
|
||||||
form: FormInstance<InboundFormValues>;
|
form: FormInstance<InboundFormValues>;
|
||||||
streamEnabled: boolean;
|
streamEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const wListen = Form.useWatch('listen', form);
|
// preserve: true — default useWatch returns only registered fields, so
|
||||||
const wPort = Form.useWatch('port', form);
|
// sub-trees we never bound (settings.clients/fallbacks, sniffing
|
||||||
const wProtocol = Form.useWatch('protocol', form);
|
// defaults, etc.) wouldn't show up. preserve switches the read to
|
||||||
const wTag = Form.useWatch('tag', form);
|
// getFieldsValue(true) which returns the full form store.
|
||||||
const wSettings = Form.useWatch('settings', form);
|
const wListen = Form.useWatch('listen', { form, preserve: true });
|
||||||
const wSniffing = Form.useWatch('sniffing', form);
|
const wPort = Form.useWatch('port', { form, preserve: true });
|
||||||
const wStream = Form.useWatch('streamSettings', form);
|
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 = () => {
|
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> = {
|
const out: Record<string, unknown> = {
|
||||||
listen: wListen ?? '',
|
listen: wListen ?? '',
|
||||||
port: wPort ?? 0,
|
port: wPort ?? 0,
|
||||||
protocol: wProtocol ?? '',
|
protocol: wProtocol ?? '',
|
||||||
tag: wTag ?? '',
|
tag: wTag ?? '',
|
||||||
settings: wSettings ?? {},
|
settings: settingsView,
|
||||||
sniffing: wSniffing ?? {},
|
sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
|
||||||
};
|
};
|
||||||
if (streamEnabled) out.streamSettings = wStream ?? {};
|
if (streamView) out.streamSettings = streamView;
|
||||||
return JSON.stringify(out, null, 2);
|
return JSON.stringify(out, null, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -368,6 +393,39 @@ export default function InboundFormModal({
|
||||||
return !!msg?.success;
|
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 = () => {
|
const addFallback = () => {
|
||||||
setFallbacks((prev) => [...prev, {
|
setFallbacks((prev) => [...prev, {
|
||||||
rowKey: `fb-${++fallbackKeyRef.current}`,
|
rowKey: `fb-${++fallbackKeyRef.current}`,
|
||||||
|
|
@ -380,7 +438,18 @@ export default function InboundFormModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
|
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) => {
|
const removeFallback = (idx: number) => {
|
||||||
|
|
@ -409,14 +478,17 @@ export default function InboundFormModal({
|
||||||
const alreadyHave = new Set(prev.map((r) => r.childId));
|
const alreadyHave = new Set(prev.map((r) => r.childId));
|
||||||
const additions = fallbackChildOptions
|
const additions = fallbackChildOptions
|
||||||
.filter((opt) => !alreadyHave.has(opt.value))
|
.filter((opt) => !alreadyHave.has(opt.value))
|
||||||
.map<FallbackRow>((opt) => ({
|
.map<FallbackRow>((opt) => {
|
||||||
|
const derived = deriveFallbackDefaults(opt.value);
|
||||||
|
return {
|
||||||
rowKey: `fb-${++fallbackKeyRef.current}`,
|
rowKey: `fb-${++fallbackKeyRef.current}`,
|
||||||
childId: opt.value,
|
childId: opt.value,
|
||||||
name: '',
|
name: derived.name ?? '',
|
||||||
alpn: '',
|
alpn: derived.alpn ?? '',
|
||||||
path: '',
|
path: derived.path ?? '',
|
||||||
xver: 0,
|
xver: derived.xver ?? 0,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
if (additions.length === 0) return prev;
|
if (additions.length === 0) return prev;
|
||||||
return [...prev, ...additions];
|
return [...prev, ...additions];
|
||||||
});
|
});
|
||||||
|
|
@ -697,20 +769,34 @@ export default function InboundFormModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
let values: InboundFormValues;
|
|
||||||
try {
|
try {
|
||||||
values = await form.validateFields();
|
await form.validateFields();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
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);
|
const parsed = InboundFormSchema.safeParse(values);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const issue = parsed.error.issues[0];
|
const issue = parsed.error.issues[0];
|
||||||
messageApi.error(
|
const path = Array.isArray(issue?.path) && issue.path.length > 0
|
||||||
t(issue?.message ?? 'somethingWentWrong', {
|
? issue.path.join('.')
|
||||||
defaultValue: issue?.message ?? 'invalid',
|
: '';
|
||||||
}),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,93 @@ import {
|
||||||
import { Protocols } from '@/schemas/primitives';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
import InfinityIcon from '@/components/InfinityIcon';
|
import InfinityIcon from '@/components/InfinityIcon';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
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 type { SubSettings } from './useInbounds';
|
||||||
import './InboundInfoModal.css';
|
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 {
|
interface ClientStats {
|
||||||
email: string;
|
email: string;
|
||||||
up: number;
|
up: number;
|
||||||
|
|
@ -44,37 +128,35 @@ interface ClientSetting {
|
||||||
updated_at?: number;
|
updated_at?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InboundLike {
|
interface InboundInfo {
|
||||||
protocol: string;
|
protocol: string;
|
||||||
clients?: ClientSetting[];
|
clients: ClientSetting[];
|
||||||
settings?: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
serverName?: string;
|
isTcp: boolean;
|
||||||
isTcp?: boolean;
|
isWs: boolean;
|
||||||
isWs?: boolean;
|
isHttpupgrade: boolean;
|
||||||
isHttpupgrade?: boolean;
|
isXHTTP: boolean;
|
||||||
isXHTTP?: boolean;
|
isGrpc: boolean;
|
||||||
isGrpc?: boolean;
|
isSSMultiUser: boolean;
|
||||||
isSSMultiUser?: boolean;
|
isSS2022: boolean;
|
||||||
isSS2022?: boolean;
|
isVlessTlsFlow: boolean;
|
||||||
host?: string;
|
host: string | null;
|
||||||
path?: string;
|
path: string | null;
|
||||||
serviceName?: string;
|
serviceName: string;
|
||||||
stream?: {
|
serverName: string;
|
||||||
network?: string;
|
stream: {
|
||||||
security?: string;
|
network: string;
|
||||||
|
security: string;
|
||||||
xhttp?: { mode?: string };
|
xhttp?: { mode?: string };
|
||||||
grpc?: { multiMode?: boolean };
|
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 {
|
interface DBInboundLike {
|
||||||
id: number;
|
id: number;
|
||||||
address: string;
|
address: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
listen: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
|
|
@ -85,9 +167,64 @@ interface DBInboundLike {
|
||||||
isMixed?: boolean;
|
isMixed?: boolean;
|
||||||
isHTTP?: boolean;
|
isHTTP?: boolean;
|
||||||
isWireguard?: boolean;
|
isWireguard?: boolean;
|
||||||
|
settings: unknown;
|
||||||
|
streamSettings: unknown;
|
||||||
|
sniffing: unknown;
|
||||||
clientStats?: ClientStats[];
|
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 {
|
interface InboundInfoModalProps {
|
||||||
|
|
@ -155,7 +292,7 @@ export default function InboundInfoModal({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { datepicker } = useDatepicker();
|
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 [clientSettings, setClientSettings] = useState<ClientSetting | null>(null);
|
||||||
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
|
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
|
||||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||||
|
|
@ -213,24 +350,51 @@ export default function InboundInfoModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !dbInbound) return;
|
if (!open || !dbInbound) return;
|
||||||
const parsed = dbInbound.toInbound();
|
const info = buildInboundInfo(dbInbound);
|
||||||
setInbound(parsed);
|
setInbound(info);
|
||||||
setActiveTab((parsed.clients?.length ?? 0) > 0 ? 'client' : 'inbound');
|
setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
|
||||||
|
|
||||||
const idx = clientIndex ?? 0;
|
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);
|
setClientSettings(clientSet);
|
||||||
const stats = clientSet
|
const stats = clientSet
|
||||||
? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null
|
? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null
|
||||||
: null;
|
: null;
|
||||||
setClientStats(stats);
|
setClientStats(stats);
|
||||||
|
|
||||||
if (parsed.protocol === Protocols.WIREGUARD) {
|
const inboundForLinks = inboundFromDb(dbInbound);
|
||||||
setWireguardConfigs(parsed.genWireguardConfigs(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
|
const fallbackHostname = window.location.hostname;
|
||||||
setWireguardLinks(parsed.genWireguardLinks(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
|
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([]);
|
setLinks([]);
|
||||||
} else {
|
} 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([]);
|
setWireguardConfigs([]);
|
||||||
setWireguardLinks([]);
|
setWireguardLinks([]);
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +504,7 @@ export default function InboundInfoModal({
|
||||||
{dbInbound.isVMess && (
|
{dbInbound.isVMess && (
|
||||||
<tr><td>{t('security')}</td><td><Tag>{clientSettings?.security}</Tag></td></tr>
|
<tr><td>{t('security')}</td><td><Tag>{clientSettings?.security}</Tag></td></tr>
|
||||||
)}
|
)}
|
||||||
{inbound.canEnableTlsFlow?.() && (
|
{inbound.isVlessTlsFlow && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Flow</td>
|
<td>Flow</td>
|
||||||
<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>
|
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||||
{links.map((link, idx) => (
|
{links.map((link, idx) => (
|
||||||
|
|
@ -584,7 +748,7 @@ export default function InboundInfoModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dbInbound.hasLink() && (
|
{hasShareLink(dbInbound.protocol) && (
|
||||||
<>
|
<>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<dt>{t('security')}</dt>
|
<dt>{t('security')}</dt>
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,43 @@ import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
import InfinityIcon from '@/components/InfinityIcon';
|
import InfinityIcon from '@/components/InfinityIcon';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
|
||||||
|
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||||
import './InboundList.css';
|
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 = {
|
type ProtocolFlags = {
|
||||||
isVMess?: boolean;
|
isVMess?: boolean;
|
||||||
isVLess?: boolean;
|
isVLess?: boolean;
|
||||||
|
|
@ -59,11 +94,8 @@ interface DBInboundRecord extends ProtocolFlags {
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
_expiryTime: { valueOf(): number } | null;
|
_expiryTime: { valueOf(): number } | null;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
toInbound: () => {
|
settings: unknown;
|
||||||
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
|
streamSettings: unknown;
|
||||||
isSSMultiUser?: boolean;
|
|
||||||
};
|
|
||||||
isMultiUser: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientCountEntry {
|
export interface ClientCountEntry {
|
||||||
|
|
@ -137,11 +169,7 @@ const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: {
|
||||||
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||||
if (dbInbound.isWireguard) return true;
|
if (dbInbound.isWireguard) return true;
|
||||||
if (dbInbound.isSS) {
|
if (dbInbound.isSS) {
|
||||||
try {
|
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
|
||||||
return !dbInbound.toInbound().isSSMultiUser;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +189,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInb
|
||||||
if (showQrCodeMenu(record)) {
|
if (showQrCodeMenu(record)) {
|
||||||
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
|
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') });
|
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
|
||||||
if (subEnable) {
|
if (subEnable) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -341,14 +369,14 @@ export default function InboundList({
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
|
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
|
||||||
if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
|
if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
|
||||||
const stream = record.toInbound().stream;
|
const stream = readStreamHints(record.streamSettings);
|
||||||
tags.push(
|
tags.push(
|
||||||
<Tag key="n" color="green">
|
<Tag key="n" color="green">
|
||||||
{record.isHysteria ? 'UDP' : stream?.network}
|
{record.isHysteria ? 'UDP' : stream.network}
|
||||||
</Tag>,
|
</Tag>,
|
||||||
);
|
);
|
||||||
if (stream?.isTls) tags.push(<Tag key="tls" color="blue">TLS</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.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
|
||||||
}
|
}
|
||||||
return <div className="protocol-tags">{tags}</div>;
|
return <div className="protocol-tags">{tags}</div>;
|
||||||
},
|
},
|
||||||
|
|
@ -578,15 +606,18 @@ export default function InboundList({
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
|
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
|
||||||
<Tag color="purple">{statsRecord.protocol}</Tag>
|
<Tag color="purple">{statsRecord.protocol}</Tag>
|
||||||
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (
|
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (() => {
|
||||||
|
const stream = readStreamHints(statsRecord.streamSettings);
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<Tag color="green">
|
<Tag color="green">
|
||||||
{statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network}
|
{statsRecord.isHysteria ? 'UDP' : stream.network}
|
||||||
</Tag>
|
</Tag>
|
||||||
{statsRecord.toInbound().stream?.isTls && <Tag color="blue">TLS</Tag>}
|
{stream.isTls && <Tag color="blue">TLS</Tag>}
|
||||||
{statsRecord.toInbound().stream?.isReality && <Tag color="blue">Reality</Tag>}
|
{stream.isReality && <Tag color="blue">Reality</Tag>}
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.inbounds.port')}</span>
|
<span className="stat-label">{t('pages.inbounds.port')}</span>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
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 { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
|
@ -179,13 +181,13 @@ export default function InboundsPage() {
|
||||||
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
|
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
|
||||||
projected.listen = master.listen;
|
projected.listen = master.listen;
|
||||||
projected.port = master.port;
|
projected.port = master.port;
|
||||||
const masterStream = master.toInbound().stream;
|
const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
|
||||||
const childInbound = child.toInbound();
|
const childStream = { ...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>) };
|
||||||
childInbound.stream.security = masterStream.security;
|
childStream.security = masterStream.security;
|
||||||
childInbound.stream.tls = masterStream.tls;
|
childStream.tlsSettings = masterStream.tlsSettings;
|
||||||
childInbound.stream.reality = masterStream.reality;
|
childStream.realitySettings = masterStream.realitySettings;
|
||||||
childInbound.stream.externalProxy = masterStream.externalProxy;
|
childStream.externalProxy = masterStream.externalProxy;
|
||||||
projected.streamSettings = childInbound.stream.toString();
|
projected.streamSettings = JSON.stringify(childStream);
|
||||||
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
|
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
|
||||||
return new Ctor(projected);
|
return new Ctor(projected);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -199,11 +201,12 @@ export default function InboundsPage() {
|
||||||
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
|
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
|
||||||
for (const candidate of dbInbounds) {
|
for (const candidate of dbInbounds) {
|
||||||
if (candidate.id === dbInbound.id) continue;
|
if (candidate.id === dbInbound.id) continue;
|
||||||
const parsed = candidate.toInbound();
|
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
|
||||||
if (!parsed.isTcp) continue;
|
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
|
||||||
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
if (candStream.network !== 'tcp') continue;
|
||||||
const fallbacks = parsed.settings.fallbacks || [];
|
const candSettings = coerceInboundJsonField(candidate.settings) as { fallbacks?: { dest?: string }[] };
|
||||||
if (!fallbacks.find((f: { dest?: string }) => f.dest === dbInbound.listen)) continue;
|
const fallbacks = candSettings.fallbacks || [];
|
||||||
|
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
||||||
return projectChildThroughMaster(dbInbound, candidate);
|
return projectChildThroughMaster(dbInbound, candidate);
|
||||||
}
|
}
|
||||||
return dbInbound;
|
return dbInbound;
|
||||||
|
|
@ -211,8 +214,8 @@ export default function InboundsPage() {
|
||||||
|
|
||||||
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
|
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
|
||||||
if (!client) return 0;
|
if (!client) return 0;
|
||||||
const inbound = dbInbound.toInbound();
|
const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: ClientMatchTarget[] };
|
||||||
const clients = (inbound?.clients || []) as ClientMatchTarget[];
|
const clients = settings.clients || [];
|
||||||
const idx = clients.findIndex((c) => {
|
const idx = clients.findIndex((c) => {
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
switch (dbInbound.protocol) {
|
switch (dbInbound.protocol) {
|
||||||
|
|
@ -230,7 +233,13 @@ export default function InboundsPage() {
|
||||||
const projected = checkFallback(dbInbound);
|
const projected = checkFallback(dbInbound);
|
||||||
openText({
|
openText({
|
||||||
title: t('pages.inbounds.exportLinksTitle'),
|
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',
|
fileName: projected.remark || 'inbound',
|
||||||
});
|
});
|
||||||
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
||||||
|
|
@ -240,8 +249,8 @@ export default function InboundsPage() {
|
||||||
}, [openText, t]);
|
}, [openText, t]);
|
||||||
|
|
||||||
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
|
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
|
||||||
const inbound = dbInbound.toInbound();
|
const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: { subId?: string }[] };
|
||||||
const clients = (inbound?.clients || []) as { subId?: string }[];
|
const clients = settings.clients || [];
|
||||||
const subLinks: string[] = [];
|
const subLinks: string[] = [];
|
||||||
for (const c of clients) {
|
for (const c of clients) {
|
||||||
if (c.subId && subSettings.subURI) {
|
if (c.subId && subSettings.subURI) {
|
||||||
|
|
@ -262,7 +271,13 @@ export default function InboundsPage() {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const ib of hydrated) {
|
for (const ib of hydrated) {
|
||||||
const projected = checkFallback(ib);
|
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' });
|
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' });
|
||||||
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
||||||
|
|
@ -273,8 +288,8 @@ export default function InboundsPage() {
|
||||||
);
|
);
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const ib of hydrated) {
|
for (const ib of hydrated) {
|
||||||
const inbound = ib.toInbound();
|
const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] };
|
||||||
const clients = (inbound?.clients || []) as { subId?: string }[];
|
const clients = settings.clients || [];
|
||||||
for (const c of clients) {
|
for (const c of clients) {
|
||||||
if (c.subId && subSettings.subURI) {
|
if (c.subId && subSettings.subURI) {
|
||||||
out.push(subSettings.subURI + c.subId);
|
out.push(subSettings.subURI + c.subId);
|
||||||
|
|
@ -347,16 +362,21 @@ export default function InboundsPage() {
|
||||||
okText: t('pages.inbounds.clone'),
|
okText: t('pages.inbounds.clone'),
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const baseInbound = dbInbound.toInbound();
|
|
||||||
let clonedSettings: string;
|
let clonedSettings: string;
|
||||||
try {
|
try {
|
||||||
const raw = coerceInboundJsonField(dbInbound.settings);
|
const raw = coerceInboundJsonField(dbInbound.settings);
|
||||||
raw.clients = [];
|
raw.clients = [];
|
||||||
clonedSettings = JSON.stringify(raw);
|
clonedSettings = JSON.stringify(raw);
|
||||||
} catch {
|
} catch {
|
||||||
const fallback = createDefaultInboundSettings(baseInbound.protocol);
|
const fallback = createDefaultInboundSettings(dbInbound.protocol);
|
||||||
clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
|
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 = {
|
const data = {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
|
|
@ -366,10 +386,10 @@ export default function InboundsPage() {
|
||||||
expiryTime: 0,
|
expiryTime: 0,
|
||||||
listen: '',
|
listen: '',
|
||||||
port: RandomUtil.randomInteger(10000, 60000),
|
port: RandomUtil.randomInteger(10000, 60000),
|
||||||
protocol: baseInbound.protocol,
|
protocol: dbInbound.protocol,
|
||||||
settings: clonedSettings,
|
settings: clonedSettings,
|
||||||
streamSettings: baseInbound.stream.toString(),
|
streamSettings: streamSettingsString,
|
||||||
sniffing: baseInbound.sniffing.toString(),
|
sniffing: sniffingString,
|
||||||
};
|
};
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||||
if (msg?.success) await refresh();
|
if (msg?.success) await refresh();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import { Collapse, Modal } from 'antd';
|
||||||
import type { CollapseProps } from 'antd';
|
import type { CollapseProps } from 'antd';
|
||||||
|
|
||||||
import { Protocols } from '@/schemas/primitives';
|
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 QrPanel from './QrPanel';
|
||||||
import type { SubSettings } from './useInbounds';
|
import type { SubSettings } from './useInbounds';
|
||||||
|
|
||||||
|
|
@ -13,22 +19,10 @@ interface ClientSetting {
|
||||||
[k: string]: unknown;
|
[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 {
|
interface QrCodeModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
dbInbound: DBInboundLike | null;
|
dbInbound: (DbInboundLike & { remark?: string }) | null;
|
||||||
client?: ClientSetting | null;
|
client?: ClientSetting | null;
|
||||||
remarkModel?: string;
|
remarkModel?: string;
|
||||||
nodeAddress?: string;
|
nodeAddress?: string;
|
||||||
|
|
@ -61,16 +55,42 @@ export default function QrCodeModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !dbInbound) return;
|
if (!open || !dbInbound) return;
|
||||||
const inbound = dbInbound.toInbound();
|
const inbound = inboundFromDb(dbInbound);
|
||||||
|
const fallbackHostname = window.location.hostname;
|
||||||
if (inbound.protocol === Protocols.WIREGUARD) {
|
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||||
const peerRemark = client?.email
|
const peerRemark = client?.email
|
||||||
? `${dbInbound.remark}-${client.email}`
|
? `${dbInbound.remark}-${client.email}`
|
||||||
: dbInbound.remark || '';
|
: dbInbound.remark || '';
|
||||||
setWireguardConfigs(inbound.genWireguardConfigs(peerRemark, '-ieo', nodeAddress).split('\r\n'));
|
setWireguardConfigs(
|
||||||
setWireguardLinks(inbound.genWireguardLinks(peerRemark, '-ieo', nodeAddress).split('\r\n'));
|
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([]);
|
setLinks([]);
|
||||||
} else {
|
} 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([]);
|
setWireguardConfigs([]);
|
||||||
setWireguardLinks([]);
|
setWireguardLinks([]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { DBInbound } from '@/models/dbinbound';
|
import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
|
||||||
import { Protocols } from '@/schemas/primitives';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
|
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
|
||||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
|
import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
|
||||||
|
|
@ -201,12 +202,14 @@ export function useInbounds() {
|
||||||
const rebuildClientCount = useCallback(() => {
|
const rebuildClientCount = useCallback(() => {
|
||||||
const counts: Record<number, ClientRollup> = {};
|
const counts: Record<number, ClientRollup> = {};
|
||||||
for (const dbInbound of dbInboundsRef.current) {
|
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.protocol;
|
||||||
const protocol = (dbInbound as unknown as { protocol: string }).protocol;
|
|
||||||
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
|
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
|
||||||
const isSS = (dbInbound as unknown as { isSS: boolean }).isSS;
|
const settings = coerceInboundJsonField(dbInbound.settings) as {
|
||||||
if (isSS && !parsed.isSSMultiUser) continue;
|
method?: string;
|
||||||
counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: 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);
|
setClientCount(counts);
|
||||||
}, [rollupClients]);
|
}, [rollupClients]);
|
||||||
|
|
@ -219,11 +222,14 @@ export function useInbounds() {
|
||||||
const counts: Record<number, ClientRollup> = {};
|
const counts: Record<number, ClientRollup> = {};
|
||||||
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
|
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
|
||||||
const dbInbound = new DBInbound(row) as DBInboundInstance;
|
const dbInbound = new DBInbound(row) as DBInboundInstance;
|
||||||
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
|
|
||||||
next.push(dbInbound);
|
next.push(dbInbound);
|
||||||
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
|
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
|
||||||
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
|
const settings = coerceInboundJsonField(dbInbound.settings) as {
|
||||||
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
|
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;
|
dbInboundsRef.current = next;
|
||||||
|
|
@ -245,8 +251,12 @@ export function useInbounds() {
|
||||||
const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
|
const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
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([
|
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.onlines() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -480,7 +480,9 @@ export default function IndexPage() {
|
||||||
open={configTextOpen}
|
open={configTextOpen}
|
||||||
title={t('pages.index.config')}
|
title={t('pages.index.config')}
|
||||||
width={isMobile ? '100%' : 900}
|
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)}
|
onCancel={() => setConfigTextOpen(false)}
|
||||||
footer={[
|
footer={[
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -505,8 +507,8 @@ export default function IndexPage() {
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={configText}
|
value={configText}
|
||||||
onChange={setConfigText}
|
onChange={setConfigText}
|
||||||
minHeight={isMobile ? '300px' : '420px'}
|
minHeight={isMobile ? '300px' : 'calc(100vh - 220px)'}
|
||||||
maxHeight={isMobile ? '500px' : '720px'}
|
maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -227,8 +227,14 @@ export default function OutboundFormModal({
|
||||||
|
|
||||||
const tag = Form.useWatch('tag', form) ?? '';
|
const tag = Form.useWatch('tag', form) ?? '';
|
||||||
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
|
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
|
||||||
const network = (Form.useWatch(['streamSettings', 'network'], form) ?? '') as string;
|
// preserve: true — without it useWatch only reflects values whose
|
||||||
const security = (Form.useWatch(['streamSettings', 'security'], form) ?? 'none') as string;
|
// 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 streamAllowed = canEnableStream({ protocol });
|
||||||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
||||||
|
|
@ -1856,7 +1862,7 @@ export default function OutboundFormModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streamAllowed && network && (
|
{((streamAllowed && network) || !streamAllowed) && (
|
||||||
<Form.Item shouldUpdate noStyle>
|
<Form.Item shouldUpdate noStyle>
|
||||||
{() => {
|
{() => {
|
||||||
const hasSockopt = !!form.getFieldValue([
|
const hasSockopt = !!form.getFieldValue([
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ describe('rawInboundToFormValues', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formValuesToWirePayload', () => {
|
describe('formValuesToWirePayload', () => {
|
||||||
it('stringifies settings/streamSettings/sniffing', () => {
|
it('stringifies settings/streamSettings/sniffing with empty-array/default pruning', () => {
|
||||||
const values = rawInboundToFormValues(vlessRow);
|
const values = rawInboundToFormValues(vlessRow);
|
||||||
const payload = formValuesToWirePayload(values);
|
const payload = formValuesToWirePayload(values);
|
||||||
|
|
||||||
|
|
@ -122,9 +122,18 @@ describe('formValuesToWirePayload', () => {
|
||||||
expect(typeof payload.streamSettings).toBe('string');
|
expect(typeof payload.streamSettings).toBe('string');
|
||||||
expect(typeof payload.sniffing).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.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', () => {
|
it('emits empty string for absent streamSettings', () => {
|
||||||
|
|
@ -145,7 +154,11 @@ describe('formValuesToWirePayload', () => {
|
||||||
expect(payload.nodeId).toBe(42);
|
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 original = rawInboundToFormValues(vlessRow);
|
||||||
const payload = formValuesToWirePayload(original);
|
const payload = formValuesToWirePayload(original);
|
||||||
const replay = rawInboundToFormValues({
|
const replay = rawInboundToFormValues({
|
||||||
|
|
@ -166,6 +179,12 @@ describe('formValuesToWirePayload', () => {
|
||||||
lastTrafficResetTime: payload.lastTrafficResetTime,
|
lastTrafficResetTime: payload.lastTrafficResetTime,
|
||||||
nodeId: payload.nodeId ?? null,
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue