mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-30 00:49:34 +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:
@@ -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 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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 { 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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
@@ -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() }),
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user