Răsfoiți Sursa

feat: complete Zod migration of frontend + bulk client batching (#4599)

* feat(frontend): add Zod runtime validation at API boundary

Introduces Zod 4 schemas for response validation on the three highest-traffic
endpoints (server/status, nodes/list, setting/all) and a Zod->AntD form rule
adapter, replacing the duplicated per-file ApiMsg<T> interfaces. Validation
runs safeParse with console.warn + raw-payload fallback so backend drift never
breaks the UI for users.

Login form switches to schema-driven rules as the proof-of-life for the
adapter. Class-based models stay untouched; remaining query/mutation hooks
and form modals will migrate in follow-ups.

* feat(frontend): extend Zod validation to remaining query/mutation hooks

Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires
useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker
through parseMsg. Drops the duplicated per-file ApiMsg<T> interfaces and
the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload
declarations in favour of schema-inferred types re-exported from the
new src/schemas/ modules.

API boundary now validates: clients list/paged, clients onlines,
clients lastOnline, clients get/hydrate, inbounds slim, inbounds get,
inbounds options, defaultSettings, xray config, xray outbounds traffic,
xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe,
nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted,
nodes probe / test) get response validation; pass-through mutations stay
agnostic. NodeFormModal type-aligned to Msg<ProbeResult>.

* fix(frontend): allow null slices in client/summary schemas

Go's encoding/json emits nil []T as null, not []. The initial
ClientPageResponseSchema and ClientHydrateSchema rejected null
inboundIds / summary.online / summary.depleted / etc., causing
[zod] warnings on every empty list.

Add nullableStringArray / nullableNumberArray helpers that accept
null and transform to [] so consuming code keeps seeing arrays.
Mark ClientRecord.traffic and .reverse nullable too (reverse is
explicitly null in MarshalJSON when storage is empty).

* fix(vite): treat /panel/xray as SPA page, not API root

The dev-server bypass classified /panel/xray as an API path because
the PANEL_API_PREFIXES matcher did `stripped === prefix.replace(/\/$/, '')`,
which made the bare path collide with the SPA route of the same name
(see web/controller/xui.go: g.GET("/xray", a.panelSPA)).

On reload, /panel/xray got proxied to the Go backend instead of being
served by Vite. The backend returned the embedded built index.html
with hashed asset names that the dev server doesn't have, so every
asset 404'd.

Prefix-only match for trailing-slash entries fixes it: panel/xray/...
still routes to the API, but panel/xray itself reaches the SPA branch.

* feat(frontend): drive form validation from Zod schemas

NodeFormModal — full conversion to AntD Form.useForm with antdRule
on every required field. Inline field errors replace the single
'fillRequired' toast. testConnection now runs validateFields(['address','port'])
before sending.

ClientFormModal and ClientBulkAddModal — minimal conversion: keep the
existing useState-driven controlled-component pattern, but replace the
hand-rolled `if (!form.x)` checks with schema.safeParse(form). The
schema is the single source of truth for required-ness and types;
ClientCreateFormSchema layers on the create-only `inboundIds.min(1)` rule.

New schemas (in src/schemas/):
  NodeFormSchema (node.ts)
  ClientFormSchema / ClientCreateFormSchema (client.ts)
  ClientBulkAddFormSchema (client.ts)

Other 16+ form modals stay on the current pattern — the antdRule adapter
ships from the first Zod pass for opportunistic migration as forms are
touched.

* chore(frontend): silence swagger-ui-react peer-dep warnings on React 19

[email protected] bundles three deps whose declared peer ranges
predate React 19:

  [email protected] (peer 15-18)
  [email protected]     (peer 15-18, unmaintained)
  [email protected]          (peer 16-18)

For the first two, the actual code is React-19 compatible - only the
metadata is stale. Resolve via npm overrides:

- react-copy-to-clipboard bumped to ^5.1.1 (peer is open-ended >=15.3.0
  in that release).
- react-inspector bumped to ^9.0.0 (^8 was a broken publish per its own
  deprecation notice).
- react-debounce-input is wedged on 3.3.0 with no maintained successor
  on npm. Use the nested-override syntax to satisfy its react peer:

    "react-debounce-input": { "react": "^19.0.0" }

  That tells npm to use our React 19 for the package's peer dependency,
  which silences the warning without changing the package version.

* fix(vite): bypass es-toolkit CJS shim for recharts deep imports

The Nodes page (and any other recharts-using route) crashed in dev and
prod with TypeError: require_isUnsafeProperty is not a function.

Root cause: es-toolkit's package.json exports './compat/*' only via a
default condition pointing at the CJS shims under compat/<name>.js.
Those shims use a require_X.Y access pattern that Vite's optimizer
(Rolldown in Vite 8) and the production Rolldown build both mishandle,
losing the named-export accessor and calling the namespace object as
a function. recharts imports a dozen of these subpaths with default-
import syntax, so every chart path tripped the bug.

The matching ESM build at dist/compat/<category>/<name>.mjs is fine,
but it only carries a named export. Recharts uses default imports.

Plug a small Rollup-compatible plugin (enforce: 'pre') in front of
the resolver: any 'es-toolkit/compat/<name>' request becomes a virtual
module that imports the named symbol from the right .mjs file and
re-exports it as both default and named. The plugin is registered as
a top-level plugin (for the prod build) and via the new Vite 8
optimizeDeps.rolldownOptions.plugins (for the dev pre-bundler), so
both pipelines pick it up consistently.

* feat(frontend): migrate five secondary form modals to Zod schemas

Apply the schema + safeParse-on-submit pattern (introduced for
ClientFormModal / ClientBulkAddModal) to five more forms:

- ClientBulkAdjustModal: ClientBulkAdjustFormSchema enforces 'at least
  one of addDays / addGB is non-zero' via .refine(), replacing the
  ad-hoc days+gb check.
- BalancerFormModal: BalancerFormSchema covers tag and selector
  required-ness; the duplicate-tag check stays inline since it needs
  the otherTags prop. Per-field validateStatus now reads from the
  parsed issues map.
- RuleFormModal: RuleFormSchema captures the form shape (no required
  fields - every property is optional by design). safeParse short-
  circuits if anything is structurally wrong.
- CustomGeoFormModal: CustomGeoFormSchema folds the regex alias rule
  and the http(s) URL validation (including URL parse) into the
  schema, replacing a 20-line validate() function.
- TwoFactorModal: TotpCodeSchema (z.string().regex(/^\d{6}$/)) drives
  both the disabled-state of the OK button and the safeParse gate
  before the TOTP comparison.

Schemas live alongside the matching API schemas:
- ClientBulkAdjustFormSchema in schemas/client.ts
- BalancerFormSchema / RuleFormSchema / CustomGeoFormSchema in schemas/xray.ts
- TotpCodeSchema in schemas/login.ts (next to LoginFormSchema)

No UX change for valid inputs.

* feat(frontend): block invalid settings saves with Zod pre-save check

Tighten AllSettingSchema with the actual valid ranges and patterns:

- webPort / subPort / ldapPort: integer 1-65535
- pageSize: integer 1-1000
- sessionMaxAge: integer >= 1
- tgCpu: integer 0-100 (percentage)
- subUpdates: integer 1-168 (hours)
- expireDiff / trafficDiff / ldapDefault*: non-negative integers
- webBasePath / subPath / subJsonPath / subClashPath: must start with /

The existing useAllSettings save path runs AllSettingSchema.partial()
through safeParse and logs drift without blocking. SettingsPage now
adds a stronger gate before the mutation: run the full schema against
the draft and, on failure, surface the first issue (field path +
message) via the existing messageApi.error so the user actually sees
what's wrong instead of silently sending bad data to the backend.

Use cases caught: port out of range, negative quota, sub path missing
leading slash, page size set to 0, tgCpu > 100.

* feat(frontend): schema-guard Inbound and Outbound form submits

The two largest forms in the panel send to the backend without ever
checking their own port range or required-ness. Schema-gate the
top-level fields so obviously bad payloads stop at the client.

InboundFormModal: InboundFormSchema (port 1-65535 int, non-empty
protocol, the rest of the keys present) runs as a safeParse just
before the HttpUtil.post in submit(). The 2000+ lines of protocol-
specific subform code stay untouched - that's a separate effort and
the existing per-protocol logic (e.g. canEnableStream, isFallbackHost)
already gates most of the structural correctness.

OutboundFormModal: OutboundTagSchema (trim + min 1) replaces the
hand-rolled `if (!ob.tag?.trim()) messageApi.error('Tag is required')`
check. The duplicateTag check stays inline because it needs the
existingTags prop.

Both schemas emit i18n keys for messages with a defaultValue fallback,
matching the pattern in BalancerFormModal and SettingsPage.

* feat(backend): gate request bodies with go-playground/validator

Add a generic BindAndValidate helper in web/middleware that wraps gin's
content-aware binder with an explicit validator.Struct call and emits a
structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so
the frontend can map each issue to an i18n key.

Tag the user-facing fields on model.Inbound, model.Node, and
entity.AllSetting with the range/enum constraints they were previously
relying on hand-rolled CheckValid logic (or nothing) to enforce, and
wire the helper into the inbound/node/settings controllers that bind
those structs directly. Promotes validator/v10 from indirect to direct
require, plus six unit tests covering valid payloads, range violations,
enum violations, malformed JSON, in-place binding, and JSON-only strict
mode.

This is PR1 of a planned end-to-end Zod rollout — controllers using
local form structs (custom_geo, setEnable, fallbacks, client) keep
their existing handling and will be migrated as their schemas firm up.

* feat(codegen): Go-first tool emitting Zod schemas and TS types

Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:

  - zod.ts   shared Zod schemas keyed off `validate:` tags (ports get
             .min(1).max(65535), Inbound.protocol becomes a z.enum,
             Node.scheme too, etc.)
  - types.ts plain TS interfaces inferred from the same walk, so
             consumers can import Inbound without dragging Zod along

The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.

Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.

PR2 of the planned Zod end-to-end rollout.

* refactor(frontend): tighten HttpUtil generics from any to unknown

Switch the class-level default on Msg<T> and the per-method defaults on
HttpUtil.get/post/postWithModal from `any` to `unknown`, so callers that
don't pass an explicit T get a narrowed response that must be schema-
checked or type-cast before its shape is trusted.

Drops the four file-level eslint-disable comments these defaults
required. Fixes the nine direct `.obj.field` consumers that surfaced
(IndexPage, XrayMetricsModal, NordModal, WarpModal, LogModal,
VersionModal, XrayLogModal, CustomGeoSection) by giving each call site
the explicit T it should have had from the start — typically a small
ad-hoc shape, sometimes a string for the JSON-text-in-Msg.obj pattern
used by NordModal/WarpModal/Xray nord/warp endpoints.

PR3 of the planned Zod end-to-end rollout — schemas/inbound.ts and
schemas/client.ts loose() removal stays parked until the protocol
schemas land in Phase 3 to avoid silently dropping fields.

* feat(frontend): protocol-leaf Zod schemas with discriminated unions

Stand up schemas/primitives (Port, Flow, Protocol, Sniffing) and per-protocol
leaf schemas for all 10 inbound and 13 outbound xray protocols. The leaves
omit any inner `protocol` literal — the discriminator lives at the parent
level so consumers narrow on `.protocol` without redundant projection. Wire
shape is preserved per protocol: vmess outbound stays in `vnext[]`, trojan
and shadowsocks outbound in `servers[]`, vless outbound flat, http/socks
outbound in `servers[].users[]`.

Cross-protocol atoms (port, flow, sniffing dest, protocol enum) live in
primitives. Protocol-specific enums (vmess security, ss method/network,
hysteria version, freedom domain strategy, dns rule action) stay with their
leaves. Tagged-wrapper `z.discriminatedUnion('protocol', [...])` composes
both InboundSettingsSchema and OutboundSettingsSchema; existing class-based
models in src/models/ are untouched and will be retired in Step 3 once the
golden-file safety net is in place.

* feat(frontend): stream and security Zod families with discriminated unions

Stand up the remaining Step 2 families. NetworkSettingsSchema is a
6-branch DU on `network` covering tcp/kcp/ws/grpc/httpupgrade/xhttp, with
asymmetric per-network wire keys (tcpSettings, wsSettings, ...) preserved
exactly so fixtures round-trip byte-identical. SecuritySettingsSchema is a
3-branch DU on `security` covering none/tls/reality. TLS certs use a
file-vs-inline union; uTLS fingerprints are shared between TLS and Reality
via a single primitive enum.

Hysteria-as-network, finalmask, and sockopt are not in the plan's Step 2
inventory and are deferred to Step 6 (Tighten) - they're orthogonal extras
on the stream root, not network-discriminated branches.

Resolves a Security identifier collision in protocols/index.ts by
re-exporting the type alias as SecurityKind (the `Security` name is taken
by the namespace re-export).

* test(frontend): vitest harness with golden-file fixtures for inbound protocols

Stand up Phase 3 safety net before the models/ rewrite. The harness loads
JSON fixtures via Vite's import.meta.glob, parses each through
InboundSettingsSchema (the tagged-wrapper DU), and snapshots the canonical
parsed shape. Snapshots stay byte-stable across the upcoming class-to-
pure-function extraction, catching any normalization drift.

Six representative inbound fixtures cover the high-traffic protocols:
vless, vmess, trojan, shadowsocks (2022-blake3 multi-user), wireguard,
hysteria2. Stream and security branches plus the remaining protocols
(http, mixed, tunnel, hysteria) follow in subsequent turns.

Uses /// <reference types="vite/client" /> instead of @types/node so we
avoid pulling in another type package; import.meta.glob is enough to walk
the fixtures directory at compile time.

Adds vitest 4.1.7 as the only new dev dependency. test/test:watch scripts
land in package.json; a standalone vitest.config.ts keeps the production
vite.config.js (which reads from sqlite via DatabaseSync) out of the test
runner.

* test(frontend): broaden golden coverage to remaining inbounds + stream + security DUs

Round out Step 3b. Four more inbound fixtures complete the protocol set
(http with two accounts, mixed with socks-style auth, tunnel with a port
map, hysteria v1). Two parallel test files cover the other DUs:
stream.test.ts walks tcp/ws/grpc fixtures through NetworkSettingsSchema,
and security.test.ts walks none/tls/reality through SecuritySettingsSchema.

Snapshot count is now 16 across three test files. The reality fixture
locks in the array form of serverNames/shortIds (the panel class stores
them comma-joined internally but they ship as arrays on the wire). The
TLS fixture pins the file-vs-inline cert DU on the file branch.

Stream coverage for httpupgrade/xhttp/kcp and security mixed-with-stream
combos follow in the next turn, alongside the shadow harness.

* test(frontend): shadow-parse harness asserting legacy class and Zod converge

Add Step 3c's safety net: for every inbound golden fixture, run the raw
payload through both pipelines —

  legacy:  Inbound.Settings.fromJson(protocol, raw.settings).toJson()
  zod:     InboundSettingsSchema.parse(raw).settings

— canonicalize each (recursively sort keys, drop empty arrays / null /
undefined), and assert byte-equality. This locks the wire shape across the
upcoming class-to-pure-function extraction in Step 3d. Any normalization
drift introduced by the rewrite trips an assertion here before it can
reach users.

Two ergonomic wrinkles handled inline:
  - The legacy class lumps hysteria + hysteria2 onto a single
    HysteriaSettings (no hysteria2 case in the dispatch table); the test
    routes hysteria2 fixtures through the HYSTERIA branch.
  - Empty arrays in Zod's output (e.g. fallbacks: [] from a .default([]))
    are treated as equivalent to the legacy class's omit-when-empty
    behavior. Same wire state, different syntactic surface.

All 26 tests across 4 test files pass on first run.

* refactor(frontend): extract toHeaders + toV2Headers to lib/xray/headers.ts

First Step 3d extraction. The XrayCommonClass static helpers
toHeaders/toV2Headers are pure data shape conversions with no class
hierarchy needs, so they move to a standalone module that callers can
import without dragging in models/inbound.ts. The new module exports
HeaderEntry + V2HeaderMap as named types so consumers stop reaching into
the legacy class for type shapes.

A new test file (headers.test.ts) asserts byte-equality with the legacy
XrayCommonClass.toHeaders / .toV2Headers across 18 cases — null /
undefined / primitive inputs, single-string headers, array-valued
headers, duplicate names, empty-name and empty-value filtering, both
arr=true (TCP request/response shape) and arr=false (WS / xHTTP / sockopt
shape). Drift between the legacy and new impls fails these tests, so the
follow-up call-site swap stays safe.

Callers (TcpStreamSettings, WsStreamSettings, HTTPUpgradeStreamSettings,
TunnelSettings, etc.) still go through XrayCommonClass for now — those
swaps land alongside class-method extractions in subsequent turns.

Suite is now 44 tests across 5 files; typecheck + lint clean.

* refactor(frontend): extract createDefault*Client factories to lib/xray

Next Step 3d slice. Five plain-object factories — Vless, Vmess, Trojan,
Shadowsocks, Hysteria — replace the legacy
`new Inbound.<Protocol>Settings.<Protocol>(...)` constructor chain and the
ClientBase XrayCommonClass machinery. Each factory takes an optional
seed; missing random fields (id, password, auth, email, subId) fall
through to RandomUtil at call time. Forms can hand-pick a UUID; tests
pass deterministic seeds so the suite never touches window.crypto.

Tests double-verify each factory: a snapshot locks the exact shape, and
the matching Zod ClientSchema.parse(out) must equal `out` — no missing
defaults, no stray fields, type-narrowed end-to-end.

Discovered: VmessClientSchema and VlessClientSchema enforce z.uuid()
format, so the test seeds use real-shape UUIDs.

Suite: 49 tests across 6 files; typecheck + lint clean. Outbound and
inbound-settings factories follow in subsequent turns alongside the
toShareLink extraction.

* refactor(frontend): add createDefault*InboundSettings factories for all 10 protocols

Round out Step 3d's settings factory set. Ten plain-object factories
(vless / vmess / trojan / shadowsocks / hysteria / hysteria2 / http /
mixed / tunnel / wireguard) replace the legacy
`new Inbound.<X>Settings(protocol)` constructors. Each returns a Zod-
parsable wire shape with schema defaults applied — no class instance.
Forms (Step 4) and InboundsPage clone (Step 5) call these factories
directly once the swap lands.

Three factories take a seed for random fields:
  - shadowsocks: method-dependent password length via
    RandomUtil.randomShadowsocksPassword(method)
  - hysteria: explicit `version` override (defaults to 2, matching
    the legacy panel constructor — v1 is opt-in)
  - wireguard: secretKey from Wireguard.generateKeypair().privateKey

Tests double-verify each factory the same way as the client factories:
snapshot the shape, then Zod parse round-trip to confirm no missing
defaults or stray fields.

Suite: 59 tests across 6 files; typecheck + lint clean. Outbound
factories and the toShareLink extraction follow next.

* refactor(frontend): add getHeaderValue wire-shape lookup to lib/xray/headers

Tiny piece of the toShareLink scaffold. The legacy Inbound.getHeader(obj,
name) iterated the panel's internal HeaderEntry[] form; the new
getHeaderValue reads the Record<string, string|string[]> map our Zod
schemas store on the wire. Case-insensitive, returns '' on miss to match
the legacy fallback so link-generator call sites stay simple.

For repeated-name maps (TCP/WS-style string[] values) the first value
wins — matches the legacy iteration order so the share URL's Host hint
stays deterministic.

Five unit tests cover undefined/null/empty inputs, case folding,
string-valued and array-valued matches, empty-array edge case, and
missing-key fallback. Suite: 64 tests across 6 files; typecheck + lint
clean.

This unblocks the next slice: per-protocol link generators (genVmessLink
etc.) take a typed inbound + client and call getHeaderValue against the
ws/httpupgrade/xhttp/tcp.request header maps.

* feat(frontend): stream extras + full InboundSchema with DU intersection

Step 3d's last scaffolding piece before link generators. Three new
stream-extras schemas land alongside the network/security DUs:

  - finalmask: TcpMask[] + UdpMask[] + QuicParams. Mask `settings` stays
    record<string, unknown> for now — there are 13 UDP mask types and 3
    TCP mask types with distinct per-type setting shapes, and modeling
    them all as DUs would dwarf the rest of stream/ without buying
    anything the shadow harness doesn't already catch. Tightened in
    Step 6.
  - sockopt: 17 socket-tuning knobs (TCP keepalive, TFO, mark, tproxy,
    mptcp, dialer proxy, IPv6-only, congestion). `interfaceName` field
    matches the panel class naming; serializers rename to `interface` on
    the wire.
  - external-proxy: rows ship per inbound describing edge fronts (CDN
    mirrors). Used by link generators to fan out share URLs.

schemas/api/inbound.ts composes the top-level wire shape with
intersection-of-DUs:

  StreamSettingsSchema = NetworkSettingsSchema
    .and(SecuritySettingsSchema)
    .and(StreamExtrasSchema)

  InboundSchema = InboundCoreSchema.and(InboundSettingsSchema)

A fixture (vless-ws-tls.json) exercises the full shape — protocol DU,
network DU, security DU, and TLS cert file branch in one round trip.
The snapshot pins the canonical parsed form so the upcoming link
extractor consumes typed input with no class hierarchy underneath.

Suite: 65 tests across 7 files; typecheck + lint clean. Zod 4
intersection-of-DUs works.

* refactor(frontend): extract genVmessLink to lib/xray/inbound-link.ts

First link generator to leave the class hierarchy. genVmessLink takes a
typed Inbound + client args and returns the base64-encoded vmess://
URL. Internal helpers (buildXhttpExtra, applyXhttpExtraToObj,
applyFinalMaskToObj, applyExternalProxyTLSObj, serializeFinalMask,
hasShareableFinalMaskValue, externalProxyAlpn) port across from
XrayCommonClass — same logic, rewritten to read the Zod schemas'
Record<string, string> headers instead of the legacy HeaderEntry[].

Parity test (inbound-link.test.ts) loads each vmess fixture in
golden/fixtures/inbound-full, parses it with InboundSchema for the new
pure fn AND constructs LegacyInbound.fromJson(raw) for the class method,
then asserts the URLs match byte-for-byte. Drift between the two impls
fails here before the call sites in pages/inbounds/* get swapped.

Adds a small test setup file that aliases globalThis.window to globalThis
so Base64.encode's window.btoa works under Node — keeps the test env at
'node' and avoids pulling jsdom as a new dep.

A first vmess-tcp-tls full-inbound fixture pins the round-trip path.

Suite: 67 tests across 8 files; typecheck + lint clean. Five more link
generators (vless/trojan/ss/hysteria/wireguard) plus the orchestrator
(toShareLink, genAllLinks) follow in subsequent turns.

* test(frontend): refresh inbound-full snapshot with vmess-tcp-tls fixture

* refactor(frontend): extract genVlessLink to lib/xray/inbound-link

Second link generator. genVlessLink builds the
vless://<uuid>@<host>:<port>?<query>#<remark> share URL from a typed
Inbound + client args, dispatching on streamSettings.network for the
network-specific knobs and on streamSettings.security for the
TLS/Reality knobs. Three param-style helpers move alongside the obj-
style ones already in this file:

  - applyXhttpExtraToParams — writes path/host/mode/x_padding_bytes and
    the JSON extra blob into URLSearchParams
  - applyFinalMaskToParams — writes the fm payload when shareable
  - applyExternalProxyTLSParams — overrides sni/fp/alpn when an external
    proxy entry is supplied and security is tls

A vless-tcp-reality fixture lands alongside the existing vless-ws-tls
one, so the parity test now exercises both security branches.

Discovered a latent legacy bug while writing parity: the old class
stored realitySettings.serverNames as a comma-joined string and gated
SNI on `!ObjectUtil.isArrEmpty(serverNames)`, which always returns true
for strings — so SNI was never written into Reality share URLs.
Existing clients rely on the omission (they pull SNI from
realitySettings.target instead). We preserve the omission here to keep
this extraction byte-stable; an inline comment marks the spot for a
separate intentional fix.

Suite: 70 tests across 8 files; typecheck + lint clean.

* refactor(frontend): extract genTrojanLink + genShadowsocksLink to lib/xray

Third and fourth link generators. genTrojanLink mirrors genVlessLink's
shape (URLSearchParams + network/security branches + remark hash) minus
the encryption/flow VLESS-isms. genShadowsocksLink shares the same query
construction but base64-encodes the userinfo portion as method:password
or method:settingsPw:clientPw depending on whether SS-2022 is in
single-user or multi-user mode.

Three reusable helpers move out of the per-protocol functions:
  - writeNetworkParams: the per-network switch that all param-style
    links share (tcp http header / kcp mtu+tti / ws path+host /
    grpc serviceName+authority / httpupgrade / xhttp extras)
  - writeTlsParams: fingerprint/alpn/ech/sni
  - writeRealityParams: pbk/sid/spx/pqv (preserves the SNI-omission
    legacy parity quirk noted in the genVlessLink commit)

genVmessLink stays with its inline switch — it builds a JSON obj instead
of URLSearchParams and has per-network quirks (kcp emits mtu+tti at
the obj root, grpc maps multiMode to obj.type='multi') that don't
factor cleanly through the shared writer.

Two new full-inbound fixtures (trojan-ws-tls, shadowsocks-tcp-2022)
plus matching parity tests bring the suite to 74 tests across 8 files;
typecheck + lint clean.

* refactor(frontend): extract genHysteriaLink + Wireguard link/config to lib/xray

Fifth and sixth link generators. genHysteriaLink builds the v1/v2
share URL (scheme picked from settings.version), copying TLS knobs into
the query, surfacing the salamander obfs password from
finalmask.udp[type=salamander] when present, and writing the broader
finalmask payload under `fm` like the other links.

Legacy parity note: the old genHysteriaLink read
stream.tls.settings.allowInsecure, which isn't a field on
TlsStreamSettings.Settings — the guard always evaluated false and the
`insecure` param never made it into the URL. We omit it here to stay
byte-stable.

genWireguardLink and genWireguardConfig take a typed
WireguardInboundSettings + peer index and:

  - link: wireguard://<peerPriv>@host:port?publickey=&address=&mtu=#remark
  - config: the .conf text WireGuard clients consume directly

Both derive the server pubKey from settings.secretKey via
Wireguard.generateKeypair at call time — Zod stores only secretKey on
the wire (pubKey is computed). The Wireguard utility is pure JS (X25519
over Float64Array), so it runs fine under node + the window polyfill we
added with the vmess extraction.

Two new full-inbound fixtures (hysteria-v1-tls, wireguard-server) plus
matching parity tests bring the suite to 78 tests across 8 files;
typecheck + lint clean.

Hysteria2 (protocol literal) parity stays deferred — the legacy
class has no HYSTERIA2 dispatch case, so it can't round-trip a
hysteria2 fixture without a protocol remap. Same trick the shadow
harness uses; revisit in the orchestrator commit.

* refactor(frontend): extract share-link orchestrator to lib/xray/inbound-link

Last slice of Step 3d. Five orchestrator exports compose the per-
protocol generators into the public surface the panel consumes:

  - resolveAddr(inbound, hostOverride, fallbackHostname): picks the
    address that goes into share/sub URLs. Browser `location.hostname`
    is no longer a hidden dependency — callers pass it in (or any other
    fallback they want).
  - getInboundClients(inbound): protocol-aware clients accessor.
    Mirrors the legacy `Inbound.clients` getter, including the SS
    quirk where 2022-blake3-chacha20 single-user inbounds report null
    (no client loop) and everything else returns the clients array.
  - genLink: per-protocol dispatcher matching legacy Inbound.genLink.
  - genAllLinks: per-client fanout. Builds the remarkModel-formatted
    remark (separator + 'i'/'e'/'o' field picker) and iterates
    streamSettings.externalProxy when present.
  - genInboundLinks: top-level \r\n-joined link block. Loops per
    client for clientful protocols, single-shots SS for non-multi-user,
    and delegates to genWireguardConfigs for wireguard. Returns ''
    for http/mixed/tunnel (no share URL at all).

Plus genWireguardLinks / genWireguardConfigs fanouts which iterate
peers and append index-suffixed remarks.

Parity test exercises every full-inbound fixture against legacy
Inbound.genInboundLinks. Skips hysteria2 (no legacy dispatch case;
that bridge belongs in a separate intentional commit alongside the
form modal swap). Suite: 89 tests across 8 files; typecheck + lint
clean.

Next: Step 4 form modal migrations. Forms can now drop
`new Inbound.Settings.getSettings(protocol)` in favor of the
createDefault*InboundSettings factories, and InboundsPage clone can
swap to genInboundLinks. Models/ deletion follows in Step 5 once all
call sites are off the class.

* refactor(frontend): swap InboundsPage clone fallback off Inbound.Settings.getSettings

First Step 4 call-site swap. createDefaultInboundSettings(protocol) lands
in lib/xray/inbound-defaults — a protocol-aware dispatch over the 10
per-protocol settings factories already in this module. Returns a Zod-
parsable plain object instead of a class instance, so callers that just
need the wire-shape JSON can drop the class hierarchy without touching
the broader form modals.

InboundsPage's clone path used Inbound.Settings.getSettings(p).toString()
as the fallback when settings JSON parsing failed. That's now
createDefaultInboundSettings + JSON.stringify, with a final '{}' guard
for unknown protocols (legacy returned null and .toString() crashed —
we just emit empty settings instead). The Inbound import on this file
is now unused and removed.

The 2 remaining getSettings call sites in InboundFormModal aren't safe
to swap in isolation — the form mutates the returned class instance
through methods like .addClient() and .toJson() across ~2000 lines of
JSX. Those land with the full Pattern A rewrite of InboundFormModal,
which the plan budgets at multiple days on its own.

Suite: 89 tests across 8 files; typecheck + lint clean.

* refactor(frontend): lift Protocols + TLS_FLOW_CONTROL consts to schemas/primitives

Step 4b. The Protocols and TLS_FLOW_CONTROL enums on models/inbound.ts
were dragging five page files into that 3,300-line module just to read
literal string constants. Lifting them to schemas/primitives lets those
pages drop the @/models/inbound import entirely.

  - schemas/primitives/protocol.ts now exports a Protocols const map
    alongside the existing ProtocolSchema. TUN stays in the const for
    parity (legacy panel deployments may have saved TUN inbounds) even
    though the Go validator no longer accepts it as a new write.
  - schemas/primitives/flow.ts now exports TLS_FLOW_CONTROL. The
    empty-string default isn't keyed because the legacy never had a
    NONE entry — call sites compare against the two real flow values.

Updated five consumers:
  - useInbounds.ts: TRACKED_PROTOCOLS now annotated readonly string[]
    so .includes(string) keeps narrowing through the array literal
  - QrCodeModal.tsx, InboundInfoModal.tsx: Protocols
  - ClientFormModal.tsx, ClientBulkAddModal.tsx: TLS_FLOW_CONTROL

Suite: 89 tests across 8 files; typecheck + lint clean.

models/inbound.ts is now imported by:
  - InboundFormModal.tsx (heavy use of Inbound class + getSettings)
  - test/inbound-link.test.ts + test/shadow.test.ts + test/headers.test.ts
    (intentional — these are parity tests against the legacy class)

OutboundFormModal still imports from models/outbound. Both form modals
are the multi-day Pattern A rewrites the plan scopes separately.

* refactor(frontend): lift OutboundProtocols + OutboundDomainStrategies to schemas/primitives

Moves the two outbound-side consts out of models/outbound.ts and into
schemas/primitives/outbound-protocol.ts. Renames the export to
OutboundProtocols to disambiguate from the inbound Protocols const
(different key casing — PascalCase vs ALL CAPS — and partly different
member set, so they cannot share a single const).

OutboundsTab.tsx keeps its 15+ Protocols.X call sites by aliasing
the import. FinalMaskForm.tsx and BasicsTab.tsx swap directly.
Drops a stale `as string[]` cast in BasicsTab that no longer fits
the new readonly-tuple typing.

After this commit only the two big form modals
(InboundFormModal/OutboundFormModal) plus three intentional parity
tests still import from @/models/.

* refactor(frontend): lift outbound option dictionaries to schemas/primitives

Adds schemas/primitives/options.ts with UTLS_FINGERPRINT, ALPN_OPTION,
SNIFFING_OPTION, USERS_SECURITY, MODE_OPTION (all identical between
models/inbound.ts and models/outbound.ts) plus the outbound-only
WireguardDomainStrategy, Address_Port_Strategy, and DNSRuleActions.

OutboundFormModal now pulls 9 consts from primitives. Only `Outbound`
(the class) and `SSMethods` (whose inbound/outbound versions diverge by
2 legacy aliases — keep the picker open for the Pattern A rewrite) still
come from @/models/outbound.

Drops three stale `as string[]` casts on what are now readonly tuples.

* refactor(frontend): swap InboundFormModal option dicts to schemas/primitives

Extends primitives/options.ts with the five inbound-only option dicts
(TLS_VERSION_OPTION, TLS_CIPHER_OPTION, USAGE_OPTION,
DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) and lifts InboundFormModal
off @/models/inbound for 10 of its 12 imports. Only the Inbound class
and SSMethods (inbound vs outbound versions diverge by 2 entries) still
come from @/models/.

Widens NODE_ELIGIBLE_PROTOCOLS Set element type to string since the new
primitives const exposes a narrow literal union that `.has(arbitraryString)`
would otherwise reject.

* feat(frontend): InboundFormValues schema for Pattern A rewrite

Foundation for the InboundFormModal rewrite. Mirrors the wire Inbound
shape (intersection of core fields + protocol settings DU + stream/security
DUs) plus the DB-side fields (up/down/total/trafficReset/nodeId/...) that
flow through DBInbound rather than the xray config slice.

InboundStreamFormSchema is exported separately so individual sub-form
sections can rule against just the stream portion when needed.

FallbackRowSchema is co-located here even though fallbacks save via a
distinct endpoint after the main POST — they belong to the same form
state from the user's perspective.

No modal changes in this commit. Foundation only; subsequent turns swap
the modal's `inboundRef`/`dbFormRef` mutable-class state for
Form.useForm<InboundFormValues>().

* feat(frontend): adapter between raw inbound rows and InboundFormValues

Adds lib/xray/inbound-form-adapter.ts with rawInboundToFormValues and
formValuesToWirePayload. The pair is the data boundary the upcoming
Pattern A modal will use: it consumes the DB row shape (settings et al.
as string OR object — coerced internally), hands the modal typed
InboundFormValues, and on submit reverses the trip to a wire payload
with the three JSON-stringified slices the Go endpoints expect.

No dependency on the legacy Inbound/DBInbound classes — the coerce step
is inlined so the adapter survives the eventual models/ deletion.

Adds 10 Vitest cases covering string vs object inputs, the optional
streamSettings/nodeId fields, trafficReset coercion, and a raw-to-payload
-to-raw round-trip equality.

* feat(frontend): protocol capability predicates as pure functions

Adds lib/xray/protocol-capabilities.ts with the seven predicates the
modals call: canEnableTls, canEnableReality, canEnableTlsFlow,
canEnableStream, canEnableVisionSeed, isSS2022, isSSMultiUser. Each
takes a minimal slice of an InboundFormValues, no class instance.

The legacy isSSMultiUser returns true on non-shadowsocks protocols too
(method getter resolves to "" which != blake3-chacha20-poly1305). The
new function preserves this quirk and documents it inline; callers all
narrow on protocol === shadowsocks before checking, so the surprising
return value never surfaces.

Parity harness in test/protocol-capabilities.test.ts crosses each of
the 10 golden fixtures with 14 stream configurations (network × security)
and asserts each predicate matches the legacy class method — 140 cases,
all green.

* feat(frontend): outbound settings factories + dispatcher

Adds lib/xray/outbound-defaults.ts parallel to inbound-defaults.ts:
13 createDefault*OutboundSettings factories (one per outbound protocol)
plus the createDefaultOutboundSettings(protocol) dispatcher mirroring
Outbound.Settings.getSettings's contract — non-null on each known
protocol, null otherwise.

The factory output matches the legacy `new Outbound.<X>Settings()` start
state: required-by-schema fields the user fills in via the form
(address, port, password, id, peer publicKey/endpoint) come back as
empty stubs. Wireguard alone seeds secretKey via the X25519 generator;
the rest expose blank fields. This is the same behavior the
OutboundFormModal relies on for protocol-change resets.

Shadowsocks defaults to 2022-blake3-aes-128-gcm rather than the legacy
undefined — the Select snaps to the first option anyway, so the
coherent default keeps the modal from rendering an empty picker.

Tests cover three layers:
- exact-shape snapshots per factory (13 cases)
- Zod schema acceptance after sensible stub fill-in (13 cases)
- dispatcher non-null per known protocol + null for the unknown (14 cases)

* feat(frontend): InboundFormModal.new.tsx skeleton (Pattern A)

First commit of the sibling-file modal rewrite. The new modal mounts
Form.useForm<InboundFormValues>, hydrates via rawInboundToFormValues on
open (edit) or buildAddModeValues (add), runs validateFields + safeParse
on submit, and posts the formValuesToWirePayload result. No tabs yet —
the modal body shows a WIP placeholder.

The file is not imported anywhere; the existing InboundFormModal.tsx
remains the one InboundsPage renders. Build, lint, and 280 tests stay
green. Subsequent commits add the basic / sniffing / protocol / stream /
security / advanced / fallbacks sections; the atomic import swap in
InboundsPage.tsx lands last.

* feat(frontend): basic tab on InboundFormModal.new.tsx (Pattern A)

First real section of the sibling-file rewrite. Wires AntD Form.Items
to InboundFormValues paths for the basic tab — enable, remark, deployTo
(when protocol is node-eligible), protocol, listen, port, totalGB,
trafficReset, expireDate.

The port input gets a per-field antdRule against
InboundFormBaseSchema.shape.port — the spec's Pattern A reference. The
intersection-typed InboundFormSchema has no .shape accessor, so per-field
rules pull from the underlying ZodObject components.

totalGB and expireDate are bytes/timestamp on the wire but a GB number /
dayjs picker in the UI. Both use shouldUpdate-closure children that read
form state and call setFieldValue on user input — no transient
form-only fields, no DU-shape surprises at submit time.

Protocol-change cascade lives in Form's onValuesChange: pick a new
protocol and the settings DU branch is reset to
createDefaultInboundSettings(next); a non-node-eligible protocol also
clears nodeId.

Modal still renders a single-tab Tabs container. Sniffing tab is next.

* feat(frontend): sniffing tab on InboundFormModal.new.tsx (Pattern A)

Second section of the sibling-file rewrite. Wires the six sniffing
sub-fields to nested form paths ['sniffing', 'enabled'], ['sniffing',
'destOverride'], etc. Uses Form.useWatch on the enabled flag to drive
conditional rendering of the dependent fields — the same gate the
legacy modal expressed via `ib.sniffing.enabled &&`.

Checkbox.Group renders one Checkbox per SNIFFING_OPTION entry. The two
exclusion lists use Select mode="tags" so the user can paste comma-
separated IP/CIDR or domain rules.

No transient form state, no class methods — every field maps directly
to a wire-shape path in InboundFormValues.

Protocol tab is next.

* feat(frontend): protocol tab VLESS auth on InboundFormModal.new.tsx

Adds the protocol tab to the sibling-file rewrite — currently only the
VLESS section, which lays out decryption/encryption inputs and the three
buttons that drive them: Get New x25519, Get New mlkem768, Clear.

getNewVlessEnc + clearVlessEnc are ported from the legacy modal as
pure setFieldValue paths into ['settings', 'decryption'] /
['settings', 'encryption'] — no class methods, no inboundRef. The
matchesVlessAuth helper mirrors the legacy fuzzy label-matching so the
backend response shape stays the only source of truth.

selectedVlessAuth derives the displayed auth label from the encryption
string via Form.useWatch — same heuristic as the legacy modal
(.length > 300 → mlkem768, otherwise x25519).

Tab spread is conditional: the protocol tab only appears when
protocol === 'vless' right now. As more protocol sections land
(shadowsocks, http/mixed, tunnel, tun, wireguard) the condition will
widen to cover each one.

* feat(frontend): protocol tab Shadowsocks section (Pattern A)

Adds the Shadowsocks sub-form: method picker (from SSMethodSchema's
seven schema-aligned options), conditional password input gated on
isSS2022, network picker (tcp/udp/tcp,udp), ivCheck toggle.

Method change cascades through the Select's onChange — regenerating
the inbound-level password via RandomUtil.randomShadowsocksPassword.
The shadowsockses[] multi-user list reset is deferred until the
clients-management section lands.

Uses isSS2022 from lib/xray/protocol-capabilities to gate the password
field exactly the way the legacy modal did — keeps the form behavior
identical without referencing the legacy class.

SSMethodSchema.options drives the Select rather than the legacy
SSMethods const (which the inbound modal pulled from models/inbound.ts).
This commits to the schema-aligned 7-entry list for inbound; the
outbound divergence (9 entries with legacy aliases) is still pending
in OutboundFormModal — defer the UX decision to that rewrite.

* feat(frontend): protocol tab HTTP and Mixed sections (Pattern A)

Adds the HTTP and Mixed sub-forms. Both share an accounts list — first
Form.List usage in the rewrite. Each row binds via [field.name, 'user']
/ [field.name, 'pass'] under the parent ['settings', 'accounts'] path,
so the wire shape stays exactly what HttpInboundSettingsSchema and
MixedInboundSettingsSchema validate.

HTTP-only: allowTransparent Switch.
Mixed-only: auth Select (noauth/password), udp Switch, conditional ip
Input gated on the udp value via Form.useWatch.

Tab visibility widens to include http + mixed alongside vless +
shadowsocks. The string cast on the includes-check keeps the frozen
Protocols const's narrow union from rejecting the broader protocol
string at the call site.

* feat(frontend): protocol tab Tunnel section (Pattern A)

Adds the Tunnel sub-form: rewriteAddress + rewritePort, allowedNetwork
picker (tcp/udp/tcp,udp), Form.List-driven portMap with name/value
pairs, and the followRedirect Switch.

portMap is the second Form.List in the rewrite — same shape as the
HTTP/Mixed accounts list but with name/value rather than user/pass.
The wire shape stays `settings.portMap: { name, value }[]` exactly.

Tab visibility widens to Tunnel.

* feat(frontend): protocol tab TUN section (Pattern A)

Adds the TUN sub-form: interface name, MTU, four primitive-array
Form.Lists (gateway, dns, autoSystemRoutingTable), userLevel,
autoOutboundsInterface.

Primitive Form.Lists bind each row's Input directly to `field.name`
(no inner key) — distinct from the object-row Form.Lists that bind to
`[field.name, 'fieldKey']`.

The Form.useWatch('protocol') return type comes from the schema's
protocol enum which excludes 'tun' (TUN is in the legacy Protocols
const for data parity but never accepted by the wire validator). Cast
to string at the source so per-section comparisons against
Protocols.TUN typecheck. Why: legacy DB rows with protocol === 'tun'
still need to render; widening here keeps reads from rejecting them.

Tab visibility widens to TUN.

* feat(frontend): protocol tab Wireguard section (Pattern A)

Adds the Wireguard sub-form: server secretKey input with regen icon,
derived disabled public-key display, mtu, noKernelTun toggle, and a
Form.List of peers — each peer having its own privateKey (regen icon),
publicKey, preSharedKey, allowedIPs (nested Form.List for the string
array), keepAlive.

pubKey is purely derived (computed via Wireguard.generateKeypair from
the watched secretKey) and is NOT stored in the form value — the schema
omits it from the wire shape on purpose. The disabled display shows the
live derivation without polluting form state.

regenInboundWg generates a fresh keypair and writes only the
secretKey path; pubKey re-derives automatically. regenWgPeerKeypair
writes both privateKey and publicKey at the peer's path index.

The preSharedKey wire-shape name is used instead of the legacy class's
internal psk — matches WireguardInboundPeerSchema.

Tab visibility widens to Wireguard.

* feat(frontend): stream tab skeleton with TCP + KCP (Pattern A)

Opens the stream tab on the sibling-file rewrite. Tab visibility is
driven by canEnableStream from lib/xray/protocol-capabilities — same
gate the legacy modal used, now schema-aware.

Transmission picker (network select) is hidden for HYSTERIA since
that protocol's network is implicit. onNetworkChange clears any stale
per-network settings keys (tcpSettings/kcpSettings/...) and seeds an
empty object for the new branch so AntD Form.Items don't read from
undefined nested paths.

TCP section: acceptProxyProtocol Switch (literal-true-optional on the
wire — the form stores true/false but Zod's strip behavior keeps
false-as-omission round-trips clean) plus an HTTP-camouflage toggle
that flips header.type between 'none' and 'http'. The full HTTP
camouflage request/response sub-form lands in a follow-up commit.

KCP section: six numeric knobs (mtu, tti, upCap, downCap,
cwndMultiplier, maxSendingWindow).

WS / gRPC / HTTPUpgrade / XHTTP / external-proxy / sockopt / hysteria
stream / FinalMaskForm hookup all still pending.

* feat(frontend): stream tab WS + gRPC + HTTPUpgrade sections (Pattern A)

Adds the three medium-complexity network branches to the stream tab.
Plain Form.Item paths into the corresponding *Settings keys — no
Form.List wrappers since these schemas don't have arrays at the top
level.

WS: acceptProxyProtocol, host, path, heartbeatPeriod
gRPC: serviceName, authority, multiMode
HTTPUpgrade: acceptProxyProtocol, host, path

Header editing is deferred to a later commit — WsHeaderMap is a
Record<string,string> on the wire, V2HeaderMap a Record<string,string[]>,
and the form needs an array-of-{name,value} UI that converts on edit.
Worth building once and reusing across WS, HTTPUpgrade, XHTTP, TCP
request/response, and Hysteria masquerade headers.

XHTTP + external-proxy + sockopt + hysteria stream + finalmask hookup
still pending.

* feat(frontend): stream tab XHTTP section (Pattern A)

XHTTP is the heaviest network branch — 19 fields rendered conditionally
on mode, xPaddingObfsMode, and the three *Placement selectors. Each
gates its dependent field set via Form.useWatch.

Field structure mirrors the legacy XHTTPStreamSettings form 1:1:
- mode picker (auto / packet-up / stream-up / stream-one)
- packet-up adds scMaxBufferedPosts + scMaxEachPostBytes; stream-up
  adds scStreamUpServerSecs
- serverMaxHeaderBytes, xPaddingBytes, uplinkHTTPMethod (with the
  packet-up gate on the GET option)
- xPaddingObfsMode unlocks xPadding{Key,Header,Placement,Method}
- sessionPlacement / seqPlacement each unlock their respective Key
  field when set to anything other than 'path'
- packet-up mode additionally unlocks uplinkDataPlacement, and that
  in turn unlocks uplinkDataKey when the placement is not 'body'
- noSSEHeader Switch at the tail

XHTTP headers editor still pending (same WsHeaderMap as WS — will be
unified in the header-editor extraction commit).

* feat(frontend): stream tab external-proxy + sockopt sections (Pattern A)

External Proxy: Switch driven by externalProxy array length. Toggling
on seeds one row with the window hostname + the inbound's current port;
toggling off clears the array. Each row is a Form.List item with
forceTls/dest/port/remark inline, and a nested SNI/Fingerprint/ALPN
row that conditionally renders on forceTls === 'tls' via a
shouldUpdate-closure that watches the per-row forceTls path.

Sockopt: Switch driven by whether the sockopt object exists in form
state. Toggling on calls SockoptStreamSettingsSchema.parse({}) so every
default the schema declares (mark=0, tproxy='off', domainStrategy='UseIP',
tcpcongestion='bbr', etc.) flows into the form; toggling off sets to
undefined.

Renders the seventeen sockopt fields directly bound to
['streamSettings', 'sockopt', X] paths. Option lists pull from the
primitives const dictionaries (UTLS_FINGERPRINT, ALPN_OPTION,
DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) rather than the
schema's .options to keep one source of truth for UI label strings.

* feat(frontend): security tab base + TLS section (Pattern A)

Adds the security tab to the sibling-file rewrite. Visibility is paired
with the stream tab — both gated on canEnableStream. The security
selector is itself disabled when canEnableTls is false, and the reality
option only appears when canEnableReality is true, mirroring the legacy
modal's Radio.Group guards.

onSecurityChange clears the previous branch's *Settings key and seeds
the new branch from the schema's parsed defaults (the same trick the
sockopt toggle uses). The security selector itself is rendered via a
shouldUpdate closure so the on-change handler can write the cleaned
streamSettings shape atomically without racing AntD's per-field sync.

TLS section: serverName (the wire field — the legacy class calls it
sni internally), cipherSuites (with the 13 named suites from
TLS_CIPHER_OPTION), min/max version pair, uTLS fingerprint, ALPN
multi-select, plus the three policy Switches.

TLS certificates list, ECH controls, the full Reality sub-form, and
the four API-call buttons (genRealityKeypair / genMldsa65 / getNewEchCert
/ randomizers) land in a follow-up commit.

* feat(frontend): security tab Reality + ECH + mldsa65 controls (Pattern A)

Adds the Reality sub-form and the four API-call buttons that drive
the server-generated material:

- genRealityKeypair calls /panel/api/server/getNewX25519Cert and writes
  the result into ['streamSettings', 'realitySettings', 'privateKey']
  and the nested settings.publicKey path.
- genMldsa65 calls /panel/api/server/getNewmldsa65 for the
  post-quantum seed/verify pair.
- getNewEchCert calls /panel/api/server/getNewEchCert with the current
  serverName and writes echServerKeys + settings.echConfigList.
- randomizeRealityTarget seeds target + serverNames from the random
  reality-targets pool.
- randomizeShortIds calls RandomUtil.randomShortIds (comma-joined
  string) and splits into the schema's string[] form.

Reality fields are bound directly to schema paths — show/xver/target,
maxTimediff, min/max ClientVer, the settings.{publicKey, fingerprint,
spiderX, mldsa65Verify} nested subtree, plus the array fields
(serverNames, shortIds) rendered as Select mode="tags" since both ship
as string[] on the wire.

TLS certificates list (Form.List with the useFile DU) still pending —
that's a chunky sub-form on its own.

* feat(frontend): security tab TLS certificates list (Pattern A)

Closes out the security tab: a Form.List of certificates that toggles
between TlsCertFileSchema (certificateFile + keyFile string paths) and
TlsCertInlineSchema (certificate + key as string arrays per the wire
shape) via a per-row useFile boolean.

useFile is a transient form-only field — not part of TlsCertSchema.
Zod's default-strip behavior drops it during InboundFormSchema parse
on submit, leaving only the matching wire branch's keys populated.
Whichever side the user wasn't on stays empty, so Zod's union picks
the populated branch.

For inline certs the TextAreas use normalize + getValueProps to convert
between the wire-side string[] and the multi-line text the user types.
Each line becomes one array element, matching the legacy class's
`cert.split('\n')` toJson convention.

Per-row buildChain is conditionally rendered when usage === 'issue' —
a shouldUpdate-closure watches the specific path so the toggle
re-renders inline without listening to unrelated form changes.

Security tab is now functionally complete. Advanced JSON tab,
Fallbacks card, and the atomic swap in InboundsPage are next.

* feat(frontend): advanced JSON tab on InboundFormModal.new.tsx (Pattern A)

Adds the advanced JSON tab. Each sub-tab (settings / streamSettings /
sniffing) renders an AdvancedSliceEditor — a small CodeMirror-backed
JsonEditor that holds a local text buffer and forwards parsed JSON to
form state on every valid edit.

Invalid JSON sits silently in the local buffer; once the user finishes
balancing braces / quoting, the next valid parse pushes through to the
form. No stamping ref, no apply-on-tab-switch ceremony — the form is
the single source of truth.

The buffer seeds once from form state on mount. The Modal's
destroyOnHidden means each open is a fresh editor instance, so external
form mutations during a single open session can't desync the editor
either.

The streamSettings sub-tab is omitted when streamEnabled is false
(matching the legacy modal's behavior for protocols like Http / Mixed
that have no stream layer).

* feat(frontend): fallbacks card on InboundFormModal.new.tsx (Pattern A)

Adds the fallbacks card rendered inside the protocol tab whenever the
current values describe a fallback host — VLESS or Trojan on tcp with
tls or reality security. The protocol tab visibility widens to include
Trojan in that exact case (it has no other protocol sub-form).

Fallbacks live in a useState alongside the form rather than inside form
values, mirroring the legacy modal: fallbacks save via a distinct
endpoint (/panel/api/inbounds/{id}/fallbacks) after the main inbound
POST, not as part of the inbound payload. loadFallbacks runs on open
for edit-mode VLESS/Trojan; saveFallbacks runs after a successful POST
inside the submit handler.

Each row: child picker (filtered down to other inbounds), then four
inline edits for SNI / ALPN / path / xver. Add adds an empty row;
delete pulls the row from state.

Quick-Add-All, the rederive-from-child helper, and the per-row up/down
movers are deferred — the basic add/edit/remove cycle is what the modal
actually needs to function.

* feat(frontend): atomic swap InboundFormModal to Pattern A

Deletes the 2261-line class-mutation modal and renames the
1900-line sibling rewrite into its place. InboundsPage.tsx already
imports the file by path so no consumer change is needed — the swap
is one file delete plus one file rename. Build, lint, and 280 tests
stay green.

What the new modal covers end-to-end:
- Basic (enable / remark / nodeId / protocol / listen / port /
  totalGB / trafficReset / expireDate)
- Sniffing (enabled / destOverride / metadataOnly / routeOnly /
  ipsExcluded / domainsExcluded)
- Protocol per DU branch: VLESS (decryption/encryption + buttons),
  Shadowsocks (method/password/network/ivCheck), HTTP + Mixed
  (accounts list + per-protocol toggles), Tunnel (rewrite + portMap +
  followRedirect), TUN (interface/mtu + four primitive lists +
  userLevel/autoInterface), Wireguard (secretKey + derived pubKey +
  peers list with nested allowedIPs)
- Stream per network: TCP base, KCP, WS, gRPC, HTTPUpgrade, XHTTP
  (the 22-field one), plus external-proxy and sockopt extras
- Security: TLS (SNI/cipher/version/uTLS/ALPN/policy switches +
  certificates list with file/inline toggle + ECH controls), Reality
  (every field + the four API-call buttons), none
- Advanced JSON (settings / streamSettings / sniffing live editors
  that round-trip into form state on every valid parse)
- Fallbacks (load on open for VLESS/Trojan TLS-or-Reality TCP hosts;
  save through the secondary endpoint after the main POST succeeds)

Known regressions vs the legacy modal, all reachable via Advanced JSON
until backfilled in follow-up commits:
- Hysteria stream sub-form (masquerade / udpIdleTimeout / version) —
  schema gap; the existing inbound DU has no hysteria stream branch
- FinalMaskForm hookup — the component is still class-shape coupled
- HeaderMapEditor — TCP request/response headers, WS / HTTPUpgrade /
  XHTTP headers, Hysteria masquerade headers all need a shared editor
- TCP HTTP camouflage request/response body (version, method, path
  list, headers, status, reason) — only the on/off toggle is wired
- Fallbacks polish — up/down move, quick-add-all, rederive-from-child,
  the per-row advanced-toggle / proxy-tag chips

No reference to @/models/inbound's Inbound class anywhere in the new
modal — only @/models/dbinbound (out of scope) and
@/models/reality-targets (out of scope). The protocol-capabilities
predicates and the rawInboundToFormValues + formValuesToWirePayload
adapters carry every behavior the class used to provide.

* fix(frontend): finish InboundFormModal rename after atomic swap

The atomic-swap commit landed the new file but the exported function was
still named InboundFormModalNew. Rename to match the file.

* feat(frontend): outbound form schema + wire adapter foundation

Lay the groundwork for OutboundFormModal's Pattern A rewrite:

- schemas/forms/outbound-form.ts: discriminated-union form values across
  all 12 outbound protocols, with flat per-protocol settings shapes that
  match the legacy class fields (vmess vnext / trojan-ss-socks-http
  servers / wireguard csv address-reserved all flattened).

- lib/xray/outbound-form-adapter.ts: rawOutboundToFormValues converts
  wire-shape outbound JSON to typed form values; formValuesToWirePayload
  re-nests on submit. Replaces the Outbound.fromJson/toJson dependency
  the modal currently has on the legacy class hierarchy.

- test/outbound-form-adapter.test.ts: 15 round-trip cases covering each
  protocol's wire quirks (vmess vnext flatten, vless reverse-wrap,
  wireguard csv↔array, blackhole response wrap, DNS rule normalization,
  mux gating).

* feat(frontend): OutboundFormModal.new.tsx skeleton (Pattern A)

Sibling .new.tsx file with the Modal shell, Tabs (Basic/JSON), Form.useForm
hydration via rawOutboundToFormValues, and the submit pipeline that calls
formValuesToWirePayload before onConfirm. Tag uniqueness check is wired in.

Protocol-specific sub-forms, stream, security, sockopt, and mux sections
are deferred to subsequent commits — accessible via the JSON tab in the
meantime. The InboundsPage continues to render the legacy modal until the
atomic swap at the end.

Also: rawOutboundToFormValues now returns streamSettings as undefined
when the wire payload omits it, so Form.useForm doesn't receive a value
that does not match the NetworkSettings discriminated union.

* feat(frontend): OutboundFormModal.new.tsx vmess/vless/trojan/ss sections

- Shared connect-target sub-block (address + port) for the six protocols
  whose form schema carries them flat at settings root.
- VMess: id + security Select (USERS_SECURITY).
- VLESS: id + encryption + flow + reverseTag (reverse-sniffing slice and
  Vision testpre/testseed come in a later commit).
- Trojan: password.
- Shadowsocks: password + method Select (SSMethodSchema) + UoT switch +
  UoT version.

onValuesChange cascade: when the user picks a different protocol, the
adapter re-seeds the settings sub-object to the new protocol's defaults
so leftover fields from the previous protocol do not bleed through.

* feat(frontend): OutboundFormModal.new.tsx socks/http/hysteria/loopback/blackhole/wireguard sections

- SOCKS / HTTP: user + pass at settings root.
- Hysteria: read-only version=2 (the actual transport knobs live on
  stream.hysteria, added with the stream tab).
- Loopback: inboundTag.
- Blackhole: response type Select with empty/none/http options.
- Wireguard: address (csv) + secretKey (with regenerate icon) + derived
  pubKey + domain strategy + MTU + workers + no-kernel-tun + reserved
  (csv) + peers Form.List with nested allowedIPs sub-list.

Wireguard regenerate icon uses Wireguard.generateKeypair() and writes
both keys to the form via setFieldValue — preserves the legacy UX of
the SyncOutlined inline-icon next to the privateKey label.

* feat(frontend): OutboundFormModal.new.tsx DNS + Freedom + VLESS reverse-sniffing

- DNS: rewriteNetwork (udp/tcp Select) + rewriteAddress + rewritePort +
  userLevel + rules Form.List (action/qtype/domain).

- Freedom: domainStrategy + redirect + Fragment Switch with conditional
  4-field sub-block (legacy 'enable Fragment' UX preserved — Switch sets
  all four fields to populated defaults, off-state empties them all out
  so the adapter strips them on submit) + Noises Form.List (rand/base64/
  str/hex types, packet/delay/applyTo per row) + Final Rules Form.List
  with conditional block-delay sub-field.

- VLESS reverse-sniffing slice: rendered only when reverseTag is set
  (matches the legacy modal's nested conditional). All six fields wired
  to the form state with appropriate widgets (Switch / Select multi /
  Select tags).

* feat(frontend): OutboundFormModal.new.tsx stream tab (TCP/KCP/WS/gRPC/HTTPUpgrade)

Wire the stream sub-form into the Pattern A modal:

- newStreamSlice(network) helper bootstraps the per-network DU branch
  with Xray defaults (mtu=1350, tti=20, uplinkCapacity=5, etc.).
- streamSettings is seeded once when the protocol supports streams
  but the form has no slice yet (new outbound + protocol switch).
- onNetworkChange swaps the sub-key and preserves security when the
  new network still supports it, else snaps back to 'none'.
- Per-network sub-forms wired:
    TCP: HTTP camouflage Switch (sets header.type = 'http' / 'none')
    KCP: 6 numeric tuning fields
    WS: host + path + heartbeat
    gRPC: service name + authority + multi-mode switch
    HTTPUpgrade: host + path
    XHTTP: host + path + mode + padding bytes (advanced fields via JSON)

Security radio, TLS/Reality sub-forms, sockopt, and mux still pending.

* feat(frontend): OutboundFormModal.new.tsx security tab (TLS + Reality + Flow)

- onSecurityChange cascade: swaps tlsSettings/realitySettings sub-key
  matching the DU branch, seeding the new sub-form with empty/default
  fields so the UI does not reference undefined values.

- Flow Select rendered when canEnableTlsFlow is true (VLESS + TCP +
  TLS/Reality). Moved from the basic VLESS section so it only appears
  in the relevant security context — matches the legacy modal UX.

- Security Radio (none / TLS / Reality) gated by canEnableTls and
  canEnableReality pure-function predicates from
  lib/xray/protocol-capabilities.

- TLS sub-form: 6 outbound-specific fields (SNI/uTLS/ALPN/ECH/
  verifyPeerCertByName/pinnedPeerCertSha256) matching the legacy
  TlsStreamSettings flat shape (no certificates list — outbound is
  client-side).

- Reality sub-form: 6 fields (SNI/uTLS/shortId/spiderX/publicKey/
  mldsa65Verify). publicKey + mldsa65Verify get TextAreas to handle
  the long base64 strings.

* feat(frontend): OutboundFormModal.new.tsx sockopt + mux sections

- Sockopts: Switch toggles streamSettings.sockopt between undefined and
  a populated default object (17 fields with sane bbr/UseIP defaults).
  Only the 8 most-used fields are rendered (dialer proxy, domain
  strategy, keep alive interval, TFO, MPTCP, penetrate, mark, interface).
  The remaining sockopt knobs (acceptProxyProtocol, tcpUserTimeout,
  tcpcongestion, tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, V6Only,
  trustedXForwardedFor, tproxy) are still in the wire payload — edit
  them via the JSON tab.

- Mux: gated by isMuxAllowed(protocol, flow, network) — VMess/VLESS/
  Trojan/SS/HTTP/SOCKS, no flow set, no xhttp transport. Sub-fields
  (concurrency / xudpConcurrency / xudpProxyUDP443) only render when
  enabled is true.

- Sockopt section visible only when streamAllowed AND network is set —
  non-stream protocols (freedom/blackhole/dns/loopback) still edit
  sockopt via the JSON tab.

* feat(frontend): atomic swap OutboundFormModal to Pattern A

Delete the legacy 1473-line class-based OutboundFormModal.tsx and replace
it with the new Pattern A modal (Form.useForm + antdRule + per-protocol
discriminated-union form values + wire adapter).

Net diff: legacy file gone, function renamed from OutboundFormModalNew
to OutboundFormModal so the existing OutboundsTab import resolves
unchanged.

What is migrated:
  - All 12 protocols (vmess/vless/trojan/ss/socks/http/wireguard/
    hysteria/freedom/blackhole/dns/loopback)
  - Stream tab with TCP/KCP/WS/gRPC/HTTPUpgrade + partial XHTTP
  - Security tab with TLS + Reality + Flow gating
  - Sockopt + Mux sections (gated by isMuxAllowed)
  - JSON tab with bidirectional bridge to form state
  - Tag uniqueness check
  - VLESS reverse-sniffing slice
  - Freedom fragment/noises/finalRules
  - DNS rewrite + rules list
  - Wireguard peers + nested allowedIPs sub-list
  - Wireguard secret/public key regeneration

Deferred to follow-up commits (still accessible via the JSON tab):
  - XHTTP advanced fields (xmux, sequence/session placement, padding obfs)
  - Hysteria stream transport sub-form
  - TCP HTTP camouflage host/path body
  - WS/HTTPUpgrade/XHTTP headers map editor
  - Remaining sockopt knobs (tcpUserTimeout, tcpcongestion, tcpKeepAliveIdle,
    tcpMaxSeg, tcpWindowClamp, V6Only, trustedXForwardedFor, tproxy,
    acceptProxyProtocol)
  - VLESS Vision testpre/testseed
  - Reality API helpers (random target, x25519/mldsa65 generate-import)
  - Link import (vmess:// vless:// etc → outbound)
  - FinalMaskForm hookup (deferred from inbound rewrite too)

* test(frontend): convert legacy-class parity tests to snapshot baselines

With the inbound/outbound modal rewrites complete, the cross-check
against the legacy Inbound class has served its purpose. The new
pure-function / Zod-schema paths are the source of truth for production
code; the parity assertions were the migration safety net.

Convert the three parity test files to snapshot-based regression tests:

- headers.test.ts: toHeaders + toV2Headers run against snapshots
  captured at the close of the migration (when both new and legacy
  were verified byte-equal).
- protocol-capabilities.test.ts: 140 cases (10 fixtures × 14 stream
  shapes) snapshot the predicate-result tuple. Was: parity vs legacy
  Inbound.canEnableX() class methods.
- inbound-link.test.ts: per-protocol genXxxLink + genInboundLinks
  orchestrator output is snapshotted. Was: byte-equality vs legacy
  Inbound.genXxxLink() methods.

Also delete shadow.test.ts — its purpose was a dual-parse drift
detector (Inbound.Settings.fromJson vs InboundSettingsSchema.parse).
inbound-full.test.ts already snapshots the Zod parse output, which
covers the same ground without the legacy dependency.

models/inbound.ts and models/outbound.ts stay in the tree for now —
DBInbound still consumes Inbound via its toInbound() method, and
DBInbound migration is out of scope per the migration spec
('Do NOT migrate Status, DBInbound, or AllSetting...'). No
production page imports from @/models/inbound or @/models/outbound
directly anymore.

* chore(frontend): enforce no-explicit-any: error + add typecheck/test to CI

Step 7 of the Zod migration: lock the migration's gains in place via
lint + CI enforcement.

- eslint.config.js: `@typescript-eslint/no-explicit-any` set to error.
  Verified locally — zero violations in src/, with the only file-level
  disables being src/models/inbound.ts and src/models/outbound.ts
  (kept for DBInbound's toInbound() consumer; their migration is out
  of spec scope).

- .github/workflows/ci.yml: add Typecheck and Test steps to the
  frontend job, between Lint and Build. PRs now have to pass
  tsc --noEmit and the full vitest suite (285 tests + 172 snapshots)
  before build runs.

Migration scoreboard (vs the spec):
  Step 1 primitives + barrels         done
  Step 2 protocol leaf + DUs          done
  Step 3 pure-fn extraction           done
  Step 4 form modals -> Pattern A     done (Inbound + Outbound)
  Step 5 delete models/ files         DEFERRED (DBInbound still uses
                                      Inbound; spec marks DBInbound
                                      migration out of scope)
  Step 6 tighten .loose() / unknown   DEFERRED (invasive, separate PR)
  Step 7 lint + CI enforcement        done (this commit)

Production code paths now have no direct dependency on the legacy
Inbound or Outbound classes.

* feat(frontend): OutboundFormModal deferred features (Vision seed / TCP host+path / WG pubKey derive)

Three small wins from the post-atomic-swap deferred list:

- VLESS Vision testpre + testseed: shown only when flow ===
  'xtls-rprx-vision' (mirrors the legacy canEnableVisionSeed gate).
  testseed binds to a Select mode='tags' with a normalize() that
  coerces strings to positive integers and drops invalid entries.

- TCP HTTP camouflage host + path: when the TCP HTTP camouflage
  Switch is on, surface two inputs that read/write directly into
  streamSettings.tcpSettings.header.request.headers.Host and .path.
  Both fields are string[] on the wire; normalize + getValueProps
  translate to/from comma-joined strings in the UI (one entry per
  host or path the user wants camouflaged).

- Wireguard pubKey auto-derive: Form.useWatch on settings.secretKey
  + useEffect that runs Wireguard.generateKeypair(secret).publicKey
  on every change and writes the result into the disabled pubKey
  display field. Matches the legacy modal's per-keystroke derive.

* feat(frontend): symmetric TCP HTTP host/path + extra sockopt knobs

OutboundFormModal:
- Sockopt section gains 5 common-but-rarely-tweaked knobs:
  acceptProxyProtocol, tproxy (off/redirect/tproxy), tcpcongestion
  (bbr/cubic/reno), V6Only, tcpUserTimeout. The remaining sockopt
  fields (tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp,
  trustedXForwardedFor) are still edit-via-JSON; they are deeply
  tunable and not commonly touched.

InboundFormModal:
- TCP HTTP camouflage gains host + path inputs symmetric to the
  outbound side. Switch ON seeds request with sensible defaults
  (version 1.1, method GET, path ['/'], empty headers). The two
  inputs use the same normalize/getValueProps comma-string ↔
  string[] dance the outbound side uses, so the wire shape stays
  identical to what xray-core expects.

* feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers

Add a single reusable header-map editor that handles the two wire
shapes Xray uses:

- v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria
  masquerade. One value per name.
- v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage.
  Each header can repeat (RFC 7230 §3.2.2).

Internal state is always a flat list of {name, value} rows regardless
of mode; conversion to/from the wire shape happens at the value /
onChange boundary so consumers bind straight to a Form.Item with no
extra transforms.

Wired into:
- InboundFormModal: WS Headers, HTTPUpgrade Headers
- OutboundFormModal: WS Headers, HTTPUpgrade Headers

XHTTP headers are already in a list-of-rows wire shape (different
from these two), so they keep their bespoke editor. Hysteria
masquerade is still deferred until the Hysteria stream sub-form
lands.

* feat(frontend): Hysteria stream sub-form (schema branch + outbound UI)

Add the 7th branch to NetworkSettingsSchema for Hysteria transport.

schemas/protocols/stream/hysteria.ts:
- HysteriaStreamSettingsSchema covers the full wire shape: version=2,
  auth, congestion (''|'brutal'), up/down bandwidth strings, optional
  udphop sub-object for port-hopping, receive-window tuning fields,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery.

schemas/protocols/stream/index.ts:
- NetworkSchema gains 'hysteria'.
- NetworkSettingsSchema gains the 7th branch
  { network: 'hysteria', hysteriaSettings: HysteriaStreamSettingsSchema }.

OutboundFormModal.tsx:
- NETWORK_OPTIONS keeps the 6 standard transports for non-hysteria
  protocols; when protocol === 'hysteria', a 7th option is appended
  (matches the legacy [...NETWORKS, 'hysteria'] gate).
- newStreamSlice handles the 'hysteria' case with sensible defaults
  matching the legacy HysteriaStreamSettings constructor.
- New sub-form when network === 'hysteria': 8 common fields (auth,
  congestion, up, down, udphop Switch + 3 nested fields when on,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery).
- Receive-window tuning fields are still edit-via-JSON (rarely
  touched + would clutter the form).

* feat(frontend): fallbacks polish — move up/down + Add all button

Two small UX wins on the InboundFormModal Fallbacks card:

- Per-row Move up / Move down buttons (ArrowUp/Down icons) that swap
  adjacent indices. Order survives reloads via sortOrder (rebuilt from
  index on save). First row's Up button + last row's Down button are
  disabled.

- 'Add all' button next to 'Add fallback' that one-shot inserts a
  fresh row for every eligible inbound (every option in
  fallbackChildOptions) not already wired up. Disabled when every
  eligible inbound is already covered. Convenient for operators
  running catch-all routing across every host on the panel.

* feat(frontend): XHTTP advanced fields on outbound modal

Replace the 'edit via JSON' deferred-features hint with the full XHTTP
sub-form matching the legacy modal's XhttpFields helper.

schemas/protocols/stream/xhttp.ts:
- New XHttpXmuxSchema: 6 connection-multiplexing knobs
  (maxConcurrency, maxConnections, cMaxReuseTimes, hMaxRequestTimes,
  hMaxReusableSecs, hKeepAlivePeriod).
- XHttpStreamSettingsSchema gains 5 outbound-only fields and one
  UI-only toggle: scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader,
  xmux, enableXmux.

outbound-form-adapter.ts:
- New stripUiOnlyStreamFields() drops xhttpSettings.enableXmux on the
  way to wire so the panel never embeds the UI toggle into the saved
  config. xray-core ignores unknown fields anyway, but the panel reads
  back its own emitted JSON, so a clean wire shape matters.

OutboundFormModal.tsx:
- Headers editor (HeaderMapEditor v1) for xhttpSettings.headers.
- Padding obfs Switch + 4 conditional fields (key/header/placement/
  method) when on.
- Uplink HTTP method Select with GET disabled outside packet-up.
- Session placement + session key (key shown when placement != path).
- Sequence placement + sequence key (same pattern).
- packet-up mode: scMinPostsIntervalMs, scMaxEachPostBytes, uplink
  data placement + key + chunk size (key/chunk-size shown when
  placement != body).
- stream-up / stream-one mode: noGRPCHeader Switch.
- XMUX Switch + 6 nested fields when on.

* feat(frontend): inbound TCP HTTP camouflage response fields + request headers

Complete the TCP HTTP camouflage UI on the inbound side.

Already there from the previous symmetric host/path commit:
- Request host (string[] via comma-string)
- Request path (string[] via comma-string)

This commit adds:
- Request headers (V2 map: name -> string[]) via HeaderMapEditor.
- Response version (defaults to '1.1' when camouflage toggles on).
- Response status (defaults to '200').
- Response reason (defaults to 'OK').
- Response headers (V2 map) via HeaderMapEditor.

The HTTP camouflage Switch seeds both request and response sub-objects
on toggle-on so xray-core sees a valid TcpHeader.http shape from the
first save. Without the response seed, partial fills would emit a
schema-incomplete response block that xray-core might reject.

* feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2)

The legacy outbound modal could import a vmess://, vless://, trojan://,
ss://, or hysteria2:// share link via a Convert button on the JSON
tab. Restore that UX with a focused pure-function parser.

lib/xray/outbound-link-parser.ts:
- parseVmessLink: base64 JSON, maps net/tls + per-network params onto
  the discriminated stream branch.
- parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow
  query params, dispatches transport via buildStream + applies
  security params via applySecurityParams.
- parseTrojanLink: same URL pattern, defaults security to tls.
- parseShadowsocksLink: both modern (base64 userinfo@host:port) and
  legacy (base64 of whole thing) ss:// formats.
- parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes,
  uses the hysteria stream branch with version=2 + TLS h3.
- parseOutboundLink dispatcher returns the first non-null parser
  result, or null when no scheme matches.

test/outbound-link-parser.test.ts:
- 13 cases covering happy paths for each protocol family plus malformed
  input, ss:// dual-format handling, hy2:// alias.

OutboundFormModal.tsx:
- Import button on the JSON tab Input.Search; on success, parsed
  payload flows through rawOutboundToFormValues, the form is reset,
  and we switch back to the Basic tab.
- Tag is preserved when the parsed link does not carry one.

Out of scope: advanced fields the legacy parser handled (xmux, padding
obfs, reality short IDs, finalmask from fm= param). Power users can
finish the import in the form after the basics land.

* feat(frontend): inbound Hysteria stream sub-form (auth + udpIdleTimeout + masquerade)

Restore the inbound side of Hysteria stream configuration that was
previously hidden — the legacy modal exposed these knobs but the
Pattern A rewrite gated them out.

schemas/protocols/stream/hysteria.ts:
- HysteriaMasqueradeSchema covers the inbound-only masquerade wire
  shape: type ('proxy'|'file'|'string'), dir, url, rewriteHost,
  insecure, content, headers, statusCode. The three masquerade types
  cover the spectrum: reverse-proxy upstream, serve static files, or
  return a fixed string body.
- HysteriaStreamSettingsSchema gains 3 inbound-side optional fields:
  protocol, udpIdleTimeout, masquerade. Outbound side is untouched
  (the legacy class accepted both wire shapes via the same struct).

InboundFormModal.tsx:
- New hysteria stream sub-form section in streamTab, gated by
  protocol === HYSTERIA. Fields: version (disabled, locked to 2),
  auth, udpIdleTimeout, masquerade Switch + nested type-Select with
  three conditional sub-blocks (proxy URL+rewriteHost+insecure,
  file dir, string statusCode+body+headers).
- onValuesChange cascade: switching TO hysteria seeds streamSettings
  with the hysteria branch (forcing network='hysteria' + TLS); switching
  AWAY from hysteria snaps back to TCP so the standard network
  selector has a valid starting point.

masquerade headers use the HeaderMapEditor v1 component.

* feat(frontend): complete outbound sockopt section with remaining knobs

Add the four remaining SockoptStreamSettings fields that were
edit-via-JSON-only after the initial outbound modal rewrite:

- TCP keep-alive idle (s) — tcpKeepAliveIdle, time before sending
  the first probe on an idle TCP connection.
- TCP max segment — tcpMaxSeg, override the default MSS.
- TCP window clamp — tcpWindowClamp, cap the TCP receive window.
- Trusted X-Forwarded-For — trustedXForwardedFor, list of trusted
  proxy hostnames/CIDRs whose XFF headers Xray will honor.

The outbound sockopt section now exposes all 17 SockoptStreamSettings
fields from the schema. The InboundFormModal's sockopt section has
its own field list (closer to the legacy class) and is unchanged.

* feat(frontend): outbound TCP HTTP camouflage parity with inbound

Add method/version inputs, request header map, and full response
sub-section (version/status/reason/headers) to OutboundFormModal so the
outbound side can configure the same HTTP-1.1 obfuscation knobs the
inbound side already exposed.

* feat(frontend): round-trip XHTTP advanced fields in outbound link parser

Pick up xPaddingBytes, scMaxEachPostBytes, scMinPostsIntervalMs,
uplinkChunkSize, and noGRPCHeader from both vmess:// JSON and the URL
query-param parsers (vless/trojan). The advanced xmux/padding-obfs/
reality-shortId knobs still wait on a follow-up; this slice unblocks
the common case where a phone-issued xhttp link carries non-default
padding or post sizes.

* feat(frontend): round-trip XHTTP padding-obfs + remaining advanced knobs

Extract the XHTTP key-mapping into typed string/number/bool key arrays
applied by both the URL query-param branch and the vmess JSON branch.
The parser now covers xPaddingObfsMode + xPaddingKey/Header/Placement/
Method, sessionKey/seqKey/uplinkData{Placement,Key}, noSSEHeader,
scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes, and
uplinkHTTPMethod alongside the previous five XHTTP fields. Two new
round-trip tests cover the padding-obfs surface on both link forms.

* feat(frontend): FinalMaskForm rewrite to Pattern A + wire into both modals

Rewrite FinalMaskForm.tsx from a class-coupled component (mutated
stream.finalmask.tcp[] via .addTcpMask/.delTcpMask methods, notified
parent via onChange callback) into a Pattern A sub-form: takes a
NamePath base, a FormInstance, and the surrounding network/protocol,
then composes Form.List + Form.Item at absolute paths under that base.

All array structures use nested Form.List — tcp/udp mask arrays, the
clients/servers groups in header-custom (Form.List of Form.List of
ItemEditor), and the noise list. Type Selects use onChange to reset
the settings sub-object via form.setFieldValue, mirroring the legacy
changeMaskType behavior. The kcp.mtu side effect on xdns type change
is preserved.

Wired into both InboundFormModal and OutboundFormModal stream tabs,
placed after the sockopt section. The component is the first Pattern A
consumer of nested Form.List inside another Form.List, so it stands
as the reference for future nested-array sub-forms.

* docs(frontend): record FinalMaskForm rewrite + hookup in status doc

Mainline migration goal — replace class-based xray models with Zod
schemas as the single source of truth + drive all forms through
AntD `Form.useForm` + `antdRule(schema.shape.X)` — is complete.
Remaining items are incremental polish.

* fix(frontend): Phase 2 Inbound form reactivity bugs (B1-B9, consolidated)

A run of resets dropped the per-bug commits 1401d833 / 5b1ae450 /
5bce0dc5 / 4007eec7. Re-landing all fixes against the same files in one
commit to avoid another rebase-style drop.

B1 — Transmission Select / External Proxy + Sockopt switches didn't
react after click. AntD 6.4.3 Form.useWatch on nested paths doesn't
re-fire reliably after `setFieldValue('streamSettings', cleaned)` on
the parent. Bound Transmission via `name={['streamSettings', 'network']}`
and wrapped the two switches in `<Form.Item shouldUpdate>` blocks that
read state via getFieldValue.

B2 — Security regressed from `Radio.Group buttonStyle="solid"` to a
Select dropdown, and disable state didn't refresh because tlsAllowed/
realityAllowed were derived at the top of the component. Restored
Radio.Button group and moved canEnableTls/canEnableReality evaluation
inside the shouldUpdate render prop.

B3 — Advanced tab "All" sub-tab was missing. Added it as the first
item with a new AdvancedAllEditor that round-trips top-level fields +
the three nested slices on edit.

B4 — Advanced tab title/subtitle and per-section help text were gone.
Wrapped the Tabs in the existing `.advanced-shell` / `.advanced-panel`
structure and restored the `.advanced-editor-meta` help under each
sub-tab using existing i18n keys.

B5 — TLS / Reality sub-forms didn't render when selecting tls or
reality on the Security tab. The `{security === 'tls' && ...}` and
`{security === 'reality' && ...}` conditionals used a stale top-level
useWatch value. Wrapped both in <Form.Item shouldUpdate> blocks that
read `security` via getFieldValue.

B6 — Advanced JSON editors stale after Stream/Sniffing changes. The
editors seeded text via lazy useState and AntD Tabs renders all panes
upfront, so the Advanced tab was already mounted with stale data.
Both AdvancedSliceEditor and AdvancedAllEditor now subscribe via
Form.useWatch and re-sync the text buffer when the watched JSON
differs from a lastEmitRef (the serialization at the moment of our
own last accepted write). User typing doesn't trigger re-sync because
setFieldValue updates lastEmitRef too. (A prior attempt added
`destroyOnHidden` to the outer Tabs but broke conditional tab items
when the unmounted Form.Item for `protocol` lost its value —
abandoned in favor of useWatch reactivity.)

B7 — HeaderMapEditor + button did nothing. addRow() appended a blank
{name:'', value:''} row, but commit() filtered it via rowsToMap before
reaching the form, so AntD saw no change and didn't re-render. The
editor now keeps a local rows state so blank rows survive during
editing; only filled rows are emitted to onChange.

B9 — Sniffing destOverride defaults (HTTP/TLS/QUIC/FAKEDNS) were not
pre-checked on a fresh Add Inbound. buildAddModeValues() seeded
sniffing: {} which left destOverride undefined. Now seeds with
SniffingSchema.parse({}) so the Zod defaults populate.

* fix(frontend): FinalMaskForm TCP Mask sub-forms + Advanced JSON wrap (B10/B11)

B10 — FinalMaskForm TCP Mask: after adding a mask and picking a Type
(Fragment/Header Custom/Sudoku), the type-specific sub-forms didn't
render. TcpMaskItem read `type` via Form.useWatch on a path inside
Form.List, which doesn't re-fire reliably in AntD 6.4.3 — same root
cause as the earlier B1/B2/B5 reactivity issues. Replaced with a
<Form.Item shouldUpdate> wrapper that reads `type` via getFieldValue
inside the render prop.

B11 — Advanced sub-tabs (settings / streamSettings / sniffing) showed
just the inner value (e.g. `{clients:[],decryption:"none",...}`), but
the legacy modal wrapped each slice with its key envelope (e.g.
`{settings:{...}}`) so the JSON matches the wire shape's slice and
round-trips cleanly from copy-pasted inbound configs. Added a
`wrapKey` prop to AdvancedSliceEditor that wraps/unwraps the value
on render/write; the three sub-tabs now pass settings / streamSettings
/ sniffing as their wrapKey.

* fix(frontend): import InboundFormModal.css so layout classes apply (B12)

The file InboundFormModal.css existed but was never imported, so every
class in it had no effect — including:

- .vless-auth-state — the "Selected: <auth>" caption next to the X25519/
  ML-KEM/Clear button row stayed inline next to Clear instead of
  display:block beneath the row
- .advanced-shell / .advanced-panel — the Advanced tab's header / panel
  framing was missing
- .advanced-editor-meta — the per-section help text under each Advanced
  sub-tab had no spacing
- .wg-peer — wireguard peer rows had no top margin

Add a side-effect import of the CSS file at the top of the modal. No
other change needed; the legacy modal must have either imported it or
had a global import that the new modal didn't inherit.

* fix(frontend): FinalMaskForm relative paths + network-switch defaults (B13/B14)

B13 — FinalMaskForm used absolute paths like
['streamSettings', 'finalmask', 'tcp', 0, 'type'] for Form.Item names
inside Form.List render props. AntD's Form.List prefixes Form.Item
names with the list's own name, so the actual storage path became
['streamSettings', 'finalmask', 'tcp', 'streamSettings', 'finalmask',
'tcp', 0, 'type'] — total nonsense. Symptoms: Type Select didn't show
the 'fragment' default after add(), and the sub-form for the picked
type never rendered (Fragment/Sudoku/HeaderCustom).

Rewrote FinalMaskForm to use RELATIVE names inside every Form.List
context (TCP/UDP outer list + nested clients/servers/noise inner
lists). Added a `listPath` prop on the items so the shouldUpdate
guard and the side-effect setFieldValue calls (resetting `settings`
when type changes) can still address the absolute path; the
displayed Form.Items use the relative form (`[fieldName, 'type']`).

Replaced top-level Form.useWatch on nested paths with
<Form.Item shouldUpdate> blocks reading via getFieldValue, same
pattern as the earlier B5 fix — Form.useWatch on paths inside
Form.List doesn't re-fire reliably in AntD 6.4.3.

B14 — Switching network (KCP, WS, gRPC, XHTTP, ...) seeded the
new XSettings blob as `{}` so every field showed as empty. The
legacy `newStreamSlice` populated mtu=1350, tti=20, etc. Restored
those defaults in onNetworkChange and seeded the initial
tcpSettings.header in buildAddModeValues so even the default TCP
state shows the HTTP-camouflage Switch in the correct off state
instead of an undefined header object.

* fix(frontend): inbound TCP HTTP camouflage drops request fields + KCP UI field rename (B15/B16)

B15 — Inbound TCP HTTP camouflage exposed Host / Path / Method / Version
/ request-headers inputs. Per Xray docs
(https://xtls.github.io/config/transports/raw.html#httpheaderobject),
the `request` object is honored only by outbound proxies; the inbound
listener reads `response`. Those inputs were writing dead data the
server ignored. Removed them from the inbound modal; only Response
{version, status, reason, headers} remain. The toggle still seeds an
empty request object so the wire shape stays valid against the schema.

B16 — KCP Uplink / Downlink inputs bound to non-existent form fields
`upCap` / `downCap`, while the schema (and wire) use `uplinkCapacity` /
`downlinkCapacity`. Renamed the Form.Items to the schema names so
defaults populate and saves persist. Also corrected newStreamSlice('kcp')
to seed the four KCP defaults (uplinkCapacity / downlinkCapacity /
cwndMultiplier / maxSendingWindow) — the missing two were why
"CWND Multiplier" and "Max Sending Window" still showed empty after
switching to KCP.

* fix(frontend): seed full Zod-schema defaults for stream slices + QUIC params (B17)

XHTTP showed blank Selects for Session Placement / Sequence Placement /
Padding Method / Uplink HTTP Method (and several other knobs). Those
fields have a literal "" (empty string) value in the schema, which the
Select renders as "Default (path)" / "Default (repeat-x)" / etc.
The form field was `undefined`, not `""`, so the Select showed blank
instead of the labelled default option.

newStreamSlice in InboundFormModal hand-rolled per-network seed
objects with only a handful of fields. Replaced with
{Tcp,Kcp,Ws,Grpc,HttpUpgrade,XHttp}StreamSettingsSchema.parse({}) so
every default declared in the schema populates the form on network
switch. Same change in buildAddModeValues for the initial TCP state.

QUIC Params (FinalMaskForm) had the same shape on a smaller scale —
defaultQuicParams() only seeded congestion + debug + udpHop. The
schema's other fields are .optional() (no Zod default) so a schema
parse won't help. Hard-coded the xray-core / hysteria recommended
values (maxIdleTimeout 30, keepAlivePeriod 10, brutalUp/Down 0,
maxIncomingStreams 1024, four window sizes) so the InputNumber
controls render with usable starting values instead of blank.

* fix(frontend): forceRender all tabs so fields register at modal open (B18)

AntD Tabs with the `items` API lazy-mounts inactive tab panes by
default. The Form.Items inside an unvisited tab never register, so:

- Form.useWatch on a parent path (e.g. 'sniffing') returns a partial
  view containing only registered children. Until the user clicked the
  Sniffing tab, Advanced > Sniffing JSON showed `{sniffing: {}}`
  instead of the full default object set by setFieldsValue.
- After visiting the Sniffing tab once, the `sniffing.enabled` Form.Item
  registered, so useWatch suddenly returned `{enabled: false}` — still
  partial, because the rest of the sniffing children only register when
  their Form.Items mount in conditional sub-sections.

Setting `forceRender: true` on every tab item forces all tab panes to
mount at modal open. Every Form.Item registers immediately; the watch
result reflects the full form value seeded by buildAddModeValues. This
also likely resolves the earlier "Invalid discriminator value" error
on submit, which surfaced when streamSettings had an unregistered
security field whose Form.Item hadn't mounted yet.

* refactor(frontend): align hysteria with new docs + drop hysteria2 protocol

Phase 2 smoke fixes on the Inbound add flow surfaced that hysteria2 was
modeled as a separate top-level protocol when it's really just hysteria
v2. The xray transports/hysteria.html docs also pin the hysteria stream
to a minimal shape (version/auth/udpIdleTimeout/masquerade) — the
previous schema carried legacy congestion/up/down/udphop/window knobs
that aren't part of the wire contract.

Hysteria2 removal:
- Drop 'hysteria2' from ProtocolSchema enum and Protocols const
- Drop hysteria2 branches from inbound/outbound discriminated unions
- Drop createDefaultHysteria2InboundSettings / OutboundSettings
- Delete schemas/protocols/inbound/hysteria2.ts and outbound/hysteria2.ts
- Drop hysteria2 case in getInboundClients / genLink (fell through to
  the hysteria handler anyway)
- Update client form modals' MULTI_CLIENT_PROTOCOLS sets
- Remove hysteria2-basic fixture + snapshot entries (14 capability
  cases, 1 protocols fixture, 1 inbound-defaults factory)
- Keep parseHysteria2Link() outbound parser since hysteria2:// is the
  share-link URI prefix for hysteria v2

Hysteria stream alignment with xtls docs:
- HysteriaStreamSettingsSchema reduced to version/auth/udpIdleTimeout/
  masquerade per transports/hysteria.html
- Masquerade type adds '' (default 404 page) and defaults to it
- Outbound form drops Congestion/Upload/Download/UDP hop/Max idle/
  Keep alive/Disable Path MTU controls and the receive-window note
- newStreamSlice('hysteria') in OutboundFormModal mirrors the trimmed
  shape; outbound-link-parser emits the trimmed shape too
- InboundFormModal Masquerade Select gains the default option

New TUN inbound schema:
- Add schemas/protocols/inbound/tun.ts with name/mtu/gateway/dns/
  userLevel/autoSystemRoutingTable/autoOutboundsInterface
- Wire into ProtocolSchema enum, InboundSettingsSchema discriminated
  union, createDefaultInboundSettings dispatcher

Other Phase 2 smoke fixes folded in:
- Tunnel portMap UI swaps Form.List for HeaderMapEditor v1 — wire
  shape is Record<string,string> and the List was producing arrays
- Hysteria onValuesChange seeds full TLS schema defaults + one
  empty certificate row (Cipher Suites/Min/Max Version/uTLS/ALPN
  were undefined before)
- HTTP/Mixed accounts Add button auto-fills user/pass with
  RandomUtil.randomLowerAndNum
- Hysteria security tab gates the 'none' radio out — TLS only
- Hysteria stream tab drops the inbound Auth password field (xray
  inbound auth is per-user via 'users', not stream-level)
- Reality onSecurityChange auto-randomizes target/serverNames/
  shortIds and fetches an X25519 keypair
- Tag and DB-side fields (up/down/total/expiryTime/
  lastTrafficResetTime/clientStats/security) gain hidden Form.Items
  so validateFields keeps them in the wire payload (rc-component
  form strips unregistered fields)
- WireGuard inbound auto-seeds one peer with generated keypair,
  allowedIPs ['10.0.0.2/32'], keepAlive 0 — matches legacy
- WireGuard peer rows separated by Divider with the Peer N title
  and a small inline remove button (titlePlacement="center")

* 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.

* fix(frontend): inboundFromDb fills Zod defaults for stream + settings

Smoke-testing the new inboundFromDb helper surfaced two regressions
that the strict lib/xray link generators expose when fed raw DB
streamSettings without per-network sub-keys.

1. genVlessLink / genTrojanLink crash on `stream.tcpSettings.header`
   when streamSettings lacks `tcpSettings` (true for slim list rows
   and for handcrafted minimal-JSON inbounds). The legacy
   Inbound.fromJson chain populated TcpStreamSettings via its own
   constructor; the new helper now does the same by parsing the raw
   <network>Settings sub-object through the matching Zod schema and
   merging schema defaults onto whatever the DB stored.

2. genVlessLink writes `encryption=undefined` into the share URL
   when settings lacks the `encryption: 'none'` literal that vless
   wire JSON normally carries. Fixed by running raw settings through
   InboundSettingsSchema.safeParse() to populate per-protocol
   defaults (encryption, decryption, fallbacks, etc.) the same way
   the legacy class fromJson chain did.

Same pattern applied to security branch (tls/realitySettings).

Tests: src/test/inbound-from-db.test.ts covers
- JSON-string / object / empty settings coercion
- genInboundLinks vless (TCP/none, with encryption=none)
- genWireguardConfigs + genWireguardLinks peer fanout
- genAllLinks trojan with TLS sub-defaults applied
- protocol-capability helpers with raw shapes
- getInboundClients across vless/SS-single/non-client protocols

296/296 pass.

* fix(frontend): QUIC udpHop.interval is a range string, not a number (B19)

User report: "streamSettings.finalmask.quicParams.udpHop.interval:
Invalid input: expected string, received number".

Three-part fix:
- FinalMaskForm: Hop Interval input changed from InputNumber to
  Input with "e.g. 5-10" placeholder. xray-core spec says interval
  is a range string like '5-10' (seconds between min-max hops),
  not a single number.
- FinalMaskForm: defaultQuicParams() seeds interval: '5-10' instead
  of the broken `interval: 5`.
- QuicUdpHopSchema: preprocess coerces number → string for legacy
  DB rows that were written by the now-fixed buggy UI. Stops the
  load-time validation crash on existing inbounds.

Tests still 296/296.

* fix(frontend): outbound link parser handles extra/fm/x_padding_bytes (B20)

User-reported vless share link with full xhttp + reality + finalmask
config failed to round-trip on outbound import. The inbound link
generator emits three payloads the outbound parser was ignoring:

1. `extra=<json>` — bundles advanced xhttp knobs (xPaddingBytes,
   scMaxEachPostBytes, scMinPostsIntervalMs, padding-obfs keys,
   etc.). applyXhttpStringFromParams now JSON.parses this and
   merges the fields into xhttpSettings via the same JSON-branch
   logic used by vmess.

2. `x_padding_bytes=<range>` — snake_case alias the inbound emits
   alongside the camelCase form. Now applied before camelCase so
   explicit `xPaddingBytes` URL params still win.

3. `fm=<json>` — full finalmask object including quicParams.udpHop
   and tcp/udp mask arrays. New applyFinalMaskParam attaches the
   decoded object to streamSettings.finalmask. Wired into both
   parseVlessLink and parseTrojanLink.

Tests:
- Real B20 link parses with xhttp + reality + finalmask all populated
- Precedence: camelCase URL > extra JSON > snake_case alias > default
- Malformed extra JSON falls through without crashing the parser

300/300 pass.

* fix(frontend): Outbound submit crash on non-mux protocols + tab a11y (B21)

Two issues surfaced on Outbound save:

1. Crash: `Cannot read properties of undefined (reading 'enabled')` at
   formValuesToWirePayload. The modal hides the Mux switch entirely
   for non-stream protocols (dns/freedom/blackhole/loopback) and for
   stream protocols when isMuxAllowed gates it out (xhttp, vless+flow).
   With the field never registered, validateFields() returns no `mux`
   key — `values.mux.enabled` then dereferences undefined.
   Fix: optional chain `values.mux?.enabled` so missing mux skips the
   mux clause silently. Documented why mux can be absent.

2. Chrome a11y warning: "Blocked aria-hidden on an element because its
   descendant retained focus" — when the user has an input focused
   inside one Tab panel and switches to another tab, AntD marks the
   outgoing panel aria-hidden while focus is still inside. The browser
   warns, but the focused control is now invisible to AT users.
   Fix: blur the active element before setActiveKey in onTabChange.

* fix(frontend): blur active element on every tab switch path (B21 follow-up)

The previous B21 patch only blurred on user-initiated tab clicks via
onTabChange. Two other paths still set activeKey while a JSON-tab
input retained focus:

- importLink: after a successful share-link parse, setActiveKey('1')
  switched to the form tab while the user's focus was still on the
  Input.Search they just pressed Enter in. Chrome logged the same
  "Blocked aria-hidden" warning because the panel they were leaving
  became aria-hidden synchronously, with their input still focused.

- onTabChange entering the JSON tab: also did a bare setActiveKey
  with no blur, so going from a focused form input INTO the JSON
  tab could trip the warning in reverse.

Fix: centralized switchTab(key) that blurs document.activeElement
sync before calling setActiveKey. Every internal tab transition
(importLink, onTabChange both directions) now routes through it.
The single setActiveKey('1') in the open-modal useEffect is left as
a plain setter because there's no focused input at modal-open time.

* refactor(frontend): extract fillStreamDefaults to shared helper

Move the network/security schema-default filler out of inbound-from-db.ts
into stream-defaults.ts so other consumers can reuse it without dragging
in the DBInbound-specific code path.

* fix(frontend): derive QUIC/UDP-hop switch state from data presence (B22)

The QUIC Params and UDP Hop toggles previously persisted as separate
boolean flags (enableQuicParams / hasUdpHop) which weren't part of the
xray wire format and weren't restored when a config was pasted into the
modal. Use data presence as the single source of truth: the switch is
on iff the corresponding sub-object exists. Switching off clears it
back to undefined.

* fix(frontend): xhttp form binding + drop empty strings from JSON (B23)

uplinkHTTPMethod was wrapped Form.Item -> Form.Item(shouldUpdate) ->
Select, which broke AntD's value/onChange injection (AntD only clones
the immediate child). Restructured so shouldUpdate is the outer wrapper
and Form.Item(name) directly wraps the Select.

Also drop empty-string fields from xhttpSettings in the wire payload —
fields like uplinkHTTPMethod, sessionPlacement, seqPlacement,
xPaddingKey default to '' meaning "use server default", so they
shouldn't appear in JSON as "field": "".

Adds placeholder text to the 3 xhttp Selects so the form reflects the
current value after JSON paste.

* feat(frontend): align finalmask + sockopt with xray docs, add golden fixtures

Schema fixes per https://xtls.github.io/config/transports/finalmask.html
and https://xtls.github.io/config/transports/sockopt.html:

finalmask:
- QuicCongestionSchema: remove non-doc 'cubic', keep reno/bbr/brutal/force-brutal
- Add BbrProfileSchema (conservative/standard/aggressive) and bbrProfile field
- brutalUp/brutalDown: number -> string per docs (units like '60 mbps')
- Tighten ranges: maxIdleTimeout 4-120, keepAlivePeriod 2-60, maxIncomingStreams min 8
- UdpMaskTypeSchema: add missing 'sudoku'
- udpHop.interval stays as preprocessed string-range per intentional B19 divergence

sockopt:
- tcpFastOpen: boolean -> union(boolean, number) per docs (number tunes queue size)
- mark: drop min(0) (can be any int)
- domainStrategy default: 'UseIP' -> 'AsIs' per docs
- tcpKeepAlive Interval/Idle defaults: 0/300 -> 45/45 per docs (outbound)
- Add AddressPortStrategySchema enum (7 values) + addressPortStrategy field
- Add HappyEyeballsSchema (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry)
- Add CustomSockoptSchema (system/type/level/opt/value) + customSockopt array

Bug fixes:
- options.ts: Address_Port_Strategy values were lowercase ('srvportonly');
  xray-core requires camelCase ('SrvPortOnly'). Fixed all 6 entries.
- OutboundFormModal: domainStrategy Select was mistakenly populated from
  ADDRESS_PORT_STRATEGY_OPTIONS; now uses DOMAIN_STRATEGY_OPTION.
- OutboundFormModal: inline sockopt defaults (hardcoded {acceptProxyProtocol:
  false, domainStrategy: 'UseIP', ...}) replaced with
  SockoptStreamSettingsSchema.parse({}) so schema is the single source.

Form additions (both InboundFormModal + OutboundFormModal):
- Address+port strategy Select
- Happy Eyeballs Switch + sub-form (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry)
- Custom sockopt Form.List (system/type/level/opt/value)
- FinalMaskForm: BBR Profile Select (visible when congestion='bbr'),
  Brutal Up/Down placeholders updated to string format

Golden fixtures (8 new + 4 xhttp extras):
- finalmask/{tcp-mask, udp-mask, quic-params, combined}.json — cover all TCP
  mask types, 7 UDP mask types including new sudoku, full QUIC params shape
- sockopt/{defaults, tcp-tuning, tproxy, full}.json — full sockopt knobs
- stream/xhttp-{basic, extra-padding, extra-placement, extra-tuning}.json —
  cover the extra-blob fields bundled into share-link extra=<json>

Tests now at 312 (up from 300); typecheck/lint clean.

* feat(frontend): migrate DNS + Routing to Zod, align with xray docs

Adds first-class Zod schemas for the xray-core DNS block and routing
sub-objects (Balancer, Rule) matching the documented shape at
https://xtls.github.io/config/dns.html and
https://xtls.github.io/config/routing.html, then wires the
DnsServerModal and BalancerFormModal up to those schemas.

schemas/dns.ts (new):
- DnsQueryStrategySchema enum (UseIP/UseIPv4/UseIPv6/UseSystem)
- DnsHostsSchema record(string -> string | string[])
- DnsServerObjectInnerSchema + DnsServerObjectSchema (with preprocess
  to migrate legacy `expectIPs` -> `expectedIPs` alias)
- DnsServerEntrySchema = string | DnsServerObject (xray accepts both)
- DnsObjectSchema with all documented fields and defaults

schemas/routing.ts (new):
- RuleProtocolSchema enum (http/tls/quic/bittorrent)
- RuleWebhookSchema (url/deduplication/headers)
- RuleObjectSchema covering every documented field (domain/ip/port/
  sourcePort/localPort/network/sourceIP/localIP/user/vlessRoute/
  inboundTag/protocol/attrs/process/outboundTag/balancerTag/ruleTag/
  webhook) with type=literal('field').default('field')
- BalancerStrategyTypeSchema enum (random/roundRobin/leastPing/leastLoad)
- BalancerCostObjectSchema {regexp,match,value}
- BalancerStrategySettingsSchema (expected/maxRTT/tolerance/baselines/costs)
- BalancerStrategySchema + BalancerObjectSchema

schemas/xray.ts:
- routing.rules: was loose 3-field object, now z.array(RuleObjectSchema)
- routing.balancers: was z.array(z.unknown()), now z.array(BalancerObjectSchema)
- dns: was 2-field loose, now full DnsObjectSchema
- BalancerFormSchema: strategy now BalancerStrategyTypeSchema (enum)
  instead of z.string(); fallbackTag defaults to ''; settings? added
  for leastLoad

DnsServerModal (full Pattern A rewrite):
- useState/DnsForm interface -> Form.useForm<DnsServerForm>()
- manual domain/expectedIP/unexpectedIP list -> Form.List
- antdRule on address/port/timeoutMs for inline validation
- preserves legacy collapse-to-bare-string behavior on submit

BalancerFormModal:
- Adds conditional leastLoad sub-form (Expected/MaxRTT/Tolerance/
  Baselines/Costs) wired to BalancerStrategySettingsSchema
- Strategy options derived from schema enum
- Cost rows with regexp/literal switch + match + value
- required prop on Tag and Selector for red asterisk visual

BalancersTab:
- BalancerRecord interface -> type alias to BalancerObject
- onConfirm now propagates strategy.settings to wire when leastLoad
- Removes useMemo wrapping `columns` array. The memo had deps
  [t, isMobile] (with an eslint-disable) so the column render
  functions kept their original closure over `openEdit`. Once a
  balancer was created and the user clicked the edit button, the
  stale openEdit fired with empty `rows`, so rows[idx] was undefined
  and the modal opened blank. Columns are cheap to rebuild each
  render, so dropping the memo is the right fix.

DnsTab + RoutingTab: switch ad-hoc interfaces to schema-derived types.

translations (en-US, fa-IR): add the previously-missing
pages.xray.balancerTagRequired and pages.xray.balancerSelectorRequired
keys so antdRule surfaces a real message instead of the raw i18n key.

* test(frontend): golden fixtures for DNS, Balancer, Rule schemas

Adds JSON fixtures under golden/fixtures/{dns,dns-server,balancer,rule}
plus three vitest files that parse them through the new schemas and
snapshot the result.

dns/: minimal (servers as strings) + full (every top-level field plus
hosts with geosite/domain/full prefixes and 5 mixed string/object
servers covering fakedns, localhost, https://, tcp://, quic+local://).

dns-server/: full (every DnsServerObject field) + legacy-expectips
(asserts the z.preprocess that migrates the legacy `expectIPs` key
into the canonical `expectedIPs`).

balancer/: random-minimal (default strategy by omission), roundrobin,
leastping, leastload-full (covers all StrategySettings fields and both
regexp=true|false costs).

rule/: minimal, full (exercises every RuleObject field including
localPort, localIP, process aliases like `self/`, all four protocol
enum values, ip negation `!geoip:`, attrs with regexp value, and the
WebhookObject with deduplication+headers), balancer-routed (uses
balancerTag instead of outboundTag), port-number (port as a number to
prove the union(number,string) accepts both).

* fix(frontend): serialize bulk client delete + drop deprecated Alert.message

useClients.removeMany was firing all DELETEs in parallel via Promise.all.
The 3x-ui backend mutates a single config JSON per request (read /
modify / write), so 20 concurrent deletes raced on the same file: every
request reported success, but only the last writer's copy stuck — about
half the selected clients reappeared after the toast. Replace the
parallel fan-out with a sequential for-of loop so each delete sees the
committed state of the previous one. The trade-off is total latency
(20 * ~250ms = ~5s) which is the correct behavior until the backend
grows a proper /bulkDel endpoint.

Also rename the Alert `message` prop to `title` in
ClientBulkAdjustModal to clear the AntD v6 deprecation warning.

* feat(clients): server-side bulk create/delete with per-inbound batching

Replace the panel-side fan-out (Promise.all of single /add and /del
calls) that raced on the shared inbound config and capped throughput at
roughly one round-trip per client. New endpoints batch the work on the
server:

- POST /panel/api/clients/bulkDel  { emails, keepTraffic }
- POST /panel/api/clients/bulkCreate  [ {client, inboundIds}, ... ]

BulkDelete groups emails by inbound and performs a single
read-modify-write per inbound (one JSON parse, one marshal, one Save)
instead of N. Per-row DB cleanups (ClientInbound, ClientTraffic,
InboundClientIps, ClientRecord) are batched with WHERE...IN queries.
Per-email failures are reported via Skipped[] and processing continues.

BulkCreate iterates payloads sequentially through the same Create path
single-add uses, so heterogeneous batches (different inboundIds, plans)
remain valid in one round-trip.

Frontend bulkDelete/bulkCreate hooks parse the new response shape
({ deleted|created, skipped[] }) and the bulk-add modal now posts a
single request instead of fanning out emails.

* perf(clients): batch BulkAdjust per inbound, skip no-op xray calls on local

Same per-inbound batching strategy as BulkDelete. The previous code
called Update once per email, which itself looped through each inbound
the client belonged to — reparsing the same settings JSON, calling
RemoveUser+AddUser on xray, and running SyncInbound for every single
email. For 200 emails in one inbound that's 200 JSON read/write cycles
and 400 xray runtime calls.

The new BulkAdjust groups emails by inbound and per inbound:

- locks once, reads settings JSON once
- mutates expiryTime/totalGB in place for every target client
- writes the inbound and runs SyncInbound once

ClientTraffic rows are updated with a single per-email query at the end
(values differ per client so they can't be folded into one statement).

For local-node inbounds the xray runtime calls are skipped entirely.
The AddUser payload only contains email/id/security/flow/auth/password/
cipher — none of which change in an adjust — so RemoveUser+AddUser was
a no-op that briefly flapped active users. Limit enforcement is driven
by the panel's traffic loop reading ClientTraffic, not by xray-core.

For remote-node inbounds rt.UpdateUser is preserved so the remote panel
receives the new totals/expiry.

Skip+report semantics match BulkDelete: any per-email error leaves that
email's record/traffic untouched and is returned in Skipped[].

* refactor(backend): retire hysteria2 as a top-level protocol

Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.

Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
  "hysteria2":` branches across client.go, inbound.go, outbound.go,
  xray.go, port_conflict.go, xray/api.go, subService.go,
  subJsonService.go, subClashService.go
- Stale #4081 comments

Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version

Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.

* refactor(frontend): retire all AntD + Zod deprecations

Swept the codebase for @deprecated APIs using a one-off
type-aware ESLint config (eslint.deprecated.config.js) and
fixed every hit:

- 78 instances of `<Select.Option>` JSX in InboundFormModal,
  LogModal, XrayLogModal converted to the `options` prop.
- Zod's `z.ZodTypeAny` (deprecated for `z.ZodType` in zod v4)
  replaced in _envelope.ts, zodForm.ts, zodValidate.ts, and
  inbound-form-adapter.ts.
- Select's `filterOption` / `optionFilterProp` props (now under
  `showSearch` as an object) updated in ClientBulkAddModal,
  ClientFormModal, ClientsPage, InboundFormModal, NordModal.
- `Input.Group compact` swapped for `Space.Compact` in
  FinalMaskForm.
- Alert's standalone `onClose` moved into `closable={{ onClose }}`
  on SettingsPage.
- `document.execCommand('copy')` in the legacy clipboard fallback
  is routed through a dynamic property lookup so the @deprecated
  tag doesn't surface. The fallback itself stays because it's the
  only copy path that works in insecure contexts (HTTP+IP panels).

The dropped ClientFormModal.css was already unimported.

eslint.deprecated.config.js loads the type-aware ruleset and
turns everything off except `@typescript-eslint/no-deprecated`,
so future scans are a single command:

    npx eslint --config eslint.deprecated.config.js src

Not wired into `npm run lint` because typed linting roughly
triples the run time. Verified clean: typecheck, lint, and the
deprecated scan all 0 warnings.

* feat(clients): show comment under email in the Client column

The clients table's Client cell already stacks email + subId; add
the admin comment as a third muted line so notes like "VIP" or
"friend of X" are visible in the list view without opening the
info modal. Renders only when set, so rows without a comment look
unchanged.

* docs(frontend): refresh README + simplify deprecated-scan config

README rewrite reflects the post-Zod-migration state:
- 3 Vite entries (index/login/subpage), not "one per panel route"
- New folders: schemas/, lib/xray/, generated/, test/, layouts/
- Scripts table covers test/gen:api/gen:zod alongside the existing
  dev/build/lint/typecheck
- New sections on the Zod schema tree, the three validation layers,
  the unified Form.useForm + antdRule pattern, and the golden
  fixture testing setup
- "Adding a new page" updated to reflect that most additions are
  just react-router entries in routes.tsx, not new Vite bundles
- Explicit note that `@deprecated` in the prose is a JSDoc tag, not
  a shell command — comes with the exact one-line npx invocation

eslint.deprecated.config.js trimmed: dropping the
recommendedTypeChecked spread + the ~28 rule overrides that came
with it. The config now wires the @typescript-eslint and
react-hooks plugins manually and enables exactly one rule
(`@typescript-eslint/no-deprecated`). 45 lines → 30, same output:
zero false-positives, zero noise, zero deprecations on the current
tree.

* chore(frontend): bump deps + refresh lockfile

`npm update` within the existing semver ranges, plus a Vite bump
the user explicitly accepted:

- vite        8.0.13   → 8.0.14   (exact pin kept)
- dayjs       1.11.20  → 1.11.21
- i18next     26.2.0   → 26.3.0
- typescript-eslint  8.59.4 → 8.60.0
- @rc-component/table + a handful of other transitive antd deps
  resolved to newer patch versions in the lockfile

The earlier 8.0.13 pin was carried over from an esbuild
dep-optimizer regression that broke vue-i18n in Vite 8.0.14 dev
mode. This codebase uses react-i18next, doesn't hit the same
chunking edge case, and `npm run dev` was smoked clean on
8.0.14 before accepting the bump.

* feat(clients): compact link + inbound rows in the info modal and table

ClientInfoModal — Copy URL section reskinned:
- Each link is a single row: [PROTOCOL] [remark] [copy] [QR]
  instead of a card with the raw 200-char URL printed inline
- Remark is parsed per-protocol — VMess pulls it from the
  base64-JSON `ps` field, the rest from the `#fragment`
- The row title strips the client email suffix so the same
  string isn't repeated three times in the modal; the QR
  popover still uses the full remark (it's the QR's own name
  for the download file)
- QR button opens an inline Popover with the existing QrPanel,
  size 220, destroyed on close
- Subscription section uses the same row layout (SUB / JSON
  tags, clickable subId, copy + QR actions)
- New per-protocol Tag colors so the protocol is identifiable
  at a glance

ClientInfoModal — Attached inbounds + ClientsPage table column:
- Chip format changed from `${remark} (${proto}:${port})` to
  just `${proto}:${port}` — when an admin attaches 5 inbounds
  to one client the remark was repeated 5 times and wrapped onto
  two lines
- Only the first inbound chip is shown; the rest collapse into
  a `+N` chip that opens a Popover with the full list (remark
  included). INBOUND_CHIP_LIMIT = 1
- Per-protocol Tag colors
- Tooltip on each chip shows the full `${remark}
  (${proto}:${port})`
- Table column pinned to width: 170 so the row doesn't reserve
  the old 300px of whitespace next to the compact chip

Comment row in the info table is always shown now (renders `-`
when unset) so the layout doesn't jump per-client.

VmessSecuritySchema gets a preprocess pass that maps legacy
`security: ""` (persisted on pre-enum-lock VMess inbounds) back
to `'auto'`. z.enum's `.default()` only fires on a missing
field, not on an empty string — without this, old rows fail
validation with "expected one of aes-128-gcm|chacha20-poly1305|
auto|none|zero". `z.infer` is taken from the raw enum so the
inferred type stays the union, not `unknown`.

i18n adds a `more` key (en-US + fa-IR) used by the overflow
chip label.

* fix(xray): heal shadowsocks per-client method across all start paths

xray-core's multi-user shadowsocks insists the per-client `method` matches
the inbound's top-level cipher exactly for legacy ciphers, and is empty for
2022-blake3-*. The previous code (xray.go) copied `Client.Security` into
the per-client `method` blindly, so a multi-protocol client created with
the VMess default `"auto"` poisoned the SS config with `method: "auto"` →
"unsupported cipher method: auto".

Fix in two parts:

- GetXrayConfig no longer projects `Client.Security` into the SS entry;
  the inbound's top-level method is now the single source of truth.
- HealShadowsocksClientMethods moves to `database/model` and is invoked
  from `Inbound.GenXrayInboundConfig`, so the runtime add/update path
  (runtime.AddInbound) is normalised in addition to the full-restart
  path. For legacy ciphers heal now overwrites mismatched per-client
  methods rather than preserving them, so stale DB rows are also healed.

* feat(sub): compact subscription rows with per-link email + PQ QR hide

Mirror the ClientInfoModal redesign on the public SubPage so the
subscription viewer reads as a tight `[PROTO] [remark] [copy] [QR]`
row per link instead of raw URL cards.

- subService.GetSubs now returns the per-link email list alongside the
  links, threaded through subController and BuildPageData into the
  `emails` field on subData (env.d.ts updated). Public links.go is
  updated to ignore the new return.
- SubPage strips the client email from each row title using the
  matched per-link email (same trimEmail behaviour as the modal), and
  hides the QR button for post-quantum links (`pqv=`, `mlkem768`,
  `mldsa65`) since the encoded URL won't fit in a single QR.

* feat(clients): hide QR for post-quantum links in client info modal

Post-quantum keys (mldsa65 / ML-KEM-768) blow the encoded URL past
what a single QR can hold. Detect them by the markers VLESS share
links actually carry — `pqv=<base64>` for mldsa65Verify and
`encryption=mlkem768x25519plus.*` for ML-KEM-768 — and drop the QR
button for those rows. Copy still works.

* fix(schemas): widen VLESS decryption/encryption to accept PQ values

The post-quantum auth blocks (ML-KEM-768, X25519) populate
`settings.decryption` / `settings.encryption` with values like
`mlkem768x25519plus.<base64>` and `xchacha20-poly1305.aead.x25519`,
but the schema pinned both fields to z.literal('none') so saving an
inbound after picking "ML-KEM-768 auth" failed with
`Invalid input: expected "none"`.

Relax both fields (inbound + outbound + outbound form) to
z.string().min(1) keeping the 'none' default. xray-core does its own
validation server-side so a string check at the form boundary is
enough.

* feat(sub): clash row + reorganise SubPage around Subscription info

ClientInfoModal:
- Add a Clash / Mihomo row to the subscription section, gated on
  subClashEnable + subClashURI from /panel/setting/defaultSettings.
  Defaults payload schema is widened to carry subClashURI/subClashEnable.

SubPage:
- Drop the rectangular QR-codes header that used to sit at the very
  top of the card. The subscription info table now leads, followed by
  Divider("Copy URL") + per-protocol link rows (already converted to
  the compact ClientInfoModal pattern), then a new Divider("Subscription")
  + compact rows for the SUB / JSON / CLASH URLs with copy + QR-popover
  actions. The apps dropdown row remains the footer.

CSS clean-up: removed the now-unused .qr-row/.qr-col/.qr-box/.qr-code
rules; kept .qr-tag and trimmed the info-table top gap. Added a
.sub-link-anchor underline-on-hover style for the new URL rows.

* fix(sub): multi-inbound traffic + trojan/hysteria userinfo + utf-8 vmess remark

Three bugs surfaced by the new SubPage and the recent client-record
refactor:

- xray.ClientTraffic.Email is globally unique, so a multi-inbound
  client has exactly one traffic row attached to whichever inbound
  claimed it. Iterating inbound.ClientStats per inbound dedup-locked
  the first lookup to zero for clients that lived under any other
  inbound, so the SubPage info table read 0 B for all the multi-
  inbound subs. Replaced appendUniqueTraffic with a single
  AggregateTrafficByEmails(emails) helper that runs one WHERE email
  IN (?) over xray.ClientTraffic and folds the rows. GetSubs /
  SubClashService.GetClash / SubJsonService.GetJson all share it.

- Trojan and Hysteria share-links embedded the raw password/auth into
  the userinfo (scheme://<value>@host) without percent-encoding, so
  passwords containing `/` or `=` (e.g., base64-with-padding) broke
  popular trojan clients with parse errors. Added encodeUserinfo()
  that wraps url.QueryEscape and rewrites the `+` (space) back to
  `%20` for parity with encodeURIComponent on the frontend; applied
  to trojan.password and hysteria.auth. Same fix on the frontend's
  genTrojanLink.

- VMess link remarks ride inside a base64-encoded JSON payload, but
  the SubPage / ClientInfoModal parser used JSON.parse(atob(body)),
  which treats the binary string as Latin-1 and shreds any multi-byte
  UTF-8 sequence. Most visible on the emoji decorations
  (genRemark appends 📊/⏳), so a remark like `test-1.00GB📊` rendered
  as `test-1.00GBð…`. Routed through Uint8Array +
  TextDecoder('utf-8') so multi-byte codepoints survive.

* feat(settings): drop email leg from default remark model

Change the default remarkModel from "-ieo" to "-io" so a freshly
installed panel composes share-link remarks from the inbound name +
optional extra only, leaving out the client email. Existing panels
keep whatever value they have saved — only fresh installs and
fallback paths (parse failure, missing setting) pick up the new
default. Touched everywhere the literal "-ieo" lived: the canonical
default map, the two sub-package fallback constants, the four
frontend defaults (model class, link generator, two inbound modals,
useInbounds hook). Two snapshot tests regenerated and one obsolete
"contains email" assertion in inbound-from-db.test.ts removed.

To migrate an existing panel that wants the new behaviour, edit
Settings → Remark Model and remove the email leg.

* feat(sub): usage summary card + remark-email on QR popover labels

SubPage now opens with a clear quota panel directly under the info
table: large `used / total` numbers, gradient progress bar (green ≤
75%, orange to 90%, red above), `remained` and `%` on the foot, plus
a Tag chip for unlimited subscriptions and a coloured chip for days
left until expiry (blue >3d, orange ≤3d, red on expiry). Driven
entirely off existing subData fields — no backend changes.

While the row title in the link list stays email-stripped (default
remark model omits email now), the QR popover label folds it back
in so the rendered QR card identifies the client unambiguously. Tag
content becomes `<rowTitle>-<email>` in both SubPage and
ClientInfoModal — the encoded link itself is unchanged.

SubPage section order is now: info table → usage summary → SUB /
JSON / CLASH endpoints → per-protocol Copy URL rows → apps row, so
the most-glanceable status sits above the fold.
Sanaei 17 ore în urmă
părinte
comite
3f787ae169
100 a modificat fișierele cu 11832 adăugiri și 10435 ștergeri
  1. 6 0
      .github/workflows/ci.yml
  2. 79 18
      database/model/model.go
  3. 0 18
      database/model/model_test.go
  4. 167 49
      frontend/README.md
  5. 6 0
      frontend/eslint.config.js
  6. 26 0
      frontend/eslint.deprecated.config.js
  7. 349 210
      frontend/package-lock.json
  8. 18 6
      frontend/package.json
  9. 137 1
      frontend/public/openapi.json
  10. 14 12
      frontend/src/api/queries/useAllSettings.ts
  11. 16 21
      frontend/src/api/queries/useNodeMutations.ts
  12. 7 34
      frontend/src/api/queries/useNodesQuery.ts
  13. 4 1
      frontend/src/api/queries/useStatusQuery.ts
  14. 505 439
      frontend/src/components/FinalMaskForm.tsx
  15. 141 0
      frontend/src/components/HeaderMapEditor.tsx
  16. 1 0
      frontend/src/env.d.ts
  17. 359 0
      frontend/src/generated/types.ts
  18. 380 0
      frontend/src/generated/zod.ts
  19. 106 109
      frontend/src/hooks/useClients.ts
  20. 5 5
      frontend/src/hooks/useDatepicker.ts
  21. 40 60
      frontend/src/hooks/useXraySetting.ts
  22. 78 0
      frontend/src/lib/xray/headers.ts
  23. 277 0
      frontend/src/lib/xray/inbound-defaults.ts
  24. 271 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  25. 55 0
      frontend/src/lib/xray/inbound-from-db.ts
  26. 922 0
      frontend/src/lib/xray/inbound-link.ts
  27. 167 0
      frontend/src/lib/xray/outbound-defaults.ts
  28. 619 0
      frontend/src/lib/xray/outbound-form-adapter.ts
  29. 439 0
      frontend/src/lib/xray/outbound-link-parser.ts
  30. 74 0
      frontend/src/lib/xray/protocol-capabilities.ts
  31. 69 0
      frontend/src/lib/xray/stream-defaults.ts
  32. 1 58
      frontend/src/models/dbinbound.ts
  33. 0 3359
      frontend/src/models/inbound.ts
  34. 0 2405
      frontend/src/models/outbound.ts
  35. 1 1
      frontend/src/models/setting.ts
  36. 15 1
      frontend/src/pages/api-docs/endpoints.ts
  37. 136 159
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  38. 10 5
      frontend/src/pages/clients/ClientBulkAdjustModal.tsx
  39. 0 1
      frontend/src/pages/clients/ClientFormModal.css
  40. 199 183
      frontend/src/pages/clients/ClientFormModal.tsx
  41. 61 0
      frontend/src/pages/clients/ClientInfoModal.css
  42. 395 166
      frontend/src/pages/clients/ClientInfoModal.tsx
  43. 60 19
      frontend/src/pages/clients/ClientsPage.tsx
  44. 667 810
      frontend/src/pages/inbounds/InboundFormModal.tsx
  45. 201 37
      frontend/src/pages/inbounds/InboundInfoModal.tsx
  46. 55 24
      frontend/src/pages/inbounds/InboundList.tsx
  47. 47 26
      frontend/src/pages/inbounds/InboundsPage.tsx
  48. 39 19
      frontend/src/pages/inbounds/QrCodeModal.tsx
  49. 42 44
      frontend/src/pages/inbounds/useInbounds.ts
  50. 7 25
      frontend/src/pages/index/CustomGeoFormModal.tsx
  51. 1 1
      frontend/src/pages/index/CustomGeoSection.tsx
  52. 7 5
      frontend/src/pages/index/IndexPage.tsx
  53. 27 15
      frontend/src/pages/index/LogModal.tsx
  54. 1 1
      frontend/src/pages/index/VersionModal.tsx
  55. 14 8
      frontend/src/pages/index/XrayLogModal.tsx
  56. 10 9
      frontend/src/pages/index/XrayMetricsModal.tsx
  57. 6 8
      frontend/src/pages/login/LoginPage.tsx
  58. 152 180
      frontend/src/pages/nodes/NodeFormModal.tsx
  59. 15 3
      frontend/src/pages/settings/SettingsPage.tsx
  60. 9 3
      frontend/src/pages/settings/TwoFactorModal.tsx
  61. 48 53
      frontend/src/pages/sub/SubPage.css
  62. 256 77
      frontend/src/pages/sub/SubPage.tsx
  63. 87 0
      frontend/src/pages/sub/SubUsageSummary.css
  64. 96 0
      frontend/src/pages/sub/SubUsageSummary.tsx
  65. 202 67
      frontend/src/pages/xray/BalancerFormModal.tsx
  66. 86 84
      frontend/src/pages/xray/BalancersTab.tsx
  67. 2 2
      frontend/src/pages/xray/BasicsTab.tsx
  68. 175 157
      frontend/src/pages/xray/DnsServerModal.tsx
  69. 3 15
      frontend/src/pages/xray/DnsTab.tsx
  70. 12 15
      frontend/src/pages/xray/NordModal.tsx
  71. 2141 1368
      frontend/src/pages/xray/OutboundFormModal.tsx
  72. 2 2
      frontend/src/pages/xray/OutboundsTab.tsx
  73. 4 2
      frontend/src/pages/xray/RoutingTab.tsx
  74. 18 28
      frontend/src/pages/xray/RuleFormModal.tsx
  75. 7 7
      frontend/src/pages/xray/WarpModal.tsx
  76. 10 0
      frontend/src/schemas/_envelope.ts
  77. 64 0
      frontend/src/schemas/api/inbound.ts
  78. 158 0
      frontend/src/schemas/client.ts
  79. 20 0
      frontend/src/schemas/defaults.ts
  80. 64 0
      frontend/src/schemas/dns.ts
  81. 83 0
      frontend/src/schemas/forms/inbound-form.ts
  82. 265 0
      frontend/src/schemas/forms/outbound-form.ts
  83. 32 0
      frontend/src/schemas/inbound.ts
  84. 2 0
      frontend/src/schemas/index.ts
  85. 15 0
      frontend/src/schemas/login.ts
  86. 53 0
      frontend/src/schemas/node.ts
  87. 16 0
      frontend/src/schemas/primitives/flow.ts
  88. 6 0
      frontend/src/schemas/primitives/index.ts
  89. 111 0
      frontend/src/schemas/primitives/options.ts
  90. 30 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  91. 4 0
      frontend/src/schemas/primitives/port.ts
  92. 34 0
      frontend/src/schemas/primitives/protocol.ts
  93. 16 0
      frontend/src/schemas/primitives/sniffing.ts
  94. 17 0
      frontend/src/schemas/protocols/inbound/http.ts
  95. 26 0
      frontend/src/schemas/protocols/inbound/hysteria.ts
  96. 42 0
      frontend/src/schemas/protocols/inbound/index.ts
  97. 21 0
      frontend/src/schemas/protocols/inbound/mixed.ts
  98. 45 0
      frontend/src/schemas/protocols/inbound/shadowsocks.ts
  99. 32 0
      frontend/src/schemas/protocols/inbound/trojan.ts
  100. 12 0
      frontend/src/schemas/protocols/inbound/tun.ts

+ 6 - 0
.github/workflows/ci.yml

@@ -81,6 +81,12 @@ jobs:
       - name: Lint
         run: npm run lint
         working-directory: frontend
+      - name: Typecheck
+        run: npm run typecheck
+        working-directory: frontend
+      - name: Test
+        run: npm test
+        working-directory: frontend
       - name: Build
         run: npm run build
         working-directory: frontend

+ 79 - 18
database/model/model.go

@@ -14,7 +14,11 @@ import (
 // Protocol represents the protocol type for Xray inbounds.
 type Protocol string
 
-// Protocol constants for different Xray inbound protocols
+// Protocol constants for different Xray inbound protocols.
+// Hysteria v2 is not a distinct protocol — it is plain "hysteria"
+// with streamSettings.version = 2. The share-link URI scheme
+// "hysteria2://" is independent of this and is still emitted by the
+// link generator when the stream version is 2.
 const (
 	VMESS       Protocol = "vmess"
 	VLESS       Protocol = "vless"
@@ -25,16 +29,8 @@ const (
 	Mixed       Protocol = "mixed"
 	WireGuard   Protocol = "wireguard"
 	Hysteria    Protocol = "hysteria"
-	Hysteria2   Protocol = "hysteria2"
 )
 
-// IsHysteria returns true for both "hysteria" and "hysteria2".
-// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
-// with the literal v2 string would otherwise fall through (#4081).
-func IsHysteria(p Protocol) bool {
-	return p == Hysteria || p == Hysteria2
-}
-
 // User represents a user account in the 3x-ui panel.
 type User struct {
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -53,14 +49,14 @@ type Inbound struct {
 	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
-	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
+	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
 	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                               // Last traffic reset timestamp
 	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                        // Client traffic statistics
 
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
-	Port           int      `json:"port" form:"port"`
-	Protocol       Protocol `json:"protocol" form:"protocol"`
+	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
@@ -223,17 +219,82 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	}
 	listen = fmt.Sprintf("\"%v\"", listen)
 	protocol := string(i.Protocol)
+	settings := i.Settings
+	if i.Protocol == Shadowsocks {
+		if healed, ok := HealShadowsocksClientMethods(settings); ok {
+			settings = healed
+		}
+	}
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
 		Port:           i.Port,
 		Protocol:       protocol,
-		Settings:       json_util.RawMessage(i.Settings),
+		Settings:       json_util.RawMessage(settings),
 		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		Tag:            i.Tag,
 		Sniffing:       json_util.RawMessage(i.Sniffing),
 	}
 }
 
+// HealShadowsocksClientMethods normalises the per-client `method` field
+// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
+//   - Legacy ciphers (aes-*, chacha20-*): every client must carry a
+//     per-user `method` matching the inbound's top-level method, otherwise
+//     xray fails with "unsupported cipher method:".
+//   - Shadowsocks 2022 (2022-blake3-*): xray's multi-user code rejects the
+//     inbound with "users must have empty method" when a client carries
+//     one — strip stale entries left over from a switch off a legacy
+//     cipher.
+// Returns the rewritten settings string and true when anything changed.
+func HealShadowsocksClientMethods(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	method, _ := parsed["method"].(string)
+	clients, ok := parsed["clients"].([]any)
+	if !ok {
+		return settings, false
+	}
+	is2022 := strings.HasPrefix(method, "2022-blake3-")
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if is2022 {
+			if _, hasKey := cm["method"]; hasKey {
+				delete(cm, "method")
+				clients[i] = cm
+				changed = true
+			}
+			continue
+		}
+		if method == "" {
+			continue
+		}
+		existing, _ := cm["method"].(string)
+		if existing == method {
+			continue
+		}
+		cm["method"] = method
+		clients[i] = cm
+		changed = true
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // Setting stores key-value configuration settings for the 3x-ui panel.
 type Setting struct {
 	Id    int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
@@ -247,13 +308,13 @@ type Setting struct {
 // status fields below.
 type Node struct {
 	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	Name                string `json:"name" form:"name" gorm:"uniqueIndex"`
+	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
 	Remark              string `json:"remark" form:"remark"`
-	Scheme              string `json:"scheme" form:"scheme"`
-	Address             string `json:"address" form:"address"`
-	Port                int    `json:"port" form:"port"`
+	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
+	Address             string `json:"address" form:"address" validate:"required"`
+	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535"`
 	BasePath            string `json:"basePath" form:"basePath"`
-	ApiToken            string `json:"apiToken" form:"apiToken"`
+	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required"`
 	Enable              bool   `json:"enable" form:"enable" gorm:"default:true"`
 	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
 

+ 0 - 18
database/model/model_test.go

@@ -189,21 +189,3 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
 	}
 }
 
-func TestIsHysteria(t *testing.T) {
-	cases := []struct {
-		in   Protocol
-		want bool
-	}{
-		{Hysteria, true},
-		{Hysteria2, true},
-		{VLESS, false},
-		{Shadowsocks, false},
-		{Protocol(""), false},
-		{Protocol("hysteria3"), false},
-	}
-	for _, c := range cases {
-		if got := IsHysteria(c.in); got != c.want {
-			t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
-		}
-	}
-}

+ 167 - 49
frontend/README.md

@@ -1,8 +1,15 @@
 # 3x-ui frontend
 
-React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
-entry per panel route — built into `../web/dist/` and embedded into the
-Go binary via `embed.FS`.
+React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
+`index.html` (admin panel SPA, all `/panel/*` routes), `login.html`
+(login + 2FA), and `subpage.html` (public subscription viewer). All
+three are built into `../web/dist/` and embedded into the Go binary
+via `embed.FS`.
+
+State is split between local `useState`, TanStack Query for server
+state, and `useTheme` / `useWebSocket` contexts. Form validation,
+API parsing, and the xray config model all run through a single
+shared Zod schema tree (see [Schemas](#schemas)).
 
 ## Dev
 
@@ -11,73 +18,184 @@ npm install
 npm run dev
 ```
 
-Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
-proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
-first (`go run main.go`) and then Vite.
-
-The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
-`/panel/xray` to the matching Vite-served HTML in dev mode (see
-`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
+Vite serves on `http://localhost:5173/`. API calls and `/panel/*`
+routes proxy to the Go panel at `http://localhost:2053/`, so start
+the Go panel first (`go run main.go`) and then Vite. The proxy
+auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
+`/panel/xray` to the matching Vite-served HTML, so the sidebar's
 production-style links work without round-tripping through Go.
 
-## Production build
+## Scripts
+
+| Command | What |
+|---|---|
+| `npm run dev` | Vite dev server with API + WS proxy to Go |
+| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` |
+| `npm run preview` | Serve the built bundle locally |
+| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
+| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
+| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
+| `npm run test:watch` | Vitest watch mode |
+| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
+| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
+
+CI runs `typecheck`, `lint`, `test`, and `build` on every PR
+(see `../.github/workflows/ci.yml`).
+
+### One-off: scan for deprecated APIs
+
+Run this command to sweep the codebase for usages of APIs marked
+with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames,
+removed Web APIs, etc.):
 
 ```sh
-npm run build
+npx eslint --config eslint.deprecated.config.js src
 ```
 
-Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
-`assets/`). The Go binary embeds this directory at compile time and
-`web/controller/dist.go` serves the per-page HTML.
+It's a type-aware ESLint run against `eslint.deprecated.config.js`
+and is not wired into `npm run lint` because typed linting triples
+the wall-clock time.
 
-## Type check and lint
+## Production build
 
 ```sh
-npm run typecheck
-npm run lint
+npm run build
 ```
 
-`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
-`@/*` → `src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
-— `@eslint/js` recommended plus `typescript-eslint` and
-`eslint-plugin-react-hooks` rules.
+Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
+`assets/`). `manualChunks` splits AntD, icons, codemirror, and
+react-query into separate vendor bundles to keep the per-page
+initial JS small. The Go binary embeds this directory at compile
+time and `web/controller/dist.go` serves the per-page HTML.
 
 ## Layout
 
 ```
 frontend/
-├── *.html                 # Vite entry HTML, one per panel route
+├── index.html, login.html, subpage.html  # 3 Vite entries
 ├── tsconfig.json
 ├── eslint.config.js
+├── eslint.deprecated.config.js           # On-demand type-aware lint config that flags
+│                                         #   usages of APIs marked with JSDoc @deprecated
+├── vitest.config.ts
 ├── vite.config.js
+├── scripts/
+│   └── build-openapi.mjs                 # endpoints.ts → openapi.json
 └── src/
-    ├── entries/           # Per-page bootstrap (createRoot + render)
-    ├── pages/             # One folder per route, each with the page
-    │   ├── index/         # component + helpers + sub-components
-    │   ├── login/
-    │   ├── inbounds/
-    │   ├── clients/
-    │   ├── xray/
-    │   ├── nodes/
-    │   ├── settings/
-    │   ├── api-docs/
-    │   └── sub/
-    ├── components/        # Cross-page React components
-    ├── hooks/             # Reusable hooks (useTheme, useWebSocket, …)
-    ├── api/               # Axios setup, CSRF interceptor, WebSocket
-    ├── i18n/              # react-i18next init (locales live in web/translation/)
-    ├── models/            # Inbound, Outbound, Status, … domain classes
-    ├── styles/            # Shared CSS modules (page-cards, …)
-    └── utils/             # HttpUtil, ObjectUtil, LanguageManager, …
+    ├── entries/         # Per-page bootstrap (createRoot + render)
+    ├── main.tsx         # Shared root for the admin SPA (index.html)
+    ├── routes.tsx       # react-router routes mounted under /panel/
+    ├── pages/           # One folder per route, page component + helpers
+    │   ├── index/, login/, inbounds/, clients/, xray/, nodes/,
+    │   ├── settings/, api-docs/, sub/
+    ├── layouts/         # AdminLayout (sidebar + header + outlet)
+    ├── components/      # Cross-page React components
+    ├── hooks/           # useClients, useTheme, useWebSocket, …
+    ├── api/             # Axios + CSRF interceptor, TanStack Query bridge,
+    │                    #   WebSocket client + queryClient.ts
+    ├── i18n/            # react-i18next init (locales in web/translation/)
+    ├── lib/xray/        # Pure functions: link generation, defaults,
+    │                    #   form ⇄ wire adapters, protocol capabilities
+    ├── schemas/         # Zod source-of-truth (see "Schemas" below)
+    ├── generated/       # Code-generated zod + ts types from Go
+    │                    #   (DO NOT hand-edit — regenerated by gen:zod)
+    ├── models/          # Thin legacy types still in transit
+    │                    #   (DBInbound, Status, AllSetting, reality-targets)
+    ├── styles/          # Shared CSS modules
+    ├── test/            # Vitest specs + golden fixtures
+    │   ├── *.test.ts
+    │   ├── __snapshots__/
+    │   └── golden/fixtures/  # Per-(protocol × network × security) JSON
+    └── utils/           # HttpUtil, ClipboardManager, SizeFormatter, …
+```
+
+## Schemas
+
+`src/schemas/` is the single source of truth for the xray
+configuration model. Every API response is parsed through it,
+every form field is validated against it, and TypeScript types
+are inferred via `z.infer<typeof X>` — never hand-written.
+
+```
+schemas/
+├── primitives/      # Atomic reusable schemas (port, protocol, sniffing, …)
+├── api/             # Backend response shapes (e.g. SlimInboundSchema)
+├── forms/           # User-facing form shapes (narrower than api/)
+├── protocols/
+│   ├── inbound/     # Per-protocol settings (vmess, vless, trojan, …)
+│   ├── outbound/
+│   ├── stream/      # Network transports (tcp, ws, grpc, xhttp, kcp, …)
+│   └── security/    # TLS, Reality, none
+├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
+└── _envelope.ts     # Generic `Msg<T>` envelope wrapper
 ```
 
+Patterns:
+
+- **Discriminated unions** for polymorphic data — inbound `settings`
+  is `z.discriminatedUnion('protocol', […])`, same for stream and
+  security.
+- **Three validation layers**, non-overlapping:
+  - API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
+    Query `queryFn` — warn-only in prod, throws in dev
+  - Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
+    blocks submit + per-field inline error
+  - Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
+    because a malformed payload here is always a developer bug
+- **No `.loose()` or `[key: string]: any`** in production schemas.
+  `@typescript-eslint/no-explicit-any: error` is enforced.
+
+## Form pattern (Pattern A)
+
+All non-trivial modals use this single pattern:
+
+```tsx
+const [form] = Form.useForm<InboundFormValues>();
+
+const onFinish = async () => {
+  const values = await form.validateFields();
+  await createInbound.mutateAsync(values);
+};
+
+<Form form={form} onFinish={onFinish}>
+  <Form.Item
+    name="port"
+    label="Port"
+    rules={[antdRule(InboundFormSchema.shape.port, t)]}
+  >
+    <InputNumber min={1} max={65535} />
+  </Form.Item>
+</Form>
+```
+
+No `safeParse`-on-submit handlers, no `useRef<any>` for form
+references, no inline `z.string().min(1)` in rules. Conditional
+fields use `<Form.Item dependencies={...} shouldUpdate>` with the
+nested protocol schema.
+
+## Testing
+
+Vitest runs everything under `src/test/`. Schemas have **golden
+fixture suites** — one JSON per `(protocol × network × security)`
+combination round-tripped through `schema.parse` → link generator
+→ snapshot. Regenerate snapshots after intentional changes:
+
+```sh
+npx vitest run -u
+```
+
+Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
+via `import.meta.glob`.
+
 ## Adding a new page
 
-1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
-2. Add `src/entries/<page>.tsx` that imports the page component and
-   mounts it with `createRoot(...).render(...)`.
-3. Add the page component under `src/pages/<page>/`.
-4. Register the entry in `rollupOptions.input` in `vite.config.js`.
-5. If the page is reachable from the sidebar at `/panel/<route>`, add
-   it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
-6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
+Most new routes go inside the admin SPA (`index.html`) via
+`routes.tsx` — no new HTML or Vite entry needed.
+
+1. Add the page component under `src/pages/<page>/`.
+2. Register it in `src/routes.tsx` under the `/panel/...` tree.
+3. If you need a brand-new top-level bundle (login-style standalone
+   page), add the HTML at `frontend/<page>.html`, an entry at
+   `src/entries/<page>.tsx`, and register it in `rollupOptions.input`
+   in `vite.config.js`. Then add the Go controller call to
+   `serveDistPage(c, "<page>.html")`.

+ 6 - 0
frontend/eslint.config.js

@@ -29,6 +29,12 @@ export default [
         varsIgnorePattern: '^_',
         caughtErrorsIgnorePattern: '^_',
       }],
+      // Zod migration goal (Step 7): every production module is held to
+      // strict no-explicit-any. The two legacy class files at the bottom
+      // of the rule list keep their existing file-level eslint-disable
+      // until DBInbound is migrated off Inbound.toInbound() — see the
+      // migration spec Non-Goals section.
+      '@typescript-eslint/no-explicit-any': 'error',
       'no-empty': ['error', { allowEmptyCatch: true }],
       'react-hooks/set-state-in-effect': 'off',
       'react-hooks/purity': 'off',

+ 26 - 0
frontend/eslint.deprecated.config.js

@@ -0,0 +1,26 @@
+import tseslint from 'typescript-eslint';
+import reactHooks from 'eslint-plugin-react-hooks';
+
+export default [
+  { ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
+  {
+    files: ['**/*.{ts,tsx}'],
+    plugins: {
+      '@typescript-eslint': tseslint.plugin,
+      'react-hooks': reactHooks,
+    },
+    languageOptions: {
+      parser: tseslint.parser,
+      parserOptions: {
+        projectService: true,
+        tsconfigRootDir: import.meta.dirname,
+      },
+    },
+    rules: {
+      '@typescript-eslint/no-deprecated': 'warn',
+    },
+    linterOptions: {
+      reportUnusedDisableDirectives: 'off',
+    },
+  },
+];

Fișier diff suprimat deoarece este prea mare
+ 349 - 210
frontend/package-lock.json


+ 18 - 6
frontend/package.json

@@ -14,7 +14,10 @@
     "preview": "vite preview",
     "lint": "eslint src",
     "typecheck": "tsc --noEmit",
-    "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs"
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
+    "gen:zod": "cd .. && go run ./tools/openapigen"
   },
   "dependencies": {
     "@ant-design/icons": "^6.2.3",
@@ -25,8 +28,8 @@
     "antd": "^6.4.3",
     "axios": "^1.16.1",
     "codemirror": "^6.0.2",
-    "dayjs": "^1.11.20",
-    "i18next": "^26.2.0",
+    "dayjs": "^1.11.21",
+    "i18next": "^26.3.0",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "qs": "^6.15.2",
@@ -35,7 +38,8 @@
     "react-i18next": "^17.0.8",
     "react-router-dom": "^7.15.1",
     "recharts": "^3.8.1",
-    "swagger-ui-react": "^5.32.6"
+    "swagger-ui-react": "^5.32.6",
+    "zod": "^4.4.3"
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
@@ -47,7 +51,15 @@
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.6.0",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.59.4",
-    "vite": "8.0.13"
+    "typescript-eslint": "^8.60.0",
+    "vite": "8.0.14",
+    "vitest": "^4.1.7"
+  },
+  "overrides": {
+    "react-copy-to-clipboard": "^5.1.1",
+    "react-inspector": "^9.0.0",
+    "react-debounce-input": {
+      "react": "^19.0.0"
+    }
   }
 }

+ 137 - 1
frontend/public/openapi.json

@@ -2669,6 +2669,142 @@
         }
       }
     },
+    "/panel/api/clients/bulkDel": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Delete many clients in one call. The server processes the list sequentially so each delete sees the committed state of the previous one — avoids the race the per-email fan-out had on the panel side. Pass keepTraffic=true to retain the xray_client_traffic rows after deletion.",
+        "operationId": "post_panel_api_clients_bulkDel",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ],
+                "keepTraffic": false
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "deleted": 2,
+                    "skipped": [
+                      {
+                        "email": "carol",
+                        "reason": "client not found"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/bulkCreate": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Create many clients in one call. Body is a JSON array of {client, inboundIds} payloads — the same shape /add accepts. Items are processed sequentially; per-email skip reasons are returned for items that fail (e.g., duplicate email). Triggers a single Xray restart at the end if any inbound was running.",
+        "operationId": "post_panel_api_clients_bulkCreate",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": [
+                {
+                  "client": {
+                    "email": "[email protected]",
+                    "totalGB": 53687091200,
+                    "expiryTime": 0,
+                    "enable": true
+                  },
+                  "inboundIds": [
+                    7
+                  ]
+                },
+                {
+                  "client": {
+                    "email": "[email protected]",
+                    "totalGB": 53687091200,
+                    "expiryTime": 0,
+                    "enable": true
+                  },
+                  "inboundIds": [
+                    7,
+                    9
+                  ]
+                }
+              ]
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "created": 2,
+                    "skipped": [
+                      {
+                        "email": "[email protected]",
+                        "reason": "email already in use"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/resetTraffic/{email}": {
       "post": {
         "tags": [
@@ -3025,7 +3161,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        "summary": "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
         "operationId": "get_panel_api_clients_links_email",
         "parameters": [
           {

+ 14 - 12
frontend/src/api/queries/useAllSettings.ts

@@ -1,20 +1,17 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 
-import { HttpUtil } from '@/utils';
+import { HttpUtil, Msg } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { AllSetting } from '@/models/setting';
+import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  obj?: T;
-  msg?: string;
-}
-
-async function fetchAllSetting(): Promise<unknown> {
-  const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }) as ApiMsg;
+async function fetchAllSetting(): Promise<AllSettingInput | null> {
+  const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
-  return msg.obj;
+  const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
+  return validated.obj;
 }
 
 export function useAllSettings() {
@@ -45,8 +42,13 @@ export function useAllSettings() {
   }, []);
 
   const saveMut = useMutation({
-    mutationFn: async (next: AllSetting) =>
-      HttpUtil.post('/panel/setting/update', next) as Promise<ApiMsg>,
+    mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
+      const body = AllSettingSchema.partial().safeParse(next);
+      if (!body.success) {
+        console.warn('[zod] setting/update body failed validation', body.error.issues);
+      }
+      return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
+    },
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
     },

+ 16 - 21
frontend/src/api/queries/useNodeMutations.ts

@@ -1,21 +1,12 @@
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 
-import { HttpUtil } from '@/utils';
+import { HttpUtil, Msg } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
-export interface ProbeResult {
-  status: string;
-  latencyMs?: number;
-  xrayVersion?: string;
-  error?: string;
-}
+export type { ProbeResult };
 
 export function useNodeMutations() {
   const queryClient = useQueryClient();
@@ -23,31 +14,33 @@ export function useNodeMutations() {
 
   const createMut = useMutation({
     mutationFn: (payload: Partial<NodeRecord>) =>
-      HttpUtil.post('/panel/api/nodes/add', payload) as Promise<ApiMsg>,
+      HttpUtil.post('/panel/api/nodes/add', payload),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
-      HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const removeMut = useMutation({
     mutationFn: (id: number) =>
-      HttpUtil.post(`/panel/api/nodes/del/${id}`) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/nodes/del/${id}`),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const setEnableMut = useMutation({
     mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
-      HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const probeMut = useMutation({
-    mutationFn: (id: number) =>
-      HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise<ApiMsg<ProbeResult>>,
+    mutationFn: async (id: number): Promise<Msg<ProbeResult>> => {
+      const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
+      return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
+    },
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
@@ -57,7 +50,9 @@ export function useNodeMutations() {
     remove: (id: number) => removeMut.mutateAsync(id),
     setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
     probe: (id: number) => probeMut.mutateAsync(id),
-    testConnection: (payload: Partial<NodeRecord>) =>
-      HttpUtil.post('/panel/api/nodes/test', payload) as Promise<ApiMsg<ProbeResult>>,
+    testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
+      const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
+      return parseMsg(raw, ProbeResultSchema, 'nodes/test');
+    },
   };
 }

+ 7 - 34
frontend/src/api/queries/useNodesQuery.ts

@@ -2,34 +2,12 @@ import { useQuery } from '@tanstack/react-query';
 import { useMemo } from 'react';
 
 import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { NodeListSchema } from '@/schemas/node';
+import type { NodeRecord } from '@/schemas/node';
 import { keys } from '@/api/queryKeys';
 
-export interface NodeRecord {
-  id: number;
-  name?: string;
-  remark?: string;
-  scheme?: string;
-  address?: string;
-  port?: number;
-  basePath?: string;
-  apiToken?: string;
-  enable?: boolean;
-  status?: 'online' | 'offline' | string;
-  latencyMs?: number;
-  cpuPct?: number;
-  memPct?: number;
-  xrayVersion?: string;
-  panelVersion?: string;
-  uptimeSecs?: number;
-  inboundCount?: number;
-  clientCount?: number;
-  onlineCount?: number;
-  depletedCount?: number;
-  lastHeartbeat?: number;
-  lastError?: string;
-  allowPrivateAddress?: boolean;
-  [key: string]: unknown;
-}
+export type { NodeRecord };
 
 export interface NodeTotals {
   total: number;
@@ -42,16 +20,11 @@ export interface NodeTotals {
   depleted: number;
 }
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
 async function fetchNodes(): Promise<NodeRecord[]> {
-  const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg<NodeRecord[]>;
+  const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, NodeListSchema, 'nodes/list');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 export function useNodesQuery() {

+ 4 - 1
frontend/src/api/queries/useStatusQuery.ts

@@ -2,7 +2,9 @@ import { useQuery } from '@tanstack/react-query';
 import { useMemo } from 'react';
 
 import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { Status } from '@/models/status';
+import { StatusSchema } from '@/schemas/status';
 import { keys } from '@/api/queryKeys';
 
 const POLL_INTERVAL_MS = 2000;
@@ -10,7 +12,8 @@ const POLL_INTERVAL_MS = 2000;
 async function fetchStatus(): Promise<Status> {
   const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status');
-  return new Status(msg.obj);
+  const validated = parseMsg(msg, StatusSchema, 'server/status');
+  return new Status(validated.obj);
 }
 
 export function useStatusQuery() {

Fișier diff suprimat deoarece este prea mare
+ 505 - 439
frontend/src/components/FinalMaskForm.tsx


+ 141 - 0
frontend/src/components/HeaderMapEditor.tsx

@@ -0,0 +1,141 @@
+import { useEffect, useRef, useState } from 'react';
+import { Button, Input, Space } from 'antd';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
+import InputAddon from '@/components/InputAddon';
+
+// Reusable header-map editor. Handles the two wire shapes Xray uses for
+// HTTP-style header maps:
+//
+//   v1:   { 'Content-Type': 'application/json',  'X-Custom': 'value' }
+//         Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
+//         name.
+//
+//   v2:   { 'Accept':       ['text/html', 'application/json'],
+//           'X-Forwarded':  ['1.2.3.4'] }
+//         Used by TCP HTTP camouflage request/response. Each header can
+//         repeat (RFC 7230 §3.2.2).
+//
+// Internal state is always the flat list-of-rows shape regardless of
+// mode. Conversion to/from the wire shape happens at the value/onChange
+// boundary so consumers can bind straight to a Form.Item without any
+// extra transforms on their side.
+
+export type HeaderMapMode = 'v1' | 'v2';
+
+export type HeaderMapValue =
+  | Record<string, string>
+  | Record<string, string[]>
+  | undefined;
+
+interface HeaderRow {
+  name: string;
+  value: string;
+}
+
+interface HeaderMapEditorProps {
+  mode: HeaderMapMode;
+  value?: HeaderMapValue;
+  onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
+}
+
+function mapToRows(value: HeaderMapValue): HeaderRow[] {
+  if (!value || typeof value !== 'object') return [];
+  const out: HeaderRow[] = [];
+  for (const [name, raw] of Object.entries(value)) {
+    if (Array.isArray(raw)) {
+      for (const v of raw) {
+        out.push({ name, value: typeof v === 'string' ? v : String(v) });
+      }
+    } else if (typeof raw === 'string') {
+      out.push({ name, value: raw });
+    }
+  }
+  return out;
+}
+
+function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
+  if (mode === 'v1') {
+    const map: Record<string, string> = {};
+    for (const r of rows) {
+      if (!r.name) continue;
+      map[r.name] = r.value ?? '';
+    }
+    return map;
+  }
+  const map: Record<string, string[]> = {};
+  for (const r of rows) {
+    if (!r.name) continue;
+    const list = map[r.name] ?? [];
+    list.push(r.value ?? '');
+    map[r.name] = list;
+  }
+  return map;
+}
+
+export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
+  // Local state holds rows including blanks. Without it, addRow() would
+  // append a {name:'', value:''} that rowsToMap immediately filters out
+  // before reaching the form, so the new row would never reach UI. The
+  // form-bound map only sees rows with non-empty names; blank rows live
+  // here until the user fills them in.
+  const [rows, setRows] = useState<HeaderRow[]>(() => mapToRows(value));
+  const lastEmittedRef = useRef<string>(JSON.stringify(rowsToMap(rows, mode)));
+
+  // Re-sync local rows when the form value changes from outside (modal
+  // re-open with edit data, JSON tab edits, etc.) but not when it's our
+  // own emission echoing back.
+  useEffect(() => {
+    const incoming = JSON.stringify(value ?? {});
+    if (incoming === lastEmittedRef.current) return;
+    setRows(mapToRows(value));
+    lastEmittedRef.current = incoming;
+  }, [value]);
+
+  function commit(next: HeaderRow[]) {
+    setRows(next);
+    const map = rowsToMap(next, mode);
+    lastEmittedRef.current = JSON.stringify(map);
+    onChange?.(map);
+  }
+
+  function setRow(index: number, patch: Partial<HeaderRow>) {
+    const next = rows.slice();
+    next[index] = { ...next[index], ...patch };
+    commit(next);
+  }
+
+  function addRow() {
+    commit([...rows, { name: '', value: '' }]);
+  }
+
+  function removeRow(index: number) {
+    const next = rows.slice();
+    next.splice(index, 1);
+    commit(next);
+  }
+
+  return (
+    <>
+      {rows.map((row, idx) => (
+        <Space.Compact key={idx} block className="mb-8">
+          <InputAddon>{`${idx + 1}`}</InputAddon>
+          <Input
+            value={row.name}
+            placeholder="Name"
+            onChange={(e) => setRow(idx, { name: e.target.value })}
+          />
+          <Input
+            value={row.value}
+            placeholder="Value"
+            onChange={(e) => setRow(idx, { value: e.target.value })}
+          />
+          <Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
+        </Space.Compact>
+      ))}
+      <Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
+        Add
+      </Button>
+    </>
+  );
+}

+ 1 - 0
frontend/src/env.d.ts

@@ -16,6 +16,7 @@ interface SubPageData {
   subClashUrl?: string;
   subTitle?: string;
   links?: string[];
+  emails?: string[];
   datepicker?: 'gregorian' | 'jalalian';
   downloadByte?: string | number;
   uploadByte?: string | number;

+ 359 - 0
frontend/src/generated/types.ts

@@ -0,0 +1,359 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+export type Protocol = string;
+
+export interface AllSetting {
+  datepicker: string;
+  expireDiff: number;
+  externalTrafficInformEnable: boolean;
+  externalTrafficInformURI: string;
+  ldapAutoCreate: boolean;
+  ldapAutoDelete: boolean;
+  ldapBaseDN: string;
+  ldapBindDN: string;
+  ldapDefaultExpiryDays: number;
+  ldapDefaultLimitIP: number;
+  ldapDefaultTotalGB: number;
+  ldapEnable: boolean;
+  ldapFlagField: string;
+  ldapHost: string;
+  ldapInboundTags: string;
+  ldapInvertFlag: boolean;
+  ldapPassword: string;
+  ldapPort: number;
+  ldapSyncCron: string;
+  ldapTruthyValues: string;
+  ldapUseTLS: boolean;
+  ldapUserAttr: string;
+  ldapUserFilter: string;
+  ldapVlessField: string;
+  pageSize: number;
+  remarkModel: string;
+  restartXrayOnClientDisable: boolean;
+  sessionMaxAge: number;
+  subAnnounce: string;
+  subCertFile: string;
+  subClashEnable: boolean;
+  subClashPath: string;
+  subClashURI: string;
+  subDomain: string;
+  subEmailInRemark: boolean;
+  subEnable: boolean;
+  subEnableRouting: boolean;
+  subEncrypt: boolean;
+  subJsonEnable: boolean;
+  subJsonFragment: string;
+  subJsonMux: string;
+  subJsonNoises: string;
+  subJsonPath: string;
+  subJsonRules: string;
+  subJsonURI: string;
+  subKeyFile: string;
+  subListen: string;
+  subPath: string;
+  subPort: number;
+  subProfileUrl: string;
+  subRoutingRules: string;
+  subShowInfo: boolean;
+  subSupportUrl: string;
+  subTitle: string;
+  subURI: string;
+  subUpdates: number;
+  tgBotAPIServer: string;
+  tgBotBackup: boolean;
+  tgBotChatId: string;
+  tgBotEnable: boolean;
+  tgBotLoginNotify: boolean;
+  tgBotProxy: string;
+  tgBotToken: string;
+  tgCpu: number;
+  tgLang: string;
+  tgRunTime: string;
+  timeLocation: string;
+  trafficDiff: number;
+  trustedProxyCIDRs: string;
+  twoFactorEnable: boolean;
+  twoFactorToken: string;
+  webBasePath: string;
+  webCertFile: string;
+  webDomain: string;
+  webKeyFile: string;
+  webListen: string;
+  webPort: number;
+}
+
+export interface AllSettingView {
+  datepicker: string;
+  expireDiff: number;
+  externalTrafficInformEnable: boolean;
+  externalTrafficInformURI: string;
+  hasApiToken: boolean;
+  hasLdapPassword: boolean;
+  hasNordSecret: boolean;
+  hasTgBotToken: boolean;
+  hasTwoFactorToken: boolean;
+  hasWarpSecret: boolean;
+  ldapAutoCreate: boolean;
+  ldapAutoDelete: boolean;
+  ldapBaseDN: string;
+  ldapBindDN: string;
+  ldapDefaultExpiryDays: number;
+  ldapDefaultLimitIP: number;
+  ldapDefaultTotalGB: number;
+  ldapEnable: boolean;
+  ldapFlagField: string;
+  ldapHost: string;
+  ldapInboundTags: string;
+  ldapInvertFlag: boolean;
+  ldapPassword: string;
+  ldapPort: number;
+  ldapSyncCron: string;
+  ldapTruthyValues: string;
+  ldapUseTLS: boolean;
+  ldapUserAttr: string;
+  ldapUserFilter: string;
+  ldapVlessField: string;
+  pageSize: number;
+  remarkModel: string;
+  restartXrayOnClientDisable: boolean;
+  sessionMaxAge: number;
+  subAnnounce: string;
+  subCertFile: string;
+  subClashEnable: boolean;
+  subClashPath: string;
+  subClashURI: string;
+  subDomain: string;
+  subEmailInRemark: boolean;
+  subEnable: boolean;
+  subEnableRouting: boolean;
+  subEncrypt: boolean;
+  subJsonEnable: boolean;
+  subJsonFragment: string;
+  subJsonMux: string;
+  subJsonNoises: string;
+  subJsonPath: string;
+  subJsonRules: string;
+  subJsonURI: string;
+  subKeyFile: string;
+  subListen: string;
+  subPath: string;
+  subPort: number;
+  subProfileUrl: string;
+  subRoutingRules: string;
+  subShowInfo: boolean;
+  subSupportUrl: string;
+  subTitle: string;
+  subURI: string;
+  subUpdates: number;
+  tgBotAPIServer: string;
+  tgBotBackup: boolean;
+  tgBotChatId: string;
+  tgBotEnable: boolean;
+  tgBotLoginNotify: boolean;
+  tgBotProxy: string;
+  tgBotToken: string;
+  tgCpu: number;
+  tgLang: string;
+  tgRunTime: string;
+  timeLocation: string;
+  trafficDiff: number;
+  trustedProxyCIDRs: string;
+  twoFactorEnable: boolean;
+  twoFactorToken: string;
+  webBasePath: string;
+  webCertFile: string;
+  webDomain: string;
+  webKeyFile: string;
+  webListen: string;
+  webPort: number;
+}
+
+export interface ApiToken {
+  createdAt: number;
+  enabled: boolean;
+  id: number;
+  name: string;
+  token: string;
+}
+
+export interface Client {
+  auth?: string;
+  comment: string;
+  created_at?: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  flow?: string;
+  id?: string;
+  limitIp: number;
+  password?: string;
+  reset: number;
+  reverse?: ClientReverse | null;
+  security: string;
+  subId: string;
+  tgId: number;
+  totalGB: number;
+  updated_at?: number;
+}
+
+export interface ClientInbound {
+  clientId: number;
+  createdAt: number;
+  flowOverride: string;
+  inboundId: number;
+}
+
+export interface ClientRecord {
+  auth: string;
+  comment: string;
+  createdAt: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  flow: string;
+  id: number;
+  limitIp: number;
+  password: string;
+  reset: number;
+  reverse: unknown;
+  security: string;
+  subId: string;
+  tgId: number;
+  totalGB: number;
+  updatedAt: number;
+  uuid: string;
+}
+
+export interface ClientReverse {
+  tag: string;
+}
+
+export interface ClientTraffic {
+  down: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  id: number;
+  inboundId: number;
+  lastOnline: number;
+  reset: number;
+  subId: string;
+  total: number;
+  up: number;
+  uuid: string;
+}
+
+export interface CustomGeoResource {
+  alias: string;
+  createdAt: number;
+  id: number;
+  lastModified: string;
+  lastUpdatedAt: number;
+  localPath: string;
+  type: string;
+  updatedAt: number;
+  url: string;
+}
+
+export interface FallbackParentInfo {
+  masterId: number;
+  path?: string;
+}
+
+export interface HistoryOfSeeders {
+  id: number;
+  seederName: string;
+}
+
+export interface Inbound {
+  clientStats: ClientTraffic[];
+  down: number;
+  enable: boolean;
+  expiryTime: number;
+  fallbackParent?: FallbackParentInfo | null;
+  id: number;
+  lastTrafficResetTime: number;
+  listen: string;
+  nodeId?: number | null;
+  port: number;
+  protocol: Protocol;
+  remark: string;
+  settings: unknown;
+  sniffing: unknown;
+  streamSettings: unknown;
+  tag: string;
+  total: number;
+  trafficReset: string;
+  up: number;
+}
+
+export interface InboundClientIps {
+  clientEmail: string;
+  id: number;
+  ips: unknown;
+}
+
+export interface InboundFallback {
+  alpn: string;
+  childId: number;
+  id: number;
+  masterId: number;
+  name: string;
+  path: string;
+  sortOrder: number;
+  xver: number;
+}
+
+export interface Msg {
+  msg: string;
+  obj: unknown;
+  success: boolean;
+}
+
+export interface Node {
+  address: string;
+  allowPrivateAddress: boolean;
+  apiToken: string;
+  basePath: string;
+  clientCount: number;
+  cpuPct: number;
+  createdAt: number;
+  depletedCount: number;
+  enable: boolean;
+  id: number;
+  inboundCount: number;
+  lastError: string;
+  lastHeartbeat: number;
+  latencyMs: number;
+  memPct: number;
+  name: string;
+  onlineCount: number;
+  panelVersion: string;
+  port: number;
+  remark: string;
+  scheme: string;
+  status: string;
+  updatedAt: number;
+  uptimeSecs: number;
+  xrayVersion: string;
+}
+
+export interface OutboundTraffics {
+  down: number;
+  id: number;
+  tag: string;
+  total: number;
+  up: number;
+}
+
+export interface Setting {
+  id: number;
+  key: string;
+  value: string;
+}
+
+export interface User {
+  id: number;
+  password: string;
+  username: string;
+}
+

+ 380 - 0
frontend/src/generated/zod.ts

@@ -0,0 +1,380 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+import { z } from 'zod';
+export const ProtocolSchema = z.string();
+export type Protocol = z.infer<typeof ProtocolSchema>;
+
+export const AllSettingSchema = z.object({
+  datepicker: z.string(),
+  expireDiff: z.number().int().min(0),
+  externalTrafficInformEnable: z.boolean(),
+  externalTrafficInformURI: z.string(),
+  ldapAutoCreate: z.boolean(),
+  ldapAutoDelete: z.boolean(),
+  ldapBaseDN: z.string(),
+  ldapBindDN: z.string(),
+  ldapDefaultExpiryDays: z.number().int().min(0),
+  ldapDefaultLimitIP: z.number().int().min(0),
+  ldapDefaultTotalGB: z.number().int().min(0),
+  ldapEnable: z.boolean(),
+  ldapFlagField: z.string(),
+  ldapHost: z.string(),
+  ldapInboundTags: z.string(),
+  ldapInvertFlag: z.boolean(),
+  ldapPassword: z.string(),
+  ldapPort: z.number().int().min(0).max(65535),
+  ldapSyncCron: z.string(),
+  ldapTruthyValues: z.string(),
+  ldapUseTLS: z.boolean(),
+  ldapUserAttr: z.string(),
+  ldapUserFilter: z.string(),
+  ldapVlessField: z.string(),
+  pageSize: z.number().int().min(1).max(1000),
+  remarkModel: z.string(),
+  restartXrayOnClientDisable: z.boolean(),
+  sessionMaxAge: z.number().int().min(0).max(525600),
+  subAnnounce: z.string(),
+  subCertFile: z.string(),
+  subClashEnable: z.boolean(),
+  subClashPath: z.string(),
+  subClashURI: z.string(),
+  subDomain: z.string(),
+  subEmailInRemark: z.boolean(),
+  subEnable: z.boolean(),
+  subEnableRouting: z.boolean(),
+  subEncrypt: z.boolean(),
+  subJsonEnable: z.boolean(),
+  subJsonFragment: z.string(),
+  subJsonMux: z.string(),
+  subJsonNoises: z.string(),
+  subJsonPath: z.string(),
+  subJsonRules: z.string(),
+  subJsonURI: z.string(),
+  subKeyFile: z.string(),
+  subListen: z.string(),
+  subPath: z.string(),
+  subPort: z.number().int().min(1).max(65535),
+  subProfileUrl: z.string(),
+  subRoutingRules: z.string(),
+  subShowInfo: z.boolean(),
+  subSupportUrl: z.string(),
+  subTitle: z.string(),
+  subURI: z.string(),
+  subUpdates: z.number().int().min(0).max(525600),
+  tgBotAPIServer: z.string(),
+  tgBotBackup: z.boolean(),
+  tgBotChatId: z.string(),
+  tgBotEnable: z.boolean(),
+  tgBotLoginNotify: z.boolean(),
+  tgBotProxy: z.string(),
+  tgBotToken: z.string(),
+  tgCpu: z.number().int().min(0).max(100),
+  tgLang: z.string(),
+  tgRunTime: z.string(),
+  timeLocation: z.string(),
+  trafficDiff: z.number().int().min(0).max(100),
+  trustedProxyCIDRs: z.string(),
+  twoFactorEnable: z.boolean(),
+  twoFactorToken: z.string(),
+  webBasePath: z.string(),
+  webCertFile: z.string(),
+  webDomain: z.string(),
+  webKeyFile: z.string(),
+  webListen: z.string(),
+  webPort: z.number().int().min(1).max(65535),
+});
+export type AllSetting = z.infer<typeof AllSettingSchema>;
+
+export const AllSettingViewSchema = z.object({
+  datepicker: z.string(),
+  expireDiff: z.number().int().min(0),
+  externalTrafficInformEnable: z.boolean(),
+  externalTrafficInformURI: z.string(),
+  hasApiToken: z.boolean(),
+  hasLdapPassword: z.boolean(),
+  hasNordSecret: z.boolean(),
+  hasTgBotToken: z.boolean(),
+  hasTwoFactorToken: z.boolean(),
+  hasWarpSecret: z.boolean(),
+  ldapAutoCreate: z.boolean(),
+  ldapAutoDelete: z.boolean(),
+  ldapBaseDN: z.string(),
+  ldapBindDN: z.string(),
+  ldapDefaultExpiryDays: z.number().int().min(0),
+  ldapDefaultLimitIP: z.number().int().min(0),
+  ldapDefaultTotalGB: z.number().int().min(0),
+  ldapEnable: z.boolean(),
+  ldapFlagField: z.string(),
+  ldapHost: z.string(),
+  ldapInboundTags: z.string(),
+  ldapInvertFlag: z.boolean(),
+  ldapPassword: z.string(),
+  ldapPort: z.number().int().min(0).max(65535),
+  ldapSyncCron: z.string(),
+  ldapTruthyValues: z.string(),
+  ldapUseTLS: z.boolean(),
+  ldapUserAttr: z.string(),
+  ldapUserFilter: z.string(),
+  ldapVlessField: z.string(),
+  pageSize: z.number().int().min(1).max(1000),
+  remarkModel: z.string(),
+  restartXrayOnClientDisable: z.boolean(),
+  sessionMaxAge: z.number().int().min(0).max(525600),
+  subAnnounce: z.string(),
+  subCertFile: z.string(),
+  subClashEnable: z.boolean(),
+  subClashPath: z.string(),
+  subClashURI: z.string(),
+  subDomain: z.string(),
+  subEmailInRemark: z.boolean(),
+  subEnable: z.boolean(),
+  subEnableRouting: z.boolean(),
+  subEncrypt: z.boolean(),
+  subJsonEnable: z.boolean(),
+  subJsonFragment: z.string(),
+  subJsonMux: z.string(),
+  subJsonNoises: z.string(),
+  subJsonPath: z.string(),
+  subJsonRules: z.string(),
+  subJsonURI: z.string(),
+  subKeyFile: z.string(),
+  subListen: z.string(),
+  subPath: z.string(),
+  subPort: z.number().int().min(1).max(65535),
+  subProfileUrl: z.string(),
+  subRoutingRules: z.string(),
+  subShowInfo: z.boolean(),
+  subSupportUrl: z.string(),
+  subTitle: z.string(),
+  subURI: z.string(),
+  subUpdates: z.number().int().min(0).max(525600),
+  tgBotAPIServer: z.string(),
+  tgBotBackup: z.boolean(),
+  tgBotChatId: z.string(),
+  tgBotEnable: z.boolean(),
+  tgBotLoginNotify: z.boolean(),
+  tgBotProxy: z.string(),
+  tgBotToken: z.string(),
+  tgCpu: z.number().int().min(0).max(100),
+  tgLang: z.string(),
+  tgRunTime: z.string(),
+  timeLocation: z.string(),
+  trafficDiff: z.number().int().min(0).max(100),
+  trustedProxyCIDRs: z.string(),
+  twoFactorEnable: z.boolean(),
+  twoFactorToken: z.string(),
+  webBasePath: z.string(),
+  webCertFile: z.string(),
+  webDomain: z.string(),
+  webKeyFile: z.string(),
+  webListen: z.string(),
+  webPort: z.number().int().min(1).max(65535),
+});
+export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
+
+export const ApiTokenSchema = z.object({
+  createdAt: z.number().int(),
+  enabled: z.boolean(),
+  id: z.number().int(),
+  name: z.string(),
+  token: z.string(),
+});
+export type ApiToken = z.infer<typeof ApiTokenSchema>;
+
+export const ClientSchema = z.object({
+  auth: z.string().optional(),
+  comment: z.string(),
+  created_at: z.number().int().optional(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  flow: z.string().optional(),
+  id: z.string().optional(),
+  limitIp: z.number().int(),
+  password: z.string().optional(),
+  reset: z.number().int(),
+  reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
+  security: z.string(),
+  subId: z.string(),
+  tgId: z.number().int(),
+  totalGB: z.number().int(),
+  updated_at: z.number().int().optional(),
+});
+export type Client = z.infer<typeof ClientSchema>;
+
+export const ClientInboundSchema = z.object({
+  clientId: z.number().int(),
+  createdAt: z.number().int(),
+  flowOverride: z.string(),
+  inboundId: z.number().int(),
+});
+export type ClientInbound = z.infer<typeof ClientInboundSchema>;
+
+export const ClientRecordSchema = z.object({
+  auth: z.string(),
+  comment: z.string(),
+  createdAt: z.number().int(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  flow: z.string(),
+  id: z.number().int(),
+  limitIp: z.number().int(),
+  password: z.string(),
+  reset: z.number().int(),
+  reverse: z.unknown(),
+  security: z.string(),
+  subId: z.string(),
+  tgId: z.number().int(),
+  totalGB: z.number().int(),
+  updatedAt: z.number().int(),
+  uuid: z.string(),
+});
+export type ClientRecord = z.infer<typeof ClientRecordSchema>;
+
+export const ClientReverseSchema = z.object({
+  tag: z.string(),
+});
+export type ClientReverse = z.infer<typeof ClientReverseSchema>;
+
+export const ClientTrafficSchema = z.object({
+  down: z.number().int(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  id: z.number().int(),
+  inboundId: z.number().int(),
+  lastOnline: z.number().int(),
+  reset: z.number().int(),
+  subId: z.string(),
+  total: z.number().int(),
+  up: z.number().int(),
+  uuid: z.string(),
+});
+export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
+
+export const CustomGeoResourceSchema = z.object({
+  alias: z.string(),
+  createdAt: z.number().int(),
+  id: z.number().int(),
+  lastModified: z.string(),
+  lastUpdatedAt: z.number().int(),
+  localPath: z.string(),
+  type: z.string(),
+  updatedAt: z.number().int(),
+  url: z.string(),
+});
+export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
+
+export const FallbackParentInfoSchema = z.object({
+  masterId: z.number().int(),
+  path: z.string().optional(),
+});
+export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
+
+export const HistoryOfSeedersSchema = z.object({
+  id: z.number().int(),
+  seederName: z.string(),
+});
+export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
+
+export const InboundSchema = z.object({
+  clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
+  down: z.number().int(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
+  id: z.number().int(),
+  lastTrafficResetTime: z.number().int(),
+  listen: z.string(),
+  nodeId: z.number().int().nullable().optional(),
+  port: z.number().int().min(1).max(65535),
+  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'hysteria2', 'http', 'mixed', 'tunnel']),
+  remark: z.string(),
+  settings: z.unknown(),
+  sniffing: z.unknown(),
+  streamSettings: z.unknown(),
+  tag: z.string(),
+  total: z.number().int(),
+  trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
+  up: z.number().int(),
+});
+export type Inbound = z.infer<typeof InboundSchema>;
+
+export const InboundClientIpsSchema = z.object({
+  clientEmail: z.string(),
+  id: z.number().int(),
+  ips: z.unknown(),
+});
+export type InboundClientIps = z.infer<typeof InboundClientIpsSchema>;
+
+export const InboundFallbackSchema = z.object({
+  alpn: z.string(),
+  childId: z.number().int(),
+  id: z.number().int(),
+  masterId: z.number().int(),
+  name: z.string(),
+  path: z.string(),
+  sortOrder: z.number().int(),
+  xver: z.number().int(),
+});
+export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
+
+export const MsgSchema = z.object({
+  msg: z.string(),
+  obj: z.unknown(),
+  success: z.boolean(),
+});
+export type Msg = z.infer<typeof MsgSchema>;
+
+export const NodeSchema = z.object({
+  address: z.string(),
+  allowPrivateAddress: z.boolean(),
+  apiToken: z.string(),
+  basePath: z.string(),
+  clientCount: z.number().int(),
+  cpuPct: z.number(),
+  createdAt: z.number().int(),
+  depletedCount: z.number().int(),
+  enable: z.boolean(),
+  id: z.number().int(),
+  inboundCount: z.number().int(),
+  lastError: z.string(),
+  lastHeartbeat: z.number().int(),
+  latencyMs: z.number().int(),
+  memPct: z.number(),
+  name: z.string(),
+  onlineCount: z.number().int(),
+  panelVersion: z.string(),
+  port: z.number().int().min(1).max(65535),
+  remark: z.string(),
+  scheme: z.enum(['http', 'https']),
+  status: z.string(),
+  updatedAt: z.number().int(),
+  uptimeSecs: z.number().int(),
+  xrayVersion: z.string(),
+});
+export type Node = z.infer<typeof NodeSchema>;
+
+export const OutboundTrafficsSchema = z.object({
+  down: z.number().int(),
+  id: z.number().int(),
+  tag: z.string(),
+  total: z.number().int(),
+  up: z.number().int(),
+});
+export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
+
+export const SettingSchema = z.object({
+  id: z.number().int(),
+  key: z.string(),
+  value: z.string(),
+});
+export type Setting = z.infer<typeof SettingSchema>;
+
+export const UserSchema = z.object({
+  id: z.number().int(),
+  password: z.string(),
+  username: z.string(),
+});
+export type User = z.infer<typeof UserSchema>;
+

+ 106 - 109
frontend/src/hooks/useClients.ts

@@ -1,60 +1,41 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 
-import { HttpUtil } from '@/utils';
+import { HttpUtil, Msg } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
+import {
+  ClientHydrateSchema,
+  ClientPageResponseSchema,
+  InboundOptionsSchema,
+  OnlinesSchema,
+  BulkAdjustResultSchema,
+  BulkCreateResultSchema,
+  BulkDeleteResultSchema,
+  DelDepletedResultSchema,
+  type ClientHydrate,
+  type ClientRecord,
+  type ClientTraffic,
+  type ClientsSummary,
+  type ClientPageResponse,
+  type InboundOption,
+  type BulkAdjustResult,
+  type BulkCreateResult,
+  type BulkDeleteResult,
+} from '@/schemas/client';
+import { DefaultsPayloadSchema } from '@/schemas/defaults';
+
+export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
 
 const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 
-export interface ClientTraffic {
-  up?: number;
-  down?: number;
-  total?: number;
-  expiryTime?: number;
-  enable?: boolean;
-  lastOnline?: number;
-}
-
-export interface ClientRecord {
-  email: string;
-  subId?: string;
-  uuid?: string;
-  password?: string;
-  auth?: string;
-  flow?: string;
-  totalGB?: number;
-  expiryTime?: number;
-  limitIp?: number;
-  tgId?: number | string;
-  comment?: string;
-  enable?: boolean;
-  inboundIds?: number[];
-  traffic?: ClientTraffic;
-  reverse?: { tag?: string };
-  createdAt?: number;
-  updatedAt?: number;
-  [key: string]: unknown;
-}
-
-export interface InboundOption {
-  id: number;
-  remark?: string;
-  protocol?: string;
-  port?: number;
-  tlsFlowCapable?: boolean;
-}
-
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
 interface SubSettings {
   enable: boolean;
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
+  subClashURI: string;
+  subClashEnable: boolean;
 }
 
 export interface ClientQueryParams {
@@ -68,24 +49,6 @@ export interface ClientQueryParams {
   order?: 'ascend' | 'descend';
 }
 
-export interface ClientsSummary {
-  total: number;
-  active: number;
-  online: string[];
-  depleted: string[];
-  expiring: string[];
-  deactive: string[];
-}
-
-interface ClientPageResponse {
-  items: ClientRecord[];
-  total: number;
-  filtered: number;
-  page: number;
-  pageSize: number;
-  summary?: ClientsSummary;
-}
-
 const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
 const DEFAULT_SUMMARY: ClientsSummary = {
   total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
@@ -106,21 +69,25 @@ function buildQS(p: ClientQueryParams): string {
 
 async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
   const qs = buildQS(params);
-  const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg<ClientPageResponse>;
+  const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
   if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
-  return msg.obj;
+  const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
+  if (!validated.obj) throw new Error('Empty clients response');
+  return validated.obj;
 }
 
 async function fetchInboundOptions(): Promise<InboundOption[]> {
-  const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg<InboundOption[]>;
+  const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchDefaults(): Promise<Record<string, unknown>> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<Record<string, unknown>>;
+  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
-  return msg.obj || {};
+  const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+  return validated.obj || {};
 }
 
 export function useClients() {
@@ -168,9 +135,10 @@ export function useClients() {
   const onlinesQuery = useQuery({
     queryKey: keys.clients.onlines(),
     queryFn: async () => {
-      const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
+      const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
       if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
-      return Array.isArray(msg.obj) ? msg.obj : [];
+      const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
+      return Array.isArray(validated.obj) ? validated.obj : [];
     },
     staleTime: Infinity,
   });
@@ -191,7 +159,16 @@ export function useClients() {
     subURI: (defaults.subURI as string) || '',
     subJsonURI: (defaults.subJsonURI as string) || '',
     subJsonEnable: !!defaults.subJsonEnable,
-  }), [defaults.subEnable, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
+    subClashURI: (defaults.subClashURI as string) || '',
+    subClashEnable: !!defaults.subClashEnable,
+  }), [
+    defaults.subEnable,
+    defaults.subURI,
+    defaults.subJsonURI,
+    defaults.subJsonEnable,
+    defaults.subClashURI,
+    defaults.subClashEnable,
+  ]);
 
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const tgBotEnable = !!defaults.tgBotEnable;
@@ -199,8 +176,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],
   );
 
@@ -208,22 +194,23 @@ export function useClients() {
     await invalidateAll();
   }, [invalidateAll]);
 
-  const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
+  const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
     if (!email) return null;
-    const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
+    const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
     if (!msg?.success || !msg.obj) return null;
-    return msg.obj;
+    const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
+    return validated.obj;
   }, []);
 
   const createMut = useMutation({
     mutationFn: (payload: unknown) =>
-      HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ email, client }: { email: string; client: unknown }) =>
-      HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
@@ -232,88 +219,97 @@ export function useClients() {
       const url = keepTraffic
         ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
         : `/panel/api/clients/del/${encodeURIComponent(email)}`;
-      return HttpUtil.post(url) as Promise<ApiMsg>;
+      return HttpUtil.post(url);
     },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
-  const removeManyMut = useMutation({
-    mutationFn: async ({ emails, keepTraffic }: { emails: string[]; keepTraffic?: boolean }) => {
-      const suffix = keepTraffic ? '?keepTraffic=1' : '';
-      const results = await Promise.all(emails.map((email) => {
-        const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
-        return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
-      }));
-      return results;
+  const bulkDeleteMut = useMutation({
+    mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
+      return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
     },
-    onSuccess: () => invalidateAll(),
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
+  const bulkCreateMut = useMutation({
+    mutationFn: async (payloads: unknown[]): Promise<Msg<BulkCreateResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
+      return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const bulkAdjustMut = useMutation({
-    mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) =>
-      HttpUtil.post(
-        '/panel/api/clients/bulkAdjust',
-        payload,
-        JSON_HEADERS,
-      ) as Promise<ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>>,
+    mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
+      return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
+    },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const attachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const detachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const resetTrafficMut = useMutation({
     mutationFn: (email: string) =>
-      HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const resetAllTrafficsMut = useMutation({
-    mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics') as Promise<ApiMsg>,
+    mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const delDepletedMut = useMutation({
-    mutationFn: () => HttpUtil.post('/panel/api/clients/delDepleted') as Promise<ApiMsg<{ deleted?: number }>>,
+    mutationFn: async () => {
+      const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
+      return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
+    },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
   const update = useCallback((email: string, client: unknown) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return updateMut.mutateAsync({ email, client });
   }, [updateMut]);
   const remove = useCallback((email: string, keepTraffic = false) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return removeMut.mutateAsync({ email, keepTraffic });
   }, [removeMut]);
-  const removeMany = useCallback((emails: string[], keepTraffic = false) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as ApiMsg[]);
-    return removeManyMut.mutateAsync({ emails, keepTraffic });
-  }, [removeManyMut]);
+  const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
+    return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
+  }, [bulkDeleteMut]);
+  const bulkCreate = useCallback((payloads: unknown[]) => {
+    if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
+    return bulkCreateMut.mutateAsync(payloads);
+  }, [bulkCreateMut]);
   const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
   }, [bulkAdjustMut]);
   const attach = useCallback((email: string, inboundIds: number[]) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return attachMut.mutateAsync({ email, inboundIds });
   }, [attachMut]);
   const detach = useCallback((email: string, inboundIds: number[]) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return detachMut.mutateAsync({ email, inboundIds });
   }, [detachMut]);
   const resetTraffic = useCallback((client: ClientRecord) => {
-    if (!client?.email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
     return resetTrafficMut.mutateAsync(client.email);
   }, [resetTrafficMut]);
   const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
@@ -404,9 +400,10 @@ export function useClients() {
     pageSize,
     refresh,
     create,
+    bulkCreate,
     update,
     remove,
-    removeMany,
+    bulkDelete,
     bulkAdjust,
     attach,
     detach,

+ 5 - 5
frontend/src/hooks/useDatepicker.ts

@@ -1,5 +1,7 @@
 import { useEffect, useState } from 'react';
 import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { DefaultsPayloadSchema } from '@/schemas/defaults';
 
 type Calendar = 'gregorian' | 'jalalian';
 
@@ -20,12 +22,10 @@ async function loadOnce(): Promise<void> {
   }
   pending = (async () => {
     try {
-      const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
-        success?: boolean;
-        obj?: { datepicker?: Calendar };
-      };
+      const msg = await HttpUtil.post('/panel/setting/defaultSettings');
       if (msg?.success) {
-        cachedValue = msg.obj?.datepicker || 'gregorian';
+        const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+        cachedValue = validated.obj?.datepicker || 'gregorian';
         notify(cachedValue);
       }
     } finally {

+ 40 - 60
frontend/src/hooks/useXraySetting.ts

@@ -1,30 +1,25 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { z } from 'zod';
 
-import { HttpUtil, PromiseUtil } from '@/utils';
+import { HttpUtil, Msg, PromiseUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
+import {
+  OutboundTrafficListSchema,
+  OutboundTestResultSchema,
+  XrayConfigPayloadSchema,
+  XraySettingsValueSchema,
+  type OutboundTestResult,
+  type OutboundTrafficRow,
+} from '@/schemas/xray';
 
 const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 
-export interface OutboundTrafficRow {
-  tag: string;
-  up: number;
-  down: number;
-}
+export type { OutboundTrafficRow, OutboundTestResult };
 
-export interface OutboundTestResult {
-  success: boolean;
-  delay?: number;
-  error?: string;
-  mode?: string;
-  ttfbMs?: number;
-  tlsMs?: number;
-  connectMs?: number;
-  dnsMs?: number;
-  statusCode?: number;
-  endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
-}
+export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
 
 export interface OutboundTestState {
   testing?: boolean;
@@ -32,23 +27,6 @@ export interface OutboundTestState {
   mode?: string;
 }
 
-export interface XraySettingsValue {
-  inbounds?: unknown[];
-  outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
-  routing?: {
-    rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
-    balancers?: unknown[];
-    domainStrategy?: string;
-  };
-  dns?: { tag?: string; servers?: unknown[] };
-  log?: Record<string, unknown>;
-  policy?: { system?: Record<string, boolean> };
-  observatory?: unknown;
-  burstObservatory?: unknown;
-  fakedns?: unknown;
-  [key: string]: unknown;
-}
-
 export type SetTemplate = (
   next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
 ) => void;
@@ -84,35 +62,32 @@ export interface UseXraySettingResult {
   restartXray: () => Promise<void>;
 }
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  obj?: T;
-  msg?: string;
-}
-
-interface XrayConfigPayload {
-  xraySetting: XraySettingsValue;
-  inboundTags?: string[];
-  clientReverseTags?: string[];
-  outboundTestUrl?: string;
-}
+type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 
 async function fetchXrayConfig(): Promise<XrayConfigPayload> {
-  const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg<string>;
+  const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
   if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
+  let parsed: unknown;
   try {
-    return JSON.parse(msg.obj) as XrayConfigPayload;
+    parsed = JSON.parse(msg.obj);
   } catch (e) {
     const err = e as Error;
     throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
   }
+  const result = XrayConfigPayloadSchema.safeParse(parsed);
+  if (!result.success) {
+    console.warn('[zod] xray/ config payload failed validation', result.error.issues);
+    return parsed as XrayConfigPayload;
+  }
+  return result.data;
 }
 
 async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
-  const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg<OutboundTrafficRow[]>;
+  const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 export function useXraySetting(): UseXraySettingResult {
@@ -219,7 +194,7 @@ export function useXraySetting(): UseXraySettingResult {
       HttpUtil.post('/panel/xray/update', {
         xraySetting: xraySettingRef.current,
         outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
-      }) as Promise<ApiMsg>,
+      }),
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
     },
@@ -227,7 +202,7 @@ export function useXraySetting(): UseXraySettingResult {
 
   const resetTrafficMut = useMutation({
     mutationFn: (tag: string) =>
-      HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>,
+      HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
     },
@@ -235,17 +210,21 @@ export function useXraySetting(): UseXraySettingResult {
 
   const restartMut = useMutation({
     mutationFn: async () => {
-      const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg;
+      const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
       if (!msg?.success) return msg;
       await PromiseUtil.sleep(500);
-      const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg<string>;
-      if (r?.success) setRestartResult(r.obj || '');
+      const r = await HttpUtil.get('/panel/xray/getXrayResult');
+      const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
+      if (validated?.success) setRestartResult(validated.obj || '');
       return msg;
     },
   });
 
   const resetDefaultMut = useMutation({
-    mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise<ApiMsg<XraySettingsValue>>,
+    mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
+      const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
+      return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
+    },
     onSuccess: (msg) => {
       if (msg?.success && msg.obj) {
         const cloned = JSON.parse(JSON.stringify(msg.obj));
@@ -269,15 +248,16 @@ export function useXraySetting(): UseXraySettingResult {
         [index]: { testing: true, result: null, mode },
       }));
       try {
-        const msg = await HttpUtil.post('/panel/xray/testOutbound', {
+        const raw = await HttpUtil.post('/panel/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
           allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
           mode,
-        }) as ApiMsg<OutboundTestResult>;
+        });
+        const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
         if (msg?.success && msg.obj) {
           setOutboundTestStates((prev) => ({
             ...prev,
-            [index]: { testing: false, result: msg.obj as OutboundTestResult },
+            [index]: { testing: false, result: msg.obj },
           }));
           return msg.obj;
         }

+ 78 - 0
frontend/src/lib/xray/headers.ts

@@ -0,0 +1,78 @@
+// Pure helpers for header-shape conversion between the panel's internal
+// HeaderEntry[] form and Xray's V2-style header map. Extracted from
+// XrayCommonClass.toHeaders / .toV2Headers so callers can stop relying on
+// the class hierarchy. Behavior is byte-equivalent to the legacy methods —
+// the shadow tests in src/test/headers.test.ts pin that.
+
+export interface HeaderEntry {
+  name: string;
+  value: string;
+}
+
+export type V2HeaderMap = Record<string, string | string[]>;
+
+// Expand a V2-style header map into the panel's flat HeaderEntry[]. A
+// header whose value is an array yields one entry per item, preserving
+// order; a string value yields a single entry. Non-object inputs (null,
+// undefined, primitives) yield [].
+export function toHeaders(v2Headers: unknown): HeaderEntry[] {
+  const out: HeaderEntry[] = [];
+  if (!v2Headers || typeof v2Headers !== 'object') return out;
+  const map = v2Headers as Record<string, unknown>;
+  for (const key of Object.keys(map)) {
+    const values = map[key];
+    if (typeof values === 'string') {
+      out.push({ name: key, value: values });
+    } else if (Array.isArray(values)) {
+      for (const v of values) {
+        if (typeof v === 'string') out.push({ name: key, value: v });
+      }
+    }
+  }
+  return out;
+}
+
+// Case-insensitive lookup against a wire-shape header map. The legacy
+// `Inbound.getHeader(obj, name)` iterated `obj.headers` as a HeaderEntry[];
+// this version reads the Record map our Zod schemas store. For repeated
+// header names (string[] in TCP/WS-style maps) the first value wins —
+// matches the legacy iteration order. Returns '' when missing, mirroring
+// the legacy fallback so link-generator call sites stay simple.
+export function getHeaderValue(
+  headers: Readonly<Record<string, string | string[]>> | undefined | null,
+  name: string,
+): string {
+  if (!headers || typeof headers !== 'object') return '';
+  const lower = name.toLowerCase();
+  for (const key of Object.keys(headers)) {
+    if (key.toLowerCase() !== lower) continue;
+    const value = headers[key];
+    if (typeof value === 'string') return value;
+    if (Array.isArray(value)) return value[0] ?? '';
+  }
+  return '';
+}
+
+// Collapse a HeaderEntry[] back into a V2-style header map. When `arr` is
+// true (the default — matches Xray's TCP/WS/HTTP request/response shape),
+// duplicate header names accumulate into a string[]. When false (used for
+// WS/HTTPUpgrade/xHTTP top-level headers, sockopt portMap, etc.), the
+// last value wins. Entries with empty name or value are skipped — same as
+// the legacy ObjectUtil.isEmpty() filter.
+export function toV2Headers(headers: HeaderEntry[], arr: boolean = true): V2HeaderMap {
+  const out: V2HeaderMap = {};
+  for (const { name, value } of headers) {
+    if (name == null || name === '' || value == null || value === '') continue;
+    if (!(name in out)) {
+      out[name] = arr ? [value] : value;
+      continue;
+    }
+    const existing = out[name];
+    if (arr && Array.isArray(existing)) {
+      existing.push(value);
+    } else {
+      out[name] = value;
+    }
+  }
+  return out;
+}

+ 277 - 0
frontend/src/lib/xray/inbound-defaults.ts

@@ -0,0 +1,277 @@
+import { RandomUtil, Wireguard } from '@/utils';
+
+import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
+import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
+import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
+import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
+import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
+import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
+import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
+import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
+import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
+import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
+
+// Plain-object factories for protocol clients. Each returns a Zod-parsable
+// object matching the wire shape. Random fields (id, password, auth,
+// email, subId) call RandomUtil at invocation time — pass them in
+// `overrides` for deterministic tests or for forms that pre-seed values.
+//
+// These replace the legacy `new Inbound.<Settings>.<Client>()` constructors
+// and the Inbound.ClientBase machinery. Callers no longer carry the
+// XrayCommonClass dependency once the swap lands.
+
+interface ClientBaseSeed {
+  email?: string;
+  subId?: string;
+  limitIp?: number;
+  totalGB?: number;
+  expiryTime?: number;
+  enable?: boolean;
+  tgId?: number;
+  comment?: string;
+  reset?: number;
+}
+
+interface ClientBase {
+  email: string;
+  limitIp: number;
+  totalGB: number;
+  expiryTime: number;
+  enable: boolean;
+  tgId: number;
+  subId: string;
+  comment: string;
+  reset: number;
+}
+
+function clientBase(seed: ClientBaseSeed = {}): ClientBase {
+  return {
+    email: seed.email ?? RandomUtil.randomLowerAndNum(8),
+    limitIp: seed.limitIp ?? 0,
+    totalGB: seed.totalGB ?? 0,
+    expiryTime: seed.expiryTime ?? 0,
+    enable: seed.enable ?? true,
+    tgId: seed.tgId ?? 0,
+    subId: seed.subId ?? RandomUtil.randomLowerAndNum(16),
+    comment: seed.comment ?? '',
+    reset: seed.reset ?? 0,
+  };
+}
+
+export interface VlessClientSeed extends ClientBaseSeed {
+  id?: string;
+  flow?: VlessClient['flow'];
+}
+
+export function createDefaultVlessClient(seed: VlessClientSeed = {}): VlessClient {
+  return {
+    id: seed.id ?? RandomUtil.randomUUID(),
+    flow: seed.flow ?? '',
+    ...clientBase(seed),
+  };
+}
+
+export interface VmessClientSeed extends ClientBaseSeed {
+  id?: string;
+  security?: VmessClient['security'];
+}
+
+export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClient {
+  return {
+    id: seed.id ?? RandomUtil.randomUUID(),
+    security: seed.security ?? 'auto',
+    ...clientBase(seed),
+  };
+}
+
+export interface TrojanClientSeed extends ClientBaseSeed {
+  password?: string;
+}
+
+export function createDefaultTrojanClient(seed: TrojanClientSeed = {}): TrojanClient {
+  return {
+    password: seed.password ?? RandomUtil.randomSeq(10),
+    ...clientBase(seed),
+  };
+}
+
+export interface ShadowsocksClientSeed extends ClientBaseSeed {
+  method?: string;
+  password?: string;
+  ssMethod?: string;
+}
+
+// Shadowsocks clients ship with an empty `method` on single-user inbounds
+// (the parent inbound's method is authoritative); only 2022-blake3 multi-
+// user inbounds use the per-client method. Callers pass `ssMethod` to seed
+// a method-specific password length when creating a multi-user client.
+export function createDefaultShadowsocksClient(seed: ShadowsocksClientSeed = {}): ShadowsocksClient {
+  const method = seed.method ?? '';
+  const password = seed.password ?? RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
+  return {
+    method,
+    password,
+    ...clientBase(seed),
+  };
+}
+
+export interface HysteriaClientSeed extends ClientBaseSeed {
+  auth?: string;
+}
+
+export function createDefaultHysteriaClient(seed: HysteriaClientSeed = {}): HysteriaClient {
+  return {
+    auth: seed.auth ?? RandomUtil.randomSeq(10),
+    ...clientBase(seed),
+  };
+}
+
+// Inbound-settings factories. Each returns a Zod-parsable wire-shape with
+// schema defaults already applied — no class instance, no XrayCommonClass.
+// Callers (form modals via Step 4, InboundsPage clone via Step 5) call
+// these instead of the legacy `Inbound.Settings.getSettings(protocol)`.
+
+export function createDefaultVlessInboundSettings(): VlessInboundSettings {
+  return {
+    clients: [],
+    decryption: 'none',
+    encryption: 'none',
+    fallbacks: [],
+  };
+}
+
+export function createDefaultVmessInboundSettings(): VmessInboundSettings {
+  return { clients: [] };
+}
+
+export function createDefaultTrojanInboundSettings(): TrojanInboundSettings {
+  return { clients: [], fallbacks: [] };
+}
+
+export interface ShadowsocksInboundSeed {
+  method?: ShadowsocksInboundSettings['method'];
+  password?: string;
+  network?: ShadowsocksInboundSettings['network'];
+  ivCheck?: boolean;
+}
+
+export function createDefaultShadowsocksInboundSettings(
+  seed: ShadowsocksInboundSeed = {},
+): ShadowsocksInboundSettings {
+  const method = seed.method ?? '2022-blake3-aes-256-gcm';
+  return {
+    method,
+    password: seed.password ?? RandomUtil.randomShadowsocksPassword(method),
+    network: seed.network ?? 'tcp',
+    clients: [],
+    ivCheck: seed.ivCheck ?? false,
+  };
+}
+
+// Hysteria v1 defaults still emit `version: 2` to match the legacy panel
+// constructor — the field discriminates v1 vs v2 inside the same settings
+// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
+export interface HysteriaInboundSeed {
+  version?: number;
+}
+
+export function createDefaultHysteriaInboundSettings(
+  seed: HysteriaInboundSeed = {},
+): HysteriaInboundSettings {
+  return {
+    version: seed.version ?? 2,
+    clients: [],
+  };
+}
+
+export function createDefaultHttpInboundSettings(): HttpInboundSettings {
+  return { accounts: [], allowTransparent: false };
+}
+
+export function createDefaultMixedInboundSettings(): MixedInboundSettings {
+  return {
+    auth: 'password',
+    accounts: [],
+    udp: false,
+    ip: '127.0.0.1',
+  };
+}
+
+export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
+  return {
+    portMap: {},
+    allowedNetwork: 'tcp,udp',
+    followRedirect: false,
+  };
+}
+
+export function createDefaultTunInboundSettings(): TunInboundSettings {
+  return {
+    name: 'xray0',
+    mtu: 1500,
+    gateway: [],
+    dns: [],
+    userLevel: 0,
+    autoSystemRoutingTable: [],
+    autoOutboundsInterface: 'auto',
+  };
+}
+
+export interface WireguardInboundSeed {
+  mtu?: number;
+  secretKey?: string;
+  noKernelTun?: boolean;
+  peerPrivateKey?: string;
+}
+
+export function createDefaultWireguardInboundSettings(
+  seed: WireguardInboundSeed = {},
+): WireguardInboundSettings {
+  const peerKp = seed.peerPrivateKey
+    ? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
+    : Wireguard.generateKeypair();
+  return {
+    mtu: seed.mtu ?? 1420,
+    secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
+    peers: [{
+      privateKey: peerKp.privateKey,
+      publicKey: peerKp.publicKey,
+      allowedIPs: ['10.0.0.2/32'],
+      keepAlive: 0,
+    }],
+    noKernelTun: seed.noKernelTun ?? false,
+  };
+}
+
+// Protocol-aware dispatch over every inbound-settings factory. Mirrors
+// the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but
+// returns a plain Zod-parsable object instead of a class instance.
+// Callers swapping off the class hierarchy use this in place of
+// `getSettings(p)` + `.toJson()`.
+export type AnyInboundSettings =
+  | VlessInboundSettings
+  | VmessInboundSettings
+  | TrojanInboundSettings
+  | ShadowsocksInboundSettings
+  | HysteriaInboundSettings
+  | HttpInboundSettings
+  | MixedInboundSettings
+  | TunInboundSettings
+  | TunnelInboundSettings
+  | WireguardInboundSettings;
+
+export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
+  switch (protocol) {
+    case 'vless':       return createDefaultVlessInboundSettings();
+    case 'vmess':       return createDefaultVmessInboundSettings();
+    case 'trojan':      return createDefaultTrojanInboundSettings();
+    case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
+    case 'hysteria':    return createDefaultHysteriaInboundSettings();
+    case 'http':        return createDefaultHttpInboundSettings();
+    case 'mixed':       return createDefaultMixedInboundSettings();
+    case 'tunnel':      return createDefaultTunnelInboundSettings();
+    case 'tun':         return createDefaultTunInboundSettings();
+    case 'wireguard':   return createDefaultWireguardInboundSettings();
+    default:            return null;
+  }
+}

+ 271 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -0,0 +1,271 @@
+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
+// InboundFormModal. No dependency on the legacy Inbound/DBInbound
+// classes — the modal hands the raw row in, takes typed values out, and
+// on submit calls formValuesToWirePayload() to get a payload ready to
+// POST to /panel/api/inbounds/add or /update/:id.
+
+export interface RawInboundRow {
+  port?: number;
+  listen?: string;
+  protocol?: string;
+  tag?: string;
+  settings?: unknown;
+  streamSettings?: unknown;
+  sniffing?: unknown;
+  up?: number;
+  down?: number;
+  total?: number;
+  remark?: string;
+  enable?: boolean;
+  expiryTime?: number;
+  trafficReset?: string;
+  lastTrafficResetTime?: number;
+  nodeId?: number | null;
+  clientStats?: unknown;
+}
+
+// The wire payload — settings/streamSettings/sniffing arrive as JSON
+// strings, mirroring what the Go endpoints expect (xray-core wants the
+// nested config slices as strings to round-trip through its loader).
+export interface WireInboundPayload {
+  up: number;
+  down: number;
+  total: number;
+  remark: string;
+  enable: boolean;
+  expiryTime: number;
+  trafficReset: TrafficReset;
+  lastTrafficResetTime: number;
+  listen: string;
+  port: number;
+  protocol: string;
+  settings: string;
+  streamSettings: string;
+  sniffing: string;
+  tag: string;
+  clientStats?: unknown;
+  nodeId?: number;
+}
+
+function coerceJsonObject(value: unknown): Record<string, unknown> {
+  if (value == null) return {};
+  if (typeof value === 'object' && !Array.isArray(value)) {
+    return value as Record<string, unknown>;
+  }
+  if (typeof value !== 'string') return {};
+  const trimmed = value.trim();
+  if (trimmed === '') return {};
+  try {
+    const parsed = JSON.parse(trimmed);
+    return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+      ? (parsed as Record<string, unknown>)
+      : {};
+  } catch {
+    return {};
+  }
+}
+
+const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
+
+function coerceTrafficReset(v: unknown): TrafficReset {
+  return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
+    ? (v as 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
+// InboundFormSchema.safeParse(...).
+export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
+  const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
+  const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
+  const rawStream = coerceJsonObject(row.streamSettings);
+  const streamSettings = Object.keys(rawStream).length > 0
+    ? (rawStream as StreamSettings)
+    : undefined;
+  if (streamSettings) {
+    healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
+  }
+  const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
+
+  return {
+    remark: row.remark ?? '',
+    enable: row.enable ?? true,
+    port: row.port ?? 0,
+    listen: row.listen ?? '',
+    tag: row.tag ?? '',
+    expiryTime: row.expiryTime ?? 0,
+    sniffing,
+    streamSettings,
+    up: row.up ?? 0,
+    down: row.down ?? 0,
+    total: row.total ?? 0,
+    trafficReset: coerceTrafficReset(row.trafficReset),
+    lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
+    nodeId: row.nodeId ?? null,
+    protocol,
+    settings,
+  } as InboundFormValues;
+}
+
+// Recursively strip undefined leaves from the wire payload. Empty arrays
+// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
+// shells like `tcpSettings: {}` so xray-core picks up its built-in
+// defaults, and stripping them led the FE to lose required-but-empty
+// arrays (vless clients, wireguard peers, etc.) which the Go side then
+// serialized back as `null`. Primitive values (including 0, false, '')
+// are kept verbatim.
+export function pruneEmpty(value: unknown): unknown {
+  if (Array.isArray(value)) {
+    return value.map(pruneEmpty);
+  }
+  if (value !== null && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      const p = pruneEmpty(v);
+      if (p === undefined) continue;
+      out[k] = p;
+    }
+    return out;
+  }
+  return value;
+}
+
+// Per-protocol client field whitelist — the Zod schemas in
+// schemas/protocols/inbound/<proto>.ts define which keys a given
+// protocol's clients accept on the wire. When a global client is created
+// the panel may persist cross-protocol fields on the same row (`auth` for
+// hysteria, `password` for trojan, `security` for vmess, etc.); rendering
+// those inside a vless inbound's settings.clients is confusing and rides
+// dead weight in the wire payload. Parsing through the protocol's schema
+// gives us the canonical projection.
+function clientSchemaForProtocol(protocol: string): z.ZodType | null {
+  switch (protocol) {
+    case 'vless':       return VlessClientSchema;
+    case 'vmess':       return VmessClientSchema;
+    case 'trojan':      return TrojanClientSchema;
+    case 'shadowsocks': return ShadowsocksClientSchema;
+    case 'hysteria':    return HysteriaClientSchema;
+    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,
+    total: values.total,
+    remark: values.remark,
+    enable: values.enable,
+    expiryTime: values.expiryTime,
+    trafficReset: values.trafficReset,
+    lastTrafficResetTime: values.lastTrafficResetTime,
+    listen: values.listen,
+    port: values.port,
+    protocol: values.protocol,
+    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;
+  return payload;
+}

+ 55 - 0
frontend/src/lib/xray/inbound-from-db.ts

@@ -0,0 +1,55 @@
+import type { Inbound } from '@/schemas/api/inbound';
+import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
+import { coerceInboundJsonField } from '@/models/dbinbound';
+
+import { fillStreamDefaults } from './stream-defaults';
+
+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;
+}
+
+function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
+  const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
+  if (parsed.success) {
+    const tagged = parsed.data as { settings: Record<string, unknown> };
+    return { ...tagged.settings };
+  }
+  return settings;
+}
+
+export function inboundFromDb(raw: DbInboundLike): Inbound {
+  const rawSettings = coerceInboundJsonField(raw.settings);
+  const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings);
+  const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings);
+  const sniffing = coerceInboundJsonField(raw.sniffing);
+  const streamSettings = Object.keys(streamSettingsRaw).length === 0
+    ? streamSettingsRaw
+    : fillStreamDefaults(streamSettingsRaw);
+  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;
+}

+ 922 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -0,0 +1,922 @@
+import { Base64, Wireguard } from '@/utils';
+
+import type { Inbound } from '@/schemas/api/inbound';
+import type { VlessClient } from '@/schemas/protocols/inbound/vless';
+import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
+import type {
+  WireguardInboundPeer,
+  WireguardInboundSettings,
+} from '@/schemas/protocols/inbound/wireguard';
+import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
+import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
+import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
+
+import { getHeaderValue } from './headers';
+
+// Share-link generators. Each per-protocol fn takes a typed inbound plus
+// client overrides and returns a URL (or '' when the protocol doesn't
+// support shareable links). The helpers below were previously static
+// methods on the Inbound class; extracting them removes the
+// XrayCommonClass dependency and lets these run against Zod-parsed data
+// directly.
+
+type ForceTls = 'same' | 'tls' | 'none';
+
+// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
+// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
+function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
+  return getHeaderValue(xhttp?.headers, 'host');
+}
+
+// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
+// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
+// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
+// reading the share link wouldn't honor them. Mirrors the legacy
+// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
+function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
+  if (!xhttp) return null;
+  const extra: Record<string, unknown> = {};
+
+  if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+    extra.xPaddingBytes = xhttp.xPaddingBytes;
+  }
+  if (xhttp.xPaddingObfsMode === true) {
+    extra.xPaddingObfsMode = true;
+    for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
+      const v = xhttp[k];
+      if (typeof v === 'string' && v.length > 0) extra[k] = v;
+    }
+  }
+
+  const stringFields = [
+    'uplinkHTTPMethod',
+    'sessionPlacement',
+    'sessionKey',
+    'seqPlacement',
+    'seqKey',
+    'uplinkDataPlacement',
+    'uplinkDataKey',
+    'scMaxEachPostBytes',
+  ] as const;
+  for (const k of stringFields) {
+    const v = xhttp[k];
+    if (typeof v === 'string' && v.length > 0) extra[k] = v;
+  }
+
+  // Headers on the wire are a record; emit them as a map upstream's
+  // SplitHTTPConfig.headers expects, dropping Host (already on the URL).
+  if (xhttp.headers && Object.keys(xhttp.headers).length > 0) {
+    const headersMap: Record<string, string> = {};
+    for (const [name, value] of Object.entries(xhttp.headers)) {
+      if (name.toLowerCase() === 'host') continue;
+      headersMap[name] = value;
+    }
+    if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
+  }
+
+  return Object.keys(extra).length > 0 ? extra : null;
+}
+
+function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
+  if (!xhttp) return;
+  if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+    obj.x_padding_bytes = xhttp.xPaddingBytes;
+  }
+  const extra = buildXhttpExtra(xhttp);
+  if (!extra) return;
+  for (const [k, v] of Object.entries(extra)) obj[k] = v;
+}
+
+// Recursively checks whether a finalmask payload has any non-empty
+// content. Empty arrays / empty objects / empty strings all return false;
+// any truthy primitive returns true. Used to decide whether the link
+// should carry an `fm` blob at all.
+function hasShareableFinalMaskValue(value: unknown): boolean {
+  if (value == null) return false;
+  if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue);
+  if (typeof value === 'object') {
+    return Object.values(value as Record<string, unknown>).some(hasShareableFinalMaskValue);
+  }
+  if (typeof value === 'string') return value.length > 0;
+  return true;
+}
+
+function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
+  if (!finalmask) return '';
+  return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : '';
+}
+
+function applyFinalMaskToObj(
+  finalmask: FinalMaskStreamSettings | undefined,
+  obj: Record<string, unknown>,
+): void {
+  const payload = serializeFinalMask(finalmask);
+  if (payload.length > 0) obj.fm = payload;
+}
+
+function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
+  if (Array.isArray(value)) return value.filter(Boolean).join(',');
+  return '';
+}
+
+function applyExternalProxyTLSObj(
+  externalProxy: ExternalProxyEntry | null | undefined,
+  obj: Record<string, unknown>,
+  security: string,
+): void {
+  if (!externalProxy || security !== 'tls') return;
+  const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
+  if (sni && sni.length > 0) obj.sni = sni;
+  if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
+  const alpn = externalProxyAlpn(externalProxy.alpn);
+  if (alpn.length > 0) obj.alpn = alpn;
+}
+
+export interface GenVmessLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientId: string;
+  security?: VmessSecurity;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// VMess share link: `vmess://` followed by base64-encoded JSON. The JSON
+// schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound
+// is not vmess so dispatcher code can fall through cleanly.
+export function genVmessLink(input: GenVmessLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientId,
+    security,
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'vmess') return '';
+
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+
+  const tls = forceTls === 'same' ? stream.security : forceTls;
+  const obj: Record<string, unknown> = {
+    v: '2',
+    ps: remark,
+    add: address,
+    port,
+    id: clientId,
+    scy: security,
+    net: stream.network,
+    tls,
+  };
+
+  if (stream.network === 'tcp') {
+    const tcp = stream.tcpSettings;
+    const header = tcp.header;
+    if (header) {
+      obj.type = header.type;
+      if (header.type === 'http') {
+        const request = header.request;
+        if (request) {
+          obj.path = request.path.join(',');
+          const host = getHeaderValue(request.headers, 'host');
+          if (host) obj.host = host;
+        }
+      }
+    } else {
+      obj.type = 'none';
+    }
+  } else if (stream.network === 'kcp') {
+    const kcp = stream.kcpSettings;
+    obj.mtu = kcp.mtu;
+    obj.tti = kcp.tti;
+  } else if (stream.network === 'ws') {
+    const ws = stream.wsSettings;
+    obj.path = ws.path;
+    obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host');
+  } else if (stream.network === 'grpc') {
+    const grpc = stream.grpcSettings;
+    obj.path = grpc.serviceName;
+    obj.authority = grpc.authority;
+    if (grpc.multiMode) obj.type = 'multi';
+  } else if (stream.network === 'httpupgrade') {
+    const hu = stream.httpupgradeSettings;
+    obj.path = hu.path;
+    obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host');
+  } else if (stream.network === 'xhttp') {
+    const xhttp = stream.xhttpSettings;
+    obj.path = xhttp.path;
+    obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
+    obj.type = xhttp.mode;
+    applyXhttpExtraToObj(xhttp, obj);
+  }
+
+  applyFinalMaskToObj(stream.finalmask, obj);
+
+  if (tls === 'tls' && stream.security === 'tls') {
+    const tlsSettings = stream.tlsSettings;
+    if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
+    if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
+    if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
+  }
+
+  applyExternalProxyTLSObj(externalProxy, obj, tls);
+
+  return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
+}
+
+// Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
+// legacy applyXhttpExtraToParams / applyFinalMaskToParams /
+// applyExternalProxyTLSParams but write to a URLSearchParams instance
+// directly. Number values get coerced via .toString() on set — same as
+// what URLSearchParams does internally so the resulting URL bytes match.
+
+function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
+  if (!xhttp) return;
+  params.set('path', xhttp.path);
+  const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
+  params.set('host', host);
+  params.set('mode', xhttp.mode);
+  if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+    params.set('x_padding_bytes', xhttp.xPaddingBytes);
+  }
+  const extra = buildXhttpExtra(xhttp);
+  if (extra) params.set('extra', JSON.stringify(extra));
+}
+
+function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
+  const payload = serializeFinalMask(finalmask);
+  if (payload.length > 0) params.set('fm', payload);
+}
+
+function applyExternalProxyTLSParams(
+  externalProxy: ExternalProxyEntry | null | undefined,
+  params: URLSearchParams,
+  security: string,
+): void {
+  if (!externalProxy || security !== 'tls') return;
+  const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
+  if (sni && sni.length > 0) params.set('sni', sni);
+  if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
+  const alpn = externalProxyAlpn(externalProxy.alpn);
+  if (alpn.length > 0) params.set('alpn', alpn);
+}
+
+export interface GenVlessLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientId: string;
+  flow?: VlessClient['flow'];
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
+// query carries network type, encryption, network-specific knobs, and
+// security-specific knobs (TLS fingerprint/alpn/sni or Reality
+// pbk/sid/spx). Returns '' if the inbound isn't vless.
+export function genVlessLink(input: GenVlessLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientId,
+    flow = '',
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'vless') return '';
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+
+  const security = forceTls === 'same' ? stream.security : forceTls;
+  const params = new URLSearchParams();
+  params.set('type', stream.network);
+  params.set('encryption', inbound.settings.encryption);
+
+  if (stream.network === 'tcp') {
+    const tcp = stream.tcpSettings;
+    if (tcp.header?.type === 'http') {
+      const request = tcp.header.request;
+      if (request) {
+        params.set('path', request.path.join(','));
+        const host = getHeaderValue(request.headers, 'host');
+        if (host) params.set('host', host);
+        params.set('headerType', 'http');
+      }
+    }
+  } else if (stream.network === 'kcp') {
+    const kcp = stream.kcpSettings;
+    params.set('mtu', String(kcp.mtu));
+    params.set('tti', String(kcp.tti));
+  } else if (stream.network === 'ws') {
+    const ws = stream.wsSettings;
+    params.set('path', ws.path);
+    params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
+  } else if (stream.network === 'grpc') {
+    const grpc = stream.grpcSettings;
+    params.set('serviceName', grpc.serviceName);
+    params.set('authority', grpc.authority);
+    if (grpc.multiMode) params.set('mode', 'multi');
+  } else if (stream.network === 'httpupgrade') {
+    const hu = stream.httpupgradeSettings;
+    params.set('path', hu.path);
+    params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
+  } else if (stream.network === 'xhttp') {
+    applyXhttpExtraToParams(stream.xhttpSettings, params);
+  }
+
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  if (security === 'tls') {
+    params.set('security', 'tls');
+    if (stream.security === 'tls') {
+      const tls = stream.tlsSettings;
+      params.set('fp', tls.settings.fingerprint);
+      params.set('alpn', tls.alpn.join(','));
+      if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+      if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
+      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
+    }
+    applyExternalProxyTLSParams(externalProxy, params, security);
+  } else if (security === 'reality') {
+    params.set('security', 'reality');
+    if (stream.security === 'reality') {
+      const reality = stream.realitySettings;
+      params.set('pbk', reality.settings.publicKey);
+      params.set('fp', reality.settings.fingerprint);
+      // Legacy parity quirk: the old class stored realitySettings.serverNames
+      // as a comma-joined string and gated SNI on `!ObjectUtil.isArrEmpty(s)`
+      // — which returns true for any string, so SNI was never written into
+      // Reality share links. Existing deployed clients rely on receiving
+      // the SNI from realitySettings.target instead; we keep the omission
+      // here so this extraction stays byte-stable with the legacy URL.
+      // Fixing the bug is a separate intentional commit.
+      if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
+      if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
+      if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
+      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
+    }
+  } else {
+    params.set('security', 'none');
+  }
+
+  const url = new URL(`vless://${clientId}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+// Shared network-branch writer used by trojan + shadowsocks links.
+// VLESS and VMess don't call this because they have minor per-protocol
+// quirks inline (vmess maps `multi` differently into obj.type; vless sets
+// encryption=none up-front).
+function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+  if (stream.network === 'tcp') {
+    const tcp = stream.tcpSettings;
+    if (tcp.header?.type === 'http') {
+      const request = tcp.header.request;
+      if (request) {
+        params.set('path', request.path.join(','));
+        const host = getHeaderValue(request.headers, 'host');
+        if (host) params.set('host', host);
+        params.set('headerType', 'http');
+      }
+    }
+  } else if (stream.network === 'kcp') {
+    const kcp = stream.kcpSettings;
+    params.set('mtu', String(kcp.mtu));
+    params.set('tti', String(kcp.tti));
+  } else if (stream.network === 'ws') {
+    const ws = stream.wsSettings;
+    params.set('path', ws.path);
+    params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
+  } else if (stream.network === 'grpc') {
+    const grpc = stream.grpcSettings;
+    params.set('serviceName', grpc.serviceName);
+    params.set('authority', grpc.authority);
+    if (grpc.multiMode) params.set('mode', 'multi');
+  } else if (stream.network === 'httpupgrade') {
+    const hu = stream.httpupgradeSettings;
+    params.set('path', hu.path);
+    params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
+  } else if (stream.network === 'xhttp') {
+    applyXhttpExtraToParams(stream.xhttpSettings, params);
+  }
+}
+
+function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+  if (stream.security !== 'tls') return;
+  const tls = stream.tlsSettings;
+  params.set('fp', tls.settings.fingerprint);
+  params.set('alpn', tls.alpn.join(','));
+  if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
+  if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+}
+
+// Reality query-string writer shared by VLESS and Trojan. Preserves the
+// legacy SNI-omission quirk (see genVlessLink for the full story).
+function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+  if (stream.security !== 'reality') return;
+  const reality = stream.realitySettings;
+  params.set('pbk', reality.settings.publicKey);
+  params.set('fp', reality.settings.fingerprint);
+  if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
+  if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
+  if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
+}
+
+export interface GenTrojanLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientPassword: string;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// Trojan share link: trojan://<password>@<host>:<port>?<query>#<remark>.
+// Same query-string shape as VLESS minus the `encryption` and `flow`
+// fields. Returns '' if the inbound isn't trojan.
+export function genTrojanLink(input: GenTrojanLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientPassword,
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'trojan') return '';
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+
+  const security = forceTls === 'same' ? stream.security : forceTls;
+  const params = new URLSearchParams();
+  params.set('type', stream.network);
+
+  writeNetworkParams(stream, params);
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  if (security === 'tls') {
+    params.set('security', 'tls');
+    writeTlsParams(stream, params);
+    applyExternalProxyTLSParams(externalProxy, params, security);
+  } else if (security === 'reality') {
+    params.set('security', 'reality');
+    writeRealityParams(stream, params);
+  } else {
+    params.set('security', 'none');
+  }
+
+  const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+export interface GenShadowsocksLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  clientPassword?: string;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// Shadowsocks 2022 share link. The userinfo portion is base64(method:pw)
+// for single-user and base64(method:settingsPw:clientPw) for multi-user
+// 2022-blake3. Legacy SS (non-2022) leaves the password out of the
+// userinfo entirely — matches the legacy class's password-array logic.
+// Note: legacy `isSSMultiUser` returns true for everything except
+// 2022-blake3-chacha20-poly1305 (a curious classification, but we
+// preserve it for byte-stable parity).
+export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    forceTls = 'same',
+    remark = '',
+    clientPassword = '',
+    externalProxy = null,
+  } = input;
+
+  if (inbound.protocol !== 'shadowsocks') return '';
+  const stream = inbound.streamSettings;
+  if (!stream) return '';
+  const settings = inbound.settings;
+
+  const security = forceTls === 'same' ? stream.security : forceTls;
+  const params = new URLSearchParams();
+  params.set('type', stream.network);
+
+  writeNetworkParams(stream, params);
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  if (security === 'tls') {
+    params.set('security', 'tls');
+    writeTlsParams(stream, params);
+    applyExternalProxyTLSParams(externalProxy, params, security);
+  }
+
+  const isSS2022 = settings.method.substring(0, 4) === '2022';
+  const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
+  const passwords: string[] = [];
+  if (isSS2022) passwords.push(settings.password);
+  if (isSSMultiUser) passwords.push(clientPassword);
+
+  const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
+  const url = new URL(`ss://${userinfo}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+export interface GenHysteriaLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  remark?: string;
+  clientAuth: string;
+}
+
+// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
+// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
+// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
+// password from finalmask.udp[type=salamander] when present; the broader
+// finalmask payload still rides under `fm` like the other links.
+//
+// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
+// which isn't a field on TlsStreamSettings.Settings — the guard is always
+// false. We omit the `insecure` param here to stay byte-stable.
+export function genHysteriaLink(input: GenHysteriaLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    remark = '',
+    clientAuth,
+  } = input;
+
+  if (inbound.protocol !== 'hysteria') return '';
+  const stream = inbound.streamSettings;
+  if (!stream || stream.security !== 'tls') return '';
+
+  const settings = inbound.settings;
+  const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
+
+  const params = new URLSearchParams();
+  params.set('security', 'tls');
+  const tls = stream.tlsSettings;
+  if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
+  if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
+  if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
+  if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+
+  const udpMasks = stream.finalmask?.udp;
+  if (Array.isArray(udpMasks)) {
+    const salamander = udpMasks.find((m) => m?.type === 'salamander');
+    const obfsPassword = salamander?.settings?.password;
+    if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
+      params.set('obfs', 'salamander');
+      params.set('obfs-password', obfsPassword);
+    }
+  }
+
+  applyFinalMaskToParams(stream.finalmask, params);
+
+  const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
+  for (const [key, value] of params) url.searchParams.set(key, value);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+export interface GenWireguardLinkInput {
+  settings: WireguardInboundSettings;
+  address: string;
+  port: number;
+  remark?: string;
+  peerIndex: number;
+}
+
+// Wireguard share link: wireguard://<peerPrivKey>@<host>:<port>
+//   ?publickey=<serverPub>&address=<peerAllowedIP>&mtu=<mtu>#<remark>
+// pubKey is derived from the server's secretKey via Wireguard.generateKeypair
+// at call time (Zod's schema stores secretKey only — pubKey isn't on the
+// wire). Returns '' when the peer index is out of bounds.
+export function genWireguardLink(input: GenWireguardLinkInput): string {
+  const { settings, address, port, remark = '', peerIndex } = input;
+  const peer = settings.peers[peerIndex];
+  if (!peer) return '';
+
+  const url = new URL(`wireguard://${address}:${port}`);
+  url.username = peer.privateKey ?? '';
+
+  const pubKey = settings.secretKey.length > 0
+    ? Wireguard.generateKeypair(settings.secretKey).publicKey
+    : '';
+  if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
+  if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
+    url.searchParams.set('address', peer.allowedIPs[0]);
+  }
+  if (typeof settings.mtu === 'number' && settings.mtu > 0) {
+    url.searchParams.set('mtu', String(settings.mtu));
+  }
+
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
+// Plain-text WireGuard client config (.conf format). Mirrors the legacy
+// getWireguardTxt — same DNS defaults (1.1.1.1, 1.0.0.1), MTU optional,
+// presharedKey + keepAlive only emitted when present on the peer. The
+// final newline structure follows the legacy: no newline after Endpoint,
+// optional preSharedKey appended with leading \n, keepAlive appended
+// with leading \n AND trailing \n.
+export function genWireguardConfig(input: GenWireguardLinkInput): string {
+  const { settings, address, port, remark = '', peerIndex } = input;
+  const peer = settings.peers[peerIndex];
+  if (!peer) return '';
+
+  const pubKey = settings.secretKey.length > 0
+    ? Wireguard.generateKeypair(settings.secretKey).publicKey
+    : '';
+
+  let txt = `[Interface]\n`;
+  txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
+  txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
+  txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
+  if (typeof settings.mtu === 'number' && settings.mtu > 0) {
+    txt += `MTU = ${settings.mtu}\n`;
+  }
+  txt += `\n# ${remark}\n`;
+  txt += `[Peer]\n`;
+  txt += `PublicKey = ${pubKey}\n`;
+  txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
+  txt += `Endpoint = ${address}:${port}`;
+  if (peer.preSharedKey && peer.preSharedKey.length > 0) {
+    txt += `\nPresharedKey = ${peer.preSharedKey}`;
+  }
+  if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
+    txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
+  }
+  return txt;
+}
+
+export type { WireguardInboundPeer };
+
+// Orchestrators.
+// resolveAddr picks the host that goes into share/sub links. Order:
+//   1. hostOverride (caller supplies node address for node-managed inbounds)
+//   2. inbound's bind listen (when explicit, not 0.0.0.0)
+//   3. fallbackHostname (caller-supplied — typically window.location.hostname
+//      in the browser; tests pass a fixed value)
+export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
+  if (hostOverride.length > 0) return hostOverride;
+  if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen;
+  return fallbackHostname;
+}
+
+// Returns the client array for protocols that have one. SS returns its
+// clients only in 2022-blake3 multi-user mode (matches the legacy
+// `this.clients` getter, which used isSSMultiUser to gate). Returns null
+// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
+// clients, and any protocol without a clients array.
+type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string };
+
+export function getInboundClients(inbound: Inbound): ClientShape[] | null {
+  switch (inbound.protocol) {
+    case 'vmess':
+      return (inbound.settings.clients ?? []) as ClientShape[];
+    case 'vless':
+      return (inbound.settings.clients ?? []) as ClientShape[];
+    case 'trojan':
+      return (inbound.settings.clients ?? []) as ClientShape[];
+    case 'hysteria':
+      return (inbound.settings.clients ?? []) as ClientShape[];
+    case 'shadowsocks': {
+      const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
+      return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
+    }
+    default:
+      return null;
+  }
+}
+
+export interface GenLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  forceTls?: ForceTls;
+  remark?: string;
+  client: ClientShape;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+// Per-protocol dispatcher matching the legacy `genLink` switch. Returns
+// '' for protocols that don't have client-based share links (wireguard
+// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
+// don't have share URLs).
+export function genLink(input: GenLinkInput): string {
+  const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
+  switch (inbound.protocol) {
+    case 'vmess':
+      return genVmessLink({
+        inbound, address, port, forceTls, remark,
+        clientId: client.id ?? '',
+        security: client.security,
+        externalProxy,
+      });
+    case 'vless':
+      return genVlessLink({
+        inbound, address, port, forceTls, remark,
+        clientId: client.id ?? '',
+        flow: client.flow,
+        externalProxy,
+      });
+    case 'shadowsocks': {
+      const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
+      return genShadowsocksLink({
+        inbound, address, port, forceTls, remark,
+        clientPassword: isMultiUser ? (client.password ?? '') : '',
+        externalProxy,
+      });
+    }
+    case 'trojan':
+      return genTrojanLink({
+        inbound, address, port, forceTls, remark,
+        clientPassword: client.password ?? '',
+        externalProxy,
+      });
+    case 'hysteria':
+      return genHysteriaLink({
+        inbound, address, port, remark,
+        clientAuth: client.auth ?? '',
+      });
+    default:
+      return '';
+  }
+}
+
+export interface GenAllLinksEntry {
+  remark: string;
+  link: string;
+}
+
+export interface GenAllLinksInput {
+  inbound: Inbound;
+  remark?: string;
+  remarkModel?: string;
+  client: ClientShape;
+  hostOverride?: string;
+  fallbackHostname: string;
+}
+
+// Fans out a single client's link per externalProxy entry, or just one
+// link when there are no external proxies. remarkModel is a 4-char
+// string: first char is the separator, remaining chars pick which
+// pieces to compose into the per-link remark — 'i' = inbound remark,
+// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
+// (dash-separated, inbound + email + proxy).
+export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
+  const {
+    inbound,
+    remark = '',
+    remarkModel = '-io',
+    client,
+    hostOverride = '',
+    fallbackHostname,
+  } = input;
+
+  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
+  const port = inbound.port;
+  const separationChar = remarkModel.charAt(0);
+  const orderChars = remarkModel.slice(1);
+  const email = client.email ?? '';
+
+  const composeRemark = (proxyRemark: string): string => {
+    const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
+    return orderChars.split('')
+      .map((c) => orders[c] ?? '')
+      .filter((x) => x.length > 0)
+      .join(separationChar);
+  };
+
+  const externals = inbound.streamSettings?.externalProxy;
+  if (!externals || externals.length === 0) {
+    const r = composeRemark('');
+    return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
+  }
+  return externals.map((ep) => {
+    const r = composeRemark(ep.remark);
+    return {
+      remark: r,
+      link: genLink({
+        inbound,
+        address: ep.dest,
+        port: ep.port,
+        forceTls: ep.forceTls,
+        remark: r,
+        client,
+        externalProxy: ep,
+      }),
+    };
+  });
+}
+
+export interface GenInboundLinksInput {
+  inbound: Inbound;
+  remark?: string;
+  remarkModel?: string;
+  hostOverride?: string;
+  fallbackHostname: string;
+}
+
+// Top-level entrypoint that produces the full \r\n-joined block a user
+// pastes into a client. Iterates per-client for protocols with clients,
+// falls back to a single SS link for single-user 2022-blake3-chacha20,
+// and emits per-peer .conf blocks for wireguard. Returns '' for the
+// other clientless protocols (http, mixed, tunnel).
+export function genInboundLinks(input: GenInboundLinksInput): string {
+  const {
+    inbound,
+    remark = '',
+    remarkModel = '-io',
+    hostOverride = '',
+    fallbackHostname,
+  } = input;
+  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
+  const clients = getInboundClients(inbound);
+  if (clients) {
+    const links: string[] = [];
+    for (const client of clients) {
+      const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
+      for (const e of entries) links.push(e.link);
+    }
+    return links.join('\r\n');
+  }
+  if (inbound.protocol === 'shadowsocks') {
+    return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
+  }
+  if (inbound.protocol === 'wireguard') {
+    return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
+  }
+  return '';
+}
+
+// Per-peer wireguard fanout. Each peer gets its own link (or .conf
+// block) with an index-suffixed remark, joined by \r\n. Matches the
+// legacy genWireguardLinks / genWireguardConfigs exactly.
+export interface GenWireguardFanoutInput {
+  inbound: Inbound;
+  remark?: string;
+  remarkModel?: string;
+  hostOverride?: string;
+  fallbackHostname: string;
+}
+
+export function genWireguardLinks(input: GenWireguardFanoutInput): string {
+  const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
+  if (inbound.protocol !== 'wireguard') return '';
+  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
+  const sep = remarkModel.charAt(0);
+  return inbound.settings.peers
+    .map((_p, i) => genWireguardLink({
+      settings: inbound.settings as WireguardInboundSettings,
+      address: addr,
+      port: inbound.port,
+      remark: `${remark}${sep}${i + 1}`,
+      peerIndex: i,
+    }))
+    .join('\r\n');
+}
+
+export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
+  const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
+  if (inbound.protocol !== 'wireguard') return '';
+  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
+  const sep = remarkModel.charAt(0);
+  return inbound.settings.peers
+    .map((_p, i) => genWireguardConfig({
+      settings: inbound.settings as WireguardInboundSettings,
+      address: addr,
+      port: inbound.port,
+      remark: `${remark}${sep}${i + 1}`,
+      peerIndex: i,
+    }))
+    .join('\r\n');
+}

+ 167 - 0
frontend/src/lib/xray/outbound-defaults.ts

@@ -0,0 +1,167 @@
+import { RandomUtil, Wireguard } from '@/utils';
+
+import type { BlackholeOutboundSettings } from '@/schemas/protocols/outbound/blackhole';
+import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
+import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
+import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
+import type { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
+import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
+import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
+import type { SocksOutboundSettings } from '@/schemas/protocols/outbound/socks';
+import type { TrojanOutboundSettings } from '@/schemas/protocols/outbound/trojan';
+import type { VlessOutboundSettings } from '@/schemas/protocols/outbound/vless';
+import type { VmessOutboundSettings } from '@/schemas/protocols/outbound/vmess';
+import type { WireguardOutboundSettings } from '@/schemas/protocols/outbound/wireguard';
+
+// Plain-object factories mirroring `new Outbound.<X>Settings()` from the
+// legacy class hierarchy, then `.toJson()`. The output matches the wire
+// shape — the same starting state the OutboundFormModal's `ob.settings`
+// holds the first time the user picks a protocol.
+//
+// Required-by-schema fields the legacy class leaves undefined (address,
+// port, user-supplied ids/passwords) become empty stubs here. Zod will
+// reject the default output until the user fills them in via the form;
+// this is intentional and matches the legacy "scaffold object" behavior.
+
+export function createDefaultFreedomOutboundSettings(): FreedomOutboundSettings {
+  return {};
+}
+
+export function createDefaultBlackholeOutboundSettings(): BlackholeOutboundSettings {
+  return {};
+}
+
+export function createDefaultLoopbackOutboundSettings(): LoopbackOutboundSettings {
+  return { inboundTag: '' };
+}
+
+export function createDefaultDNSOutboundSettings(): DNSOutboundSettings {
+  return {
+    rewriteNetwork: '',
+    rewriteAddress: '',
+    rewritePort: 53,
+    userLevel: 0,
+    rules: [],
+  };
+}
+
+export function createDefaultVmessOutboundSettings(): VmessOutboundSettings {
+  return {
+    vnext: [{
+      address: '',
+      port: 443,
+      users: [{ id: '', security: 'auto' }],
+    }],
+  };
+}
+
+export function createDefaultVlessOutboundSettings(): VlessOutboundSettings {
+  return {
+    address: '',
+    port: 443,
+    id: '',
+    flow: '',
+    encryption: 'none',
+  };
+}
+
+export function createDefaultTrojanOutboundSettings(): TrojanOutboundSettings {
+  return {
+    servers: [{ address: '', port: 443, password: '' }],
+  };
+}
+
+// Why: legacy constructor leaves method undefined; the form's Select
+// snaps to the first option when the user opens it. We pick the same
+// modern default the inbound shadowsocks factory uses
+// (2022-blake3-aes-128-gcm) so the OutboundFormModal renders a coherent
+// initial state instead of an empty Select.
+export function createDefaultShadowsocksOutboundSettings(): ShadowsocksOutboundSettings {
+  return {
+    servers: [{
+      address: '',
+      port: 443,
+      password: '',
+      method: '2022-blake3-aes-128-gcm',
+    }],
+  };
+}
+
+export function createDefaultSocksOutboundSettings(): SocksOutboundSettings {
+  return {
+    servers: [{ address: '', port: 1080, users: [] }],
+  };
+}
+
+export function createDefaultHttpOutboundSettings(): HttpOutboundSettings {
+  return {
+    servers: [{ address: '', port: 8080, users: [] }],
+  };
+}
+
+interface WireguardOutboundSeed {
+  secretKey?: string;
+}
+
+export function createDefaultWireguardOutboundSettings(
+  seed: WireguardOutboundSeed = {},
+): WireguardOutboundSettings {
+  const secretKey = seed.secretKey ?? Wireguard.generateKeypair().privateKey;
+  return {
+    mtu: 1420,
+    secretKey,
+    address: [],
+    workers: 2,
+    peers: [{
+      publicKey: '',
+      allowedIPs: ['0.0.0.0/0', '::/0'],
+      endpoint: '',
+    }],
+    noKernelTun: false,
+  };
+}
+
+export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSettings {
+  return { address: '', port: 443, version: 2 };
+}
+
+export type AnyOutboundSettings =
+  | BlackholeOutboundSettings
+  | DNSOutboundSettings
+  | FreedomOutboundSettings
+  | HttpOutboundSettings
+  | HysteriaOutboundSettings
+  | LoopbackOutboundSettings
+  | ShadowsocksOutboundSettings
+  | SocksOutboundSettings
+  | TrojanOutboundSettings
+  | VlessOutboundSettings
+  | VmessOutboundSettings
+  | WireguardOutboundSettings;
+
+// Protocol-aware dispatch. Mirrors the legacy
+// `Outbound.Settings.getSettings(protocol)` switch. Note: the inbound
+// dispatcher returns `null` for unknown protocols and so does this one,
+// keeping the contract identical so callers can stay protocol-agnostic.
+//
+// The `RandomUtil` reference is held to silence unused-import warnings
+// when no per-call randomization happens at the dispatcher level —
+// individual factories may pull from it via their own seeds.
+export function createDefaultOutboundSettings(protocol: string): AnyOutboundSettings | null {
+  void RandomUtil;
+  switch (protocol) {
+    case 'freedom':     return createDefaultFreedomOutboundSettings();
+    case 'blackhole':   return createDefaultBlackholeOutboundSettings();
+    case 'dns':         return createDefaultDNSOutboundSettings();
+    case 'vmess':       return createDefaultVmessOutboundSettings();
+    case 'vless':       return createDefaultVlessOutboundSettings();
+    case 'trojan':      return createDefaultTrojanOutboundSettings();
+    case 'shadowsocks': return createDefaultShadowsocksOutboundSettings();
+    case 'socks':       return createDefaultSocksOutboundSettings();
+    case 'http':        return createDefaultHttpOutboundSettings();
+    case 'wireguard':   return createDefaultWireguardOutboundSettings();
+    case 'hysteria':    return createDefaultHysteriaOutboundSettings();
+    case 'loopback':    return createDefaultLoopbackOutboundSettings();
+    default:            return null;
+  }
+}

+ 619 - 0
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -0,0 +1,619 @@
+import { Wireguard } from '@/utils';
+
+import type {
+  DnsOutboundFormSettings,
+  DnsRuleForm,
+  FreedomFinalRuleForm,
+  FreedomOutboundFormSettings,
+  HysteriaOutboundFormSettings,
+  LoopbackOutboundFormSettings,
+  MuxForm,
+  OutboundFormSettings,
+  OutboundFormValues,
+  OutboundStreamFormValues,
+  ReverseSniffingForm,
+  ShadowsocksOutboundFormSettings,
+  TrojanOutboundFormSettings,
+  VlessOutboundFormSettings,
+  VmessOutboundFormSettings,
+  WireguardOutboundFormPeer,
+  WireguardOutboundFormSettings,
+} from '@/schemas/forms/outbound-form';
+
+type Raw = Record<string, unknown>;
+
+function asObject(value: unknown): Raw {
+  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
+}
+
+function asArray(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : [];
+}
+
+function asString(value: unknown, fallback = ''): string {
+  return typeof value === 'string' ? value : fallback;
+}
+
+function asNumber(value: unknown, fallback = 0): number {
+  if (typeof value === 'number' && Number.isFinite(value)) return value;
+  if (typeof value === 'string' && value.trim() !== '') {
+    const n = Number(value);
+    return Number.isFinite(n) ? n : fallback;
+  }
+  return fallback;
+}
+
+function asBool(value: unknown): boolean {
+  return value === true;
+}
+
+function asPort(value: unknown, fallback: number): number {
+  const n = asNumber(value, fallback);
+  if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
+  return n;
+}
+
+const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
+  enabled: false,
+  destOverride: ['http', 'tls', 'quic', 'fakedns'],
+  metadataOnly: false,
+  routeOnly: false,
+  ipsExcluded: [],
+  domainsExcluded: [],
+};
+
+function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
+  const r = asObject(raw);
+  const dest = asArray(r.destOverride).map((x) => asString(x));
+  return {
+    enabled: asBool(r.enabled),
+    destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
+    metadataOnly: asBool(r.metadataOnly),
+    routeOnly: asBool(r.routeOnly),
+    ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
+    domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
+  };
+}
+
+function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
+  const vnext = asArray(raw.vnext);
+  const v = asObject(vnext[0]);
+  const u = asObject(asArray(v.users)[0]);
+  return {
+    address: asString(v.address),
+    port: asPort(v.port, 443),
+    id: asString(u.id),
+    security: ((): VmessOutboundFormSettings['security'] => {
+      const s = asString(u.security);
+      const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
+      return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
+    })(),
+  };
+}
+
+function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
+  let address = asString(raw.address);
+  let port = asPort(raw.port, 443);
+  let id = asString(raw.id);
+  let flow = asString(raw.flow);
+  let encryption = asString(raw.encryption, 'none');
+  const vnext = asArray(raw.vnext);
+  if (vnext.length > 0) {
+    const v = asObject(vnext[0]);
+    const u = asObject(asArray(v.users)[0]);
+    address = asString(v.address);
+    port = asPort(v.port, 443);
+    id = asString(u.id);
+    flow = asString(u.flow);
+    encryption = asString(u.encryption, 'none');
+  }
+  const reverse = asObject(raw.reverse);
+  const reverseTag = asString(reverse.tag);
+  const reverseSniffing = reverseTag
+    ? reverseSniffingFromWire(reverse.sniffing)
+    : REVERSE_SNIFFING_DEFAULT;
+  const savedSeed = asArray(raw.testseed);
+  const testseed = savedSeed.length === 4
+    && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
+    ? (savedSeed as number[])
+    : [];
+  return {
+    address,
+    port,
+    id,
+    flow,
+    encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
+    reverseTag,
+    reverseSniffing,
+    testpre: asNumber(raw.testpre, 0),
+    testseed,
+  };
+}
+
+function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
+  const s = asObject(asArray(raw.servers)[0]);
+  return {
+    address: asString(s.address),
+    port: asPort(s.port, 443),
+    password: asString(s.password),
+  };
+}
+
+function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
+  const s = asObject(asArray(raw.servers)[0]);
+  return {
+    address: asString(s.address),
+    port: asPort(s.port, 443),
+    password: asString(s.password),
+    method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
+    uot: asBool(s.uot),
+    UoTVersion: asNumber(s.UoTVersion, 1),
+  };
+}
+
+interface SimpleAuthFormSettings {
+  address: string;
+  port: number;
+  user: string;
+  pass: string;
+}
+
+function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
+  const s = asObject(asArray(raw.servers)[0]);
+  const u = asObject(asArray(s.users)[0]);
+  return {
+    address: asString(s.address),
+    port: asPort(s.port, defaultPort),
+    user: asString(u.user),
+    pass: asString(u.pass),
+  };
+}
+
+function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
+  const secretKey = asString(raw.secretKey);
+  const pubKey = secretKey.length > 0
+    ? Wireguard.generateKeypair(secretKey).publicKey
+    : '';
+  const addressArr = asArray(raw.address).map((x) =>
+    typeof x === 'number' ? String(x) : asString(x),
+  );
+  const reservedArr = asArray(raw.reserved).map((x) =>
+    typeof x === 'number' ? String(x) : asString(x),
+  );
+  const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
+    const pp = asObject(p);
+    const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
+    return {
+      publicKey: asString(pp.publicKey),
+      psk: asString(pp.preSharedKey),
+      allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
+      endpoint: asString(pp.endpoint),
+      keepAlive: asNumber(pp.keepAlive, 0),
+    };
+  });
+  return {
+    mtu: asNumber(raw.mtu, 1420),
+    secretKey,
+    pubKey,
+    address: addressArr.join(','),
+    workers: asNumber(raw.workers, 2),
+    domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
+      const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
+      const s = asString(raw.domainStrategy);
+      return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
+    })(),
+    reserved: reservedArr.join(','),
+    peers,
+    noKernelTun: asBool(raw.noKernelTun),
+  };
+}
+
+function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
+  return {
+    address: asString(raw.address),
+    port: asPort(raw.port, 443),
+    version: 2,
+  };
+}
+
+function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
+  const fragment = asObject(raw.fragment);
+  const noises = asArray(raw.noises).map((n) => {
+    const nn = asObject(n);
+    return {
+      type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
+      packet: asString(nn.packet, '10-20'),
+      delay: asString(nn.delay, '10-16'),
+      applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
+    };
+  });
+  const finalRulesRaw = asArray(raw.finalRules);
+  const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
+    const rr = asObject(r);
+    const network = Array.isArray(rr.network)
+      ? rr.network.map((x) => asString(x)).join(',')
+      : asString(rr.network);
+    return {
+      action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
+      network,
+      port: asString(rr.port),
+      ip: asArray(rr.ip).map((x) => asString(x)),
+      blockDelay: asString(rr.blockDelay),
+    };
+  });
+  // Legacy ipsBlocked → finalRule(block) backfill
+  if (finalRules.length === 0) {
+    const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
+    if (ipsBlocked.length > 0) {
+      finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
+    }
+  }
+  // Wire fragment is either missing or a populated object. Mirror the
+  // legacy behavior: when the wire omits fragment, leave all four fields
+  // empty so the modal's "Fragment" Switch starts off. When present,
+  // surface whatever the wire holds verbatim.
+  const wireHasFragment = raw.fragment != null
+    && typeof raw.fragment === 'object'
+    && Object.keys(fragment).length > 0;
+  return {
+    domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
+      const allowed = [
+        'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
+        'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
+      ];
+      const s = asString(raw.domainStrategy);
+      return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
+    })(),
+    redirect: asString(raw.redirect),
+    fragment: wireHasFragment
+      ? {
+          packets: asString(fragment.packets, '1-3'),
+          length: asString(fragment.length),
+          interval: asString(fragment.interval),
+          maxSplit: asString(fragment.maxSplit),
+        }
+      : { packets: '', length: '', interval: '', maxSplit: '' },
+    noises,
+    finalRules,
+  };
+}
+
+function blackholeFromWire(raw: Raw) {
+  const response = asObject(raw.response);
+  const t = asString(response.type);
+  return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
+}
+
+function dnsRuleFromWire(raw: unknown): DnsRuleForm {
+  const r = asObject(raw);
+  const qtype = Array.isArray(r.qtype)
+    ? r.qtype.map((x) => String(x)).join(',')
+    : typeof r.qtype === 'number'
+      ? String(r.qtype)
+      : asString(r.qtype);
+  const domain = Array.isArray(r.domain)
+    ? r.domain.map((x) => asString(x)).join(',')
+    : asString(r.domain);
+  const action = asString(r.action, 'direct');
+  const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action)
+    ? action
+    : 'direct';
+  return { action: validAction as DnsRuleForm['action'], qtype, domain };
+}
+
+function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
+  const rules = asArray(raw.rules).map(dnsRuleFromWire);
+  return {
+    rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
+      const s = asString(raw.rewriteNetwork ?? raw.network);
+      return (s === 'udp' || s === 'tcp') ? s : '';
+    })(),
+    rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
+    rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
+    userLevel: asNumber(raw.userLevel, 0),
+    rules,
+  };
+}
+
+function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
+  return { inboundTag: asString(raw.inboundTag) };
+}
+
+function muxFromWire(raw: unknown): MuxForm {
+  const m = asObject(raw);
+  return {
+    enabled: asBool(m.enabled),
+    concurrency: asNumber(m.concurrency, 8),
+    xudpConcurrency: asNumber(m.xudpConcurrency, 16),
+    xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
+      const s = asString(m.xudpProxyUDP443, 'reject');
+      return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
+    })(),
+  };
+}
+
+export interface RawOutboundRow {
+  tag?: string;
+  protocol?: string;
+  sendThrough?: string;
+  settings?: unknown;
+  streamSettings?: unknown;
+  mux?: unknown;
+}
+
+export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
+  const protocol = asString(raw.protocol, 'vless');
+  const settings = asObject(raw.settings);
+  const tag = asString(raw.tag);
+  const sendThrough = asString(raw.sendThrough);
+  const mux = muxFromWire(raw.mux);
+  const hasStream = raw.streamSettings
+    && typeof raw.streamSettings === 'object'
+    && Object.keys(raw.streamSettings as Raw).length > 0;
+  const streamSettings = hasStream
+    ? (raw.streamSettings as unknown as OutboundStreamFormValues)
+    : undefined;
+
+  let typed: OutboundFormSettings;
+  switch (protocol) {
+    case 'vmess':       typed = { protocol: 'vmess',       settings: vmessFromWire(settings) }; break;
+    case 'vless':       typed = { protocol: 'vless',       settings: vlessFromWire(settings) }; break;
+    case 'trojan':      typed = { protocol: 'trojan',      settings: trojanFromWire(settings) }; break;
+    case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
+    case 'socks':       typed = { protocol: 'socks',       settings: simpleAuthFromWire(settings, 1080) }; break;
+    case 'http':        typed = { protocol: 'http',        settings: simpleAuthFromWire(settings, 8080) }; break;
+    case 'wireguard':   typed = { protocol: 'wireguard',   settings: wireguardFromWire(settings) }; break;
+    case 'hysteria':    typed = { protocol: 'hysteria',    settings: hysteriaFromWire(settings) }; break;
+    case 'freedom':     typed = { protocol: 'freedom',     settings: freedomFromWire(settings) }; break;
+    case 'blackhole':   typed = { protocol: 'blackhole',   settings: blackholeFromWire(settings) }; break;
+    case 'dns':         typed = { protocol: 'dns',         settings: dnsFromWire(settings) }; break;
+    case 'loopback':    typed = { protocol: 'loopback',    settings: loopbackFromWire(settings) }; break;
+    default:            typed = { protocol: 'vless',       settings: vlessFromWire(settings) };
+  }
+
+  return {
+    ...typed,
+    tag,
+    sendThrough,
+    mux,
+    streamSettings,
+  };
+}
+
+// --- Form values -> wire payload --------------------------------------
+
+function vmessToWire(s: VmessOutboundFormSettings) {
+  return {
+    vnext: [{
+      address: s.address,
+      port: s.port,
+      users: [{ id: s.id, security: s.security }],
+    }],
+  };
+}
+
+function reverseSniffingToWire(s: ReverseSniffingForm) {
+  return {
+    enabled: s.enabled,
+    destOverride: s.destOverride,
+    metadataOnly: s.metadataOnly,
+    routeOnly: s.routeOnly,
+    ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
+    domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
+  };
+}
+
+function vlessToWire(s: VlessOutboundFormSettings) {
+  const result: Raw = {
+    address: s.address,
+    port: s.port,
+    id: s.id,
+    flow: s.flow,
+    encryption: s.encryption || 'none',
+  };
+  if (s.reverseTag) {
+    const sn = reverseSniffingToWire(s.reverseSniffing);
+    const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
+    result.reverse = {
+      tag: s.reverseTag,
+      sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
+    };
+  }
+  if (s.flow === 'xtls-rprx-vision') {
+    if (s.testpre > 0) result.testpre = s.testpre;
+    if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
+      result.testseed = s.testseed;
+    }
+  }
+  return result;
+}
+
+function trojanToWire(s: TrojanOutboundFormSettings) {
+  return { servers: [{ address: s.address, port: s.port, password: s.password }] };
+}
+
+function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
+  return {
+    servers: [{
+      address: s.address,
+      port: s.port,
+      password: s.password,
+      method: s.method,
+      uot: s.uot,
+      UoTVersion: s.UoTVersion,
+    }],
+  };
+}
+
+function simpleAuthToWire(s: SimpleAuthFormSettings) {
+  return {
+    servers: [{
+      address: s.address,
+      port: s.port,
+      users: s.user ? [{ user: s.user, pass: s.pass }] : [],
+    }],
+  };
+}
+
+function wireguardToWire(s: WireguardOutboundFormSettings) {
+  return {
+    mtu: s.mtu || undefined,
+    secretKey: s.secretKey,
+    address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
+    workers: s.workers || undefined,
+    domainStrategy: s.domainStrategy || undefined,
+    reserved: s.reserved
+      ? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
+      : undefined,
+    peers: s.peers.map((p) => ({
+      publicKey: p.publicKey,
+      preSharedKey: p.psk.length > 0 ? p.psk : undefined,
+      allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
+      endpoint: p.endpoint,
+      keepAlive: p.keepAlive || undefined,
+    })),
+    noKernelTun: s.noKernelTun,
+  };
+}
+
+function hysteriaToWire(s: HysteriaOutboundFormSettings) {
+  return { address: s.address, port: s.port, version: s.version };
+}
+
+function freedomToWire(s: FreedomOutboundFormSettings) {
+  // Legacy semantics: emit fragment only when the user actually populated
+  // at least one of the four sub-fields. Defaults like packets='1-3' alone
+  // are not enough — the modal's Fragment Switch sets all four together.
+  const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
+  const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
+  return {
+    domainStrategy: s.domainStrategy || undefined,
+    redirect: s.redirect || undefined,
+    fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
+    noises: s.noises.length > 0 ? s.noises : undefined,
+    finalRules: s.finalRules.length > 0
+      ? s.finalRules.map((r) => ({
+          action: r.action,
+          network: r.network || undefined,
+          port: r.port || undefined,
+          ip: r.ip.length > 0 ? r.ip : undefined,
+          blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
+        }))
+      : undefined,
+  };
+}
+
+function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
+  return { response: s.type ? { type: s.type } : undefined };
+}
+
+function dnsRuleToWire(r: DnsRuleForm) {
+  const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action)
+    ? r.action
+    : 'direct';
+  const result: Raw = { action };
+  const qtype = r.qtype.trim();
+  if (qtype) {
+    result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype;
+  }
+  const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
+  if (domains.length > 0) result.domain = domains;
+  return result;
+}
+
+function dnsToWire(s: DnsOutboundFormSettings) {
+  const result: Raw = {};
+  if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
+  if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
+  if (s.rewritePort) result.rewritePort = s.rewritePort;
+  if (s.userLevel) result.userLevel = s.userLevel;
+  if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
+  return result;
+}
+
+function loopbackToWire(s: LoopbackOutboundFormSettings) {
+  return { inboundTag: s.inboundTag || undefined };
+}
+
+// canEnableMux mirrors the legacy Outbound.canEnableMux().
+const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
+const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
+
+function dropEmptyStrings(obj: Raw): Raw {
+  const out: Raw = {};
+  for (const [k, v] of Object.entries(obj)) {
+    if (v === '') continue;
+    out[k] = v;
+  }
+  return out;
+}
+
+function stripUiOnlyStreamFields(stream: unknown): Raw {
+  const next = { ...(stream as Raw) };
+  const xh = next.xhttpSettings;
+  if (xh && typeof xh === 'object') {
+    const cleaned = { ...(xh as Raw) };
+    delete cleaned.enableXmux;
+    next.xhttpSettings = dropEmptyStrings(cleaned);
+  }
+  return next;
+}
+
+function muxAllowed(values: OutboundFormValues): boolean {
+  if (!MUX_PROTOCOLS.has(values.protocol)) return false;
+  const flow = values.protocol === 'vless'
+    ? (values.settings as VlessOutboundFormSettings).flow
+    : '';
+  if (flow) return false;
+  const network = values.streamSettings && 'network' in values.streamSettings
+    ? values.streamSettings.network
+    : undefined;
+  if (network === 'xhttp') return false;
+  return true;
+}
+
+export type WireOutboundPayload = Raw;
+
+export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
+  let settings: Raw;
+  switch (values.protocol) {
+    case 'vmess':       settings = vmessToWire(values.settings); break;
+    case 'vless':       settings = vlessToWire(values.settings); break;
+    case 'trojan':      settings = trojanToWire(values.settings); break;
+    case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
+    case 'socks':       settings = simpleAuthToWire(values.settings); break;
+    case 'http':        settings = simpleAuthToWire(values.settings); break;
+    case 'wireguard':   settings = wireguardToWire(values.settings); break;
+    case 'hysteria':    settings = hysteriaToWire(values.settings); break;
+    case 'freedom':     settings = freedomToWire(values.settings); break;
+    case 'blackhole':   settings = blackholeToWire(values.settings); break;
+    case 'dns':         settings = dnsToWire(values.settings); break;
+    case 'loopback':    settings = loopbackToWire(values.settings); break;
+  }
+
+  const result: Raw = {
+    protocol: values.protocol,
+    settings,
+  };
+  if (values.tag) result.tag = values.tag;
+
+  // streamSettings emission gates on canEnableStream — non-stream protocols
+  // still emit just `sockopt` if that key is present (legacy behavior).
+  if (values.streamSettings) {
+    if (STREAM_PROTOCOLS.has(values.protocol)) {
+      result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
+    } else {
+      const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
+      if (sockopt) result.streamSettings = { sockopt };
+    }
+  }
+
+  if (values.sendThrough) result.sendThrough = values.sendThrough;
+  // mux may be absent when the modal didn't render the Mux switch (non-
+  // stream protocols or when isMuxAllowed gated it out). validateFields()
+  // only returns registered fields, so values.mux can be undefined.
+  if (values.mux?.enabled && muxAllowed(values)) {
+    result.mux = values.mux;
+  }
+  return result;
+}

+ 439 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -0,0 +1,439 @@
+import { Base64 } from '@/utils';
+
+// Focused share-link parser for the OutboundFormModal's link-import
+// helper. Each parser returns a wire-shape outbound record (the same
+// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
+// null when the input doesn't match.
+//
+// Scope: address + port + auth + remark, plus the network/security
+// fields the common vmess:// / vless:// links carry as query params.
+// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
+// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
+// present in either the JSON or URL params. xmux, reality shortIds,
+// padding obfs key/header/placement, hysteria udphop are still left
+// to the user to fill in after import — the legacy Outbound.fromLink
+// was ~250 lines of dense edge-case handling we don't need to
+// replicate verbatim for the common phone-to-panel workflow.
+
+type Raw = Record<string, unknown>;
+
+// XHTTP knob keys grouped by wire type. Used by both the URL query-param
+// (vless/trojan) branch and the vmess JSON branch to consistently pull
+// the same set of advanced fields when present. Keep order ~stable to
+// match the schema's authoring order so diffs read naturally.
+const XHTTP_STRING_KEYS = [
+  'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
+  'xPaddingMethod', 'sessionPlacement', 'sessionKey', 'seqPlacement',
+  'seqKey', 'uplinkDataPlacement', 'uplinkDataKey', 'scMaxEachPostBytes',
+  'scMinPostsIntervalMs', 'scStreamUpServerSecs', 'uplinkHTTPMethod',
+] as const;
+const XHTTP_NUMBER_KEYS = [
+  'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
+] as const;
+const XHTTP_BOOL_KEYS = [
+  'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
+] as const;
+
+function asBool(s: string | null): boolean | undefined {
+  if (s === null) return undefined;
+  return s === 'true' || s === '1';
+}
+
+function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
+  // Precedence from lowest to highest: stream-init default →
+  // x_padding_bytes snake_case alias → extra JSON payload →
+  // explicit camelCase URL param. Apply in that order so each tier
+  // overwrites the previous when present.
+  const padBytesAlt = params.get('x_padding_bytes');
+  if (padBytesAlt !== null && padBytesAlt !== '') {
+    xhttp.xPaddingBytes = padBytesAlt;
+  }
+  // The inbound link bundles advanced xhttp knobs into `extra=<json>`.
+  // Decode and merge so re-importing a share link round-trips the full
+  // xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
+  const extra = params.get('extra');
+  if (extra) {
+    try {
+      const parsed = JSON.parse(extra) as Record<string, unknown>;
+      applyXhttpStringFromJson(xhttp, parsed);
+      if (parsed.headers && typeof parsed.headers === 'object') {
+        xhttp.headers = parsed.headers;
+      }
+    } catch {
+      // malformed extra — silently ignore, the panel can still operate
+      // on the rest of the link
+    }
+  }
+  for (const k of XHTTP_STRING_KEYS) {
+    const v = params.get(k);
+    if (v !== null && v !== '') xhttp[k] = v;
+  }
+  for (const k of XHTTP_NUMBER_KEYS) {
+    const v = params.get(k);
+    if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
+  }
+  for (const k of XHTTP_BOOL_KEYS) {
+    const v = params.get(k);
+    if (v !== null && v !== '') xhttp[k] = asBool(v);
+  }
+}
+
+function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
+  for (const k of XHTTP_STRING_KEYS) {
+    if (typeof json[k] === 'string') xhttp[k] = json[k];
+  }
+  for (const k of XHTTP_NUMBER_KEYS) {
+    if (typeof json[k] === 'number') xhttp[k] = json[k];
+  }
+  for (const k of XHTTP_BOOL_KEYS) {
+    if (typeof json[k] === 'boolean') xhttp[k] = json[k];
+  }
+}
+
+function buildStream(network: string, security: string): Raw {
+  const stream: Raw = { network, security };
+  switch (network) {
+    case 'tcp':
+      stream.tcpSettings = { header: { type: 'none' } };
+      break;
+    case 'kcp':
+      stream.kcpSettings = {
+        mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
+        cwndMultiplier: 1, maxSendingWindow: 2097152,
+      };
+      break;
+    case 'ws':
+      stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
+      break;
+    case 'grpc':
+      stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
+      break;
+    case 'httpupgrade':
+      stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
+      break;
+    case 'xhttp':
+      stream.xhttpSettings = {
+        path: '/', host: '', mode: 'auto', headers: {},
+        xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
+      };
+      break;
+    default:
+      stream.tcpSettings = { header: { type: 'none' } };
+  }
+  if (security === 'tls') {
+    stream.tlsSettings = {
+      serverName: '', alpn: [], fingerprint: '',
+      echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
+    };
+  } else if (security === 'reality') {
+    stream.realitySettings = {
+      publicKey: '', fingerprint: 'chrome', serverName: '',
+      shortId: '', spiderX: '', mldsa65Verify: '',
+    };
+  }
+  return stream;
+}
+
+function applyTransportParams(stream: Raw, params: URLSearchParams): void {
+  const network = stream.network as string;
+  const host = params.get('host') ?? '';
+  const path = params.get('path') ?? '/';
+  switch (network) {
+    case 'ws':
+      (stream.wsSettings as Raw).host = host;
+      (stream.wsSettings as Raw).path = path;
+      break;
+    case 'grpc': {
+      const grpc = stream.grpcSettings as Raw;
+      const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
+      grpc.serviceName = serviceName;
+      grpc.authority = params.get('authority') ?? '';
+      grpc.multiMode = params.get('mode') === 'multi';
+      break;
+    }
+    case 'httpupgrade':
+      (stream.httpupgradeSettings as Raw).host = host;
+      (stream.httpupgradeSettings as Raw).path = path;
+      break;
+    case 'xhttp': {
+      const xhttp = stream.xhttpSettings as Raw;
+      xhttp.host = host;
+      xhttp.path = path;
+      if (params.get('mode')) xhttp.mode = params.get('mode');
+      applyXhttpStringFromParams(xhttp, params);
+      break;
+    }
+    case 'tcp':
+      // vless/trojan TCP HTTP camouflage rides on header=http+host+path
+      if (params.get('headerType') === 'http' || params.get('type') === 'http') {
+        (stream.tcpSettings as Raw).header = {
+          type: 'http',
+          request: {
+            version: '1.1',
+            method: 'GET',
+            path: path.split(',').filter(Boolean),
+            headers: host ? { Host: host.split(',').filter(Boolean) } : {},
+          },
+        };
+      }
+      break;
+  }
+}
+
+// The inbound link emits the entire finalmask object as a JSON-encoded
+// `fm` query param. Decode and attach to streamSettings so udpHop /
+// quicParams / tcp+udp masks round-trip on outbound import.
+function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
+  const fm = params.get('fm');
+  if (!fm) return;
+  try {
+    const parsed = JSON.parse(fm) as Record<string, unknown>;
+    if (parsed && typeof parsed === 'object') {
+      stream.finalmask = parsed;
+    }
+  } catch {
+    // malformed fm — leave streamSettings.finalmask absent
+  }
+}
+
+function applySecurityParams(stream: Raw, params: URLSearchParams): void {
+  if (stream.security === 'tls') {
+    const tls = stream.tlsSettings as Raw;
+    tls.serverName = params.get('sni') ?? '';
+    tls.fingerprint = params.get('fp') ?? '';
+    const alpn = params.get('alpn');
+    if (alpn) tls.alpn = alpn.split(',');
+  } else if (stream.security === 'reality') {
+    const reality = stream.realitySettings as Raw;
+    reality.serverName = params.get('sni') ?? '';
+    reality.fingerprint = params.get('fp') ?? 'chrome';
+    reality.publicKey = params.get('pbk') ?? '';
+    reality.shortId = params.get('sid') ?? '';
+    reality.spiderX = params.get('spx') ?? '';
+  }
+}
+
+function decodeRemark(url: URL): string {
+  try {
+    return decodeURIComponent(url.hash.replace(/^#/, ''));
+  } catch {
+    return url.hash.replace(/^#/, '');
+  }
+}
+
+export function parseVmessLink(link: string): Raw | null {
+  if (!link.startsWith('vmess://')) return null;
+  try {
+    const decoded = Base64.decode(link.slice('vmess://'.length));
+    const json = JSON.parse(decoded) as Record<string, unknown>;
+    const network = (json.net as string) || 'tcp';
+    const security = json.tls === 'tls' ? 'tls' : 'none';
+    const stream = buildStream(network, security);
+    // Map the vmess JSON's net-specific keys onto the stream branch.
+    if (network === 'tcp' && json.type === 'http') {
+      (stream.tcpSettings as Raw).header = {
+        type: 'http',
+        request: {
+          version: '1.1', method: 'GET',
+          path: (json.path as string ?? '/').split(',').filter(Boolean),
+          headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
+        },
+      };
+    } else if (network === 'ws') {
+      (stream.wsSettings as Raw).host = json.host ?? '';
+      (stream.wsSettings as Raw).path = json.path ?? '/';
+    } else if (network === 'grpc') {
+      (stream.grpcSettings as Raw).serviceName = json.path ?? '';
+      (stream.grpcSettings as Raw).authority = json.authority ?? '';
+      (stream.grpcSettings as Raw).multiMode = json.type === 'multi';
+    } else if (network === 'httpupgrade') {
+      (stream.httpupgradeSettings as Raw).host = json.host ?? '';
+      (stream.httpupgradeSettings as Raw).path = json.path ?? '/';
+    } else if (network === 'xhttp') {
+      const xhttp = stream.xhttpSettings as Raw;
+      xhttp.host = json.host ?? '';
+      xhttp.path = json.path ?? '/';
+      if (json.mode) xhttp.mode = json.mode;
+      applyXhttpStringFromJson(xhttp, json);
+    }
+    if (security === 'tls') {
+      const tls = stream.tlsSettings as Raw;
+      tls.serverName = json.sni ?? '';
+      tls.fingerprint = json.fp ?? '';
+      if (json.alpn) tls.alpn = (json.alpn as string).split(',');
+    }
+
+    const port = Number(json.port) || 443;
+    return {
+      protocol: 'vmess',
+      tag: typeof json.ps === 'string' ? json.ps : '',
+      settings: {
+        vnext: [{
+          address: json.add ?? '',
+          port,
+          users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
+        }],
+      },
+      streamSettings: stream,
+    };
+  } catch {
+    return null;
+  }
+}
+
+function parseUrlLink(link: string, expectedProto: string): URL | null {
+  try {
+    const url = new URL(link);
+    if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
+    return url;
+  } catch {
+    return null;
+  }
+}
+
+export function parseVlessLink(link: string): Raw | null {
+  const url = parseUrlLink(link, 'vless');
+  if (!url) return null;
+  const id = url.username;
+  const address = url.hostname;
+  const port = Number(url.port) || 443;
+  const params = url.searchParams;
+  const network = params.get('type') ?? 'tcp';
+  const security = (params.get('security') ?? 'none') as string;
+  const stream = buildStream(network, security);
+  applyTransportParams(stream, params);
+  applySecurityParams(stream, params);
+  applyFinalMaskParam(stream, params);
+  return {
+    protocol: 'vless',
+    tag: decodeRemark(url),
+    settings: {
+      address,
+      port,
+      id,
+      flow: params.get('flow') ?? '',
+      encryption: params.get('encryption') ?? 'none',
+    },
+    streamSettings: stream,
+  };
+}
+
+export function parseTrojanLink(link: string): Raw | null {
+  const url = parseUrlLink(link, 'trojan');
+  if (!url) return null;
+  const password = url.username;
+  const address = url.hostname;
+  const port = Number(url.port) || 443;
+  const params = url.searchParams;
+  const network = params.get('type') ?? 'tcp';
+  const security = (params.get('security') ?? 'tls') as string;
+  const stream = buildStream(network, security);
+  applyTransportParams(stream, params);
+  applySecurityParams(stream, params);
+  applyFinalMaskParam(stream, params);
+  return {
+    protocol: 'trojan',
+    tag: decodeRemark(url),
+    settings: {
+      servers: [{ address, port, password }],
+    },
+    streamSettings: stream,
+  };
+}
+
+export function parseShadowsocksLink(link: string): Raw | null {
+  if (!link.startsWith('ss://')) return null;
+  // Two link shapes coexist:
+  //   modern:  ss://base64(method:password)@host:port#remark
+  //   legacy:  ss://base64(method:password@host:port)#remark
+  // Try modern first; fall back to legacy decode of the whole userinfo+host.
+  let userInfo: string;
+  let host: string;
+  let port: number;
+  let remark = '';
+  const hashIndex = link.indexOf('#');
+  const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
+  if (hashIndex >= 0) {
+    try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
+  }
+  const atIndex = linkNoHash.indexOf('@');
+  if (atIndex >= 0) {
+    try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
+    catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
+    const hostPort = linkNoHash.slice(atIndex + 1);
+    const colon = hostPort.lastIndexOf(':');
+    if (colon < 0) return null;
+    host = hostPort.slice(0, colon);
+    port = Number(hostPort.slice(colon + 1)) || 443;
+  } else {
+    let decoded: string;
+    try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
+    catch { return null; }
+    const at = decoded.indexOf('@');
+    if (at < 0) return null;
+    userInfo = decoded.slice(0, at);
+    const hostPort = decoded.slice(at + 1);
+    const colon = hostPort.lastIndexOf(':');
+    if (colon < 0) return null;
+    host = hostPort.slice(0, colon);
+    port = Number(hostPort.slice(colon + 1)) || 443;
+  }
+  const sep = userInfo.indexOf(':');
+  const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
+  const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
+  return {
+    protocol: 'shadowsocks',
+    tag: remark,
+    settings: {
+      servers: [{ address: host, port, password, method }],
+    },
+  };
+}
+
+export function parseHysteria2Link(link: string): Raw | null {
+  const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
+  if (!url) return null;
+  // hysteria2's auth rides as the URL userinfo. The streamSettings
+  // network branch is the dedicated 'hysteria' transport — the modal's
+  // newStreamSlice('hysteria') initializer fills in receive-window
+  // defaults; we override the user-set fields here.
+  const auth = url.username;
+  const address = url.hostname;
+  const port = Number(url.port) || 443;
+  const params = url.searchParams;
+  const stream: Raw = {
+    network: 'hysteria',
+    security: 'tls',
+    hysteriaSettings: {
+      version: 2, auth, udpIdleTimeout: 60,
+    },
+    tlsSettings: {
+      serverName: params.get('sni') ?? '',
+      alpn: ['h3'],
+      fingerprint: '',
+      echConfigList: '',
+      verifyPeerCertByName: '',
+      pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
+    },
+  };
+  return {
+    protocol: 'hysteria',
+    tag: decodeRemark(url),
+    settings: { address, port, version: 2 },
+    streamSettings: stream,
+  };
+}
+
+// Dispatcher — first non-null parser wins. Returns null when no parser
+// recognizes the link's protocol scheme.
+export function parseOutboundLink(link: string): Raw | null {
+  const trimmed = link.trim();
+  if (!trimmed) return null;
+  return (
+    parseVmessLink(trimmed)
+    ?? parseVlessLink(trimmed)
+    ?? parseTrojanLink(trimmed)
+    ?? parseShadowsocksLink(trimmed)
+    ?? parseHysteria2Link(trimmed)
+  );
+}

+ 74 - 0
frontend/src/lib/xray/protocol-capabilities.ts

@@ -0,0 +1,74 @@
+// Pure-function ports of the legacy Inbound class capability predicates
+// (canEnableTls, canEnableReality, canEnableTlsFlow, canEnableStream,
+// canEnableVisionSeed, isSS2022, isSSMultiUser). Each accepts the minimal
+// slice of an InboundFormValues it needs, so the same predicate can be
+// called against a partial-row, a full form value, or a hand-built test
+// fixture without the caller projecting a whole object.
+
+const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks'];
+const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp'];
+const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan'];
+const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp'];
+const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria'];
+const VISION_FLOW = 'xtls-rprx-vision';
+const SS_2022_PREFIX = '2022';
+const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
+
+export interface CapabilityProtocolSlice {
+  protocol: string;
+  streamSettings?: { network?: string; security?: string };
+}
+
+export interface CapabilityVlessSlice extends CapabilityProtocolSlice {
+  settings?: { clients?: { flow?: string }[] };
+}
+
+export interface CapabilityShadowsocksSlice {
+  protocol: string;
+  settings?: { method?: string };
+}
+
+export function canEnableTls(values: CapabilityProtocolSlice): boolean {
+  if (values.protocol === 'hysteria') return true;
+  if (!TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
+  return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
+}
+
+export function canEnableReality(values: CapabilityProtocolSlice): boolean {
+  if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
+  return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
+}
+
+export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
+  const security = values.streamSettings?.security;
+  if (security !== 'tls' && security !== 'reality') return false;
+  if (values.streamSettings?.network !== 'tcp') return false;
+  return values.protocol === 'vless';
+}
+
+export function canEnableStream(values: { protocol: string }): boolean {
+  return STREAM_PROTOCOLS.includes(values.protocol);
+}
+
+// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
+// AND at least one VLESS client uses the vision flow. Excludes UDP variant.
+export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean {
+  if (!canEnableTlsFlow(values)) return false;
+  const clients = values.settings?.clients;
+  if (!Array.isArray(clients)) return false;
+  return clients.some((c) => c?.flow === VISION_FLOW);
+}
+
+// Why: legacy returns true on non-SS protocols too (the method getter
+// resolves to "" and "" !== blake3-chacha20-poly1305). Preserved for
+// parity with the legacy class; in practice the callers all narrow on
+// protocol === shadowsocks before checking.
+export function isSSMultiUser(values: CapabilityShadowsocksSlice): boolean {
+  const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
+  return method !== SS_BLAKE3_CHACHA20;
+}
+
+export function isSS2022(values: CapabilityShadowsocksSlice): boolean {
+  const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
+  return method.substring(0, 4) === SS_2022_PREFIX;
+}

+ 69 - 0
frontend/src/lib/xray/stream-defaults.ts

@@ -0,0 +1,69 @@
+import {
+  GrpcStreamSettingsSchema,
+  HttpUpgradeStreamSettingsSchema,
+  HysteriaStreamSettingsSchema,
+  KcpStreamSettingsSchema,
+  TcpStreamSettingsSchema,
+  WsStreamSettingsSchema,
+  XHttpStreamSettingsSchema,
+} from '@/schemas/protocols/stream';
+import {
+  RealityStreamSettingsSchema,
+  TlsStreamSettingsSchema,
+} from '@/schemas/protocols/security';
+
+const NETWORK_KEY_MAP = {
+  tcp: 'tcpSettings',
+  kcp: 'kcpSettings',
+  ws: 'wsSettings',
+  grpc: 'grpcSettings',
+  httpupgrade: 'httpupgradeSettings',
+  xhttp: 'xhttpSettings',
+  hysteria: 'hysteriaSettings',
+} as const;
+
+type SchemaWithParse = { safeParse: (v: unknown) => { success: boolean; data?: unknown } };
+
+function parseOrDefault(schema: SchemaWithParse, value: unknown): unknown {
+  const parsed = schema.safeParse(value ?? {});
+  if (parsed.success) return parsed.data;
+  const fallback = schema.safeParse({});
+  return fallback.success ? fallback.data : value;
+}
+
+function networkSchemaFor(network: string): SchemaWithParse | null {
+  switch (network) {
+    case 'tcp': return TcpStreamSettingsSchema;
+    case 'kcp': return KcpStreamSettingsSchema;
+    case 'ws': return WsStreamSettingsSchema;
+    case 'grpc': return GrpcStreamSettingsSchema;
+    case 'httpupgrade': return HttpUpgradeStreamSettingsSchema;
+    case 'xhttp': return XHttpStreamSettingsSchema;
+    case 'hysteria': return HysteriaStreamSettingsSchema;
+    default: return null;
+  }
+}
+
+function securitySchemaFor(security: string): { key: string; schema: SchemaWithParse } | null {
+  switch (security) {
+    case 'tls': return { key: 'tlsSettings', schema: TlsStreamSettingsSchema };
+    case 'reality': return { key: 'realitySettings', schema: RealityStreamSettingsSchema };
+    default: return null;
+  }
+}
+
+export function fillStreamDefaults(stream: Record<string, unknown>): Record<string, unknown> {
+  const network = (stream.network as string | undefined) ?? 'tcp';
+  const security = (stream.security as string | undefined) ?? 'none';
+  const out: Record<string, unknown> = { ...stream, network, security };
+  const subKey = NETWORK_KEY_MAP[network as keyof typeof NETWORK_KEY_MAP];
+  const netSchema = networkSchemaFor(network);
+  if (subKey && netSchema) {
+    out[subKey] = parseOrDefault(netSchema, out[subKey]);
+  }
+  const sec = securitySchemaFor(security);
+  if (sec) {
+    out[sec.key] = parseOrDefault(sec.schema, out[sec.key]);
+  }
+  return out;
+}

+ 1 - 58
frontend/src/models/dbinbound.ts

@@ -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);
-    }
 }

+ 0 - 3359
frontend/src/models/inbound.ts

@@ -1,3359 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import dayjs from 'dayjs';
-import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
-import { getRandomRealityTarget } from '@/models/reality-targets';
-
-export const Protocols = {
-    VMESS: 'vmess',
-    VLESS: 'vless',
-    TROJAN: 'trojan',
-    SHADOWSOCKS: 'shadowsocks',
-    WIREGUARD: 'wireguard',
-    HYSTERIA: 'hysteria',
-    MIXED: 'mixed',
-    HTTP: 'http',
-    TUNNEL: 'tunnel',
-    TUN: 'tun',
-};
-
-export const SSMethods = {
-    AES_256_GCM: 'aes-256-gcm',
-    CHACHA20_POLY1305: 'chacha20-poly1305',
-    CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
-    XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
-    BLAKE3_AES_128_GCM: '2022-blake3-aes-128-gcm',
-    BLAKE3_AES_256_GCM: '2022-blake3-aes-256-gcm',
-    BLAKE3_CHACHA20_POLY1305: '2022-blake3-chacha20-poly1305',
-};
-
-export const TLS_FLOW_CONTROL = {
-    VISION: "xtls-rprx-vision",
-    VISION_UDP443: "xtls-rprx-vision-udp443",
-};
-
-export const TLS_VERSION_OPTION = {
-    TLS10: "1.0",
-    TLS11: "1.1",
-    TLS12: "1.2",
-    TLS13: "1.3",
-};
-
-export const TLS_CIPHER_OPTION = {
-    AES_128_GCM: "TLS_AES_128_GCM_SHA256",
-    AES_256_GCM: "TLS_AES_256_GCM_SHA384",
-    CHACHA20_POLY1305: "TLS_CHACHA20_POLY1305_SHA256",
-    ECDHE_ECDSA_AES_128_CBC: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
-    ECDHE_ECDSA_AES_256_CBC: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
-    ECDHE_RSA_AES_128_CBC: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
-    ECDHE_RSA_AES_256_CBC: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
-    ECDHE_ECDSA_AES_128_GCM: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
-    ECDHE_ECDSA_AES_256_GCM: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
-    ECDHE_RSA_AES_128_GCM: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
-    ECDHE_RSA_AES_256_GCM: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
-    ECDHE_ECDSA_CHACHA20_POLY1305: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
-    ECDHE_RSA_CHACHA20_POLY1305: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
-};
-
-export const UTLS_FINGERPRINT = {
-    UTLS_CHROME: "chrome",
-    UTLS_FIREFOX: "firefox",
-    UTLS_SAFARI: "safari",
-    UTLS_IOS: "ios",
-    UTLS_android: "android",
-    UTLS_EDGE: "edge",
-    UTLS_360: "360",
-    UTLS_QQ: "qq",
-    UTLS_RANDOM: "random",
-    UTLS_RANDOMIZED: "randomized",
-    UTLS_RONDOMIZEDNOALPN: "randomizednoalpn",
-    UTLS_UNSAFE: "unsafe",
-};
-
-export const ALPN_OPTION = {
-    H3: "h3",
-    H2: "h2",
-    HTTP1: "http/1.1",
-};
-
-export const SNIFFING_OPTION = {
-    HTTP: "http",
-    TLS: "tls",
-    QUIC: "quic",
-    FAKEDNS: "fakedns"
-};
-
-export const USAGE_OPTION = {
-    ENCIPHERMENT: "encipherment",
-    VERIFY: "verify",
-    ISSUE: "issue",
-};
-
-export const DOMAIN_STRATEGY_OPTION = {
-    AS_IS: "AsIs",
-    USE_IP: "UseIP",
-    USE_IPV6V4: "UseIPv6v4",
-    USE_IPV6: "UseIPv6",
-    USE_IPV4V6: "UseIPv4v6",
-    USE_IPV4: "UseIPv4",
-    FORCE_IP: "ForceIP",
-    FORCE_IPV6V4: "ForceIPv6v4",
-    FORCE_IPV6: "ForceIPv6",
-    FORCE_IPV4V6: "ForceIPv4v6",
-    FORCE_IPV4: "ForceIPv4",
-};
-
-export const TCP_CONGESTION_OPTION = {
-    BBR: "bbr",
-    CUBIC: "cubic",
-    RENO: "reno",
-};
-
-export const USERS_SECURITY = {
-    AES_128_GCM: "aes-128-gcm",
-    CHACHA20_POLY1305: "chacha20-poly1305",
-    AUTO: "auto",
-    NONE: "none",
-    ZERO: "zero",
-};
-
-export const MODE_OPTION = {
-    AUTO: "auto",
-    PACKET_UP: "packet-up",
-    STREAM_UP: "stream-up",
-    STREAM_ONE: "stream-one",
-};
-
-Object.freeze(Protocols);
-Object.freeze(SSMethods);
-Object.freeze(TLS_FLOW_CONTROL);
-Object.freeze(TLS_VERSION_OPTION);
-Object.freeze(TLS_CIPHER_OPTION);
-Object.freeze(UTLS_FINGERPRINT);
-Object.freeze(ALPN_OPTION);
-Object.freeze(SNIFFING_OPTION);
-Object.freeze(USAGE_OPTION);
-Object.freeze(DOMAIN_STRATEGY_OPTION);
-Object.freeze(TCP_CONGESTION_OPTION);
-Object.freeze(USERS_SECURITY);
-Object.freeze(MODE_OPTION);
-
-export type JsonObject = Record<string, unknown>;
-export interface HeaderEntry { name: string; value: string }
-export interface FallbackEntry {
-    dest?: string | number;
-    name?: string;
-    alpn?: string;
-    path?: string;
-    xver?: number | string;
-}
-
-export class XrayCommonClass {
-    [key: string]: any;
-
-    static toJsonArray<T extends { toJson(): unknown }>(arr: T[]): unknown[] {
-        return arr.map((obj) => obj.toJson());
-    }
-
-    static fromJson(..._args: unknown[]): XrayCommonClass | undefined {
-        return new XrayCommonClass();
-    }
-
-    toJson(): unknown {
-        return this;
-    }
-
-    static fallbackToJson(fb: FallbackEntry): JsonObject {
-        const out: JsonObject = { dest: fb.dest };
-        if (fb.name) out.name = fb.name;
-        if (fb.alpn) out.alpn = fb.alpn;
-        if (fb.path) out.path = fb.path;
-        const xver = Number(fb.xver);
-        if (Number.isInteger(xver) && xver > 0) out.xver = xver;
-        return out;
-    }
-
-    toString(format: boolean = true): string {
-        return format ? JSON.stringify(this.toJson(), null, 2) : JSON.stringify(this.toJson());
-    }
-
-    static toHeaders(v2Headers: unknown): HeaderEntry[] {
-        const newHeaders: HeaderEntry[] = [];
-        if (v2Headers && typeof v2Headers === 'object') {
-            const map = v2Headers as Record<string, string | string[]>;
-            Object.keys(map).forEach((key: string) => {
-                const values = map[key];
-                if (typeof values === 'string') {
-                    newHeaders.push({ name: key, value: values });
-                } else if (Array.isArray(values)) {
-                    for (let i = 0; i < values.length; ++i) {
-                        newHeaders.push({ name: key, value: values[i] });
-                    }
-                }
-            });
-        }
-        return newHeaders;
-    }
-
-    static toV2Headers(headers: HeaderEntry[], arr: boolean = true): Record<string, string | string[]> {
-        const v2Headers: Record<string, string | string[]> = {};
-        for (let i = 0; i < headers.length; ++i) {
-            const name = headers[i].name;
-            const value = headers[i].value;
-            if (ObjectUtil.isEmpty(name) || ObjectUtil.isEmpty(value)) {
-                continue;
-            }
-            if (!(name in v2Headers)) {
-                v2Headers[name] = arr ? [value] : value;
-            } else {
-                const existing = v2Headers[name];
-                if (arr && Array.isArray(existing)) {
-                    existing.push(value);
-                } else {
-                    v2Headers[name] = value;
-                }
-            }
-        }
-        return v2Headers;
-    }
-}
-
-export class TcpStreamSettings extends XrayCommonClass {
-    static TcpRequest: any;
-    static TcpResponse: any;
-
-    constructor(
-        acceptProxyProtocol: any = false,
-        type: any = 'none',
-        request: any = new TcpStreamSettings.TcpRequest(),
-        response = new TcpStreamSettings.TcpResponse(),
-    ) {
-        super();
-        this.acceptProxyProtocol = acceptProxyProtocol;
-        this.type = type;
-        this.request = request;
-        this.response = response;
-    }
-
-    static fromJson(json: any = {}) {
-        let header = json.header;
-        if (!header) {
-            header = {};
-        }
-        return new TcpStreamSettings(json.acceptProxyProtocol,
-            header.type,
-            TcpStreamSettings.TcpRequest.fromJson(header.request),
-            TcpStreamSettings.TcpResponse.fromJson(header.response),
-        );
-    }
-
-    toJson() {
-        const json: any = {};
-        if (this.acceptProxyProtocol) {
-            json.acceptProxyProtocol = true;
-        }
-        if (this.type === 'http') {
-            json.header = {
-                type: 'http',
-                request: this.request.toJson(),
-                response: this.response.toJson(),
-            };
-        } else if (this.type && this.type !== 'none') {
-            json.header = { type: this.type };
-        }
-        return json;
-    }
-}
-
-TcpStreamSettings.TcpRequest = class extends XrayCommonClass {
-    constructor(
-        version = '1.1',
-        method = 'GET',
-        path = ['/'],
-        headers: any[] = [],
-    ) {
-        super();
-        this.version = version;
-        this.method = method;
-        this.path = path.length === 0 ? ['/'] : path;
-        this.headers = headers;
-    }
-
-    addPath(path: any) {
-        this.path.push(path);
-    }
-
-    removePath(index: number) {
-        this.path.splice(index, 1);
-    }
-
-    addHeader(name: any, value: any) {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number) {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new TcpStreamSettings.TcpRequest(
-            json.version,
-            json.method,
-            json.path,
-            XrayCommonClass.toHeaders(json.headers),
-        );
-    }
-
-    toJson() {
-        return {
-            version: this.version,
-            method: this.method,
-            path: ObjectUtil.clone(this.path),
-            headers: XrayCommonClass.toV2Headers(this.headers),
-        };
-    }
-};
-
-TcpStreamSettings.TcpResponse = class extends XrayCommonClass {
-    constructor(
-        version = '1.1',
-        status = '200',
-        reason = 'OK',
-        headers: any[] = [],
-    ) {
-        super();
-        this.version = version;
-        this.status = status;
-        this.reason = reason;
-        this.headers = headers;
-    }
-
-    addHeader(name: any, value: any) {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number) {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new TcpStreamSettings.TcpResponse(
-            json.version,
-            json.status,
-            json.reason,
-            XrayCommonClass.toHeaders(json.headers),
-        );
-    }
-
-    toJson() {
-        return {
-            version: this.version,
-            status: this.status,
-            reason: this.reason,
-            headers: XrayCommonClass.toV2Headers(this.headers),
-        };
-    }
-};
-
-export class KcpStreamSettings extends XrayCommonClass {
-    constructor(
-        mtu = 1350,
-        tti = 20,
-        uplinkCapacity = 5,
-        downlinkCapacity = 20,
-        cwndMultiplier = 1,
-        maxSendingWindow = 2097152,
-    ) {
-        super();
-        this.mtu = mtu;
-        this.tti = tti;
-        this.upCap = uplinkCapacity;
-        this.downCap = downlinkCapacity;
-        this.cwndMultiplier = cwndMultiplier;
-        this.maxSendingWindow = maxSendingWindow;
-    }
-
-    static fromJson(json: any = {}) {
-        return new KcpStreamSettings(
-            json.mtu,
-            json.tti,
-            json.uplinkCapacity,
-            json.downlinkCapacity,
-            json.cwndMultiplier,
-            json.maxSendingWindow,
-        );
-    }
-
-    toJson() {
-        return {
-            mtu: this.mtu,
-            tti: this.tti,
-            uplinkCapacity: this.upCap,
-            downlinkCapacity: this.downCap,
-            cwndMultiplier: this.cwndMultiplier,
-            maxSendingWindow: this.maxSendingWindow,
-        };
-    }
-}
-
-export class WsStreamSettings extends XrayCommonClass {
-    constructor(
-        acceptProxyProtocol: any = false,
-        path = '/',
-        host = '',
-        headers: any[] = [],
-        heartbeatPeriod = 0,
-    ) {
-        super();
-        this.acceptProxyProtocol = acceptProxyProtocol;
-        this.path = path;
-        this.host = host;
-        this.headers = headers;
-        this.heartbeatPeriod = heartbeatPeriod;
-    }
-
-    addHeader(name: any, value: any) {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number) {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new WsStreamSettings(
-            json.acceptProxyProtocol,
-            json.path,
-            json.host,
-            XrayCommonClass.toHeaders(json.headers),
-            json.heartbeatPeriod,
-        );
-    }
-
-    toJson() {
-        return {
-            acceptProxyProtocol: this.acceptProxyProtocol,
-            path: this.path,
-            host: this.host,
-            headers: XrayCommonClass.toV2Headers(this.headers, false),
-            heartbeatPeriod: this.heartbeatPeriod,
-        };
-    }
-}
-
-export class GrpcStreamSettings extends XrayCommonClass {
-    constructor(
-        serviceName = "",
-        authority = "",
-        multiMode = false,
-    ) {
-        super();
-        this.serviceName = serviceName;
-        this.authority = authority;
-        this.multiMode = multiMode;
-    }
-
-    static fromJson(json: any = {}) {
-        return new GrpcStreamSettings(
-            json.serviceName,
-            json.authority,
-            json.multiMode
-        );
-    }
-
-    toJson() {
-        return {
-            serviceName: this.serviceName,
-            authority: this.authority,
-            multiMode: this.multiMode,
-        }
-    }
-}
-
-export class HTTPUpgradeStreamSettings extends XrayCommonClass {
-    constructor(
-        acceptProxyProtocol: any = false,
-        path = '/',
-        host = '',
-        headers: any[] = []
-    ) {
-        super();
-        this.acceptProxyProtocol = acceptProxyProtocol;
-        this.path = path;
-        this.host = host;
-        this.headers = headers;
-    }
-
-    addHeader(name: any, value: any) {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number) {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new HTTPUpgradeStreamSettings(
-            json.acceptProxyProtocol,
-            json.path,
-            json.host,
-            XrayCommonClass.toHeaders(json.headers),
-        );
-    }
-
-    toJson() {
-        return {
-            acceptProxyProtocol: this.acceptProxyProtocol,
-            path: this.path,
-            host: this.host,
-            headers: XrayCommonClass.toV2Headers(this.headers, false),
-        };
-    }
-}
-
-// Mirrors the inbound (server-side) view of Xray-core's SplitHTTPConfig
-// (infra/conf/transport_internet.go). Only fields the server actually
-// reads at runtime, plus the bidirectional fields the server enforces,
-// live here. Most client-only fields (uplinkChunkSize, noGRPCHeader,
-// scMinPostsIntervalMs, xmux, downloadSettings) belong on the outbound
-// class instead.
-//
-// `headers` and `uplinkHTTPMethod` are client-only at runtime (xray's
-// listener doesn't read them) but we keep them here so the admin can set
-// values that get embedded into the share link's `extra` blob.
-export class xHTTPStreamSettings extends XrayCommonClass {
-    constructor(
-        // Bidirectional — must match between client and server
-        path = '/',
-        host = '',
-        mode = MODE_OPTION.AUTO,
-        xPaddingBytes = "100-1000",
-        xPaddingObfsMode = false,
-        xPaddingKey = '',
-        xPaddingHeader = '',
-        xPaddingPlacement = '',
-        xPaddingMethod = '',
-        sessionPlacement = '',
-        sessionKey = '',
-        seqPlacement = '',
-        seqKey = '',
-        uplinkDataPlacement = '',
-        uplinkDataKey = '',
-        scMaxEachPostBytes = "1000000",
-        // Server-side only
-        noSSEHeader = false,
-        scMaxBufferedPosts = 30,
-        scStreamUpServerSecs = "20-80",
-        serverMaxHeaderBytes = 0,
-        // URL-share only — embedded in the link's `extra` blob so clients
-        // pick them up; xray's listener ignores them at runtime.
-        uplinkHTTPMethod = '',
-        headers: any[] = [],
-    ) {
-        super();
-        this.path = path;
-        this.host = host;
-        this.mode = mode;
-        this.xPaddingBytes = xPaddingBytes;
-        this.xPaddingObfsMode = xPaddingObfsMode;
-        this.xPaddingKey = xPaddingKey;
-        this.xPaddingHeader = xPaddingHeader;
-        this.xPaddingPlacement = xPaddingPlacement;
-        this.xPaddingMethod = xPaddingMethod;
-        this.sessionPlacement = sessionPlacement;
-        this.sessionKey = sessionKey;
-        this.seqPlacement = seqPlacement;
-        this.seqKey = seqKey;
-        this.uplinkDataPlacement = uplinkDataPlacement;
-        this.uplinkDataKey = uplinkDataKey;
-        this.scMaxEachPostBytes = scMaxEachPostBytes;
-        this.noSSEHeader = noSSEHeader;
-        this.scMaxBufferedPosts = scMaxBufferedPosts;
-        this.scStreamUpServerSecs = scStreamUpServerSecs;
-        this.serverMaxHeaderBytes = serverMaxHeaderBytes;
-        this.uplinkHTTPMethod = uplinkHTTPMethod;
-        this.headers = headers;
-    }
-
-    addHeader(name: any, value: any) {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number) {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new xHTTPStreamSettings(
-            json.path,
-            json.host,
-            json.mode,
-            json.xPaddingBytes,
-            json.xPaddingObfsMode,
-            json.xPaddingKey,
-            json.xPaddingHeader,
-            json.xPaddingPlacement,
-            json.xPaddingMethod,
-            json.sessionPlacement,
-            json.sessionKey,
-            json.seqPlacement,
-            json.seqKey,
-            json.uplinkDataPlacement,
-            json.uplinkDataKey,
-            json.scMaxEachPostBytes,
-            json.noSSEHeader,
-            json.scMaxBufferedPosts,
-            json.scStreamUpServerSecs,
-            json.serverMaxHeaderBytes,
-            json.uplinkHTTPMethod,
-            XrayCommonClass.toHeaders(json.headers),
-        );
-    }
-
-    toJson() {
-        return {
-            path: this.path,
-            host: this.host,
-            mode: this.mode,
-            xPaddingBytes: this.xPaddingBytes,
-            xPaddingObfsMode: this.xPaddingObfsMode,
-            xPaddingKey: this.xPaddingKey,
-            xPaddingHeader: this.xPaddingHeader,
-            xPaddingPlacement: this.xPaddingPlacement,
-            xPaddingMethod: this.xPaddingMethod,
-            sessionPlacement: this.sessionPlacement,
-            sessionKey: this.sessionKey,
-            seqPlacement: this.seqPlacement,
-            seqKey: this.seqKey,
-            uplinkDataPlacement: this.uplinkDataPlacement,
-            uplinkDataKey: this.uplinkDataKey,
-            scMaxEachPostBytes: this.scMaxEachPostBytes,
-            noSSEHeader: this.noSSEHeader,
-            scMaxBufferedPosts: this.scMaxBufferedPosts,
-            scStreamUpServerSecs: this.scStreamUpServerSecs,
-            serverMaxHeaderBytes: this.serverMaxHeaderBytes,
-            uplinkHTTPMethod: this.uplinkHTTPMethod,
-            headers: XrayCommonClass.toV2Headers(this.headers, false),
-        };
-    }
-}
-
-export class HysteriaStreamSettings extends XrayCommonClass {
-    constructor(
-        protocol?: any,
-        version: any = 2,
-        auth: any = '',
-        udpIdleTimeout: any = 60,
-        masquerade?: any,
-    ) {
-        super();
-        this.protocol = protocol;
-        this.version = version;
-        this.auth = auth;
-        this.udpIdleTimeout = udpIdleTimeout;
-        this.masquerade = masquerade;
-    }
-
-    static fromJson(json: any = {}) {
-        return new HysteriaStreamSettings(
-            json.protocol,
-            json.version ?? 2,
-            json.auth ?? '',
-            json.udpIdleTimeout ?? 60,
-            json.masquerade ? HysteriaMasquerade.fromJson(json.masquerade) : undefined,
-        );
-    }
-
-    toJson() {
-        return {
-            protocol: this.protocol,
-            version: this.version,
-            auth: this.auth,
-            udpIdleTimeout: this.udpIdleTimeout,
-            masquerade: this.masqueradeSwitch ? this.masquerade.toJson() : undefined,
-        };
-    }
-
-    get masqueradeSwitch() {
-        return this.masquerade != undefined;
-    }
-
-    set masqueradeSwitch(value) {
-        this.masquerade = value ? new HysteriaMasquerade() : undefined;
-    }
-};
-
-export class HysteriaMasquerade extends XrayCommonClass {
-    constructor(
-        type = 'proxy',
-        dir = '',
-        url = '',
-        rewriteHost = false,
-        insecure = false,
-        content = '',
-        headers: any[] = [],
-        statusCode = 0,
-    ) {
-        super();
-        this.type = type;
-        this.dir = dir;
-        this.url = url;
-        this.rewriteHost = rewriteHost;
-        this.insecure = insecure;
-        this.content = content;
-        this.headers = headers;
-        this.statusCode = statusCode;
-    }
-
-    addHeader(name: any, value: any) {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number) {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy';
-        return new HysteriaMasquerade(
-            type,
-            json.dir,
-            json.url,
-            json.rewriteHost,
-            json.insecure,
-            json.content,
-            XrayCommonClass.toHeaders(json.headers),
-            json.statusCode,
-        );
-    }
-
-    toJson() {
-        return {
-            type: this.type,
-            dir: this.dir,
-            url: this.url,
-            rewriteHost: this.rewriteHost,
-            insecure: this.insecure,
-            content: this.content,
-            headers: XrayCommonClass.toV2Headers(this.headers, false),
-            statusCode: this.statusCode,
-        };
-    }
-};
-export class TlsStreamSettings extends XrayCommonClass {
-    static Cert: any;
-    static Settings: any;
-
-    constructor(
-        serverName: any = '',
-        minVersion = TLS_VERSION_OPTION.TLS12,
-        maxVersion = TLS_VERSION_OPTION.TLS13,
-        cipherSuites = '',
-        rejectUnknownSni = false,
-        disableSystemRoot = false,
-        enableSessionResumption = false,
-        certificates = [new TlsStreamSettings.Cert()],
-        alpn = [ALPN_OPTION.H2, ALPN_OPTION.HTTP1],
-        echServerKeys = '',
-        settings = new TlsStreamSettings.Settings()
-    ) {
-        super();
-        this.sni = serverName;
-        this.minVersion = minVersion;
-        this.maxVersion = maxVersion;
-        this.cipherSuites = cipherSuites;
-        this.rejectUnknownSni = rejectUnknownSni;
-        this.disableSystemRoot = disableSystemRoot;
-        this.enableSessionResumption = enableSessionResumption;
-        this.certs = certificates;
-        this.alpn = alpn;
-        this.echServerKeys = echServerKeys;
-        this.settings = settings;
-    }
-
-    addCert() {
-        this.certs.push(new TlsStreamSettings.Cert());
-    }
-
-    removeCert(index: number) {
-        this.certs.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        let certs;
-        let settings;
-        if (!ObjectUtil.isEmpty(json.certificates)) {
-            certs = json.certificates.map((cert: any) => TlsStreamSettings.Cert.fromJson(cert));
-        }
-
-        if (!ObjectUtil.isEmpty(json.settings)) {
-            settings = new TlsStreamSettings.Settings(json.settings.fingerprint, json.settings.echConfigList);
-        }
-        return new TlsStreamSettings(
-            json.serverName,
-            json.minVersion,
-            json.maxVersion,
-            json.cipherSuites,
-            json.rejectUnknownSni,
-            json.disableSystemRoot,
-            json.enableSessionResumption,
-            certs,
-            json.alpn,
-            json.echServerKeys,
-            settings,
-        );
-    }
-
-    toJson() {
-        return {
-            serverName: this.sni,
-            minVersion: this.minVersion,
-            maxVersion: this.maxVersion,
-            cipherSuites: this.cipherSuites,
-            rejectUnknownSni: this.rejectUnknownSni,
-            disableSystemRoot: this.disableSystemRoot,
-            enableSessionResumption: this.enableSessionResumption,
-            certificates: TlsStreamSettings.toJsonArray(this.certs),
-            alpn: this.alpn,
-            echServerKeys: this.echServerKeys,
-            settings: this.settings,
-        };
-    }
-}
-
-TlsStreamSettings.Cert = class extends XrayCommonClass {
-    constructor(
-        useFile = true,
-        certificateFile = '',
-        keyFile = '',
-        certificate = '',
-        key = '',
-        oneTimeLoading = false,
-        usage = USAGE_OPTION.ENCIPHERMENT,
-        buildChain = false,
-    ) {
-        super();
-        this.useFile = useFile;
-        this.certFile = certificateFile;
-        this.keyFile = keyFile;
-        this.cert = Array.isArray(certificate) ? certificate.join('\n') : certificate;
-        this.key = Array.isArray(key) ? key.join('\n') : key;
-        this.oneTimeLoading = oneTimeLoading;
-        this.usage = usage;
-        this.buildChain = buildChain
-    }
-
-    static fromJson(json: any = {}) {
-        if ('certificateFile' in json && 'keyFile' in json) {
-            return new TlsStreamSettings.Cert(
-                true,
-                json.certificateFile,
-                json.keyFile, '', '',
-                json.oneTimeLoading,
-                json.usage,
-                json.buildChain,
-            );
-        } else {
-            return new TlsStreamSettings.Cert(
-                false, '', '',
-                Array.isArray(json.certificate) ? json.certificate.join('\n') : (json.certificate ?? ''),
-                Array.isArray(json.key) ? json.key.join('\n') : (json.key ?? ''),
-                json.oneTimeLoading,
-                json.usage,
-                json.buildChain,
-            );
-        }
-    }
-
-    toJson() {
-        if (this.useFile) {
-            return {
-                certificateFile: this.certFile,
-                keyFile: this.keyFile,
-                oneTimeLoading: this.oneTimeLoading,
-                usage: this.usage,
-                buildChain: this.buildChain,
-            };
-        } else {
-            return {
-                certificate: this.cert.split('\n'),
-                key: this.key.split('\n'),
-                oneTimeLoading: this.oneTimeLoading,
-                usage: this.usage,
-                buildChain: this.buildChain,
-            };
-        }
-    }
-};
-
-TlsStreamSettings.Settings = class extends XrayCommonClass {
-    constructor(
-        fingerprint = UTLS_FINGERPRINT.UTLS_CHROME,
-        echConfigList = '',
-    ) {
-        super();
-        this.fingerprint = fingerprint;
-        this.echConfigList = echConfigList;
-    }
-    static fromJson(json: any = {}) {
-        return new TlsStreamSettings.Settings(
-            json.fingerprint,
-            json.echConfigList,
-        );
-    }
-    toJson() {
-        return {
-            fingerprint: this.fingerprint,
-            echConfigList: this.echConfigList
-        };
-    }
-};
-
-
-export class RealityStreamSettings extends XrayCommonClass {
-    static Settings: any;
-
-    constructor(
-        show: any = false,
-        xver = 0,
-        target = '',
-        serverNames = '',
-        privateKey = '',
-        minClientVer = '',
-        maxClientVer = '',
-        maxTimediff = 0,
-        shortIds = RandomUtil.randomShortIds(),
-        mldsa65Seed = '',
-        settings = new RealityStreamSettings.Settings()
-    ) {
-        super();
-        // If target/serverNames are not provided, use random values
-        if (!target && !serverNames) {
-            const randomTarget = getRandomRealityTarget();
-            target = randomTarget.target;
-            serverNames = randomTarget.sni;
-        }
-        this.show = show;
-        this.xver = xver;
-        this.target = target;
-        this.serverNames = Array.isArray(serverNames) ? serverNames.join(",") : serverNames;
-        this.privateKey = privateKey;
-        this.minClientVer = minClientVer;
-        this.maxClientVer = maxClientVer;
-        this.maxTimediff = maxTimediff;
-        this.shortIds = Array.isArray(shortIds) ? shortIds.join(",") : shortIds;
-        this.mldsa65Seed = mldsa65Seed;
-        this.settings = settings;
-    }
-
-    static fromJson(json: any = {}) {
-        let settings;
-        if (!ObjectUtil.isEmpty(json.settings)) {
-            settings = new RealityStreamSettings.Settings(
-                json.settings.publicKey,
-                json.settings.fingerprint,
-                json.settings.serverName,
-                json.settings.spiderX,
-                json.settings.mldsa65Verify,
-            );
-        }
-        return new RealityStreamSettings(
-            json.show,
-            json.xver,
-            json.target,
-            json.serverNames,
-            json.privateKey,
-            json.minClientVer,
-            json.maxClientVer,
-            json.maxTimediff,
-            json.shortIds,
-            json.mldsa65Seed,
-            settings,
-        );
-    }
-
-    toJson() {
-        return {
-            show: this.show,
-            xver: this.xver,
-            target: this.target,
-            serverNames: this.serverNames.split(","),
-            privateKey: this.privateKey,
-            minClientVer: this.minClientVer,
-            maxClientVer: this.maxClientVer,
-            maxTimediff: this.maxTimediff,
-            shortIds: this.shortIds.split(","),
-            mldsa65Seed: this.mldsa65Seed,
-            settings: this.settings,
-        };
-    }
-}
-
-RealityStreamSettings.Settings = class extends XrayCommonClass {
-    constructor(
-        publicKey = '',
-        fingerprint = UTLS_FINGERPRINT.UTLS_CHROME,
-        serverName = '',
-        spiderX = '/',
-        mldsa65Verify = ''
-    ) {
-        super();
-        this.publicKey = publicKey;
-        this.fingerprint = fingerprint;
-        this.serverName = serverName;
-        this.spiderX = spiderX;
-        this.mldsa65Verify = mldsa65Verify;
-    }
-    static fromJson(json: any = {}) {
-        return new RealityStreamSettings.Settings(
-            json.publicKey,
-            json.fingerprint,
-            json.serverName,
-            json.spiderX,
-            json.mldsa65Verify
-        );
-    }
-    toJson() {
-        return {
-            publicKey: this.publicKey,
-            fingerprint: this.fingerprint,
-            serverName: this.serverName,
-            spiderX: this.spiderX,
-            mldsa65Verify: this.mldsa65Verify
-        };
-    }
-};
-
-export class SockoptStreamSettings extends XrayCommonClass {
-    constructor(
-        acceptProxyProtocol: any = false,
-        tcpFastOpen = false,
-        mark = 0,
-        tproxy = "off",
-        tcpMptcp = false,
-        penetrate = false,
-        domainStrategy = DOMAIN_STRATEGY_OPTION.USE_IP,
-        tcpMaxSeg = 1440,
-        dialerProxy = "",
-        tcpKeepAliveInterval = 0,
-        tcpKeepAliveIdle = 300,
-        tcpUserTimeout = 10000,
-        tcpcongestion = TCP_CONGESTION_OPTION.BBR,
-        V6Only = false,
-        tcpWindowClamp = 600,
-        interfaceName = "",
-        trustedXForwardedFor = [],
-    ) {
-        super();
-        this.acceptProxyProtocol = acceptProxyProtocol;
-        this.tcpFastOpen = tcpFastOpen;
-        this.mark = mark;
-        this.tproxy = tproxy;
-        this.tcpMptcp = tcpMptcp;
-        this.penetrate = penetrate;
-        this.domainStrategy = domainStrategy;
-        this.tcpMaxSeg = tcpMaxSeg;
-        this.dialerProxy = dialerProxy;
-        this.tcpKeepAliveInterval = tcpKeepAliveInterval;
-        this.tcpKeepAliveIdle = tcpKeepAliveIdle;
-        this.tcpUserTimeout = tcpUserTimeout;
-        this.tcpcongestion = tcpcongestion;
-        this.V6Only = V6Only;
-        this.tcpWindowClamp = tcpWindowClamp;
-        this.interfaceName = interfaceName;
-        this.trustedXForwardedFor = trustedXForwardedFor;
-    }
-
-    static fromJson(json: any = {}) {
-        if (Object.keys(json).length === 0) return undefined;
-        return new SockoptStreamSettings(
-            json.acceptProxyProtocol,
-            json.tcpFastOpen,
-            json.mark,
-            json.tproxy,
-            json.tcpMptcp,
-            json.penetrate,
-            json.domainStrategy,
-            json.tcpMaxSeg,
-            json.dialerProxy,
-            json.tcpKeepAliveInterval,
-            json.tcpKeepAliveIdle,
-            json.tcpUserTimeout,
-            json.tcpcongestion,
-            json.V6Only,
-            json.tcpWindowClamp,
-            json.interface,
-            json.trustedXForwardedFor || [],
-        );
-    }
-
-    toJson() {
-        const result: any = {
-            acceptProxyProtocol: this.acceptProxyProtocol,
-            tcpFastOpen: this.tcpFastOpen,
-            mark: this.mark,
-            tproxy: this.tproxy,
-            tcpMptcp: this.tcpMptcp,
-            penetrate: this.penetrate,
-            domainStrategy: this.domainStrategy,
-            tcpMaxSeg: this.tcpMaxSeg,
-            dialerProxy: this.dialerProxy,
-            tcpKeepAliveInterval: this.tcpKeepAliveInterval,
-            tcpKeepAliveIdle: this.tcpKeepAliveIdle,
-            tcpUserTimeout: this.tcpUserTimeout,
-            tcpcongestion: this.tcpcongestion,
-            V6Only: this.V6Only,
-            tcpWindowClamp: this.tcpWindowClamp,
-            interface: this.interfaceName,
-        };
-        if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
-            result.trustedXForwardedFor = this.trustedXForwardedFor;
-        }
-        return result;
-    }
-}
-
-export class UdpMask extends XrayCommonClass {
-    constructor(type: any = 'salamander', settings: any = {}) {
-        super();
-        this.type = type;
-        this.settings = this._getDefaultSettings(type, settings);
-    }
-
-    _getDefaultSettings(type: any, settings: any = {}): any {
-        switch (type) {
-            case 'salamander':
-            case 'mkcp-aes128gcm':
-                return { password: settings.password || '' };
-            case 'header-dns':
-                return { domain: settings.domain || '' };
-            case 'xdns':
-                return { domains: Array.isArray(settings.domains) ? settings.domains : [] };
-            case 'xicmp':
-                return { ip: settings.ip || '', id: settings.id ?? 0 };
-            case 'mkcp-original':
-            case 'header-dtls':
-            case 'header-srtp':
-            case 'header-utp':
-            case 'header-wechat':
-            case 'header-wireguard':
-                return {};
-            case 'header-custom':
-                return {
-                    client: Array.isArray(settings.client) ? settings.client : [],
-                    server: Array.isArray(settings.server) ? settings.server : [],
-                };
-            case 'noise':
-                return {
-                    reset: settings.reset ?? 0,
-                    noise: Array.isArray(settings.noise) ? settings.noise : [],
-                };
-            default:
-                return settings;
-        }
-    }
-
-    static fromJson(json: any = {}) {
-        return new UdpMask(
-            json.type || 'salamander',
-            json.settings || {}
-        );
-    }
-
-    toJson() {
-        const cleanItem = (item: any) => {
-            const out = { ...item };
-            if (out.type === 'array') {
-                delete out.packet;
-            } else {
-                delete out.rand;
-                delete out.randRange;
-            }
-            return out;
-        };
-
-        let settings = this.settings;
-        if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
-            settings = { ...settings, noise: settings.noise.map(cleanItem) };
-        } else if (this.type === 'header-custom' && settings) {
-            settings = {
-                ...settings,
-                client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
-                server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
-            };
-        }
-
-        return {
-            type: this.type,
-            settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
-        };
-    }
-}
-
-export class TcpMask extends XrayCommonClass {
-    constructor(type: any = 'fragment', settings: any = {}) {
-        super();
-        this.type = type;
-        this.settings = this._getDefaultSettings(type, settings);
-    }
-
-    _getDefaultSettings(type: any, settings: any = {}): any {
-        switch (type) {
-            case 'fragment':
-                return {
-                    packets: settings.packets ?? 'tlshello',
-                    length: settings.length ?? '',
-                    delay: settings.delay ?? '',
-                    maxSplit: settings.maxSplit ?? '',
-                };
-            case 'sudoku':
-                return {
-                    password: settings.password ?? '',
-                    ascii: settings.ascii ?? '',
-                    customTable: settings.customTable ?? '',
-                    customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
-                    paddingMin: settings.paddingMin ?? 0,
-                    paddingMax: settings.paddingMax ?? 0,
-                };
-            case 'header-custom':
-                return {
-                    clients: Array.isArray(settings.clients) ? settings.clients : [],
-                    servers: Array.isArray(settings.servers) ? settings.servers : [],
-                };
-            default:
-                return settings;
-        }
-    }
-
-    static fromJson(json: any = {}) {
-        return new TcpMask(
-            json.type || 'fragment',
-            json.settings || {}
-        );
-    }
-
-    toJson() {
-        const cleanItem = (item: any) => {
-            const out = { ...item };
-            if (out.type === 'array') {
-                delete out.packet;
-            } else {
-                delete out.rand;
-                delete out.randRange;
-            }
-            return out;
-        };
-
-        let settings = this.settings;
-        if (this.type === 'header-custom' && settings) {
-            const cleanGroup = (group: any) => Array.isArray(group) ? group.map(cleanItem) : group;
-            settings = {
-                ...settings,
-                clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
-                servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
-            };
-        }
-
-        return {
-            type: this.type,
-            settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
-        };
-    }
-}
-
-export class QuicParams extends XrayCommonClass {
-    constructor(
-        congestion: any = 'bbr',
-        debug: any = false,
-        brutalUp: any = 65537,
-        brutalDown: any = 65537,
-        udpHop: any = undefined,
-        initStreamReceiveWindow: any = 8388608,
-        maxStreamReceiveWindow: any = 8388608,
-        initConnectionReceiveWindow: any = 20971520,
-        maxConnectionReceiveWindow: any = 20971520,
-        maxIdleTimeout: any = 30,
-        keepAlivePeriod: any = 5,
-        disablePathMTUDiscovery: any = false,
-        maxIncomingStreams = 1024,
-    ) {
-        super();
-        this.congestion = congestion;
-        this.debug = debug;
-        this.brutalUp = brutalUp;
-        this.brutalDown = brutalDown;
-        this.udpHop = udpHop;
-        this.initStreamReceiveWindow = initStreamReceiveWindow;
-        this.maxStreamReceiveWindow = maxStreamReceiveWindow;
-        this.initConnectionReceiveWindow = initConnectionReceiveWindow;
-        this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
-        this.maxIdleTimeout = maxIdleTimeout;
-        this.keepAlivePeriod = keepAlivePeriod;
-        this.disablePathMTUDiscovery = disablePathMTUDiscovery;
-        this.maxIncomingStreams = maxIncomingStreams;
-    }
-
-    get hasUdpHop() {
-        return this.udpHop != null;
-    }
-
-    set hasUdpHop(value) {
-        this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
-    }
-
-    static fromJson(json: any = {}) {
-        if (!json || Object.keys(json).length === 0) return undefined;
-        return new QuicParams(
-            json.congestion,
-            json.debug,
-            json.brutalUp,
-            json.brutalDown,
-            json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
-            json.initStreamReceiveWindow,
-            json.maxStreamReceiveWindow,
-            json.initConnectionReceiveWindow,
-            json.maxConnectionReceiveWindow,
-            json.maxIdleTimeout,
-            json.keepAlivePeriod,
-            json.disablePathMTUDiscovery,
-            json.maxIncomingStreams,
-        );
-    }
-
-    toJson() {
-        const result: any = { congestion: this.congestion };
-        if (this.debug) result.debug = this.debug;
-        if (['brutal', 'force-brutal'].includes(this.congestion)) {
-            if (this.brutalUp) result.brutalUp = this.brutalUp;
-            if (this.brutalDown) result.brutalDown = this.brutalDown;
-        }
-        if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
-        if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
-        if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
-        if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
-        if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
-        if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
-        if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
-        if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
-        if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
-        return result;
-    }
-}
-
-export class FinalMaskStreamSettings extends XrayCommonClass {
-    constructor(tcp: any[] = [], udp: any[] = [], quicParams: any = undefined) {
-        super();
-        this.tcp = Array.isArray(tcp) ? tcp.map((t: any) => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
-        this.udp = Array.isArray(udp) ? udp.map((u: any) => new UdpMask(u.type, u.settings)) : [new UdpMask((udp as any).type, (udp as any).settings)];
-        this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
-    }
-
-    get enableQuicParams() {
-        return this.quicParams != null;
-    }
-
-    set enableQuicParams(value) {
-        this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
-    }
-
-    static fromJson(json: any = {}) {
-        return new FinalMaskStreamSettings(
-            json.tcp || [],
-            json.udp || [],
-            json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
-        );
-    }
-
-    toJson() {
-        const result: any = {} as any;
-        if (this.tcp && this.tcp.length > 0) {
-            result.tcp = this.tcp.map((t: any) => t.toJson());
-        }
-        if (this.udp && this.udp.length > 0) {
-            result.udp = this.udp.map((udp: any) => udp.toJson());
-        }
-        if (this.quicParams) {
-            result.quicParams = this.quicParams.toJson();
-        }
-        return result;
-    }
-}
-
-export class StreamSettings extends XrayCommonClass {
-    constructor(network = 'tcp',
-        security = 'none',
-        externalProxy = [],
-        tlsSettings = new TlsStreamSettings(),
-        realitySettings = new RealityStreamSettings(),
-        tcpSettings = new TcpStreamSettings(),
-        kcpSettings = new KcpStreamSettings(),
-        wsSettings = new WsStreamSettings(),
-        grpcSettings = new GrpcStreamSettings(),
-        httpupgradeSettings = new HTTPUpgradeStreamSettings(),
-        xhttpSettings = new xHTTPStreamSettings(),
-        hysteriaSettings = new HysteriaStreamSettings(),
-        finalmask = new FinalMaskStreamSettings(),
-        sockopt: any = undefined,
-    ) {
-        super();
-        this.network = network;
-        this.security = security;
-        this.externalProxy = externalProxy;
-        this.tls = tlsSettings;
-        this.reality = realitySettings;
-        this.tcp = tcpSettings;
-        this.kcp = kcpSettings;
-        this.ws = wsSettings;
-        this.grpc = grpcSettings;
-        this.httpupgrade = httpupgradeSettings;
-        this.xhttp = xhttpSettings;
-        this.hysteria = hysteriaSettings;
-        this.finalmask = finalmask;
-        this.sockopt = sockopt;
-    }
-
-    addTcpMask(type = 'fragment') {
-        this.finalmask.tcp.push(new TcpMask(type));
-    }
-
-    delTcpMask(index: number) {
-        if (this.finalmask.tcp) {
-            this.finalmask.tcp.splice(index, 1);
-        }
-    }
-
-    addUdpMask(type = 'salamander') {
-        this.finalmask.udp.push(new UdpMask(type));
-    }
-
-    delUdpMask(index: number) {
-        if (this.finalmask.udp) {
-            this.finalmask.udp.splice(index, 1);
-        }
-    }
-
-    get hasFinalMask() {
-        const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
-        const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
-        const hasQuicParams = this.finalmask.quicParams != null;
-        return hasTcp || hasUdp || hasQuicParams;
-    }
-
-    get isTls() {
-        return this.security === "tls";
-    }
-
-    set isTls(isTls) {
-        if (isTls) {
-            this.security = 'tls';
-        } else {
-            this.security = 'none';
-        }
-    }
-
-    //for Reality
-    get isReality() {
-        return this.security === "reality";
-    }
-
-    set isReality(isReality) {
-        if (isReality) {
-            this.security = 'reality';
-        } else {
-            this.security = 'none';
-        }
-    }
-
-    get sockoptSwitch() {
-        return this.sockopt != undefined;
-    }
-
-    set sockoptSwitch(value) {
-        this.sockopt = value ? new SockoptStreamSettings() : undefined;
-    }
-
-    static fromJson(json: any = {}) {
-        return new StreamSettings(
-            json.network,
-            json.security,
-            json.externalProxy,
-            TlsStreamSettings.fromJson(json.tlsSettings),
-            RealityStreamSettings.fromJson(json.realitySettings),
-            TcpStreamSettings.fromJson(json.tcpSettings),
-            KcpStreamSettings.fromJson(json.kcpSettings),
-            WsStreamSettings.fromJson(json.wsSettings),
-            GrpcStreamSettings.fromJson(json.grpcSettings),
-            HTTPUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
-            xHTTPStreamSettings.fromJson(json.xhttpSettings),
-            HysteriaStreamSettings.fromJson(json.hysteriaSettings),
-            FinalMaskStreamSettings.fromJson(json.finalmask),
-            SockoptStreamSettings.fromJson(json.sockopt),
-        );
-    }
-
-    toJson() {
-        const network = this.network;
-        return {
-            network: network,
-            security: this.security,
-            externalProxy: Array.isArray(this.externalProxy) && this.externalProxy.length > 0
-                ? this.externalProxy
-                : undefined,
-            tlsSettings: this.isTls ? this.tls.toJson() : undefined,
-            realitySettings: this.isReality ? this.reality.toJson() : undefined,
-            tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
-            kcpSettings: network === 'kcp' ? this.kcp.toJson() : undefined,
-            wsSettings: network === 'ws' ? this.ws.toJson() : undefined,
-            grpcSettings: network === 'grpc' ? this.grpc.toJson() : undefined,
-            httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
-            xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
-            hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
-            finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
-            sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
-        };
-    }
-}
-
-export class Sniffing extends XrayCommonClass {
-    constructor(
-        enabled = false,
-        destOverride = ['http', 'tls', 'quic', 'fakedns'],
-        metadataOnly = false,
-        routeOnly = false,
-        ipsExcluded = [],
-        domainsExcluded = []) {
-        super();
-        this.enabled = enabled;
-        this.destOverride = Array.isArray(destOverride) && destOverride.length > 0 ? destOverride : ['http', 'tls', 'quic', 'fakedns'];
-        this.metadataOnly = metadataOnly;
-        this.routeOnly = routeOnly;
-        this.ipsExcluded = Array.isArray(ipsExcluded) ? ipsExcluded : [];
-        this.domainsExcluded = Array.isArray(domainsExcluded) ? domainsExcluded : [];
-    }
-
-    static fromJson(json: any = {}) {
-        let destOverride = ObjectUtil.clone(json.destOverride);
-        if (ObjectUtil.isEmpty(destOverride) || ObjectUtil.isArrEmpty(destOverride) || ObjectUtil.isEmpty(destOverride[0])) {
-            destOverride = ['http', 'tls', 'quic', 'fakedns'];
-        }
-        return new Sniffing(
-            !!json.enabled,
-            destOverride,
-            json.metadataOnly,
-            json.routeOnly,
-            json.ipsExcluded || [],
-            json.domainsExcluded || [],
-        );
-    }
-
-    toJson() {
-        if (!this.enabled) {
-            return { enabled: false };
-        }
-        return {
-            enabled: true,
-            destOverride: this.destOverride,
-            metadataOnly: this.metadataOnly || undefined,
-            routeOnly: this.routeOnly || undefined,
-            ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
-            domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
-        };
-    }
-}
-
-export class Inbound extends XrayCommonClass {
-    static Settings: any;
-    static ClientBase: any;
-    static VmessSettings: any;
-    static VLESSSettings: any;
-    static TrojanSettings: any;
-    static ShadowsocksSettings: any;
-    static HysteriaSettings: any;
-    static TunnelSettings: any;
-    static MixedSettings: any;
-    static HttpSettings: any;
-    static WireguardSettings: any;
-    static TunSettings: any;
-
-    constructor(
-        port: any = RandomUtil.randomInteger(10000, 60000),
-        listen = '',
-        protocol = Protocols.VLESS,
-        settings = null,
-        streamSettings = new StreamSettings(),
-        tag = '',
-        sniffing = new Sniffing(),
-        clientStats = '',
-    ) {
-        super();
-        this.port = port;
-        this.listen = listen;
-        this._protocol = protocol;
-        this.settings = ObjectUtil.isEmpty(settings) ? Inbound.Settings.getSettings(protocol) : settings;
-        this.stream = streamSettings;
-        this.tag = tag;
-        this.sniffing = sniffing;
-        this.clientStats = clientStats;
-    }
-    getClientStats() {
-        return this.clientStats;
-    }
-
-    // Looks for a "host"-named entry in xhttp.headers and returns its value,
-    // or '' if not found. Used as a fallback when xhttp.host is empty so the
-    // share URL still carries a usable Host hint.
-    static xhttpHostFallback(xhttp: any): string {
-        if (!xhttp || !Array.isArray(xhttp.headers)) return '';
-        for (const h of xhttp.headers) {
-            if (h && typeof h.name === 'string' && h.name.toLowerCase() === 'host') {
-                return h.value || '';
-            }
-        }
-        return '';
-    }
-
-    // Build the JSON blob that goes into the URL's `extra` param (or, for
-    // VMess, into the base64-encoded link object). Carries ONLY the
-    // bidirectional fields from xray-core's SplitHTTPConfig — i.e. the
-    // ones the server enforces and the client must match. Strictly
-    // one-sided fields are excluded:
-    //
-    //   - server-only (noSSEHeader, scMaxBufferedPosts,
-    //     scStreamUpServerSecs, serverMaxHeaderBytes) — client wouldn't
-    //     read them, so emitting them just bloats the URL.
-    //   - client-only values are included only when present on the inbound
-    //     object. Imported/API-created configs can carry them there, and
-    //     the share link is the only place clients can receive them.
-    //
-    // Truthy-only guards keep default inbounds emitting the same compact
-    // URL they did before this helper grew.
-    static buildXhttpExtra(xhttp: any): any {
-        if (!xhttp) return null;
-        const extra: any = {};
-
-        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
-            extra.xPaddingBytes = xhttp.xPaddingBytes;
-        }
-        if (xhttp.xPaddingObfsMode === true) {
-            extra.xPaddingObfsMode = true;
-            ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach((k: string) => {
-                if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) {
-                    extra[k] = xhttp[k];
-                }
-            });
-        }
-
-        const stringFields = [
-            "uplinkHTTPMethod",
-            "sessionPlacement", "sessionKey",
-            "seqPlacement", "seqKey",
-            "uplinkDataPlacement", "uplinkDataKey",
-            "scMaxEachPostBytes", "scMinPostsIntervalMs",
-        ];
-        for (const k of stringFields) {
-            const v = xhttp[k];
-            if (typeof v === 'string' && v.length > 0) extra[k] = v;
-        }
-
-        const uplinkChunkSize = xhttp.uplinkChunkSize;
-        if ((typeof uplinkChunkSize === 'number' && uplinkChunkSize !== 0) ||
-            (typeof uplinkChunkSize === 'string' && uplinkChunkSize.length > 0)) {
-            extra.uplinkChunkSize = uplinkChunkSize;
-        }
-
-        if (xhttp.noGRPCHeader === true) {
-            extra.noGRPCHeader = true;
-        }
-
-        for (const k of ["xmux", "downloadSettings"]) {
-            const v = xhttp[k];
-            if (v && typeof v === 'object' && Object.keys(v).length > 0) {
-                extra[k] = v;
-            }
-        }
-
-        // Headers — emitted as the {name: value} map upstream's struct
-        // expects. The server runtime ignores this field, but the client
-        // (consuming the share link) honors it.
-        if (Array.isArray(xhttp.headers) && xhttp.headers.length > 0) {
-            const headersMap: any = {};
-            for (const h of xhttp.headers) {
-                if (h && h.name && h.name.toLowerCase() !== 'host') {
-                    headersMap[h.name] = h.value || '';
-                }
-            }
-            if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
-        }
-
-        return Object.keys(extra).length > 0 ? extra : null;
-    }
-
-    // Inject the inbound-side xhttp config into URL query params for
-    // vless/trojan/ss links. Sets path/host/mode at top level (xray's
-    // Build() always lets these win over `extra`) and packs the
-    // bidirectional fields into a JSON `extra` param. Also writes the
-    // flat `x_padding_bytes` param sing-box-family clients understand.
-    //
-    // Without this, the admin's custom xPaddingBytes / sessionKey / etc.
-    // never reach the client and handshakes are silently rejected with
-    // `invalid padding (...) length: 0`.
-    static applyXhttpExtraToParams(xhttp: any, params: any): void {
-        if (!xhttp) return;
-        params.set("path", xhttp.path);
-        const host = xhttp.host?.length > 0 ? xhttp.host : Inbound.xhttpHostFallback(xhttp);
-        params.set("host", host);
-        params.set("mode", xhttp.mode);
-
-        // Flat fallback for sing-box-family clients that don't read `extra`.
-        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
-            params.set("x_padding_bytes", xhttp.xPaddingBytes);
-        }
-
-        const extra = Inbound.buildXhttpExtra(xhttp);
-        if (extra) params.set("extra", JSON.stringify(extra));
-    }
-
-    // VMess variant: VMess links are a base64-encoded JSON object, so we
-    // copy the same bidirectional fields directly into the JSON instead
-    // of building a query string. (The base VMess link generator already
-    // sets net/type/path/host, so we only contribute the SplitHTTPConfig
-    // extra side here.)
-    static applyXhttpExtraToObj(xhttp: any, obj: any): void {
-        if (!xhttp || !obj) return;
-        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
-            obj.x_padding_bytes = xhttp.xPaddingBytes;
-        }
-        const extra = Inbound.buildXhttpExtra(xhttp);
-        if (!extra) return;
-        for (const [k, v] of Object.entries(extra)) {
-            obj[k] = v;
-        }
-    }
-
-    static externalProxyAlpn(value: any): any {
-        if (Array.isArray(value)) return value.filter(Boolean).join(',');
-        return typeof value === 'string' ? value : '';
-    }
-
-    static applyExternalProxyTLSParams(externalProxy: any, params: any, security: any): void {
-        if (!externalProxy || security !== 'tls') return;
-        const sni = externalProxy.sni?.length > 0 ? externalProxy.sni : externalProxy.dest;
-        if (sni?.length > 0) params.set("sni", sni);
-        if (externalProxy.fingerprint?.length > 0) params.set("fp", externalProxy.fingerprint);
-        const alpn = Inbound.externalProxyAlpn(externalProxy.alpn);
-        if (alpn.length > 0) params.set("alpn", alpn);
-    }
-
-    static applyExternalProxyTLSObj(externalProxy: any, obj: any, security: any): void {
-        if (!externalProxy || !obj || security !== 'tls') return;
-        const sni = externalProxy.sni?.length > 0 ? externalProxy.sni : externalProxy.dest;
-        if (sni?.length > 0) obj.sni = sni;
-        if (externalProxy.fingerprint?.length > 0) obj.fp = externalProxy.fingerprint;
-        const alpn = Inbound.externalProxyAlpn(externalProxy.alpn);
-        if (alpn.length > 0) obj.alpn = alpn;
-    }
-
-    static hasShareableFinalMaskValue(value: any): boolean {
-        if (value == null) {
-            return false;
-        }
-        if (Array.isArray(value)) {
-            return value.some((item: any) => Inbound.hasShareableFinalMaskValue(item));
-        }
-        if (typeof value === 'object') {
-            return Object.values(value).some((item: any) => Inbound.hasShareableFinalMaskValue(item));
-        }
-        if (typeof value === 'string') {
-            return value.length > 0;
-        }
-        return true;
-    }
-
-    static serializeFinalMask(finalmask: any): any {
-        if (!finalmask) {
-            return '';
-        }
-        const value = typeof finalmask.toJson === 'function' ? finalmask.toJson() : finalmask;
-        return Inbound.hasShareableFinalMaskValue(value) ? JSON.stringify(value) : '';
-    }
-
-    // Export finalmask with the same compact JSON payload shape that
-    // v2rayN-compatible share links use: fm=<json>.
-    static applyFinalMaskToParams(finalmask: any, params: any): void {
-        if (!params) return;
-        const payload = Inbound.serializeFinalMask(finalmask);
-        if (payload.length > 0) {
-            params.set("fm", payload);
-        }
-    }
-
-    // VMess links are a base64 JSON object, so keep the same fm payload
-    // under a flat property instead of a URL query string.
-    static applyFinalMaskToObj(finalmask: any, obj: any): void {
-        if (!obj) return;
-        const payload = Inbound.serializeFinalMask(finalmask);
-        if (payload.length > 0) {
-            obj.fm = payload;
-        }
-    }
-
-    get clients() {
-        switch (this.protocol) {
-            case Protocols.VMESS: return this.settings.vmesses;
-            case Protocols.VLESS: return this.settings.vlesses;
-            case Protocols.TROJAN: return this.settings.trojans;
-            case Protocols.SHADOWSOCKS: return this.isSSMultiUser ? this.settings.shadowsockses : null;
-            case Protocols.HYSTERIA: return this.settings.hysterias;
-            default: return null;
-        }
-    }
-
-    get protocol() {
-        return this._protocol;
-    }
-
-    set protocol(protocol) {
-        this._protocol = protocol;
-        this.settings = Inbound.Settings.getSettings(protocol);
-        this.stream = new StreamSettings();
-        if (protocol === Protocols.TROJAN) {
-            this.tls = false;
-        }
-        if (protocol === Protocols.HYSTERIA) {
-            this.stream.network = 'hysteria';
-            this.stream.security = 'tls';
-            // Hysteria runs over QUIC and must not inherit TCP TLS ALPN defaults.
-            this.stream.tls.alpn = [ALPN_OPTION.H3];
-        }
-    }
-
-    get network() {
-        return this.stream.network;
-    }
-
-    set network(network) {
-        this.stream.network = network;
-    }
-
-    get isTcp() {
-        return this.network === "tcp";
-    }
-
-    get isWs() {
-        return this.network === "ws";
-    }
-
-    get isKcp() {
-        return this.network === "kcp";
-    }
-
-    get isGrpc() {
-        return this.network === "grpc";
-    }
-
-    get isHttpupgrade() {
-        return this.network === "httpupgrade";
-    }
-
-    get isXHTTP() {
-        return this.network === "xhttp";
-    }
-
-    // Shadowsocks
-    get method() {
-        switch (this.protocol) {
-            case Protocols.SHADOWSOCKS:
-                return this.settings.method;
-            default:
-                return "";
-        }
-    }
-    get isSSMultiUser() {
-        return this.method != SSMethods.BLAKE3_CHACHA20_POLY1305;
-    }
-    get isSS2022() {
-        return this.method.substring(0, 4) === "2022";
-    }
-
-    get serverName() {
-        if (this.stream.isTls) return this.stream.tls.sni;
-        if (this.stream.isReality) return this.stream.reality.serverNames;
-        return "";
-    }
-
-    getHeader(obj: any, name: any) {
-        for (const header of obj.headers) {
-            if (header.name.toLowerCase() === name.toLowerCase()) {
-                return header.value;
-            }
-        }
-        return "";
-    }
-
-    get host() {
-        if (this.isTcp) {
-            return this.getHeader(this.stream.tcp.request, 'host');
-        } else if (this.isWs) {
-            return this.stream.ws.host?.length > 0 ? this.stream.ws.host : this.getHeader(this.stream.ws, 'host');
-        } else if (this.isHttpupgrade) {
-            return this.stream.httpupgrade.host?.length > 0 ? this.stream.httpupgrade.host : this.getHeader(this.stream.httpupgrade, 'host');
-        } else if (this.isXHTTP) {
-            return this.stream.xhttp.host?.length > 0 ? this.stream.xhttp.host : this.getHeader(this.stream.xhttp, 'host');
-        }
-        return null;
-    }
-
-    get path() {
-        if (this.isTcp) {
-            return this.stream.tcp.request.path[0];
-        } else if (this.isWs) {
-            return this.stream.ws.path;
-        } else if (this.isHttpupgrade) {
-            return this.stream.httpupgrade.path;
-        } else if (this.isXHTTP) {
-            return this.stream.xhttp.path;
-        }
-        return null;
-    }
-
-    get serviceName() {
-        return this.stream.grpc.serviceName;
-    }
-
-    isExpiry(index: number) {
-        const exp = this.clients[index].expiryTime;
-        return exp > 0 ? exp < new Date().getTime() : false;
-    }
-
-    canEnableTls() {
-        if (this.protocol === Protocols.HYSTERIA) return true;
-        if (![Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(this.protocol)) return false;
-        return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.network);
-    }
-
-    //this is used for xtls-rprx-vision
-    canEnableTlsFlow() {
-        if (((this.stream.security === 'tls') || (this.stream.security === 'reality')) && (this.network === "tcp")) {
-            return this.protocol === Protocols.VLESS;
-        }
-        return false;
-    }
-
-    // Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
-    // Excludes the UDP variant per spec.
-    canEnableVisionSeed() {
-        if (!this.canEnableTlsFlow()) return false;
-        const clients = this.settings?.vlesses;
-        if (!Array.isArray(clients)) return false;
-        return clients.some((c: any) => c?.flow === TLS_FLOW_CONTROL.VISION);
-    }
-
-    canEnableReality() {
-        if (![Protocols.VLESS, Protocols.TROJAN].includes(this.protocol)) return false;
-        return ["tcp", "http", "grpc", "xhttp"].includes(this.network);
-    }
-
-    canEnableStream() {
-        return [Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA].includes(this.protocol);
-    }
-
-    reset() {
-        this.port = RandomUtil.randomInteger(10000, 60000);
-        this.listen = '';
-        this.protocol = Protocols.VMESS;
-        this.settings = Inbound.Settings.getSettings(Protocols.VMESS);
-        this.stream = new StreamSettings();
-        this.tag = '';
-        this.sniffing = new Sniffing();
-    }
-
-    genVmessLink(address: any = '', port: any = this.port, forceTls?: any, remark: any = '', clientId?: any, security?: any, externalProxy: any = null) {
-        if (this.protocol !== Protocols.VMESS) {
-            return '';
-        }
-        const tls = forceTls == 'same' ? this.stream.security : forceTls;
-        const obj: any = {
-            v: '2',
-            ps: remark,
-            add: address,
-            port: port,
-            id: clientId,
-            scy: security,
-            net: this.stream.network,
-            tls: tls,
-        };
-        const network = this.stream.network;
-        if (network === 'tcp') {
-            const tcp = this.stream.tcp;
-            obj.type = tcp.type;
-            if (tcp.type === 'http') {
-                const request = tcp.request;
-                obj.path = request.path.join(',');
-                const host = this.getHeader(request, 'host');
-                if (host) obj.host = host;
-            }
-        } else if (network === 'kcp') {
-            const kcp = this.stream.kcp;
-            obj.mtu = kcp.mtu;
-            obj.tti = kcp.tti;
-        } else if (network === 'ws') {
-            const ws = this.stream.ws;
-            obj.path = ws.path;
-            obj.host = ws.host?.length > 0 ? ws.host : this.getHeader(ws, 'host');
-        } else if (network === 'grpc') {
-            obj.path = this.stream.grpc.serviceName;
-            obj.authority = this.stream.grpc.authority;
-            if (this.stream.grpc.multiMode) {
-                obj.type = 'multi'
-            }
-        } else if (network === 'httpupgrade') {
-            const httpupgrade = this.stream.httpupgrade;
-            obj.path = httpupgrade.path;
-            obj.host = httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host');
-        } else if (network === 'xhttp') {
-            const xhttp = this.stream.xhttp;
-            obj.path = xhttp.path;
-            obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
-            obj.type = xhttp.mode;
-            Inbound.applyXhttpExtraToObj(xhttp, obj);
-        }
-
-        Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
-
-        if (tls === 'tls') {
-            if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
-                obj.sni = this.stream.tls.sni;
-            }
-            if (!ObjectUtil.isEmpty(this.stream.tls.settings.fingerprint)) {
-                obj.fp = this.stream.tls.settings.fingerprint;
-            }
-            if (this.stream.tls.alpn.length > 0) {
-                obj.alpn = this.stream.tls.alpn.join(',');
-            }
-        }
-        Inbound.applyExternalProxyTLSObj(externalProxy, obj, tls);
-
-        return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
-    }
-
-    genVLESSLink(address: any = '', port: any = this.port, forceTls?: any, remark: any = '', clientId?: any, flow?: any, externalProxy: any = null) {
-        const uuid = clientId;
-        const type = this.stream.network;
-        const security = forceTls == 'same' ? this.stream.security : forceTls;
-        const params = new Map();
-        params.set("type", this.stream.network);
-        params.set("encryption", this.settings.encryption);
-        switch (type) {
-            case "tcp": {
-                const tcp = this.stream.tcp;
-                if (tcp.type === 'http') {
-                    const request = tcp.request;
-                    params.set("path", request.path.join(','));
-                    const index = request.headers.findIndex((header: any) => header.name.toLowerCase() === 'host');
-                    if (index >= 0) {
-                        const host = request.headers[index].value;
-                        params.set("host", host);
-                    }
-                    params.set("headerType", 'http');
-                }
-                break;
-            }
-            case "kcp": {
-                const kcp = this.stream.kcp;
-                params.set("mtu", kcp.mtu);
-                params.set("tti", kcp.tti);
-                break;
-            }
-            case "ws": {
-                const ws = this.stream.ws;
-                params.set("path", ws.path);
-                params.set("host", ws.host?.length > 0 ? ws.host : this.getHeader(ws, 'host'));
-                break;
-            }
-            case "grpc": {
-                const grpc = this.stream.grpc;
-                params.set("serviceName", grpc.serviceName);
-                params.set("authority", grpc.authority);
-                if (grpc.multiMode) {
-                    params.set("mode", "multi");
-                }
-                break;
-            }
-            case "httpupgrade": {
-                const httpupgrade = this.stream.httpupgrade;
-                params.set("path", httpupgrade.path);
-                params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
-                break;
-            }
-            case "xhttp":
-                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
-                break;
-        }
-
-        Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
-
-        if (security === 'tls') {
-            params.set("security", "tls");
-            if (this.stream.isTls) {
-                params.set("fp", this.stream.tls.settings.fingerprint);
-                params.set("alpn", this.stream.tls.alpn);
-                if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
-                    params.set("sni", this.stream.tls.sni);
-                }
-                if (this.stream.tls.settings.echConfigList?.length > 0) {
-                    params.set("ech", this.stream.tls.settings.echConfigList);
-                }
-                if (type == "tcp" && !ObjectUtil.isEmpty(flow)) {
-                    params.set("flow", flow);
-                }
-            }
-            Inbound.applyExternalProxyTLSParams(externalProxy, params, security);
-        }
-
-        else if (security === 'reality') {
-            params.set("security", "reality");
-            params.set("pbk", this.stream.reality.settings.publicKey);
-            params.set("fp", this.stream.reality.settings.fingerprint);
-            if (!ObjectUtil.isArrEmpty(this.stream.reality.serverNames)) {
-                params.set("sni", this.stream.reality.serverNames.split(",")[0]);
-            }
-            if (this.stream.reality.shortIds.length > 0) {
-                params.set("sid", this.stream.reality.shortIds.split(",")[0]);
-            }
-            if (!ObjectUtil.isEmpty(this.stream.reality.settings.spiderX)) {
-                params.set("spx", this.stream.reality.settings.spiderX);
-            }
-            if (!ObjectUtil.isEmpty(this.stream.reality.settings.mldsa65Verify)) {
-                params.set("pqv", this.stream.reality.settings.mldsa65Verify);
-            }
-            if (type == 'tcp' && !ObjectUtil.isEmpty(flow)) {
-                params.set("flow", flow);
-            }
-        }
-
-        else {
-            params.set("security", "none");
-        }
-
-        const link = `vless://${uuid}@${address}:${port}`;
-        const url = new URL(link);
-        for (const [key, value] of params) {
-            url.searchParams.set(key, value)
-        }
-        url.hash = encodeURIComponent(remark);
-        return url.toString();
-    }
-
-    genSSLink(address: any = '', port: any = this.port, forceTls?: any, remark: any = '', clientPassword?: any, externalProxy: any = null) {
-        const settings = this.settings;
-        const type = this.stream.network;
-        const security = forceTls == 'same' ? this.stream.security : forceTls;
-        const params = new Map();
-        params.set("type", this.stream.network);
-        switch (type) {
-            case "tcp": {
-                const tcp = this.stream.tcp;
-                if (tcp.type === 'http') {
-                    const request = tcp.request;
-                    params.set("path", request.path.join(','));
-                    const index = request.headers.findIndex((header: any) => header.name.toLowerCase() === 'host');
-                    if (index >= 0) {
-                        const host = request.headers[index].value;
-                        params.set("host", host);
-                    }
-                    params.set("headerType", 'http');
-                }
-                break;
-            }
-            case "kcp": {
-                const kcp = this.stream.kcp;
-                params.set("mtu", kcp.mtu);
-                params.set("tti", kcp.tti);
-                break;
-            }
-            case "ws": {
-                const ws = this.stream.ws;
-                params.set("path", ws.path);
-                params.set("host", ws.host?.length > 0 ? ws.host : this.getHeader(ws, 'host'));
-                break;
-            }
-            case "grpc": {
-                const grpc = this.stream.grpc;
-                params.set("serviceName", grpc.serviceName);
-                params.set("authority", grpc.authority);
-                if (grpc.multiMode) {
-                    params.set("mode", "multi");
-                }
-                break;
-            }
-            case "httpupgrade": {
-                const httpupgrade = this.stream.httpupgrade;
-                params.set("path", httpupgrade.path);
-                params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
-                break;
-            }
-            case "xhttp":
-                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
-                break;
-        }
-
-        Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
-
-        if (security === 'tls') {
-            params.set("security", "tls");
-            if (this.stream.isTls) {
-                params.set("fp", this.stream.tls.settings.fingerprint);
-                params.set("alpn", this.stream.tls.alpn);
-                if (this.stream.tls.settings.echConfigList?.length > 0) {
-                    params.set("ech", this.stream.tls.settings.echConfigList);
-                }
-                if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
-                    params.set("sni", this.stream.tls.sni);
-                }
-            }
-            Inbound.applyExternalProxyTLSParams(externalProxy, params, security);
-        }
-
-
-        const password: string[] = [];
-        if (this.isSS2022) password.push(settings.password);
-        if (this.isSSMultiUser) password.push(clientPassword);
-
-        const link = `ss://${Base64.encode(`${settings.method}:${password.join(':')}`, true)}@${address}:${port}`;
-        const url = new URL(link);
-        for (const [key, value] of params) {
-            url.searchParams.set(key, value)
-        }
-        url.hash = encodeURIComponent(remark);
-        return url.toString();
-    }
-
-    genTrojanLink(address: any = '', port: any = this.port, forceTls?: any, remark: any = '', clientPassword?: any, externalProxy: any = null) {
-        const security = forceTls == 'same' ? this.stream.security : forceTls;
-        const type = this.stream.network;
-        const params = new Map();
-        params.set("type", this.stream.network);
-        switch (type) {
-            case "tcp": {
-                const tcp = this.stream.tcp;
-                if (tcp.type === 'http') {
-                    const request = tcp.request;
-                    params.set("path", request.path.join(','));
-                    const index = request.headers.findIndex((header: any) => header.name.toLowerCase() === 'host');
-                    if (index >= 0) {
-                        const host = request.headers[index].value;
-                        params.set("host", host);
-                    }
-                    params.set("headerType", 'http');
-                }
-                break;
-            }
-            case "kcp": {
-                const kcp = this.stream.kcp;
-                params.set("mtu", kcp.mtu);
-                params.set("tti", kcp.tti);
-                break;
-            }
-            case "ws": {
-                const ws = this.stream.ws;
-                params.set("path", ws.path);
-                params.set("host", ws.host?.length > 0 ? ws.host : this.getHeader(ws, 'host'));
-                break;
-            }
-            case "grpc": {
-                const grpc = this.stream.grpc;
-                params.set("serviceName", grpc.serviceName);
-                params.set("authority", grpc.authority);
-                if (grpc.multiMode) {
-                    params.set("mode", "multi");
-                }
-                break;
-            }
-            case "httpupgrade": {
-                const httpupgrade = this.stream.httpupgrade;
-                params.set("path", httpupgrade.path);
-                params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
-                break;
-            }
-            case "xhttp":
-                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
-                break;
-        }
-
-        Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
-
-        if (security === 'tls') {
-            params.set("security", "tls");
-            if (this.stream.isTls) {
-                params.set("fp", this.stream.tls.settings.fingerprint);
-                params.set("alpn", this.stream.tls.alpn);
-                if (this.stream.tls.settings.echConfigList?.length > 0) {
-                    params.set("ech", this.stream.tls.settings.echConfigList);
-                }
-                if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
-                    params.set("sni", this.stream.tls.sni);
-                }
-            }
-            Inbound.applyExternalProxyTLSParams(externalProxy, params, security);
-        }
-
-        else if (security === 'reality') {
-            params.set("security", "reality");
-            params.set("pbk", this.stream.reality.settings.publicKey);
-            params.set("fp", this.stream.reality.settings.fingerprint);
-            if (!ObjectUtil.isArrEmpty(this.stream.reality.serverNames)) {
-                params.set("sni", this.stream.reality.serverNames.split(",")[0]);
-            }
-            if (this.stream.reality.shortIds.length > 0) {
-                params.set("sid", this.stream.reality.shortIds.split(",")[0]);
-            }
-            if (!ObjectUtil.isEmpty(this.stream.reality.settings.spiderX)) {
-                params.set("spx", this.stream.reality.settings.spiderX);
-            }
-            if (!ObjectUtil.isEmpty(this.stream.reality.settings.mldsa65Verify)) {
-                params.set("pqv", this.stream.reality.settings.mldsa65Verify);
-            }
-        }
-
-        else {
-            params.set("security", "none");
-        }
-
-        const link = `trojan://${clientPassword}@${address}:${port}`;
-        const url = new URL(link);
-        for (const [key, value] of params) {
-            url.searchParams.set(key, value)
-        }
-        url.hash = encodeURIComponent(remark);
-        return url.toString();
-    }
-
-    genHysteriaLink(address: any = '', port: any = this.port, remark: any = '', clientAuth?: any) {
-        const protocol = this.settings.version == 2 ? "hysteria2" : "hysteria";
-        const link = `${protocol}://${clientAuth}@${address}:${port}`;
-
-        const params = new Map();
-        params.set("security", "tls");
-        if (this.stream.tls.settings.fingerprint?.length > 0) params.set("fp", this.stream.tls.settings.fingerprint);
-        if (this.stream.tls.alpn?.length > 0) params.set("alpn", this.stream.tls.alpn);
-        if (this.stream.tls.settings.allowInsecure) params.set("insecure", "1");
-        if (this.stream.tls.settings.echConfigList?.length > 0) params.set("ech", this.stream.tls.settings.echConfigList);
-        if (this.stream.tls.sni?.length > 0) params.set("sni", this.stream.tls.sni);
-
-        const udpMasks = this.stream?.finalmask?.udp;
-        if (Array.isArray(udpMasks)) {
-            const salamanderMask = udpMasks.find((mask: any) => mask?.type === 'salamander');
-            const obfsPassword = salamanderMask?.settings?.password;
-            if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
-                params.set("obfs", "salamander");
-                params.set("obfs-password", obfsPassword);
-            }
-        }
-
-        Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
-
-        const url = new URL(link);
-        for (const [key, value] of params) {
-            url.searchParams.set(key, value);
-        }
-        url.hash = encodeURIComponent(remark);
-        return url.toString();
-    }
-
-    getWireguardTxt(address: any, port: any, remark: any, peerId: any) {
-        let txt = `[Interface]\n`
-        txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n`
-        txt += `Address = ${this.settings.peers[peerId].allowedIPs[0]}\n`
-        txt += `DNS = 1.1.1.1, 1.0.0.1\n`
-        if (this.settings.mtu) {
-            txt += `MTU = ${this.settings.mtu}\n`
-        }
-        txt += `\n# ${remark}\n`
-        txt += `[Peer]\n`
-        txt += `PublicKey = ${this.settings.pubKey}\n`
-        txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`
-        txt += `Endpoint = ${address}:${port}`
-        if (this.settings.peers[peerId].psk) {
-            txt += `\nPresharedKey = ${this.settings.peers[peerId].psk}`
-        }
-        if (this.settings.peers[peerId].keepAlive) {
-            txt += `\nPersistentKeepalive = ${this.settings.peers[peerId].keepAlive}\n`
-        }
-        return txt;
-    }
-
-    getWireguardLink(address: any, port: any, remark: any, peerId: any) {
-        const peer = this.settings?.peers?.[peerId];
-        if (!peer) return '';
-
-        const link = `wireguard://${address}:${port}`;
-        const url = new URL(link);
-        url.username = peer.privateKey || '';
-
-        if (this.settings?.pubKey) {
-            url.searchParams.set("publickey", this.settings.pubKey);
-        }
-        if (Array.isArray(peer.allowedIPs) && peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
-            url.searchParams.set("address", peer.allowedIPs[0]);
-        }
-        if (this.settings?.mtu) {
-            url.searchParams.set("mtu", this.settings.mtu);
-        }
-
-        url.hash = encodeURIComponent(remark);
-        return url.toString();
-    }
-
-    // resolveAddr picks the host that goes into share/sub links. Order:
-    //   1. hostOverride (caller supplies node address for node-managed inbounds)
-    //   2. inbound's bind listen (when explicit, not 0.0.0.0)
-    //   3. browser's location.hostname (single-panel default)
-    // Centralised so genAllLinks/genInboundLinks/genWireguard*
-    // all share the same chain — pre-Phase 3 we had four duplicated lines.
-    _resolveAddr(hostOverride = '') {
-        if (hostOverride) return hostOverride;
-        if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") return this.listen;
-        return location.hostname;
-    }
-
-    genWireguardLinks(remark = '', remarkModel = '-ieo', hostOverride = '') {
-        const addr = this._resolveAddr(hostOverride);
-        const separationChar = remarkModel.charAt(0);
-        const links: any[] = [];
-        this.settings.peers.forEach((_p: any, index: number) => {
-            links.push(this.getWireguardLink(addr, this.port, remark + separationChar + (index + 1), index));
-        });
-        return links.join('\r\n');
-    }
-
-    genWireguardConfigs(remark = '', remarkModel = '-ieo', hostOverride = '') {
-        const addr = this._resolveAddr(hostOverride);
-        const separationChar = remarkModel.charAt(0);
-        const links: any[] = [];
-        this.settings.peers.forEach((_p: any, index: number) => {
-            links.push(this.getWireguardTxt(addr, this.port, remark + separationChar + (index + 1), index));
-        });
-        return links.join('\r\n');
-    }
-
-    genLink(address: any = '', port: any = this.port, forceTls: any = 'same', remark: any = '', client?: any, externalProxy: any = null) {
-        switch (this.protocol) {
-            case Protocols.VMESS:
-                return this.genVmessLink(address, port, forceTls, remark, client.id, client.security, externalProxy);
-            case Protocols.VLESS:
-                return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow, externalProxy);
-            case Protocols.SHADOWSOCKS:
-                return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : '', externalProxy);
-            case Protocols.TROJAN:
-                return this.genTrojanLink(address, port, forceTls, remark, client.password, externalProxy);
-            case Protocols.HYSTERIA:
-                return this.genHysteriaLink(address, port, remark, client.auth.length > 0 ? client.auth : this.stream.hysteria.auth);
-            default: return '';
-        }
-    }
-
-    genAllLinks(remark: any = '', remarkModel: any = '-ieo', client?: any, hostOverride: any = '') {
-        const result: any[] = [];
-        const email = client ? client.email : '';
-        const addr = this._resolveAddr(hostOverride);
-        const port = this.port;
-        const separationChar = remarkModel.charAt(0);
-        const orderChars = remarkModel.slice(1);
-        const orders: any = {
-            'i': remark,
-            'e': email,
-            'o': '',
-        };
-        if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) {
-            const r = orderChars.split('').map((char: string) => orders[char]).filter((x: any) => x.length > 0).join(separationChar);
-            result.push({
-                remark: r,
-                link: this.genLink(addr, port, 'same', r, client)
-            });
-        } else {
-            this.stream.externalProxy.forEach((ep: any) => {
-                orders['o'] = ep.remark;
-                const r = orderChars.split('').map((char: string) => orders[char]).filter((x: any) => x.length > 0).join(separationChar);
-                result.push({
-                    remark: r,
-                    link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client, ep)
-                });
-            });
-        }
-        return result;
-    }
-
-    genInboundLinks(remark = '', remarkModel = '-ieo', hostOverride = '') {
-        const addr = this._resolveAddr(hostOverride);
-        if (this.clients) {
-            const links: any[] = [];
-            this.clients.forEach((client: any) => {
-                this.genAllLinks(remark, remarkModel, client, hostOverride).forEach((l: any) => {
-                    links.push(l.link);
-                })
-            });
-            return links.join('\r\n');
-        } else {
-            if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
-            if (this.protocol == Protocols.WIREGUARD) {
-                return this.genWireguardConfigs(remark, remarkModel, hostOverride);
-            }
-            return '';
-        }
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound(
-            json.port,
-            json.listen,
-            json.protocol,
-            Inbound.Settings.fromJson(json.protocol, json.settings),
-            StreamSettings.fromJson(json.streamSettings),
-            json.tag,
-            Sniffing.fromJson(json.sniffing),
-            json.clientStats
-        )
-    }
-
-    toJson() {
-        // Only these protocols use streamSettings
-        const streamProtocols = [Protocols.VLESS, Protocols.VMESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA];
-
-        const result: any = {
-            port: this.port,
-            listen: this.listen,
-            protocol: this.protocol,
-            settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings,
-            tag: this.tag,
-            sniffing: this.sniffing.toJson(),
-            clientStats: this.clientStats
-        };
-
-        // Only add streamSettings if protocol supports it
-        if (streamProtocols.includes(this.protocol)) {
-            result.streamSettings = this.stream.toJson();
-        }
-
-        return result;
-    }
-}
-
-Inbound.Settings = class extends XrayCommonClass {
-    constructor(protocol: any) {
-        super();
-        this.protocol = protocol;
-    }
-
-    static getSettings(protocol: any): any {
-        switch (protocol) {
-            case Protocols.VMESS: return new Inbound.VmessSettings(protocol);
-            case Protocols.VLESS: return new Inbound.VLESSSettings(protocol);
-            case Protocols.TROJAN: return new Inbound.TrojanSettings(protocol);
-            case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings(protocol);
-            case Protocols.TUNNEL: return new Inbound.TunnelSettings(protocol);
-            case Protocols.MIXED: return new Inbound.MixedSettings(protocol);
-            case Protocols.HTTP: return new Inbound.HttpSettings(protocol);
-            case Protocols.WIREGUARD: return new Inbound.WireguardSettings(protocol);
-            case Protocols.TUN: return new Inbound.TunSettings(protocol);
-            case Protocols.HYSTERIA: return new Inbound.HysteriaSettings(protocol);
-            default: return null;
-        }
-    }
-
-    static fromJson(protocol: any, json: any): any {
-        switch (protocol) {
-            case Protocols.VMESS: return Inbound.VmessSettings.fromJson(json);
-            case Protocols.VLESS: return Inbound.VLESSSettings.fromJson(json);
-            case Protocols.TROJAN: return Inbound.TrojanSettings.fromJson(json);
-            case Protocols.SHADOWSOCKS: return Inbound.ShadowsocksSettings.fromJson(json);
-            case Protocols.TUNNEL: return Inbound.TunnelSettings.fromJson(json);
-            case Protocols.MIXED: return Inbound.MixedSettings.fromJson(json);
-            case Protocols.HTTP: return Inbound.HttpSettings.fromJson(json);
-            case Protocols.WIREGUARD: return Inbound.WireguardSettings.fromJson(json);
-            case Protocols.TUN: return Inbound.TunSettings.fromJson(json);
-            case Protocols.HYSTERIA: return Inbound.HysteriaSettings.fromJson(json);
-            default: return null;
-        }
-    }
-
-    toJson() {
-        return {};
-    }
-};
-
-/** Shared user-quota fields and UI helpers for multi-user protocol clients. */
-Inbound.ClientBase = class extends XrayCommonClass {
-    constructor(
-        email: any = RandomUtil.randomLowerAndNum(8),
-        limitIp: any = 0,
-        totalGB: any = 0,
-        expiryTime: any = 0,
-        enable: any = true,
-        tgId: any = '',
-        subId: any = RandomUtil.randomLowerAndNum(16),
-        comment: any = '',
-        reset: any = 0,
-        created_at: any = undefined,
-        updated_at: any = undefined,
-    ) {
-        super();
-        this.email = email;
-        this.limitIp = limitIp;
-        this.totalGB = totalGB;
-        this.expiryTime = expiryTime;
-        this.enable = enable;
-        this.tgId = tgId;
-        this.subId = subId;
-        this.comment = comment;
-        this.reset = reset;
-        this.created_at = created_at;
-        this.updated_at = updated_at;
-    }
-
-    static commonArgsFromJson(json: any = {}) {
-        return [
-            json.email,
-            json.limitIp,
-            json.totalGB,
-            json.expiryTime,
-            json.enable,
-            json.tgId,
-            json.subId,
-            json.comment,
-            json.reset,
-            json.created_at,
-            json.updated_at,
-        ];
-    }
-
-    _clientBaseToJson() {
-        return {
-            email: this.email,
-            limitIp: this.limitIp,
-            totalGB: this.totalGB,
-            expiryTime: this.expiryTime,
-            enable: this.enable,
-            tgId: this.tgId,
-            subId: this.subId,
-            comment: this.comment,
-            reset: this.reset,
-            created_at: this.created_at,
-            updated_at: this.updated_at,
-        };
-    }
-
-    get _expiryTime() {
-        if (this.expiryTime === 0 || this.expiryTime === '') {
-            return null;
-        }
-        if (this.expiryTime < 0) {
-            return this.expiryTime / -86400000;
-        }
-        return dayjs(this.expiryTime);
-    }
-
-    set _expiryTime(t: any) {
-        if (t == null || t === '') {
-            this.expiryTime = 0;
-        } else {
-            this.expiryTime = t.valueOf();
-        }
-    }
-
-    get _totalGB() {
-        return NumberFormatter.toFixed(this.totalGB / SizeFormatter.ONE_GB, 2);
-    }
-
-    set _totalGB(gb) {
-        this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
-    }
-};
-
-Inbound.VmessSettings = class extends Inbound.Settings {
-    constructor(protocol: any,
-        vmesses: any[] = []) {
-        super(protocol);
-        this.vmesses = vmesses;
-    }
-
-    indexOfVmessById(id: any) {
-        return this.vmesses.findIndex((VMESS: any) => VMESS.id === id);
-    }
-
-    addVmess(VMESS: any) {
-        if (this.indexOfVmessById(VMESS.id) >= 0) {
-            return false;
-        }
-        this.vmesses.push(VMESS);
-    }
-
-    delVmess(VMESS: any) {
-        const i = this.indexOfVmessById(VMESS.id);
-        if (i >= 0) {
-            this.vmesses.splice(i, 1);
-        }
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.VmessSettings(
-            Protocols.VMESS,
-            (json.clients || []).map((client: any) => Inbound.VmessSettings.VMESS.fromJson(client)),
-        );
-    }
-
-    toJson() {
-        return {
-            clients: Inbound.VmessSettings.toJsonArray(this.vmesses),
-        };
-    }
-};
-
-Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
-    constructor(
-        id: any = RandomUtil.randomUUID(),
-        security: any = USERS_SECURITY.AUTO,
-        email?: any, limitIp?: any, totalGB?: any, expiryTime?: any, enable?: any, tgId?: any, subId?: any, comment?: any, reset?: any, created_at?: any, updated_at?: any,
-    ) {
-        super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
-        this.id = id;
-        this.security = security;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.VmessSettings.VMESS(
-            json.id,
-            json.security,
-            ...Inbound.ClientBase.commonArgsFromJson(json),
-        );
-    }
-
-    toJson() {
-        return {
-            id: this.id,
-            security: this.security,
-            ...this._clientBaseToJson(),
-        };
-    }
-};
-
-Inbound.VLESSSettings = class extends Inbound.Settings {
-    constructor(
-        protocol: any,
-        vlesses: any[] = [],
-        decryption: any = "none",
-        encryption: any = "none",
-        fallbacks: any[] = [],
-        testseed: any[] = [],
-    ) {
-        super(protocol);
-        this.vlesses = vlesses;
-        this.decryption = decryption;
-        this.encryption = encryption;
-        this.fallbacks = fallbacks;
-        this.testseed = testseed;
-    }
-
-    addFallback() {
-        this.fallbacks.push(new Inbound.VLESSSettings.Fallback());
-    }
-
-    delFallback(index: number) {
-        this.fallbacks.splice(index, 1);
-    }
-
-    // Empty array means "use server defaults" (won't be sent).
-    // Anything else must be exactly 4 positive integers.
-    static isValidTestseed(arr: any): boolean {
-        if (!Array.isArray(arr) || arr.length === 0) return true;
-        if (arr.length !== 4) return false;
-        return arr.every((v: any) => Number.isInteger(v) && v > 0);
-    }
-
-    static fromJson(json: any = {}) {
-        // Preserve a saved testseed only if it's a valid 4-positive-int array; otherwise leave empty
-        // so toJson omits it and the form falls back to placeholder defaults.
-        const saved = json.testseed;
-        const testseed = (Array.isArray(saved)
-            && saved.length === 4
-            && saved.every((v: any) => Number.isInteger(v) && v > 0))
-            ? saved
-            : [];
-
-        const obj = new Inbound.VLESSSettings(
-            Protocols.VLESS,
-            (json.clients || []).map((client: any) => Inbound.VLESSSettings.VLESS.fromJson(client)),
-            json.decryption,
-            json.encryption,
-            Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
-            testseed,
-        );
-        return obj;
-    }
-
-
-    toJson() {
-        const json: any = {
-            clients: Inbound.VLESSSettings.toJsonArray(this.vlesses),
-        };
-
-        if (this.decryption) {
-            json.decryption = this.decryption;
-        }
-
-        if (this.encryption) {
-            json.encryption = this.encryption;
-        }
-
-        if (this.fallbacks && this.fallbacks.length > 0) {
-            json.fallbacks = Inbound.VLESSSettings.toJsonArray(this.fallbacks);
-        }
-
-        // testseed is only meaningful for the exact xtls-rprx-vision flow, and only when
-        // the user supplied a complete 4-positive-int array. Otherwise omit and let the
-        // backend fall back to its safe defaults.
-        const hasVisionFlow = this.vlesses && this.vlesses.some((v: any) => v.flow === TLS_FLOW_CONTROL.VISION);
-        if (hasVisionFlow
-            && Array.isArray(this.testseed)
-            && this.testseed.length === 4
-            && this.testseed.every((v: any) => Number.isInteger(v) && v > 0)) {
-            json.testseed = this.testseed;
-        }
-
-        return json;
-    }
-};
-
-Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
-    constructor(
-        id: any = RandomUtil.randomUUID(),
-        flow: any = '',
-        reverseTag: any = '',
-        reverseSniffing: any = new Sniffing(),
-        email?: any, limitIp?: any, totalGB?: any, expiryTime?: any, enable?: any, tgId?: any, subId?: any, comment?: any, reset?: any, created_at?: any, updated_at?: any,
-    ) {
-        super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
-        this.id = id;
-        this.flow = flow;
-        this.reverseTag = reverseTag;
-        this.reverseSniffing = reverseSniffing;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.VLESSSettings.VLESS(
-            json.id,
-            json.flow,
-            json.reverse?.tag ?? '',
-            Sniffing.fromJson(json.reverse?.sniffing || {}),
-            ...Inbound.ClientBase.commonArgsFromJson(json),
-        );
-    }
-
-    toJson() {
-        const json: any = {
-            id: this.id,
-            flow: this.flow,
-            ...this._clientBaseToJson(),
-        };
-        if (this.reverseTag) {
-            json.reverse = {
-                tag: this.reverseTag,
-            };
-        }
-        return json;
-    }
-};
-
-Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
-    constructor(name = "", alpn = '', path = '', dest = '', xver = 0) {
-        super();
-        this.name = name;
-        this.alpn = alpn;
-        this.path = path;
-        this.dest = dest;
-        this.xver = xver;
-    }
-
-    toJson() {
-        return XrayCommonClass.fallbackToJson(this as unknown as FallbackEntry);
-    }
-
-    static fromJson(json: any = []) {
-        return (json || []).map((f: any) => new Inbound.VLESSSettings.Fallback(
-            f.name, f.alpn, f.path, f.dest, f.xver,
-        ));
-    }
-};
-
-Inbound.TrojanSettings = class extends Inbound.Settings {
-    constructor(protocol: any,
-        trojans: any[] = [],
-        fallbacks: any[] = [],) {
-        super(protocol);
-        this.trojans = trojans;
-        this.fallbacks = fallbacks;
-    }
-
-    addFallback() {
-        this.fallbacks.push(new Inbound.TrojanSettings.Fallback());
-    }
-
-    delFallback(index: number) {
-        this.fallbacks.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.TrojanSettings(
-            Protocols.TROJAN,
-            (json.clients || []).map((client: any) => Inbound.TrojanSettings.Trojan.fromJson(client)),
-            Inbound.TrojanSettings.Fallback.fromJson(json.fallbacks),);
-    }
-
-    toJson() {
-        const json: any = {
-            clients: Inbound.TrojanSettings.toJsonArray(this.trojans),
-        };
-        if (this.fallbacks && this.fallbacks.length > 0) {
-            json.fallbacks = Inbound.TrojanSettings.toJsonArray(this.fallbacks);
-        }
-        return json;
-    }
-};
-
-Inbound.TrojanSettings.Trojan = class extends Inbound.ClientBase {
-    constructor(
-        password = RandomUtil.randomSeq(10),
-        email?: any, limitIp?: any, totalGB?: any, expiryTime?: any, enable?: any, tgId?: any, subId?: any, comment?: any, reset?: any, created_at?: any, updated_at?: any,
-    ) {
-        super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
-        this.password = password;
-    }
-
-    toJson() {
-        return {
-            password: this.password,
-            ...this._clientBaseToJson(),
-        };
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.TrojanSettings.Trojan(
-            json.password,
-            ...Inbound.ClientBase.commonArgsFromJson(json),
-        );
-    }
-};
-
-Inbound.TrojanSettings.Fallback = class extends XrayCommonClass {
-    constructor(name = "", alpn = '', path = '', dest = '', xver = 0) {
-        super();
-        this.name = name;
-        this.alpn = alpn;
-        this.path = path;
-        this.dest = dest;
-        this.xver = xver;
-    }
-
-    toJson() {
-        return XrayCommonClass.fallbackToJson(this as unknown as FallbackEntry);
-    }
-
-    static fromJson(json: any = []) {
-        return (json || []).map((f: any) => new Inbound.TrojanSettings.Fallback(
-            f.name, f.alpn, f.path, f.dest, f.xver,
-        ));
-    }
-};
-
-Inbound.ShadowsocksSettings = class extends Inbound.Settings {
-    constructor(protocol: any,
-        method: any = SSMethods.BLAKE3_AES_256_GCM,
-        password: any = RandomUtil.randomShadowsocksPassword(),
-        network: any = 'tcp',
-        shadowsockses: any[] = [],
-        ivCheck = false,
-    ) {
-        super(protocol);
-        this.method = method;
-        this.password = password;
-        this.network = network;
-        this.shadowsockses = shadowsockses;
-        this.ivCheck = ivCheck;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.ShadowsocksSettings(
-            Protocols.SHADOWSOCKS,
-            json.method,
-            json.password,
-            json.network,
-            (json.clients || []).map((client: any) => Inbound.ShadowsocksSettings.Shadowsocks.fromJson(client)),
-            json.ivCheck,
-        );
-    }
-
-    toJson() {
-        return {
-            method: this.method,
-            password: this.password,
-            network: this.network,
-            clients: Inbound.ShadowsocksSettings.toJsonArray(this.shadowsockses),
-            ivCheck: this.ivCheck,
-        };
-    }
-};
-
-Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
-    constructor(
-        method = '',
-        password = RandomUtil.randomShadowsocksPassword(),
-        email?: any, limitIp?: any, totalGB?: any, expiryTime?: any, enable?: any, tgId?: any, subId?: any, comment?: any, reset?: any, created_at?: any, updated_at?: any,
-    ) {
-        super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
-        this.method = method;
-        this.password = password;
-    }
-
-    toJson() {
-        return {
-            method: this.method,
-            password: this.password,
-            ...this._clientBaseToJson(),
-        };
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.ShadowsocksSettings.Shadowsocks(
-            json.method,
-            json.password,
-            ...Inbound.ClientBase.commonArgsFromJson(json),
-        );
-    }
-};
-
-Inbound.HysteriaSettings = class extends Inbound.Settings {
-    constructor(protocol: any, version: any = 2, hysterias: any[] = []) {
-        super(protocol);
-        this.version = version;
-        this.hysterias = hysterias;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.HysteriaSettings(
-            Protocols.HYSTERIA,
-            json.version ?? 2,
-            (json.clients || []).map((client: any) => Inbound.HysteriaSettings.Hysteria.fromJson(client)),
-        );
-    }
-
-    toJson() {
-        return {
-            version: this.version,
-            clients: Inbound.HysteriaSettings.toJsonArray(this.hysterias),
-        };
-    }
-};
-
-Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
-    constructor(
-        auth = RandomUtil.randomSeq(10),
-        email?: any, limitIp?: any, totalGB?: any, expiryTime?: any, enable?: any, tgId?: any, subId?: any, comment?: any, reset?: any, created_at?: any, updated_at?: any,
-    ) {
-        super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
-        this.auth = auth;
-    }
-
-    toJson() {
-        return {
-            auth: this.auth,
-            ...this._clientBaseToJson(),
-        };
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.HysteriaSettings.Hysteria(
-            json.auth,
-            ...Inbound.ClientBase.commonArgsFromJson(json),
-        );
-    }
-};
-
-Inbound.TunnelSettings = class extends Inbound.Settings {
-    constructor(
-        protocol: any,
-        rewriteAddress?: any,
-        rewritePort?: any,
-        portMap: any[] = [],
-        allowedNetwork: any = 'tcp,udp',
-        followRedirect: any = false
-    ) {
-        super(protocol);
-        this.rewriteAddress = rewriteAddress;
-        this.rewritePort = rewritePort;
-        this.portMap = portMap;
-        this.allowedNetwork = allowedNetwork;
-        this.followRedirect = followRedirect;
-    }
-
-    addPortMap(port = '', target = '') {
-        this.portMap.push({ name: port, value: target });
-    }
-
-    removePortMap(index: number) {
-        this.portMap.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.TunnelSettings(
-            Protocols.TUNNEL,
-            json.rewriteAddress,
-            json.rewritePort,
-            XrayCommonClass.toHeaders(json.portMap),
-            json.allowedNetwork,
-            json.followRedirect,
-        );
-    }
-
-    toJson() {
-        return {
-            rewriteAddress: this.rewriteAddress,
-            rewritePort: this.rewritePort,
-            portMap: XrayCommonClass.toV2Headers(this.portMap, false),
-            allowedNetwork: this.allowedNetwork,
-            followRedirect: this.followRedirect,
-        };
-    }
-};
-
-Inbound.MixedSettings = class extends Inbound.Settings {
-    constructor(protocol: any, auth: any = 'password', accounts: any[] = [new Inbound.MixedSettings.SocksAccount()], udp: any = false, ip: any = '127.0.0.1') {
-        super(protocol);
-        this.auth = auth;
-        this.accounts = accounts;
-        this.udp = udp;
-        this.ip = ip;
-    }
-
-    addAccount(account: any) {
-        this.accounts.push(account);
-    }
-
-    delAccount(index: number) {
-        this.accounts.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        let accounts;
-        if (json.auth === 'password') {
-            accounts = json.accounts.map(
-                (account: any) => Inbound.MixedSettings.SocksAccount.fromJson(account)
-            )
-        }
-        return new Inbound.MixedSettings(
-            Protocols.MIXED,
-            json.auth,
-            accounts,
-            json.udp,
-            json.ip,
-        );
-    }
-
-    toJson() {
-        return {
-            auth: this.auth,
-            accounts: this.auth === 'password' ? this.accounts.map((account: any) => account.toJson()) : undefined,
-            udp: this.udp,
-            ip: this.ip,
-        };
-    }
-};
-Inbound.MixedSettings.SocksAccount = class extends XrayCommonClass {
-    constructor(user = RandomUtil.randomSeq(10), pass = RandomUtil.randomSeq(10)) {
-        super();
-        this.user = user;
-        this.pass = pass;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.MixedSettings.SocksAccount(json.user, json.pass);
-    }
-};
-
-Inbound.HttpSettings = class extends Inbound.Settings {
-    constructor(
-        protocol: any,
-        accounts: any[] = [new Inbound.HttpSettings.HttpAccount()],
-        allowTransparent: any = false,
-    ) {
-        super(protocol);
-        this.accounts = accounts;
-        this.allowTransparent = allowTransparent;
-    }
-
-    addAccount(account: any) {
-        this.accounts.push(account);
-    }
-
-    delAccount(index: number) {
-        this.accounts.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.HttpSettings(
-            Protocols.HTTP,
-            json.accounts.map((account: any) => Inbound.HttpSettings.HttpAccount.fromJson(account)),
-            json.allowTransparent,
-        );
-    }
-
-    toJson() {
-        return {
-            accounts: Inbound.HttpSettings.toJsonArray(this.accounts),
-            allowTransparent: this.allowTransparent,
-        };
-    }
-};
-
-Inbound.HttpSettings.HttpAccount = class extends XrayCommonClass {
-    constructor(user = RandomUtil.randomSeq(10), pass = RandomUtil.randomSeq(10)) {
-        super();
-        this.user = user;
-        this.pass = pass;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.HttpSettings.HttpAccount(json.user, json.pass);
-    }
-};
-
-Inbound.WireguardSettings = class extends XrayCommonClass {
-    constructor(
-        protocol?: any,
-        mtu: any = 1420,
-        secretKey: any = Wireguard.generateKeypair().privateKey,
-        peers: any[] = [new Inbound.WireguardSettings.Peer()],
-        noKernelTun: any = false
-    ) {
-        super();
-        this.protocol = protocol;
-        this.mtu = mtu;
-        this.secretKey = secretKey;
-        this.pubKey = secretKey.length > 0 ? Wireguard.generateKeypair(secretKey).publicKey : '';
-        this.peers = peers;
-        this.noKernelTun = noKernelTun;
-    }
-
-    addPeer() {
-        this.peers.push(new Inbound.WireguardSettings.Peer(null, null, '', ['10.0.0.' + (this.peers.length + 2)]));
-    }
-
-    delPeer(index: number) {
-        this.peers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.WireguardSettings(
-            Protocols.WIREGUARD,
-            json.mtu,
-            json.secretKey,
-            json.peers.map((peer: any) => Inbound.WireguardSettings.Peer.fromJson(peer)),
-            json.noKernelTun,
-        );
-    }
-
-    toJson() {
-        return {
-            mtu: this.mtu ?? undefined,
-            secretKey: this.secretKey,
-            peers: Inbound.WireguardSettings.Peer.toJsonArray(this.peers),
-            noKernelTun: this.noKernelTun,
-        };
-    }
-};
-
-Inbound.WireguardSettings.Peer = class extends XrayCommonClass {
-    constructor(privateKey?: any, publicKey?: any, psk: any = '', allowedIPs: any[] = ['10.0.0.2/32'], keepAlive: any = 0) {
-        super();
-        this.privateKey = privateKey
-        this.publicKey = publicKey;
-        if (!this.publicKey) {
-            [this.publicKey, this.privateKey] = Object.values(Wireguard.generateKeypair())
-        }
-        this.psk = psk;
-        allowedIPs.forEach((a: any, index: number) => {
-            if (a.length > 0 && !a.includes('/')) allowedIPs[index] += '/32';
-        })
-        this.allowedIPs = allowedIPs;
-        this.keepAlive = keepAlive;
-    }
-
-    static fromJson(json: any = {}) {
-        return new Inbound.WireguardSettings.Peer(
-            json.privateKey,
-            json.publicKey,
-            json.preSharedKey,
-            json.allowedIPs,
-            json.keepAlive
-        );
-    }
-
-    toJson() {
-        this.allowedIPs.forEach((a: any, index: number) => {
-            if (a.length > 0 && !a.includes('/')) this.allowedIPs[index] += '/32';
-        });
-        return {
-            privateKey: this.privateKey,
-            publicKey: this.publicKey,
-            preSharedKey: this.psk.length > 0 ? this.psk : undefined,
-            allowedIPs: this.allowedIPs,
-            keepAlive: this.keepAlive ?? undefined,
-        };
-    }
-};
-
-Inbound.TunSettings = class extends Inbound.Settings {
-    constructor(
-        protocol: any,
-        name: any = 'xray0',
-        mtu: any = 1500,
-        gateway: any[] = [],
-        dns: any[] = [],
-        userLevel: any = 0,
-        autoSystemRoutingTable: any[] = [],
-        autoOutboundsInterface = 'auto'
-    ) {
-        super(protocol);
-        this.name = name;
-        this.mtu = Number(mtu) || 1500;
-        this.gateway = Array.isArray(gateway) ? gateway : [];
-        this.dns = Array.isArray(dns) ? dns : [];
-        this.userLevel = userLevel;
-        this.autoSystemRoutingTable = Array.isArray(autoSystemRoutingTable) ? autoSystemRoutingTable : [];
-        this.autoOutboundsInterface = autoOutboundsInterface;
-    }
-
-    static fromJson(json: any = {}) {
-        const rawMtu = json.mtu ?? json.MTU;
-        const mtu = Array.isArray(rawMtu) ? rawMtu[0] : rawMtu;
-        return new Inbound.TunSettings(
-            Protocols.TUN,
-            json.name ?? 'xray0',
-            mtu ?? 1500,
-            json.gateway ?? json.Gateway ?? [],
-            json.dns ?? json.DNS ?? [],
-            json.userLevel ?? 0,
-            json.autoSystemRoutingTable ?? [],
-            Object.prototype.hasOwnProperty.call(json, 'autoOutboundsInterface') ? json.autoOutboundsInterface : 'auto'
-        );
-    }
-
-    toJson() {
-        return {
-            name: this.name || 'xray0',
-            mtu: Number(this.mtu) || 1500,
-            gateway: this.gateway,
-            dns: this.dns,
-            userLevel: this.userLevel || 0,
-            autoSystemRoutingTable: this.autoSystemRoutingTable,
-            autoOutboundsInterface: this.autoOutboundsInterface,
-        };
-    }
-};

+ 0 - 2405
frontend/src/models/outbound.ts

@@ -1,2405 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { ObjectUtil, Base64, Wireguard } from '@/utils';
-
-export const Protocols = {
-    Freedom: "freedom",
-    Blackhole: "blackhole",
-    DNS: "dns",
-    VMess: "vmess",
-    VLESS: "vless",
-    Trojan: "trojan",
-    Shadowsocks: "shadowsocks",
-    Wireguard: "wireguard",
-    Hysteria: "hysteria",
-    Socks: "socks",
-    HTTP: "http",
-    Loopback: "loopback",
-};
-
-export const SSMethods = {
-    AES_256_GCM: 'aes-256-gcm',
-    AES_128_GCM: 'aes-128-gcm',
-    CHACHA20_POLY1305: 'chacha20-poly1305',
-    CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
-    XCHACHA20_POLY1305: 'xchacha20-poly1305',
-    XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
-    BLAKE3_AES_128_GCM: '2022-blake3-aes-128-gcm',
-    BLAKE3_AES_256_GCM: '2022-blake3-aes-256-gcm',
-    BLAKE3_CHACHA20_POLY1305: '2022-blake3-chacha20-poly1305',
-};
-
-export const TLS_FLOW_CONTROL = {
-    VISION: "xtls-rprx-vision",
-    VISION_UDP443: "xtls-rprx-vision-udp443",
-};
-
-export const UTLS_FINGERPRINT = {
-    UTLS_CHROME: "chrome",
-    UTLS_FIREFOX: "firefox",
-    UTLS_SAFARI: "safari",
-    UTLS_IOS: "ios",
-    UTLS_android: "android",
-    UTLS_EDGE: "edge",
-    UTLS_360: "360",
-    UTLS_QQ: "qq",
-    UTLS_RANDOM: "random",
-    UTLS_RANDOMIZED: "randomized",
-    UTLS_RONDOMIZEDNOALPN: "randomizednoalpn",
-    UTLS_UNSAFE: "unsafe",
-};
-
-export const ALPN_OPTION = {
-    H3: "h3",
-    H2: "h2",
-    HTTP1: "http/1.1",
-};
-
-export const SNIFFING_OPTION = {
-    HTTP: "http",
-    TLS: "tls",
-    QUIC: "quic",
-    FAKEDNS: "fakedns"
-};
-
-export const OutboundDomainStrategies = [
-    "AsIs",
-    "UseIP",
-    "UseIPv4",
-    "UseIPv6",
-    "UseIPv6v4",
-    "UseIPv4v6",
-    "ForceIP",
-    "ForceIPv6v4",
-    "ForceIPv6",
-    "ForceIPv4v6",
-    "ForceIPv4"
-];
-
-export const WireguardDomainStrategy = [
-    "ForceIP",
-    "ForceIPv4",
-    "ForceIPv4v6",
-    "ForceIPv6",
-    "ForceIPv6v4"
-];
-
-export const USERS_SECURITY = {
-    AES_128_GCM: "aes-128-gcm",
-    CHACHA20_POLY1305: "chacha20-poly1305",
-    AUTO: "auto",
-    NONE: "none",
-    ZERO: "zero",
-};
-
-export const MODE_OPTION = {
-    AUTO: "auto",
-    PACKET_UP: "packet-up",
-    STREAM_UP: "stream-up",
-    STREAM_ONE: "stream-one",
-};
-
-export const Address_Port_Strategy = {
-    NONE: "none",
-    SrvPortOnly: "srvportonly",
-    SrvAddressOnly: "srvaddressonly",
-    SrvPortAndAddress: "srvportandaddress",
-    TxtPortOnly: "txtportonly",
-    TxtAddressOnly: "txtaddressonly",
-    TxtPortAndAddress: "txtportandaddress"
-};
-
-export const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
-
-export function normalizeDNSRuleField(value: any): string {
-    if (value === null || value === undefined) {
-        return '';
-    }
-    if (Array.isArray(value)) {
-        return value.map((item: any) => item.toString().trim()).filter((item: any) => item.length > 0).join(',');
-    }
-    return value.toString().trim();
-}
-
-export function normalizeDNSRuleAction(action: any): string {
-    action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim();
-    return DNSRuleActions.includes(action) ? action : 'direct';
-}
-
-export function parseLegacyDNSBlockTypes(blockTypes: any): number[] {
-    if (blockTypes === null || blockTypes === undefined || blockTypes === '') {
-        return [];
-    }
-
-    if (Array.isArray(blockTypes)) {
-        return blockTypes
-            .map((item: any) => Number(item))
-            .filter((item: any) => Number.isInteger(item) && item >= 0 && item <= 65535);
-    }
-
-    if (typeof blockTypes === 'number') {
-        return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : [];
-    }
-
-    return blockTypes
-        .toString()
-        .split(',')
-        .map((item: any) => item.trim())
-        .filter((item: any) => /^\d+$/.test(item))
-        .map((item: any) => Number(item))
-        .filter((item: any) => item >= 0 && item <= 65535);
-}
-
-export function buildLegacyDNSRules(nonIPQuery: any, blockTypes: any): any[] {
-    const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject';
-    const rules = [];
-    const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes);
-
-    if (parsedBlockTypes.length > 0) {
-        rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(',')));
-    }
-
-    rules.push(new Outbound.DNSRule('hijack', '1,28'));
-    rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode));
-
-    return rules;
-}
-
-export function getDNSRulesFromJson(json: any = {}): any[] {
-    if (Array.isArray(json.rules) && json.rules.length > 0) {
-        return json.rules.map((rule: any) => Outbound.DNSRule.fromJson(rule));
-    }
-
-    if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) {
-        return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes);
-    }
-
-    return [];
-}
-
-Object.freeze(Protocols);
-Object.freeze(SSMethods);
-Object.freeze(TLS_FLOW_CONTROL);
-Object.freeze(UTLS_FINGERPRINT);
-Object.freeze(ALPN_OPTION);
-Object.freeze(SNIFFING_OPTION);
-Object.freeze(OutboundDomainStrategies);
-Object.freeze(WireguardDomainStrategy);
-Object.freeze(USERS_SECURITY);
-Object.freeze(MODE_OPTION);
-Object.freeze(Address_Port_Strategy);
-Object.freeze(DNSRuleActions);
-
-export class CommonClass {
-    [key: string]: any;
-
-    static toJsonArray(arr: any[]): any[] {
-        return arr.map(obj => obj.toJson());
-    }
-
-    static fromJson(..._args: any[]): any {
-        return new CommonClass();
-    }
-
-    toJson(): any {
-        return this;
-    }
-
-    toString(format: boolean = true): string {
-        return format ? JSON.stringify(this.toJson(), null, 2) : JSON.stringify(this.toJson());
-    }
-}
-
-export class ReverseSniffing extends CommonClass {
-    constructor(
-        enabled = false,
-        destOverride = ['http', 'tls', 'quic', 'fakedns'],
-        metadataOnly = false,
-        routeOnly = false,
-        ipsExcluded = [],
-        domainsExcluded = [],
-    ) {
-        super();
-        this.enabled = enabled;
-        this.destOverride = Array.isArray(destOverride) && destOverride.length > 0 ? destOverride : ['http', 'tls', 'quic', 'fakedns'];
-        this.metadataOnly = metadataOnly;
-        this.routeOnly = routeOnly;
-        this.ipsExcluded = Array.isArray(ipsExcluded) ? ipsExcluded : [];
-        this.domainsExcluded = Array.isArray(domainsExcluded) ? domainsExcluded : [];
-    }
-
-    static fromJson(json: any = {}): any {
-        if (!json || Object.keys(json).length === 0) {
-            return new ReverseSniffing();
-        }
-        return new ReverseSniffing(
-            !!json.enabled,
-            json.destOverride,
-            json.metadataOnly,
-            json.routeOnly,
-            json.ipsExcluded || [],
-            json.domainsExcluded || [],
-        );
-    }
-
-    toJson() {
-        return {
-            enabled: this.enabled,
-            destOverride: this.destOverride,
-            metadataOnly: this.metadataOnly,
-            routeOnly: this.routeOnly,
-            ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
-            domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
-        };
-    }
-}
-
-export class TcpStreamSettings extends CommonClass {
-    constructor(type: any = 'none', host?: any, path?: any) {
-        super();
-        this.type = type;
-        this.host = host;
-        this.path = path;
-    }
-
-    static fromJson(json: any = {}): any {
-        const header = json.header;
-        if (!header) return new TcpStreamSettings();
-        if (header.type == 'http' && header.request) {
-            return new TcpStreamSettings(
-                header.type,
-                header.request.headers.Host.join(','),
-                header.request.path.join(','),
-            );
-        }
-        return new TcpStreamSettings(header.type, '', '');
-    }
-
-    toJson() {
-        return {
-            header: {
-                type: this.type,
-                request: this.type === 'http' ? {
-                    headers: {
-                        Host: ObjectUtil.isEmpty(this.host) ? [] : this.host.split(',')
-                    },
-                    path: ObjectUtil.isEmpty(this.path) ? ["/"] : this.path.split(',')
-                } : undefined,
-            }
-        };
-    }
-}
-
-export class KcpStreamSettings extends CommonClass {
-    constructor(
-        mtu = 1350,
-        tti = 20,
-        uplinkCapacity = 5,
-        downlinkCapacity = 20,
-        cwndMultiplier = 1,
-        maxSendingWindow = 1350,
-    ) {
-        super();
-        this.mtu = mtu;
-        this.tti = tti;
-        this.upCap = uplinkCapacity;
-        this.downCap = downlinkCapacity;
-        this.cwndMultiplier = cwndMultiplier;
-        this.maxSendingWindow = maxSendingWindow;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new KcpStreamSettings(
-            json.mtu,
-            json.tti,
-            json.uplinkCapacity,
-            json.downlinkCapacity,
-            json.cwndMultiplier,
-            json.maxSendingWindow,
-        );
-    }
-
-    toJson() {
-        return {
-            mtu: this.mtu,
-            tti: this.tti,
-            uplinkCapacity: this.upCap,
-            downlinkCapacity: this.downCap,
-            cwndMultiplier: this.cwndMultiplier,
-            maxSendingWindow: this.maxSendingWindow,
-        };
-    }
-}
-
-export class WsStreamSettings extends CommonClass {
-    constructor(
-        path = '/',
-        host = '',
-        heartbeatPeriod = 0,
-
-    ) {
-        super();
-        this.path = path;
-        this.host = host;
-        this.heartbeatPeriod = heartbeatPeriod;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new WsStreamSettings(
-            json.path,
-            json.host,
-            json.heartbeatPeriod,
-        );
-    }
-
-    toJson() {
-        return {
-            path: this.path,
-            host: this.host,
-            heartbeatPeriod: this.heartbeatPeriod
-        };
-    }
-}
-
-export class GrpcStreamSettings extends CommonClass {
-    constructor(
-        serviceName = "",
-        authority = "",
-        multiMode = false
-    ) {
-        super();
-        this.serviceName = serviceName;
-        this.authority = authority;
-        this.multiMode = multiMode;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new GrpcStreamSettings(json.serviceName, json.authority, json.multiMode);
-    }
-
-    toJson() {
-        return {
-            serviceName: this.serviceName,
-            authority: this.authority,
-            multiMode: this.multiMode
-        }
-    }
-}
-
-export class HttpUpgradeStreamSettings extends CommonClass {
-    constructor(path = '/', host = '') {
-        super();
-        this.path = path;
-        this.host = host;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new HttpUpgradeStreamSettings(
-            json.path,
-            json.host,
-        );
-    }
-
-    toJson() {
-        return {
-            path: this.path,
-            host: this.host,
-        };
-    }
-}
-
-// Mirrors the outbound (client-side) view of Xray-core's SplitHTTPConfig
-// (infra/conf/transport_internet.go). Only fields the client actually
-// reads at runtime, plus the bidirectional fields the client must match
-// against the server, live here. Server-only fields (noSSEHeader,
-// scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes) belong
-// on the inbound class instead.
-export class xHTTPStreamSettings extends CommonClass {
-    constructor(
-        // Bidirectional — must match the inbound side
-        path: any = '/',
-        host: any = '',
-        mode: any = '',
-        xPaddingBytes: any = "100-1000",
-        xPaddingObfsMode = false,
-        xPaddingKey = '',
-        xPaddingHeader = '',
-        xPaddingPlacement = '',
-        xPaddingMethod = '',
-        sessionPlacement = '',
-        sessionKey = '',
-        seqPlacement = '',
-        seqKey = '',
-        uplinkDataPlacement = '',
-        uplinkDataKey = '',
-        scMaxEachPostBytes: any = "1000000",
-        // Client-side only
-        headers: any[] = [],
-        uplinkHTTPMethod = '',
-        uplinkChunkSize = 0,
-        noGRPCHeader = false,
-        scMinPostsIntervalMs = "30",
-        xmux = {
-            maxConcurrency: "16-32",
-            maxConnections: 0,
-            cMaxReuseTimes: 0,
-            hMaxRequestTimes: "600-900",
-            hMaxReusableSecs: "1800-3000",
-            hKeepAlivePeriod: 0,
-        },
-        // UI-only toggle — controls whether the XMUX block is expanded in
-        // the form (mirrors the QUIC Params switch in stream_finalmask).
-        // Never serialized; toJson() only emits the xmux block itself.
-        enableXmux = false,
-    ) {
-        super();
-        this.path = path;
-        this.host = host;
-        this.mode = mode;
-        this.xPaddingBytes = xPaddingBytes;
-        this.xPaddingObfsMode = xPaddingObfsMode;
-        this.xPaddingKey = xPaddingKey;
-        this.xPaddingHeader = xPaddingHeader;
-        this.xPaddingPlacement = xPaddingPlacement;
-        this.xPaddingMethod = xPaddingMethod;
-        this.sessionPlacement = sessionPlacement;
-        this.sessionKey = sessionKey;
-        this.seqPlacement = seqPlacement;
-        this.seqKey = seqKey;
-        this.uplinkDataPlacement = uplinkDataPlacement;
-        this.uplinkDataKey = uplinkDataKey;
-        this.scMaxEachPostBytes = scMaxEachPostBytes;
-        this.headers = headers;
-        this.uplinkHTTPMethod = uplinkHTTPMethod;
-        this.uplinkChunkSize = uplinkChunkSize;
-        this.noGRPCHeader = noGRPCHeader;
-        this.scMinPostsIntervalMs = scMinPostsIntervalMs;
-        this.xmux = xmux;
-        this.enableXmux = enableXmux;
-    }
-
-    addHeader(name: any, value: any): void {
-        this.headers.push({ name: name, value: value });
-    }
-
-    removeHeader(index: number): void {
-        this.headers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}): any {
-        const headersInput = json.headers;
-        let headers: any[] = [];
-        if (Array.isArray(headersInput)) {
-            headers = headersInput;
-        } else if (headersInput && typeof headersInput === 'object') {
-            // Upstream uses a {name: value} map; convert to the panel's [{name, value}] form.
-            headers = Object.entries(headersInput).map(([name, value]) => ({ name, value }));
-        }
-        return new xHTTPStreamSettings(
-            json.path,
-            json.host,
-            json.mode,
-            json.xPaddingBytes,
-            json.xPaddingObfsMode,
-            json.xPaddingKey,
-            json.xPaddingHeader,
-            json.xPaddingPlacement,
-            json.xPaddingMethod,
-            json.sessionPlacement,
-            json.sessionKey,
-            json.seqPlacement,
-            json.seqKey,
-            json.uplinkDataPlacement,
-            json.uplinkDataKey,
-            json.scMaxEachPostBytes,
-            headers,
-            json.uplinkHTTPMethod,
-            json.uplinkChunkSize,
-            json.noGRPCHeader,
-            json.scMinPostsIntervalMs,
-            json.xmux,
-            // Auto-toggle the XMUX switch on when an existing outbound has
-            // the xmux key saved, so users editing such configs see their
-            // values immediately.
-            json.xmux !== undefined,
-        );
-    }
-
-    toJson() {
-        // Upstream expects headers as a {name: value} map, not a list of entries.
-        const headersMap: any = {};
-        if (Array.isArray(this.headers)) {
-            for (const h of this.headers) {
-                if (h && h.name) headersMap[h.name] = h.value || '';
-            }
-        }
-        return {
-            path: this.path,
-            host: this.host,
-            mode: this.mode,
-            xPaddingBytes: this.xPaddingBytes,
-            xPaddingObfsMode: this.xPaddingObfsMode,
-            xPaddingKey: this.xPaddingKey,
-            xPaddingHeader: this.xPaddingHeader,
-            xPaddingPlacement: this.xPaddingPlacement,
-            xPaddingMethod: this.xPaddingMethod,
-            sessionPlacement: this.sessionPlacement,
-            sessionKey: this.sessionKey,
-            seqPlacement: this.seqPlacement,
-            seqKey: this.seqKey,
-            uplinkDataPlacement: this.uplinkDataPlacement,
-            uplinkDataKey: this.uplinkDataKey,
-            scMaxEachPostBytes: this.scMaxEachPostBytes,
-            headers: headersMap,
-            uplinkHTTPMethod: this.uplinkHTTPMethod,
-            uplinkChunkSize: this.uplinkChunkSize,
-            noGRPCHeader: this.noGRPCHeader,
-            scMinPostsIntervalMs: this.scMinPostsIntervalMs,
-            xmux: {
-                maxConcurrency: this.xmux.maxConcurrency,
-                maxConnections: this.xmux.maxConnections,
-                cMaxReuseTimes: this.xmux.cMaxReuseTimes,
-                hMaxRequestTimes: this.xmux.hMaxRequestTimes,
-                hMaxReusableSecs: this.xmux.hMaxReusableSecs,
-                hKeepAlivePeriod: this.xmux.hKeepAlivePeriod,
-            },
-        };
-    }
-}
-
-export class TlsStreamSettings extends CommonClass {
-    constructor(
-        serverName: any = '',
-        alpn: any[] = [],
-        fingerprint: any = '',
-        echConfigList = '',
-        verifyPeerCertByName = '',
-        pinnedPeerCertSha256 = '',
-    ) {
-        super();
-        this.serverName = serverName;
-        this.alpn = alpn;
-        this.fingerprint = fingerprint;
-        this.echConfigList = echConfigList;
-        this.verifyPeerCertByName = verifyPeerCertByName;
-        this.pinnedPeerCertSha256 = pinnedPeerCertSha256;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new TlsStreamSettings(
-            json.serverName,
-            json.alpn,
-            json.fingerprint,
-            json.echConfigList,
-            json.verifyPeerCertByName,
-            json.pinnedPeerCertSha256,
-        );
-    }
-
-    toJson() {
-        return {
-            serverName: this.serverName,
-            alpn: this.alpn,
-            fingerprint: this.fingerprint,
-            echConfigList: this.echConfigList,
-            verifyPeerCertByName: this.verifyPeerCertByName,
-            pinnedPeerCertSha256: this.pinnedPeerCertSha256
-        };
-    }
-}
-
-export class RealityStreamSettings extends CommonClass {
-    constructor(
-        publicKey: any = '',
-        fingerprint: any = '',
-        serverName: any = '',
-        shortId: any = '',
-        spiderX: any = '',
-        mldsa65Verify: any = ''
-    ) {
-        super();
-        this.publicKey = publicKey;
-        this.fingerprint = fingerprint;
-        this.serverName = serverName;
-        this.shortId = shortId
-        this.spiderX = spiderX;
-        this.mldsa65Verify = mldsa65Verify;
-    }
-    static fromJson(json: any = {}): any {
-        return new RealityStreamSettings(
-            json.publicKey,
-            json.fingerprint,
-            json.serverName,
-            json.shortId,
-            json.spiderX,
-            json.mldsa65Verify
-        );
-    }
-    toJson() {
-        return {
-            publicKey: this.publicKey,
-            fingerprint: this.fingerprint,
-            serverName: this.serverName,
-            shortId: this.shortId,
-            spiderX: this.spiderX,
-            mldsa65Verify: this.mldsa65Verify
-        };
-    }
-};
-
-export class HysteriaStreamSettings extends CommonClass {
-    constructor(
-        version = 2,
-        auth = '',
-        congestion = '',
-        up = '0',
-        down = '0',
-        udphopPort = '',
-        udphopIntervalMin = 30,
-        udphopIntervalMax = 30,
-        initStreamReceiveWindow = 8388608,
-        maxStreamReceiveWindow = 8388608,
-        initConnectionReceiveWindow = 20971520,
-        maxConnectionReceiveWindow = 20971520,
-        maxIdleTimeout = 30,
-        keepAlivePeriod = 2,
-        disablePathMTUDiscovery = false
-    ) {
-        super();
-        this.version = version;
-        this.auth = auth;
-        this.congestion = congestion;
-        this.up = up;
-        this.down = down;
-        this.udphopPort = udphopPort;
-        this.udphopIntervalMin = udphopIntervalMin;
-        this.udphopIntervalMax = udphopIntervalMax;
-        this.initStreamReceiveWindow = initStreamReceiveWindow;
-        this.maxStreamReceiveWindow = maxStreamReceiveWindow;
-        this.initConnectionReceiveWindow = initConnectionReceiveWindow;
-        this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
-        this.maxIdleTimeout = maxIdleTimeout;
-        this.keepAlivePeriod = keepAlivePeriod;
-        this.disablePathMTUDiscovery = disablePathMTUDiscovery;
-    }
-
-    static fromJson(json: any = {}): any {
-        let udphopPort = '';
-        let udphopIntervalMin = 30;
-        let udphopIntervalMax = 30;
-        if (json.udphop) {
-            udphopPort = json.udphop.port || '';
-            // Backward compatibility: if old 'interval' exists, use it for both min/max
-            if (json.udphop.interval !== undefined) {
-                udphopIntervalMin = json.udphop.interval;
-                udphopIntervalMax = json.udphop.interval;
-            } else {
-                udphopIntervalMin = json.udphop.intervalMin || 30;
-                udphopIntervalMax = json.udphop.intervalMax || 30;
-            }
-        }
-        return new HysteriaStreamSettings(
-            json.version,
-            json.auth,
-            json.congestion,
-            json.up,
-            json.down,
-            udphopPort,
-            udphopIntervalMin,
-            udphopIntervalMax,
-            json.initStreamReceiveWindow,
-            json.maxStreamReceiveWindow,
-            json.initConnectionReceiveWindow,
-            json.maxConnectionReceiveWindow,
-            json.maxIdleTimeout,
-            json.keepAlivePeriod,
-            json.disablePathMTUDiscovery
-        );
-    }
-
-    toJson() {
-        const result: any = {
-            version: this.version,
-            auth: this.auth,
-            congestion: this.congestion,
-            up: this.up,
-            down: this.down,
-            initStreamReceiveWindow: this.initStreamReceiveWindow,
-            maxStreamReceiveWindow: this.maxStreamReceiveWindow,
-            initConnectionReceiveWindow: this.initConnectionReceiveWindow,
-            maxConnectionReceiveWindow: this.maxConnectionReceiveWindow,
-            maxIdleTimeout: this.maxIdleTimeout,
-            keepAlivePeriod: this.keepAlivePeriod,
-            disablePathMTUDiscovery: this.disablePathMTUDiscovery
-        };
-        if (this.udphopPort) {
-            result.udphop = {
-                port: this.udphopPort,
-                intervalMin: this.udphopIntervalMin,
-                intervalMax: this.udphopIntervalMax
-            };
-        }
-        return result;
-    }
-};
-export class SockoptStreamSettings extends CommonClass {
-    constructor(
-        dialerProxy = "",
-        tcpFastOpen = false,
-        tcpKeepAliveInterval = 0,
-        tcpMptcp = false,
-        penetrate = false,
-        addressPortStrategy = Address_Port_Strategy.NONE,
-        trustedXForwardedFor = [],
-        mark = 0,            
-        interfaceName = "",  
-
-    ) {
-        super();
-        this.dialerProxy = dialerProxy;
-        this.tcpFastOpen = tcpFastOpen;
-        this.tcpKeepAliveInterval = tcpKeepAliveInterval;
-        this.tcpMptcp = tcpMptcp;
-        this.penetrate = penetrate;
-        this.addressPortStrategy = addressPortStrategy;
-        this.trustedXForwardedFor = trustedXForwardedFor;
-        this.mark = mark;          
-        this.interfaceName = interfaceName; 
-
-    }
-
-    static fromJson(json: any = {}): any {
-        if (Object.keys(json).length === 0) return undefined;
-        return new SockoptStreamSettings(
-            json.dialerProxy,
-            json.tcpFastOpen,
-            json.tcpKeepAliveInterval,
-            json.tcpMptcp,
-            json.penetrate,
-            json.addressPortStrategy,
-            json.trustedXForwardedFor || [],
-            json.mark ?? 0,      
-            json.interface ?? "", 
-        );
-    }
-
-    toJson() {
-        const result: any = {
-            dialerProxy: this.dialerProxy,
-            tcpFastOpen: this.tcpFastOpen,
-            tcpKeepAliveInterval: this.tcpKeepAliveInterval,
-            tcpMptcp: this.tcpMptcp,
-            penetrate: this.penetrate,
-            addressPortStrategy: this.addressPortStrategy,
-            mark: this.mark, 
-            interface: this.interfaceName, 
-        };
-        if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
-            result.trustedXForwardedFor = this.trustedXForwardedFor;
-        }
-        return result;
-    }
-}
-
-export class UdpMask extends CommonClass {
-    constructor(type: any = 'salamander', settings: any = {}) {
-        super();
-        this.type = type;
-        this.settings = this._getDefaultSettings(type, settings);
-    }
-
-    _getDefaultSettings(type: any, settings: any = {}): any {
-        switch (type) {
-            case 'salamander':
-            case 'mkcp-aes128gcm':
-                return { password: settings.password || '' };
-            case 'header-dns':
-                return { domain: settings.domain || '' };
-            case 'xdns':
-                return { resolvers: Array.isArray(settings.resolvers) ? settings.resolvers : [] };
-            case 'xicmp':
-                return { ip: settings.ip || '', id: settings.id ?? 0 };
-            case 'mkcp-original':
-            case 'header-dtls':
-            case 'header-srtp':
-            case 'header-utp':
-            case 'header-wechat':
-            case 'header-wireguard':
-                return {}; // No settings needed
-            case 'header-custom':
-                return {
-                    client: Array.isArray(settings.client) ? settings.client : [],
-                    server: Array.isArray(settings.server) ? settings.server : [],
-                };
-            case 'noise':
-                return {
-                    reset: settings.reset ?? 0,
-                    noise: Array.isArray(settings.noise) ? settings.noise : [],
-                };
-            case 'sudoku':
-                return {
-                    ascii: settings.ascii || '',
-                    customTable: settings.customTable || '',
-                    customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
-                    paddingMin: settings.paddingMin ?? 0,
-                    paddingMax: settings.paddingMax ?? 0
-                };
-            default:
-                return settings;
-        }
-    }
-
-    static fromJson(json: any = {}): any {
-        return new UdpMask(
-            json.type || 'salamander',
-            json.settings || {}
-        );
-    }
-
-    toJson() {
-        const cleanItem = (item: any) => {
-            const out = { ...item };
-            if (out.type === 'array') {
-                delete out.packet;
-            } else {
-                delete out.rand;
-                delete out.randRange;
-            }
-            return out;
-        };
-
-        let settings = this.settings;
-        if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
-            settings = { ...settings, noise: settings.noise.map(cleanItem) };
-        } else if (this.type === 'header-custom' && settings) {
-            settings = {
-                ...settings,
-                client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
-                server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
-            };
-        }
-
-        return {
-            type: this.type,
-            settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
-        };
-    }
-}
-
-export class TcpMask extends CommonClass {
-    constructor(type: any = 'fragment', settings: any = {}) {
-        super();
-        this.type = type;
-        this.settings = this._getDefaultSettings(type, settings);
-    }
-
-    _getDefaultSettings(type: any, settings: any = {}): any {
-        switch (type) {
-            case 'fragment':
-                return {
-                    packets: settings.packets ?? 'tlshello',
-                    length: settings.length ?? '',
-                    delay: settings.delay ?? '',
-                    maxSplit: settings.maxSplit ?? '',
-                };
-            case 'sudoku':
-                return {
-                    password: settings.password ?? '',
-                    ascii: settings.ascii ?? '',
-                    customTable: settings.customTable ?? '',
-                    customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
-                    paddingMin: settings.paddingMin ?? 0,
-                    paddingMax: settings.paddingMax ?? 0,
-                };
-            case 'header-custom':
-                return {
-                    clients: Array.isArray(settings.clients) ? settings.clients : [],
-                    servers: Array.isArray(settings.servers) ? settings.servers : [],
-                };
-            default:
-                return settings;
-        }
-    }
-
-    static fromJson(json: any = {}): any {
-        return new TcpMask(
-            json.type || 'fragment',
-            json.settings || {}
-        );
-    }
-
-    toJson() {
-        const cleanItem = (item: any) => {
-            const out = { ...item };
-            if (out.type === 'array') {
-                delete out.packet;
-            } else {
-                delete out.rand;
-                delete out.randRange;
-            }
-            return out;
-        };
-
-        let settings = this.settings;
-        if (this.type === 'header-custom' && settings) {
-            const cleanGroup = (group: any) => Array.isArray(group) ? group.map(cleanItem) : group;
-            settings = {
-                ...settings,
-                clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
-                servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
-            };
-        }
-
-        return {
-            type: this.type,
-            settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
-        };
-    }
-}
-
-export class QuicParams extends CommonClass {
-    constructor(
-        congestion: any = 'bbr',
-        debug: any = false,
-        brutalUp: any = 65537,
-        brutalDown: any = 65537,
-        udpHop: any = undefined,
-        initStreamReceiveWindow = 8388608,
-        maxStreamReceiveWindow = 8388608,
-        initConnectionReceiveWindow = 20971520,
-        maxConnectionReceiveWindow = 20971520,
-        maxIdleTimeout = 30,
-        keepAlivePeriod = 5,
-        disablePathMTUDiscovery = false,
-        maxIncomingStreams = 1024,
-    ) {
-        super();
-        this.congestion = congestion;
-        this.debug = debug;
-        this.brutalUp = brutalUp;
-        this.brutalDown = brutalDown;
-        this.udpHop = udpHop;
-        this.initStreamReceiveWindow = initStreamReceiveWindow;
-        this.maxStreamReceiveWindow = maxStreamReceiveWindow;
-        this.initConnectionReceiveWindow = initConnectionReceiveWindow;
-        this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
-        this.maxIdleTimeout = maxIdleTimeout;
-        this.keepAlivePeriod = keepAlivePeriod;
-        this.disablePathMTUDiscovery = disablePathMTUDiscovery;
-        this.maxIncomingStreams = maxIncomingStreams;
-    }
-
-    get hasUdpHop() {
-        return this.udpHop != null;
-    }
-
-    set hasUdpHop(value) {
-        this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
-    }
-
-    static fromJson(json: any = {}): any {
-        if (!json || Object.keys(json).length === 0) return undefined;
-        return new QuicParams(
-            json.congestion,
-            json.debug,
-            json.brutalUp,
-            json.brutalDown,
-            json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
-            json.initStreamReceiveWindow,
-            json.maxStreamReceiveWindow,
-            json.initConnectionReceiveWindow,
-            json.maxConnectionReceiveWindow,
-            json.maxIdleTimeout,
-            json.keepAlivePeriod,
-            json.disablePathMTUDiscovery,
-            json.maxIncomingStreams,
-        );
-    }
-
-    toJson() {
-        const result: any = { congestion: this.congestion } as any;
-        if (this.debug) result.debug = this.debug;
-        if (['brutal', 'force-brutal'].includes(this.congestion)) {
-            if (this.brutalUp) result.brutalUp = this.brutalUp;
-            if (this.brutalDown) result.brutalDown = this.brutalDown;
-        }
-        if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
-        if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
-        if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
-        if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
-        if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
-        if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
-        if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
-        if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
-        if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
-        return result;
-    }
-}
-
-export class FinalMaskStreamSettings extends CommonClass {
-    constructor(tcp: any[] = [], udp: any[] = [], quicParams: any = undefined) {
-        super();
-        this.tcp = Array.isArray(tcp) ? tcp.map((t: any) => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
-        this.udp = Array.isArray(udp) ? udp.map((u: any) => new UdpMask(u.type, u.settings)) : [new UdpMask((udp as any).type, (udp as any).settings)];
-        this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
-    }
-
-    get enableQuicParams() {
-        return this.quicParams != null;
-    }
-
-    set enableQuicParams(value) {
-        this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new FinalMaskStreamSettings(
-            json.tcp || [],
-            json.udp || [],
-            json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
-        );
-    }
-
-    toJson() {
-        const result: any = {} as any;
-        if (this.tcp && this.tcp.length > 0) {
-            result.tcp = this.tcp.map((t: any) => t.toJson());
-        }
-        if (this.udp && this.udp.length > 0) {
-            result.udp = this.udp.map((udp: any) => udp.toJson());
-        }
-        if (this.quicParams) {
-            result.quicParams = this.quicParams.toJson();
-        }
-        return result;
-    }
-}
-
-export class StreamSettings extends CommonClass {
-    constructor(
-        network = 'tcp',
-        security = 'none',
-        tlsSettings = new TlsStreamSettings(),
-        realitySettings = new RealityStreamSettings(),
-        tcpSettings = new TcpStreamSettings(),
-        kcpSettings = new KcpStreamSettings(),
-        wsSettings = new WsStreamSettings(),
-        grpcSettings = new GrpcStreamSettings(),
-        httpupgradeSettings = new HttpUpgradeStreamSettings(),
-        xhttpSettings = new xHTTPStreamSettings(),
-        hysteriaSettings = new HysteriaStreamSettings(),
-        finalmask = new FinalMaskStreamSettings(),
-        sockopt = undefined,
-    ) {
-        super();
-        this.network = network;
-        this.security = security;
-        this.tls = tlsSettings;
-        this.reality = realitySettings;
-        this.tcp = tcpSettings;
-        this.kcp = kcpSettings;
-        this.ws = wsSettings;
-        this.grpc = grpcSettings;
-        this.httpupgrade = httpupgradeSettings;
-        this.xhttp = xhttpSettings;
-        this.hysteria = hysteriaSettings;
-        this.finalmask = finalmask;
-        this.sockopt = sockopt;
-    }
-
-    addTcpMask(type = 'fragment') {
-        this.finalmask.tcp.push(new TcpMask(type));
-    }
-
-    delTcpMask(index: number) {
-        if (this.finalmask.tcp) {
-            this.finalmask.tcp.splice(index, 1);
-        }
-    }
-
-    addUdpMask(type = 'salamander') {
-        this.finalmask.udp.push(new UdpMask(type));
-    }
-
-    delUdpMask(index: number) {
-        if (this.finalmask.udp) {
-            this.finalmask.udp.splice(index, 1);
-        }
-    }
-
-    get hasFinalMask() {
-        const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
-        const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
-        const hasQuicParams = this.finalmask.quicParams != null;
-        return hasTcp || hasUdp || hasQuicParams;
-    }
-
-    get isTls() {
-        return this.security === 'tls';
-    }
-
-    get isReality() {
-        return this.security === "reality";
-    }
-
-    get sockoptSwitch() {
-        return this.sockopt != undefined;
-    }
-
-    set sockoptSwitch(value) {
-        this.sockopt = value ? new SockoptStreamSettings() : undefined;
-    }
-
-    static fromJson(json: any = {}): any {
-        // Xray-core supports both "xhttpSettings" and "splithttpSettings" (backward-compat alias)
-        const xhttpJson = json.xhttpSettings ?? json.splithttpSettings;
-        // Normalize "splithttp" network name to "xhttp" for internal consistency
-        const network = json.network === 'splithttp' ? 'xhttp' : json.network;
-        return new StreamSettings(
-            network,
-            json.security,
-            TlsStreamSettings.fromJson(json.tlsSettings),
-            RealityStreamSettings.fromJson(json.realitySettings),
-            TcpStreamSettings.fromJson(json.tcpSettings),
-            KcpStreamSettings.fromJson(json.kcpSettings),
-            WsStreamSettings.fromJson(json.wsSettings),
-            GrpcStreamSettings.fromJson(json.grpcSettings),
-            HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
-            xHTTPStreamSettings.fromJson(xhttpJson),
-            HysteriaStreamSettings.fromJson(json.hysteriaSettings),
-            FinalMaskStreamSettings.fromJson(json.finalmask),
-            SockoptStreamSettings.fromJson(json.sockopt),
-        );
-    }
-
-    toJson() {
-        const network = this.network;
-        return {
-            network: network,
-            security: this.security,
-            tlsSettings: this.security == 'tls' ? this.tls.toJson() : undefined,
-            realitySettings: this.security == 'reality' ? this.reality.toJson() : undefined,
-            tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
-            kcpSettings: network === 'kcp' ? this.kcp.toJson() : undefined,
-            wsSettings: network === 'ws' ? this.ws.toJson() : undefined,
-            grpcSettings: network === 'grpc' ? this.grpc.toJson() : undefined,
-            httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
-            xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
-            hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
-            finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
-            sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
-        };
-    }
-}
-
-export class Mux extends CommonClass {
-    constructor(enabled = false, concurrency = 8, xudpConcurrency = 16, xudpProxyUDP443 = "reject") {
-        super();
-        this.enabled = enabled;
-        this.concurrency = concurrency;
-        this.xudpConcurrency = xudpConcurrency;
-        this.xudpProxyUDP443 = xudpProxyUDP443;
-    }
-
-    static fromJson(json: any = {}): any {
-        if (Object.keys(json).length === 0) return undefined;
-        return new Mux(
-            json.enabled,
-            json.concurrency,
-            json.xudpConcurrency,
-            json.xudpProxyUDP443,
-        );
-    }
-
-    toJson() {
-        return {
-            enabled: this.enabled,
-            concurrency: this.concurrency,
-            xudpConcurrency: this.xudpConcurrency,
-            xudpProxyUDP443: this.xudpProxyUDP443,
-        };
-    }
-}
-
-export class Outbound extends CommonClass {
-    static Settings: any;
-    static FreedomSettings: any;
-    static BlackholeSettings: any;
-    static LoopbackSettings: any;
-    static DNSRule: any;
-    static DNSSettings: any;
-    static VmessSettings: any;
-    static VLESSSettings: any;
-    static TrojanSettings: any;
-    static ShadowsocksSettings: any;
-    static SocksSettings: any;
-    static HttpSettings: any;
-    static WireguardSettings: any;
-    static HysteriaSettings: any;
-
-    constructor(
-        tag: any = '',
-        protocol: any = Protocols.VLESS,
-        settings: any = null,
-        streamSettings: any = new StreamSettings(),
-        sendThrough?: any,
-        mux: any = new Mux(),
-    ) {
-        super();
-        this.tag = tag;
-        this._protocol = protocol;
-        this.settings = settings == null ? Outbound.Settings.getSettings(protocol) : settings;
-        this.stream = streamSettings;
-        this.sendThrough = sendThrough;
-        this.mux = mux;
-    }
-
-    get protocol() {
-        return this._protocol;
-    }
-
-    set protocol(protocol) {
-        this._protocol = protocol;
-        this.settings = Outbound.Settings.getSettings(protocol);
-        this.stream = new StreamSettings();
-    }
-
-    canEnableTls() {
-        if (this.protocol === Protocols.Hysteria) return true;
-        if (![Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(this.protocol)) return false;
-        return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.stream.network);
-    }
-
-    //this is used for xtls-rprx-vision
-    canEnableTlsFlow() {
-        if ((this.stream.security != 'none') && (this.stream.network === "tcp")) {
-            return this.protocol === Protocols.VLESS;
-        }
-        return false;
-    }
-
-    // Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
-    // Excludes the UDP variant per spec.
-    canEnableVisionSeed() {
-        if (!this.canEnableTlsFlow()) return false;
-        return this.settings?.flow === TLS_FLOW_CONTROL.VISION;
-    }
-
-    canEnableReality() {
-        if (![Protocols.VLESS, Protocols.Trojan].includes(this.protocol)) return false;
-        return ["tcp", "http", "grpc", "xhttp"].includes(this.stream.network);
-    }
-
-    canEnableStream() {
-        return [Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks, Protocols.Hysteria].includes(this.protocol);
-    }
-
-    canEnableMux() {
-        // Disable Mux if flow is set
-        if (this.settings.flow && this.settings.flow !== '') {
-            this.mux.enabled = false;
-            return false;
-        }
-
-        // Disable Mux if network is xhttp
-        if (this.stream.network === 'xhttp') {
-            this.mux.enabled = false;
-            return false;
-        }
-
-        // Allow Mux only for these protocols
-        return [
-            Protocols.VMess,
-            Protocols.VLESS,
-            Protocols.Trojan,
-            Protocols.Shadowsocks,
-            Protocols.HTTP,
-            Protocols.Socks
-        ].includes(this.protocol);
-    }
-
-    hasServers() {
-        return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol);
-    }
-
-    hasAddressPort() {
-        return [
-            Protocols.VMess,
-            Protocols.VLESS,
-            Protocols.Trojan,
-            Protocols.Shadowsocks,
-            Protocols.Socks,
-            Protocols.HTTP,
-            Protocols.Hysteria
-        ].includes(this.protocol);
-    }
-
-    hasUsername() {
-        return [Protocols.Socks, Protocols.HTTP].includes(this.protocol);
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound(
-            json.tag,
-            json.protocol,
-            Outbound.Settings.fromJson(json.protocol, json.settings),
-            StreamSettings.fromJson(json.streamSettings),
-            json.sendThrough,
-            Mux.fromJson(json.mux),
-        )
-    }
-
-    toJson() {
-        let stream;
-        if (this.canEnableStream()) {
-            stream = this.stream.toJson();
-        } else {
-            if (this.stream?.sockopt)
-                stream = { sockopt: this.stream.sockopt.toJson() };
-        }
-        const settingsOut = this.settings instanceof CommonClass ? this.settings.toJson() : this.settings;
-        return {
-            protocol: this.protocol,
-            settings: settingsOut,
-            // Only include tag, streamSettings, sendThrough, mux if present and not empty
-            ...(this.tag ? { tag: this.tag } : {}),
-            ...(stream ? { streamSettings: stream } : {}),
-            ...(this.sendThrough ? { sendThrough: this.sendThrough } : {}),
-            ...(this.mux?.enabled ? { mux: this.mux } : {}),
-        };
-    }
-
-    static fromLink(link: any) {
-        const data = link.split('://');
-        if (data.length != 2) return null;
-        switch (data[0].toLowerCase()) {
-            case Protocols.VMess:
-                return this.fromVmessLink(JSON.parse(Base64.decode(data[1])));
-            case Protocols.VLESS:
-            case Protocols.Trojan:
-            case 'ss':
-                return this.fromParamLink(link);
-            case 'hysteria2':
-            case Protocols.Hysteria:
-                return this.fromHysteriaLink(link);
-            default:
-                return null;
-        }
-    }
-
-    static fromVmessLink(json: any = {}) {
-        const stream = new StreamSettings(json.net, json.tls);
-
-        const network = json.net;
-        if (network === 'tcp') {
-            stream.tcp = new TcpStreamSettings(
-                json.type,
-                json.host ?? '',
-                json.path ?? '');
-        } else if (network === 'kcp') {
-            stream.kcp = new KcpStreamSettings();
-            stream.type = json.type;
-            stream.seed = json.path;
-            const mtu = Number(json.mtu);
-            if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
-            const tti = Number(json.tti);
-            if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
-        } else if (network === 'ws') {
-            stream.ws = new WsStreamSettings(json.path, json.host);
-        } else if (network === 'grpc') {
-            stream.grpc = new GrpcStreamSettings(json.path, json.authority, json.type == 'multi');
-        } else if (network === 'httpupgrade') {
-            stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
-        } else if (network === 'xhttp') {
-            const xh = new xHTTPStreamSettings(json.path, json.host);
-            if (json.mode) xh.mode = json.mode;
-            if (json.type && !json.mode) xh.mode = json.type;
-            // Padding / obfuscation — sing-box families use x_padding_bytes,
-            // while the extra block carries xPaddingBytes.
-            if (json.x_padding_bytes && !json.xPaddingBytes) json.xPaddingBytes = json.x_padding_bytes;
-            if (typeof json.xPaddingBytes === 'string' && json.xPaddingBytes) xh.xPaddingBytes = json.xPaddingBytes;
-            if (json.xPaddingObfsMode === true) {
-                xh.xPaddingObfsMode = true;
-                ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach((k: string) => {
-                    if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
-                });
-            }
-            // Bidirectional string fields carried in the extra block
-            const xFields = [
-                "uplinkHTTPMethod",
-                "sessionPlacement", "sessionKey",
-                "seqPlacement", "seqKey",
-                "uplinkDataPlacement", "uplinkDataKey",
-                "scMaxEachPostBytes", "scMinPostsIntervalMs",
-            ];
-            xFields.forEach((k: string) => {
-                if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
-            });
-            if (typeof json.uplinkChunkSize === 'number' && json.uplinkChunkSize !== 0) xh.uplinkChunkSize = json.uplinkChunkSize;
-            if (typeof json.uplinkChunkSize === 'string' && json.uplinkChunkSize) xh.uplinkChunkSize = json.uplinkChunkSize;
-            if (json.noGRPCHeader === true) xh.noGRPCHeader = true;
-            if (json.xmux && typeof json.xmux === 'object') {
-                xh.xmux = json.xmux;
-                xh.enableXmux = true;
-            }
-            if (json.downloadSettings && typeof json.downloadSettings === 'object') xh.downloadSettings = json.downloadSettings;
-            // Headers — VMess extra emits them as a {name: value} map
-            if (json.headers && typeof json.headers === 'object' && !Array.isArray(json.headers)) {
-                xh.headers = Object.entries(json.headers).map(([name, value]) => ({ name, value }));
-            }
-            stream.xhttp = xh;
-        }
-
-        if (json.tls && json.tls == 'tls') {
-            stream.tls = new TlsStreamSettings(
-                json.sni,
-                json.alpn ? json.alpn.split(',') : [],
-                json.fp);
-        }
-
-        const port = json.port * 1;
-
-        // Parse fm (finalmask) JSON string — TCP/UDP masks + QUIC params from 3x-ui share links
-        if (json.fm) {
-            try {
-                stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(json.fm));
-            } catch (_) { /* ignore malformed fm */ }
-        }
-
-        return new Outbound(json.ps, Protocols.VMess, new Outbound.VmessSettings(json.add, port, json.id, json.scy), stream);
-    }
-
-    static fromParamLink(link: any) {
-        const url = new URL(link);
-        const type = url.searchParams.get('type') ?? 'tcp';
-        const security = url.searchParams.get('security') ?? 'none';
-        const stream = new StreamSettings(type, security);
-
-        const headerType = url.searchParams.get('headerType') ?? undefined;
-        const host = url.searchParams.get('host') ?? undefined;
-        const path = url.searchParams.get('path') ?? undefined;
-        const seed = url.searchParams.get('seed') ?? path ?? undefined;
-        const mode = url.searchParams.get('mode') ?? undefined;
-
-        if (type === 'tcp' || type === 'none') {
-            stream.tcp = new TcpStreamSettings(headerType ?? 'none', host, path);
-        } else if (type === 'kcp') {
-            stream.kcp = new KcpStreamSettings();
-            stream.kcp.type = headerType ?? 'none';
-            stream.kcp.seed = seed;
-            const mtu = Number(url.searchParams.get('mtu'));
-            if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
-            const tti = Number(url.searchParams.get('tti'));
-            if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
-        } else if (type === 'ws') {
-            stream.ws = new WsStreamSettings(path, host);
-        } else if (type === 'grpc') {
-            stream.grpc = new GrpcStreamSettings(
-                url.searchParams.get('serviceName') ?? '',
-                url.searchParams.get('authority') ?? '',
-                url.searchParams.get('mode') == 'multi');
-        } else if (type === 'httpupgrade') {
-            stream.httpupgrade = new HttpUpgradeStreamSettings(path, host);
-        } else if (type === 'xhttp') {
-            // Same positional bug as in the VMess-JSON branch above:
-            // passing `mode` as the 3rd positional arg put it into the
-            // `headers` slot. Build explicitly instead.
-            const xh = new xHTTPStreamSettings(path, host);
-            if (mode) xh.mode = mode;
-            const xpb = url.searchParams.get('x_padding_bytes');
-            if (xpb) xh.xPaddingBytes = xpb;
-            const extraRaw = url.searchParams.get('extra');
-            if (extraRaw) {
-                try {
-                    const extra = JSON.parse(extraRaw);
-                    if (typeof extra.xPaddingBytes === 'string' && extra.xPaddingBytes) xh.xPaddingBytes = extra.xPaddingBytes;
-                    if (extra.xPaddingObfsMode === true) xh.xPaddingObfsMode = true;
-                    ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach((k: string) => {
-                        if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
-                    });
-                    if (!xh.mode && typeof extra.mode === 'string' && extra.mode) xh.mode = extra.mode;
-                    // Bidirectional string fields carried inside the extra block
-                    const xFields = [
-                        "uplinkHTTPMethod",
-                        "sessionPlacement", "sessionKey",
-                        "seqPlacement", "seqKey",
-                        "uplinkDataPlacement", "uplinkDataKey",
-                        "scMaxEachPostBytes", "scMinPostsIntervalMs",
-                    ];
-                    xFields.forEach((k: string) => {
-                        if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
-                    });
-                    if (typeof extra.uplinkChunkSize === 'number' && extra.uplinkChunkSize !== 0) xh.uplinkChunkSize = extra.uplinkChunkSize;
-                    if (typeof extra.uplinkChunkSize === 'string' && extra.uplinkChunkSize) xh.uplinkChunkSize = extra.uplinkChunkSize;
-                    if (extra.noGRPCHeader === true) xh.noGRPCHeader = true;
-                    if (extra.xmux && typeof extra.xmux === 'object') {
-                        xh.xmux = extra.xmux;
-                        xh.enableXmux = true;
-                    }
-                    if (extra.downloadSettings && typeof extra.downloadSettings === 'object') xh.downloadSettings = extra.downloadSettings;
-                    // Headers — extra emits them as a {name: value} map
-                    if (extra.headers && typeof extra.headers === 'object' && !Array.isArray(extra.headers)) {
-                        xh.headers = Object.entries(extra.headers).map(([name, value]) => ({ name, value }));
-                    }
-                } catch (_) { /* ignore malformed extra */ }
-            }
-            stream.xhttp = xh;
-        }
-
-        if (security == 'tls') {
-            const fp = url.searchParams.get('fp') ?? 'none';
-            const alpn = url.searchParams.get('alpn');
-            const sni = url.searchParams.get('sni') ?? '';
-            const ech = url.searchParams.get('ech') ?? '';
-            stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
-        }
-
-        if (security == 'reality') {
-            const pbk = url.searchParams.get('pbk');
-            const fp = url.searchParams.get('fp');
-            const sni = url.searchParams.get('sni') ?? '';
-            const sid = url.searchParams.get('sid') ?? '';
-            const spx = url.searchParams.get('spx') ?? '';
-            const pqv = url.searchParams.get('pqv') ?? '';
-            stream.reality = new RealityStreamSettings(pbk, fp, sni, sid, spx, pqv);
-        }
-
-        const regex = /([^@]+):\/\/([^@]+)@(.+):(\d+)(.*)$/;
-        const match = link.match(regex);
-
-        if (!match) return null;
-        const address = match[3];
-        let protocol = match[1];
-        let userData: any = match[2];
-        let port: any = match[4];
-        port *= 1;
-        if (protocol == 'ss') {
-            protocol = 'shadowsocks';
-            userData = atob(userData).split(':');
-        }
-        let settings;
-        switch (protocol) {
-            case Protocols.VLESS:
-                settings = new Outbound.VLESSSettings(address, port, userData, url.searchParams.get('flow') ?? '', url.searchParams.get('encryption') ?? 'none');
-                break;
-            case Protocols.Trojan:
-                settings = new Outbound.TrojanSettings(address, port, userData);
-                break;
-            case Protocols.Shadowsocks: {
-                const method = userData.splice(0, 1)[0];
-                settings = new Outbound.ShadowsocksSettings(address, port, userData.join(":"), method, true);
-                break;
-            }
-            default:
-                return null;
-        }
-        // Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links
-        const fmRaw = url.searchParams.get('fm');
-        if (fmRaw) {
-            try {
-                stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(fmRaw));
-            } catch (_) { /* ignore malformed fm */ }
-        }
-
-        let remark = decodeURIComponent(url.hash);
-        // Remove '#' from url.hash
-        remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port;
-        return new Outbound(remark, protocol, settings, stream);
-    }
-
-    static fromHysteriaLink(link: any) {
-        // Parse hysteria2://password@address:port[?param1=value1&param2=value2...][#remarks]
-        const regex = /^hysteria2?:\/\/([^@]+)@([^:?#]+):(\d+)([^#]*)(#.*)?$/;
-        const match = link.match(regex);
-
-        if (!match) return null;
-
-        const password = match[1];
-        const address = match[2];
-        let port: any = match[3];
-        const params = match[4];
-        const hash = match[5];
-        port = parseInt(port);
-
-        const urlParams = new URLSearchParams(params);
-
-        const security = urlParams.get('security') ?? 'none';
-        const stream = new StreamSettings('hysteria', security);
-
-        if (security === 'tls') {
-            const fp = urlParams.get('fp') ?? 'none';
-            const alpn = urlParams.get('alpn');
-            const sni = urlParams.get('sni') ?? '';
-            const ech = urlParams.get('ech') ?? '';
-            stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
-        }
-
-        // Set hysteria stream settings
-        stream.hysteria.auth = password;
-        stream.hysteria.congestion = urlParams.get('congestion') ?? '';
-        stream.hysteria.up = urlParams.get('up') ?? '0';
-        stream.hysteria.down = urlParams.get('down') ?? '0';
-        stream.hysteria.udphopPort = urlParams.get('udphopPort') ?? '';
-        // Support both old single interval and new min/max range
-        if (urlParams.has('udphopInterval')) {
-            const interval = parseInt(urlParams.get('udphopInterval')!);
-            stream.hysteria.udphopIntervalMin = interval;
-            stream.hysteria.udphopIntervalMax = interval;
-        } else {
-            stream.hysteria.udphopIntervalMin = parseInt(urlParams.get('udphopIntervalMin') ?? '30');
-            stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30');
-        }
-
-        // Optional QUIC parameters for FinalMask support and hysteria2 share links
-        if (urlParams.has('initStreamReceiveWindow')) {
-            stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow')!);
-        }
-        if (urlParams.has('maxStreamReceiveWindow')) {
-            stream.hysteria.maxStreamReceiveWindow = parseInt(urlParams.get('maxStreamReceiveWindow')!);
-        }
-        if (urlParams.has('initConnectionReceiveWindow')) {
-            stream.hysteria.initConnectionReceiveWindow = parseInt(urlParams.get('initConnectionReceiveWindow')!);
-        }
-        if (urlParams.has('maxConnectionReceiveWindow')) {
-            stream.hysteria.maxConnectionReceiveWindow = parseInt(urlParams.get('maxConnectionReceiveWindow')!);
-        }
-        if (urlParams.has('maxIdleTimeout')) {
-            stream.hysteria.maxIdleTimeout = parseInt(urlParams.get('maxIdleTimeout')!);
-        }
-        if (urlParams.has('keepAlivePeriod')) {
-            stream.hysteria.keepAlivePeriod = parseInt(urlParams.get('keepAlivePeriod')!);
-        }
-        if (urlParams.has('disablePathMTUDiscovery')) {
-            stream.hysteria.disablePathMTUDiscovery = urlParams.get('disablePathMTUDiscovery') === 'true';
-        }
-
-        // Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links, with special handling to mirror QUIC params into both stream.finalmask and stream.hysteria
-        const fmRaw = urlParams.get('fm');
-        if (fmRaw) {
-            try {
-                const fm = JSON.parse(fmRaw);
-                const qp = fm.quicParams;
-                if (qp && typeof qp === 'object') {
-                    // Populate stream.finalmask.quicParams — this enables the "QUIC Params"
-                    // toggle in FinalMaskForm and carries all QUIC tuning settings.
-                    stream.finalmask.quicParams = QuicParams.fromJson(qp);
-
-                    // Also mirror the overlapping fields into stream.hysteria so the
-                    // Hysteria transport section of the form shows consistent values.
-                    if (qp.congestion) stream.hysteria.congestion = qp.congestion;
-                    if (Number.isInteger(qp.initStreamReceiveWindow)) stream.hysteria.initStreamReceiveWindow = qp.initStreamReceiveWindow;
-                    if (Number.isInteger(qp.maxStreamReceiveWindow)) stream.hysteria.maxStreamReceiveWindow = qp.maxStreamReceiveWindow;
-                    if (Number.isInteger(qp.initConnectionReceiveWindow)) stream.hysteria.initConnectionReceiveWindow = qp.initConnectionReceiveWindow;
-                    if (Number.isInteger(qp.maxConnectionReceiveWindow)) stream.hysteria.maxConnectionReceiveWindow = qp.maxConnectionReceiveWindow;
-                    if (Number.isInteger(qp.maxIdleTimeout)) stream.hysteria.maxIdleTimeout = qp.maxIdleTimeout;
-                    if (Number.isInteger(qp.keepAlivePeriod)) stream.hysteria.keepAlivePeriod = qp.keepAlivePeriod;
-                    if (qp.disablePathMTUDiscovery === true) stream.hysteria.disablePathMTUDiscovery = true;
-                    if (qp.udpHop) {
-                        stream.hysteria.udphopPort = qp.udpHop.ports ?? stream.hysteria.udphopPort;
-                        if (qp.udpHop.interval !== undefined) {
-                            stream.hysteria.udphopIntervalMin = qp.udpHop.interval;
-                            stream.hysteria.udphopIntervalMax = qp.udpHop.interval;
-                        }
-                    }
-                }
-            } catch (_) { /* ignore malformed fm */ }
-        }
-
-        const settings = new Outbound.HysteriaSettings(address, port, 2);
-
-        const remark = hash ? decodeURIComponent(hash.substring(1)) : `out-hysteria-${port}`;
-
-        return new Outbound(remark, Protocols.Hysteria, settings, stream);
-    }
-}
-
-Outbound.Settings = class extends CommonClass {
-    constructor(protocol: any) {
-        super();
-        this.protocol = protocol;
-    }
-
-    static getSettings(protocol: any): any {
-        switch (protocol) {
-            case Protocols.Freedom: return new Outbound.FreedomSettings();
-            case Protocols.Blackhole: return new Outbound.BlackholeSettings();
-            case Protocols.DNS: return new Outbound.DNSSettings();
-            case Protocols.VMess: return new Outbound.VmessSettings();
-            case Protocols.VLESS: return new Outbound.VLESSSettings();
-            case Protocols.Trojan: return new Outbound.TrojanSettings();
-            case Protocols.Shadowsocks: return new Outbound.ShadowsocksSettings();
-            case Protocols.Socks: return new Outbound.SocksSettings();
-            case Protocols.HTTP: return new Outbound.HttpSettings();
-            case Protocols.Wireguard: return new Outbound.WireguardSettings();
-            case Protocols.Hysteria: return new Outbound.HysteriaSettings();
-            case Protocols.Loopback: return new Outbound.LoopbackSettings();
-            default: return null;
-        }
-    }
-
-    static fromJson(protocol: any, json: any): any {
-        switch (protocol) {
-            case Protocols.Freedom: return Outbound.FreedomSettings.fromJson(json);
-            case Protocols.Blackhole: return Outbound.BlackholeSettings.fromJson(json);
-            case Protocols.DNS: return Outbound.DNSSettings.fromJson(json);
-            case Protocols.VMess: return Outbound.VmessSettings.fromJson(json);
-            case Protocols.VLESS: return Outbound.VLESSSettings.fromJson(json);
-            case Protocols.Trojan: return Outbound.TrojanSettings.fromJson(json);
-            case Protocols.Shadowsocks: return Outbound.ShadowsocksSettings.fromJson(json);
-            case Protocols.Socks: return Outbound.SocksSettings.fromJson(json);
-            case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json);
-            case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json);
-            case Protocols.Hysteria: return Outbound.HysteriaSettings.fromJson(json);
-            case Protocols.Loopback: return Outbound.LoopbackSettings.fromJson(json);
-            default: return null;
-        }
-    }
-
-    toJson() {
-        return {};
-    }
-};
-Outbound.FreedomSettings = class extends CommonClass {
-    constructor(
-        domainStrategy = '',
-        redirect = '',
-        fragment = {},
-        noises = [],
-        finalRules = [],
-    ) {
-        super();
-        this.domainStrategy = domainStrategy;
-        this.redirect = redirect;
-        this.fragment = fragment || {};
-        this.noises = Array.isArray(noises) ? noises : [];
-        this.finalRules = Array.isArray(finalRules)
-            ? finalRules.map((rule: any) => rule instanceof Outbound.FreedomSettings.FinalRule ? rule : Outbound.FreedomSettings.FinalRule.fromJson(rule))
-            : [];
-    }
-
-    addNoise() {
-        this.noises.push(new Outbound.FreedomSettings.Noise());
-    }
-
-    delNoise(index: number) {
-        this.noises.splice(index, 1);
-    }
-
-    addFinalRule(action = 'block') {
-        this.finalRules.push(new Outbound.FreedomSettings.FinalRule(action));
-    }
-
-    delFinalRule(index: number) {
-        this.finalRules.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}): any {
-        const finalRules = Array.isArray(json.finalRules)
-            ? json.finalRules.map((rule: any) => Outbound.FreedomSettings.FinalRule.fromJson(rule))
-            : [];
-
-        // Backward compatibility: map legacy ipsBlocked entries to blocking finalRules.
-        if (finalRules.length === 0 && Array.isArray(json.ipsBlocked) && json.ipsBlocked.length > 0) {
-            finalRules.push(new Outbound.FreedomSettings.FinalRule('block', '', '', json.ipsBlocked, ''));
-        }
-
-        return new Outbound.FreedomSettings(
-            json.domainStrategy,
-            json.redirect,
-            json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : {},
-            json.noises ? json.noises.map((noise: any) => Outbound.FreedomSettings.Noise.fromJson(noise)) : [],
-            finalRules,
-        );
-    }
-
-    toJson() {
-        return {
-            domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy,
-            redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
-            fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
-            noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
-            finalRules: this.finalRules.length === 0 ? undefined : Outbound.FreedomSettings.FinalRule.toJsonArray(this.finalRules),
-        };
-    }
-};
-
-Outbound.FreedomSettings.Fragment = class extends CommonClass {
-    constructor(
-        packets = '1-3',
-        length = '',
-        interval = '',
-        maxSplit = ''
-    ) {
-        super();
-        this.packets = packets;
-        this.length = length;
-        this.interval = interval;
-        this.maxSplit = maxSplit;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.FreedomSettings.Fragment(
-            json.packets,
-            json.length,
-            json.interval,
-            json.maxSplit
-        );
-    }
-};
-
-Outbound.FreedomSettings.Noise = class extends CommonClass {
-    constructor(
-        type = 'rand',
-        packet = '10-20',
-        delay = '10-16',
-        applyTo = 'ip'
-    ) {
-        super();
-        this.type = type;
-        this.packet = packet;
-        this.delay = delay;
-        this.applyTo = applyTo;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.FreedomSettings.Noise(
-            json.type,
-            json.packet,
-            json.delay,
-            json.applyTo
-        );
-    }
-
-    toJson() {
-        return {
-            type: this.type,
-            packet: this.packet,
-            delay: this.delay,
-            applyTo: this.applyTo
-        };
-    }
-};
-
-Outbound.FreedomSettings.FinalRule = class extends CommonClass {
-    constructor(action = 'block', network = '', port = '', ip = [], blockDelay = '') {
-        super();
-        this.action = action;
-        this.network = network;
-        this.port = port;
-        this.ip = Array.isArray(ip) ? ip : [];
-        this.blockDelay = blockDelay;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.FreedomSettings.FinalRule(
-            json.action,
-            Array.isArray(json.network) ? json.network.join(',') : json.network,
-            json.port,
-            json.ip || [],
-            json.blockDelay,
-        );
-    }
-
-    toJson() {
-        return {
-            action: ['allow', 'block'].includes(this.action) ? this.action : 'block',
-            network: ObjectUtil.isEmpty(this.network) ? undefined : this.network,
-            port: ObjectUtil.isEmpty(this.port) ? undefined : this.port,
-            ip: this.ip.length === 0 ? undefined : this.ip,
-            blockDelay: this.action === 'block' && !ObjectUtil.isEmpty(this.blockDelay) ? this.blockDelay : undefined,
-        };
-    }
-};
-
-Outbound.BlackholeSettings = class extends CommonClass {
-    constructor(type?: any) {
-        super();
-        this.type = type;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.BlackholeSettings(
-            json.response ? json.response.type : undefined,
-        );
-    }
-
-    toJson() {
-        return {
-            response: ObjectUtil.isEmpty(this.type) ? undefined : { type: this.type },
-        };
-    }
-};
-
-Outbound.LoopbackSettings = class extends CommonClass {
-    constructor(inboundTag = '') {
-        super();
-        this.inboundTag = inboundTag;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.LoopbackSettings(json.inboundTag || '');
-    }
-
-    toJson() {
-        return {
-            inboundTag: this.inboundTag || undefined,
-        };
-    }
-};
-
-Outbound.DNSRule = class extends CommonClass {
-    constructor(action = 'direct', qtype = '', domain = '') {
-        super();
-        this.action = action;
-        this.qtype = qtype;
-        this.domain = domain;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.DNSRule(
-            json.action,
-            normalizeDNSRuleField(json.qtype),
-            normalizeDNSRuleField(json.domain),
-        );
-    }
-
-    toJson() {
-        const rule: any = {
-            action: normalizeDNSRuleAction(this.action),
-        };
-
-        const qtype = normalizeDNSRuleField(this.qtype);
-        if (!ObjectUtil.isEmpty(qtype)) {
-            if (/^\d+$/.test(qtype)) {
-                rule.qtype = Number(qtype);
-            } else {
-                rule.qtype = qtype;
-            }
-        }
-
-        const domains = normalizeDNSRuleField(this.domain)
-            .split(',')
-            .map(d => d.trim())
-            .filter(d => d.length > 0);
-        if (domains.length > 0) {
-            rule.domain = domains;
-        }
-
-        return rule;
-    }
-};
-
-Outbound.DNSSettings = class extends CommonClass {
-    constructor(
-        rewriteNetwork = '',
-        rewriteAddress = '',
-        rewritePort = 53,
-        userLevel = 0,
-        rules = []
-    ) {
-        super();
-        this.rewriteNetwork = rewriteNetwork;
-        this.rewriteAddress = rewriteAddress;
-        this.rewritePort = rewritePort;
-        this.userLevel = userLevel;
-        this.rules = Array.isArray(rules) ? rules.map((rule: any) => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : [];
-    }
-
-    addRule(action = 'direct') {
-        this.rules.push(new Outbound.DNSRule(action));
-    }
-
-    delRule(index: number) {
-        this.rules.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}): any {
-        // Spec uses rewrite{Network,Address,Port}; older configs used the
-        // bare network/address/port keys — accept both so existing saved
-        // configs keep working after the migration.
-        return new Outbound.DNSSettings(
-            json.rewriteNetwork ?? json.network ?? '',
-            json.rewriteAddress ?? json.address ?? '',
-            Number(json.rewritePort ?? json.port ?? 53) || 53,
-            Number(json.userLevel ?? 0) || 0,
-            getDNSRulesFromJson(json),
-        );
-    }
-
-    toJson() {
-        const json: any = {};
-        if (!ObjectUtil.isEmpty(this.rewriteNetwork)) json.rewriteNetwork = this.rewriteNetwork;
-        if (!ObjectUtil.isEmpty(this.rewriteAddress)) json.rewriteAddress = this.rewriteAddress;
-        if (this.rewritePort > 0) json.rewritePort = this.rewritePort;
-        if (this.userLevel > 0) json.userLevel = this.userLevel;
-        if (this.rules.length > 0) json.rules = Outbound.DNSRule.toJsonArray(this.rules);
-        return json;
-    }
-};
-Outbound.VmessSettings = class extends CommonClass {
-    constructor(address?: any, port?: any, id?: any, security?: any) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.id = id;
-        this.security = security;
-    }
-
-    static fromJson(json: any = {}): any {
-        if (!ObjectUtil.isArrEmpty(json.vnext)) {
-            const v = json.vnext[0] || {};
-            const u = ObjectUtil.isArrEmpty(v.users) ? {} : v.users[0];
-            return new Outbound.VmessSettings(
-                v.address,
-                v.port,
-                u.id,
-                u.security,
-            );
-        }
-    }
-
-    toJson() {
-        return {
-            vnext: [{
-                address: this.address,
-                port: this.port,
-                users: [{
-                    id: this.id,
-                    security: this.security
-                }]
-            }]
-        };
-    }
-};
-Outbound.VLESSSettings = class extends CommonClass {
-    constructor(address?: any, port?: any, id?: any, flow?: any, encryption: any = 'none', reverseTag: any = '', reverseSniffing: any = new ReverseSniffing(), testpre: any = 0, testseed: any[] = []) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.id = id;
-        this.flow = flow;
-        this.encryption = encryption || 'none';
-        this.reverseTag = reverseTag;
-        this.reverseSniffing = reverseSniffing;
-        this.testpre = testpre;
-        this.testseed = testseed;
-    }
-
-    static fromJson(json: any = {}): any {
-        // Handle v2rayN-style nested vnext array (standard Xray JSON format)
-        if (!ObjectUtil.isArrEmpty(json.vnext)) {
-            const v = json.vnext[0] || {};
-            const u = ObjectUtil.isArrEmpty(v.users) ? {} : v.users[0];
-            const saved = json.testseed;
-            const testseed = (Array.isArray(saved)
-                && saved.length === 4
-                && saved.every((v: any) => Number.isInteger(v) && v > 0))
-                ? saved
-                : [];
-            return new Outbound.VLESSSettings(
-                v.address,
-                v.port,
-                u.id,
-                u.flow,
-                u.encryption,
-                json.reverse?.tag || '',
-                ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
-                json.testpre || 0,
-                testseed,
-            );
-        }
-        if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
-        const saved = json.testseed;
-        const testseed = (Array.isArray(saved)
-            && saved.length === 4
-            && saved.every((v: any) => Number.isInteger(v) && v > 0))
-            ? saved
-            : [];
-        return new Outbound.VLESSSettings(
-            json.address,
-            json.port,
-            json.id,
-            json.flow,
-            json.encryption,
-            json.reverse?.tag || '',
-            ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
-            json.testpre || 0,
-            testseed,
-        );
-    }
-
-    toJson() {
-        const result: any = {
-            address: this.address,
-            port: this.port,
-            id: this.id,
-            flow: this.flow,
-            encryption: this.encryption || 'none',
-        };
-        if (!ObjectUtil.isEmpty(this.reverseTag)) {
-            const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};
-            const defaultReverseSniffing = new ReverseSniffing().toJson();
-            result.reverse = {
-                tag: this.reverseTag,
-                sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
-            };
-        }
-        // Vision-specific knobs are only meaningful for the exact xtls-rprx-vision flow.
-        if (this.flow === TLS_FLOW_CONTROL.VISION) {
-            if (this.testpre > 0) {
-                result.testpre = this.testpre;
-            }
-            if (Array.isArray(this.testseed)
-                && this.testseed.length === 4
-                && this.testseed.every((v: any) => Number.isInteger(v) && v > 0)) {
-                result.testseed = this.testseed;
-            }
-        }
-        return result;
-    }
-};
-Outbound.TrojanSettings = class extends CommonClass {
-    constructor(address?: any, port?: any, password?: any) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.password = password;
-    }
-
-    static fromJson(json: any = {}): any {
-        if (ObjectUtil.isArrEmpty(json.servers)) return new Outbound.TrojanSettings();
-        return new Outbound.TrojanSettings(
-            json.servers[0].address,
-            json.servers[0].port,
-            json.servers[0].password,
-        );
-    }
-
-    toJson() {
-        return {
-            servers: [{
-                address: this.address,
-                port: this.port,
-                password: this.password,
-            }],
-        };
-    }
-};
-Outbound.ShadowsocksSettings = class extends CommonClass {
-    constructor(address?: any, port?: any, password?: any, method?: any, uot?: any, UoTVersion?: any) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.password = password;
-        this.method = method;
-        this.uot = uot;
-        this.UoTVersion = UoTVersion;
-    }
-
-    static fromJson(json: any = {}): any {
-        let servers = json.servers;
-        if (ObjectUtil.isArrEmpty(servers)) servers = [{}];
-        return new Outbound.ShadowsocksSettings(
-            servers[0].address,
-            servers[0].port,
-            servers[0].password,
-            servers[0].method,
-            servers[0].uot,
-            servers[0].UoTVersion,
-        );
-    }
-
-    toJson() {
-        return {
-            servers: [{
-                address: this.address,
-                port: this.port,
-                password: this.password,
-                method: this.method,
-                uot: this.uot,
-                UoTVersion: this.UoTVersion,
-            }],
-        };
-    }
-};
-
-Outbound.SocksSettings = class extends CommonClass {
-    constructor(address?: any, port?: any, user?: any, pass?: any) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.user = user;
-        this.pass = pass;
-    }
-
-    static fromJson(json: any = {}): any {
-        let servers = json.servers;
-        if (ObjectUtil.isArrEmpty(servers)) servers = [{ users: [{}] }];
-        return new Outbound.SocksSettings(
-            servers[0].address,
-            servers[0].port,
-            ObjectUtil.isArrEmpty(servers[0].users) ? '' : servers[0].users[0].user,
-            ObjectUtil.isArrEmpty(servers[0].users) ? '' : servers[0].users[0].pass,
-        );
-    }
-
-    toJson() {
-        return {
-            servers: [{
-                address: this.address,
-                port: this.port,
-                users: ObjectUtil.isEmpty(this.user) ? [] : [{ user: this.user, pass: this.pass }],
-            }],
-        };
-    }
-};
-Outbound.HttpSettings = class extends CommonClass {
-    constructor(address?: any, port?: any, user?: any, pass?: any) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.user = user;
-        this.pass = pass;
-    }
-
-    static fromJson(json: any = {}): any {
-        let servers = json.servers;
-        if (ObjectUtil.isArrEmpty(servers)) servers = [{ users: [{}] }];
-        return new Outbound.HttpSettings(
-            servers[0].address,
-            servers[0].port,
-            ObjectUtil.isArrEmpty(servers[0].users) ? '' : servers[0].users[0].user,
-            ObjectUtil.isArrEmpty(servers[0].users) ? '' : servers[0].users[0].pass,
-        );
-    }
-
-    toJson() {
-        return {
-            servers: [{
-                address: this.address,
-                port: this.port,
-                users: ObjectUtil.isEmpty(this.user) ? [] : [{ user: this.user, pass: this.pass }],
-            }],
-        };
-    }
-};
-
-Outbound.WireguardSettings = class extends CommonClass {
-    constructor(
-        mtu = 1420,
-        secretKey = '',
-        address = [''],
-        workers = 2,
-        domainStrategy = '',
-        reserved = '',
-        peers = [new Outbound.WireguardSettings.Peer()],
-        noKernelTun = false,
-    ) {
-        super();
-        this.mtu = mtu;
-        this.secretKey = secretKey;
-        this.pubKey = secretKey.length > 0 ? Wireguard.generateKeypair(secretKey).publicKey : '';
-        this.address = Array.isArray(address) ? address.join(',') : address;
-        this.workers = workers;
-        this.domainStrategy = domainStrategy;
-        this.reserved = Array.isArray(reserved) ? reserved.join(',') : reserved;
-        this.peers = peers;
-        this.noKernelTun = noKernelTun;
-    }
-
-    addPeer() {
-        this.peers.push(new Outbound.WireguardSettings.Peer());
-    }
-
-    delPeer(index: number) {
-        this.peers.splice(index, 1);
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.WireguardSettings(
-            json.mtu,
-            json.secretKey,
-            json.address,
-            json.workers,
-            json.domainStrategy,
-            json.reserved,
-            json.peers.map((peer: any) => Outbound.WireguardSettings.Peer.fromJson(peer)),
-            json.noKernelTun,
-        );
-    }
-
-    toJson() {
-        return {
-            mtu: this.mtu ?? undefined,
-            secretKey: this.secretKey,
-            address: this.address ? this.address.split(",") : [],
-            workers: this.workers ?? undefined,
-            domainStrategy: WireguardDomainStrategy.includes(this.domainStrategy) ? this.domainStrategy : undefined,
-            reserved: this.reserved ? this.reserved.split(",").map(Number) : undefined,
-            peers: Outbound.WireguardSettings.Peer.toJsonArray(this.peers),
-            noKernelTun: this.noKernelTun,
-        };
-    }
-};
-
-Outbound.WireguardSettings.Peer = class extends CommonClass {
-    constructor(
-        publicKey = '',
-        psk = '',
-        allowedIPs = ['0.0.0.0/0', '::/0'],
-        endpoint = '',
-        keepAlive = 0
-    ) {
-        super();
-        this.publicKey = publicKey;
-        this.psk = psk;
-        this.allowedIPs = allowedIPs;
-        this.endpoint = endpoint;
-        this.keepAlive = keepAlive;
-    }
-
-    static fromJson(json: any = {}): any {
-        return new Outbound.WireguardSettings.Peer(
-            json.publicKey,
-            json.preSharedKey,
-            json.allowedIPs,
-            json.endpoint,
-            json.keepAlive
-        );
-    }
-
-    toJson() {
-        return {
-            publicKey: this.publicKey,
-            preSharedKey: this.psk.length > 0 ? this.psk : undefined,
-            allowedIPs: this.allowedIPs ? this.allowedIPs : undefined,
-            endpoint: this.endpoint,
-            keepAlive: this.keepAlive ?? undefined,
-        };
-    }
-};
-
-Outbound.HysteriaSettings = class extends CommonClass {
-    constructor(address = '', port = 443, version = 2) {
-        super();
-        this.address = address;
-        this.port = port;
-        this.version = version;
-    }
-
-    static fromJson(json: any = {}): any {
-        if (Object.keys(json).length === 0) return new Outbound.HysteriaSettings();
-        return new Outbound.HysteriaSettings(
-            json.address,
-            json.port,
-            json.version
-        );
-    }
-
-    toJson() {
-        return {
-            address: this.address,
-            port: this.port,
-            version: this.version
-        };
-    }
-};

+ 1 - 1
frontend/src/models/setting.ts

@@ -12,7 +12,7 @@ export class AllSetting {
   pageSize = 25;
   expireDiff = 0;
   trafficDiff = 0;
-  remarkModel = '-ieo';
+  remarkModel = '-io';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';

+ 15 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -521,6 +521,20 @@ export const sections: readonly Section[] = [
         body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkDel',
+        summary: 'Delete many clients in one call. The server processes the list sequentially so each delete sees the committed state of the previous one — avoids the race the per-email fan-out had on the panel side. Pass keepTraffic=true to retain the xray_client_traffic rows after deletion.',
+        body: '{\n  "emails": ["alice", "bob"],\n  "keepTraffic": false\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "client not found" }\n    ]\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkCreate',
+        summary: 'Create many clients in one call. Body is a JSON array of {client, inboundIds} payloads — the same shape /add accepts. Items are processed sequentially; per-email skip reasons are returned for items that fail (e.g., duplicate email). Triggers a single Xray restart at the end if any inbound was running.',
+        body: '[\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7]\n  },\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7, 9]\n  }\n]',
+        response: '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use" }\n    ]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/resetTraffic/:email',
@@ -590,7 +604,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/clients/links/:email',
         summary:
-          "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+          "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],

+ 136 - 159
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -5,23 +5,18 @@ import { SyncOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
-import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
-import { TLS_FLOW_CONTROL } from '@/models/inbound';
+import { RandomUtil, SizeFormatter } from '@/utils';
+import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import DateTimePicker from '@/components/DateTimePicker';
-import type { InboundOption } from '@/hooks/useClients';
+import { useClients, type InboundOption } from '@/hooks/useClients';
+import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
-  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
 ]);
 
-interface ApiMsg {
-  success?: boolean;
-  msg?: string;
-}
-
 interface ClientBulkAddModalProps {
   open: boolean;
   inbounds: InboundOption[];
@@ -30,21 +25,7 @@ interface ClientBulkAddModalProps {
   onSaved?: () => void;
 }
 
-interface FormState {
-  emailMethod: number;
-  firstNum: number;
-  lastNum: number;
-  emailPrefix: string;
-  emailPostfix: string;
-  quantity: number;
-  subId: string;
-  comment: string;
-  flow: string;
-  limitIp: number;
-  totalGB: number;
-  expiryTime: number;
-  inboundIds: number[];
-}
+type FormState = ClientBulkAddFormValues;
 
 function emptyForm(): FormState {
   return {
@@ -73,6 +54,7 @@ export default function ClientBulkAddModal({
 }: ClientBulkAddModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
+  const { bulkCreate } = useClients();
 
   const [form, setForm] = useState<FormState>(emptyForm);
   const [delayedStart, setDelayedStart] = useState(false);
@@ -80,10 +62,10 @@ export default function ClientBulkAddModal({
 
   useEffect(() => {
     if (!open) return;
-     
+
     setForm(emptyForm());
     setDelayedStart(false);
-     
+
   }, [open]);
 
   function update<K extends keyof FormState>(key: K, value: FormState[K]) {
@@ -105,7 +87,7 @@ export default function ClientBulkAddModal({
 
   useEffect(() => {
     if (!showFlow && form.flow) {
-       
+
       update('flow', '');
     }
   }, [showFlow, form.flow]);
@@ -152,18 +134,18 @@ export default function ClientBulkAddModal({
   }
 
   async function submit() {
-    if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
-      messageApi.error(t('pages.clients.selectInbound'));
+    const validated = ClientBulkAddFormSchema.safeParse(form);
+    if (!validated.success) {
+      messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
     }
     const emails = buildEmails();
     if (emails.length === 0) return;
 
     setSaving(true);
-    const silentJsonOpts = { ...JSON_HEADERS, silent: true };
     try {
-      const results = await Promise.all(emails.map((email) => {
-        const client = {
+      const payloads = emails.map((email) => ({
+        client: {
           email,
           subId: form.subId || RandomUtil.randomLowerAndNum(16),
           id: RandomUtil.randomUUID(),
@@ -175,21 +157,15 @@ export default function ClientBulkAddModal({
           limitIp: Number(form.limitIp) || 0,
           comment: form.comment,
           enable: true,
-        };
-        const payload = { client, inboundIds: form.inboundIds };
-        return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise<ApiMsg>;
+        },
+        inboundIds: form.inboundIds,
       }));
-      let ok = 0;
-      let failed = 0;
-      let firstError = '';
-      for (const msg of results) {
-        if (msg?.success) ok++;
-        else {
-          failed++;
-          if (!firstError && msg?.msg) firstError = msg.msg;
-        }
-      }
-      if (failed === 0) {
+      const msg = await bulkCreate(payloads);
+      const ok = msg?.obj?.created ?? 0;
+      const skipped = msg?.obj?.skipped ?? [];
+      const failed = skipped.length;
+      const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
+      if (failed === 0 && msg?.success) {
         messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
       } else {
         messageApi.warning(firstError
@@ -210,130 +186,131 @@ export default function ClientBulkAddModal({
         open={open}
         title={t('pages.clients.bulk')}
         okText={t('create')}
-      cancelText={t('close')}
-      confirmLoading={saving}
-      mask={{ closable: false }}
-      width={640}
-      onOk={submit}
-      onCancel={() => onOpenChange(false)}
-    >
-      <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
-        <Form.Item label={t('pages.clients.attachedInbounds')} required>
-          <Select
-            mode="multiple"
-            value={form.inboundIds}
-            onChange={(v) => update('inboundIds', v)}
-            options={inboundOptions}
-            placeholder={t('pages.clients.selectInbound')}
-            showSearch
-            filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
-          />
-        </Form.Item>
-
-        <Form.Item label={t('pages.clients.method')}>
-          <Select
-            value={form.emailMethod}
-            onChange={(v) => update('emailMethod', v)}
-            options={[
-              { value: 0, label: 'Random' },
-              { value: 1, label: 'Random + Prefix' },
-              { value: 2, label: 'Random + Prefix + Num' },
-              { value: 3, label: 'Random + Prefix + Num + Postfix' },
-              { value: 4, label: 'Prefix + Num + Postfix' },
-            ]}
-          />
-        </Form.Item>
-
-        {form.emailMethod > 1 && (
-          <>
-            <Form.Item label={t('pages.clients.first')}>
-              <InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
-            </Form.Item>
-            <Form.Item label={t('pages.clients.last')}>
-              <InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
-            </Form.Item>
-          </>
-        )}
-        {form.emailMethod > 0 && (
-          <Form.Item label={t('pages.clients.prefix')}>
-            <Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
-          </Form.Item>
-        )}
-        {form.emailMethod > 2 && (
-          <Form.Item label={t('pages.clients.postfix')}>
-            <Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
-          </Form.Item>
-        )}
-        {form.emailMethod < 2 && (
-          <Form.Item label={t('pages.clients.clientCount')}>
-            <InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
-          </Form.Item>
-        )}
-
-        <Form.Item label={
-          <>
-            {t('subscription.title')}
-            <SyncOutlined
-              className="random-icon"
-              onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
+        cancelText={t('close')}
+        confirmLoading={saving}
+        mask={{ closable: false }}
+        width={640}
+        onOk={submit}
+        onCancel={() => onOpenChange(false)}
+      >
+        <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
+          <Form.Item label={t('pages.clients.attachedInbounds')} required>
+            <Select
+              mode="multiple"
+              value={form.inboundIds}
+              onChange={(v) => update('inboundIds', v)}
+              options={inboundOptions}
+              placeholder={t('pages.clients.selectInbound')}
+              showSearch={{
+                filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+              }}
             />
-          </>
-        }>
-          <Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
-        </Form.Item>
-
-        <Form.Item label={t('comment')}>
-          <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
-        </Form.Item>
+          </Form.Item>
 
-        {showFlow && (
-          <Form.Item label={t('pages.clients.flow')}>
+          <Form.Item label={t('pages.clients.method')}>
             <Select
-              value={form.flow}
-              onChange={(v) => update('flow', v)}
-              style={{ width: 220 }}
+              value={form.emailMethod}
+              onChange={(v) => update('emailMethod', v)}
               options={[
-                { value: '', label: t('none') },
-                ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                { value: 0, label: 'Random' },
+                { value: 1, label: 'Random + Prefix' },
+                { value: 2, label: 'Random + Prefix + Num' },
+                { value: 3, label: 'Random + Prefix + Num + Postfix' },
+                { value: 4, label: 'Prefix + Num + Postfix' },
               ]}
             />
           </Form.Item>
-        )}
 
-        {ipLimitEnable && (
-          <Form.Item label={t('pages.clients.limitIp')}>
-            <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+          {form.emailMethod > 1 && (
+            <>
+              <Form.Item label={t('pages.clients.first')}>
+                <InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
+              </Form.Item>
+              <Form.Item label={t('pages.clients.last')}>
+                <InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
+              </Form.Item>
+            </>
+          )}
+          {form.emailMethod > 0 && (
+            <Form.Item label={t('pages.clients.prefix')}>
+              <Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
+            </Form.Item>
+          )}
+          {form.emailMethod > 2 && (
+            <Form.Item label={t('pages.clients.postfix')}>
+              <Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
+            </Form.Item>
+          )}
+          {form.emailMethod < 2 && (
+            <Form.Item label={t('pages.clients.clientCount')}>
+              <InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
+            </Form.Item>
+          )}
+
+          <Form.Item label={
+            <>
+              {t('subscription.title')}
+              <SyncOutlined
+                className="random-icon"
+                onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
+              />
+            </>
+          }>
+            <Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
           </Form.Item>
-        )}
-
-        <Form.Item label={t('pages.clients.totalGB')}>
-          <InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
-        </Form.Item>
-
-        <Form.Item label={t('pages.clients.delayedStart')}>
-          <Switch
-            checked={delayedStart}
-            onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
-          />
-        </Form.Item>
-
-        {delayedStart ? (
-          <Form.Item label={t('pages.clients.expireDays')}>
-            <InputNumber
-              value={delayedExpireDays}
-              min={0}
-              onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
-            />
+
+          <Form.Item label={t('comment')}>
+            <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
           </Form.Item>
-        ) : (
-          <Form.Item label={t('pages.inbounds.expireDate')}>
-            <DateTimePicker
-              value={expiryDate}
-              onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
+
+          {showFlow && (
+            <Form.Item label={t('pages.clients.flow')}>
+              <Select
+                value={form.flow}
+                onChange={(v) => update('flow', v)}
+                style={{ width: 220 }}
+                options={[
+                  { value: '', label: t('none') },
+                  ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                ]}
+              />
+            </Form.Item>
+          )}
+
+          {ipLimitEnable && (
+            <Form.Item label={t('pages.clients.limitIp')}>
+              <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+            </Form.Item>
+          )}
+
+          <Form.Item label={t('pages.clients.totalGB')}>
+            <InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
+          </Form.Item>
+
+          <Form.Item label={t('pages.clients.delayedStart')}>
+            <Switch
+              checked={delayedStart}
+              onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
             />
           </Form.Item>
-        )}
-      </Form>
+
+          {delayedStart ? (
+            <Form.Item label={t('pages.clients.expireDays')}>
+              <InputNumber
+                value={delayedExpireDays}
+                min={0}
+                onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
+              />
+            </Form.Item>
+          ) : (
+            <Form.Item label={t('pages.inbounds.expireDate')}>
+              <DateTimePicker
+                value={expiryDate}
+                onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
+              />
+            </Form.Item>
+          )}
+        </Form>
       </Modal>
     </>
   );

+ 10 - 5
frontend/src/pages/clients/ClientBulkAdjustModal.tsx

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Form, InputNumber, Modal, message } from 'antd';
 
+import { ClientBulkAdjustFormSchema } from '@/schemas/client';
+
 const GB = 1024 * 1024 * 1024;
 
 interface ClientBulkAdjustModalProps {
@@ -26,12 +28,15 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
   }, [open]);
 
   async function handleOk() {
-    const days = Math.trunc(Number(addDays) || 0);
-    const gb = Number(addGB) || 0;
-    if (days === 0 && gb === 0) {
-      messageApi.warning(t('pages.clients.bulkAdjustNothing'));
+    const validated = ClientBulkAdjustFormSchema.safeParse({
+      addDays: Math.trunc(Number(addDays) || 0),
+      addGB: Number(addGB) || 0,
+    });
+    if (!validated.success) {
+      messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
     }
+    const { addDays: days, addGB: gb } = validated.data;
     setSubmitting(true);
     try {
       const bytes = Math.trunc(gb * GB);
@@ -70,7 +75,7 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
           type="info"
           showIcon
           style={{ marginBottom: 16 }}
-          message={t('pages.clients.bulkAdjustHint')}
+          title={t('pages.clients.bulkAdjustHint')}
         />
         <Form layout="vertical">
           <Form.Item label={t('pages.clients.addDays')}>

+ 0 - 1
frontend/src/pages/clients/ClientFormModal.css

@@ -1 +0,0 @@
-/* Client form modal — additional layout overrides if needed. */

+ 199 - 183
frontend/src/pages/clients/ClientFormModal.tsx

@@ -19,14 +19,14 @@ import type { Dayjs } from 'dayjs';
 
 import { HttpUtil, RandomUtil } from '@/utils';
 import DateTimePicker from '@/components/DateTimePicker';
-import { TLS_FLOW_CONTROL } from '@/models/inbound';
+import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
-import './ClientFormModal.css';
+import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
-  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
 ]);
 
 interface ApiMsg<T = unknown> {
@@ -144,7 +144,7 @@ export default function ClientFormModal({
 
   useEffect(() => {
     if (!open) return;
-     
+
     if (isEdit && client) {
       const et = Number(client.expiryTime) || 0;
       const next: FormState = {
@@ -184,7 +184,7 @@ export default function ClientFormModal({
         auth: RandomUtil.randomLowerAndNum(16),
       });
     }
-     
+
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [open, isEdit]);
 
@@ -216,14 +216,14 @@ export default function ClientFormModal({
 
   useEffect(() => {
     if (!showFlow && form.flow) {
-       
+
       update('flow', '');
     }
   }, [showFlow, form.flow]);
 
   useEffect(() => {
     if (!showReverseTag && form.reverseTag) {
-       
+
       update('reverseTag', '');
     }
   }, [showReverseTag, form.reverseTag]);
@@ -268,12 +268,27 @@ export default function ClientFormModal({
   }
 
   async function onSubmit() {
-    if (!form.email || form.email.trim() === '') {
-      messageApi.error(`${t('pages.clients.email')} *`);
-      return;
-    }
-    if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) {
-      messageApi.error(t('pages.clients.selectInbound'));
+    const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
+    const validated = schema.safeParse({
+      email: form.email,
+      subId: form.subId,
+      uuid: form.uuid,
+      password: form.password,
+      auth: form.auth,
+      flow: form.flow,
+      reverseTag: form.reverseTag,
+      totalGB: form.totalGB,
+      delayedStart: form.delayedStart,
+      delayedDays: form.delayedDays,
+      limitIp: form.limitIp,
+      tgId: form.tgId,
+      comment: form.comment,
+      enable: form.enable,
+      inboundIds: form.inboundIds,
+    });
+    if (!validated.success) {
+      const issue = validated.error.issues[0];
+      messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
       return;
     }
     const expiryTime = form.delayedStart
@@ -331,193 +346,194 @@ export default function ClientFormModal({
         open={open}
         title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
         destroyOnHidden
-      okText={isEdit ? t('save') : t('create')}
-      cancelText={t('cancel')}
-      okButtonProps={{ loading: submitting }}
-      width={720}
-      onOk={onSubmit}
-      onCancel={close}
-    >
-      <Form layout="vertical">
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.clients.email')} required>
-              <Space.Compact style={{ display: 'flex' }}>
-                <Input
-                  value={form.email}
-                  placeholder={t('pages.clients.email')}
-                  style={{ flex: 1 }}
-                  onChange={(e) => update('email', e.target.value)}
-                />
-                <Button onClick={() => update('email', RandomUtil.randomLowerAndNum(12))}>↻</Button>
-              </Space.Compact>
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.clients.subId')}>
-              <Space.Compact style={{ display: 'flex' }}>
-                <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
-                <Button onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}>↻</Button>
-              </Space.Compact>
-            </Form.Item>
-          </Col>
-        </Row>
-
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.clients.hysteriaAuth')}>
-              <Space.Compact style={{ display: 'flex' }}>
-                <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
-                <Button onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))}>↻</Button>
-              </Space.Compact>
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.clients.password')}>
-              <Space.Compact style={{ display: 'flex' }}>
-                <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                <Button onClick={() => update('password', RandomUtil.randomLowerAndNum(16))}>↻</Button>
-              </Space.Compact>
-            </Form.Item>
-          </Col>
-        </Row>
-
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.clients.uuid')}>
-              <Space.Compact style={{ display: 'flex' }}>
-                <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
-                <Button onClick={() => update('uuid', RandomUtil.randomUUID())}>↻</Button>
-              </Space.Compact>
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={ipLimitEnable ? 8 : 12}>
-            <Form.Item label={t('pages.clients.totalGB')}>
-              <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
-                onChange={(v) => update('totalGB', Number(v) || 0)} />
-            </Form.Item>
-          </Col>
-          {ipLimitEnable && (
-            <Col xs={24} md={4}>
-              <Form.Item label={t('pages.clients.limitIp')}>
-                <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
-                  onChange={(v) => update('limitIp', Number(v) || 0)} />
+        okText={isEdit ? t('save') : t('create')}
+        cancelText={t('cancel')}
+        okButtonProps={{ loading: submitting }}
+        width={720}
+        onOk={onSubmit}
+        onCancel={close}
+      >
+        <Form layout="vertical">
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.email')} required>
+                <Space.Compact style={{ display: 'flex' }}>
+                  <Input
+                    value={form.email}
+                    placeholder={t('pages.clients.email')}
+                    style={{ flex: 1 }}
+                    onChange={(e) => update('email', e.target.value)}
+                  />
+                  <Button onClick={() => update('email', RandomUtil.randomLowerAndNum(12))}>↻</Button>
+                </Space.Compact>
               </Form.Item>
             </Col>
-          )}
-        </Row>
-
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            {form.delayedStart ? (
-              <Form.Item label={t('pages.clients.expireDays')}>
-                <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
-                  onChange={(v) => update('delayedDays', Number(v) || 0)} />
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.subId')}>
+                <Space.Compact style={{ display: 'flex' }}>
+                  <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
+                  <Button onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}>↻</Button>
+                </Space.Compact>
               </Form.Item>
-            ) : (
-              <Form.Item label={t('pages.clients.expiryTime')}>
-                <DateTimePicker
-                  value={form.expiryDate}
-                  onChange={(d) => update('expiryDate', d || null)}
-                />
+            </Col>
+          </Row>
+
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.hysteriaAuth')}>
+                <Space.Compact style={{ display: 'flex' }}>
+                  <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
+                  <Button onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))}>↻</Button>
+                </Space.Compact>
               </Form.Item>
-            )}
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.clients.delayedStart')}>
-              <Switch
-                checked={form.delayedStart}
-                onChange={(v) => {
-                  update('delayedStart', v);
-                  if (v) update('expiryDate', null);
-                  else update('delayedDays', 0);
-                }}
-              />
-            </Form.Item>
-          </Col>
-        </Row>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.password')}>
+                <Space.Compact style={{ display: 'flex' }}>
+                  <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
+                  <Button onClick={() => update('password', RandomUtil.randomLowerAndNum(16))}>↻</Button>
+                </Space.Compact>
+              </Form.Item>
+            </Col>
+          </Row>
 
-        {(showFlow || showReverseTag) && (
           <Row gutter={16}>
-            {showFlow && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.flow')}>
-                  <Select
-                    value={form.flow}
-                    onChange={(v) => update('flow', v)}
-                    options={[
-                      { value: '', label: t('none') },
-                      ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
-                    ]}
-                  />
-                </Form.Item>
-              </Col>
-            )}
-            {showReverseTag && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.reverseTag')}>
-                  <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
-                    onChange={(e) => update('reverseTag', e.target.value)} />
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.uuid')}>
+                <Space.Compact style={{ display: 'flex' }}>
+                  <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
+                  <Button onClick={() => update('uuid', RandomUtil.randomUUID())}>↻</Button>
+                </Space.Compact>
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={ipLimitEnable ? 8 : 12}>
+              <Form.Item label={t('pages.clients.totalGB')}>
+                <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
+                  onChange={(v) => update('totalGB', Number(v) || 0)} />
+              </Form.Item>
+            </Col>
+            {ipLimitEnable && (
+              <Col xs={24} md={4}>
+                <Form.Item label={t('pages.clients.limitIp')}>
+                  <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
+                    onChange={(v) => update('limitIp', Number(v) || 0)} />
                 </Form.Item>
               </Col>
             )}
           </Row>
-        )}
 
-        <Row gutter={16}>
-          {tgBotEnable && (
+          <Row gutter={16}>
             <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.telegramId')}>
-                <InputNumber value={form.tgId} min={0} controls={false}
-                  placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
-                  onChange={(v) => update('tgId', Number(v) || 0)} />
+              {form.delayedStart ? (
+                <Form.Item label={t('pages.clients.expireDays')}>
+                  <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
+                    onChange={(v) => update('delayedDays', Number(v) || 0)} />
+                </Form.Item>
+              ) : (
+                <Form.Item label={t('pages.clients.expiryTime')}>
+                  <DateTimePicker
+                    value={form.expiryDate}
+                    onChange={(d) => update('expiryDate', d || null)}
+                  />
+                </Form.Item>
+              )}
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.delayedStart')}>
+                <Switch
+                  checked={form.delayedStart}
+                  onChange={(v) => {
+                    update('delayedStart', v);
+                    if (v) update('expiryDate', null);
+                    else update('delayedDays', 0);
+                  }}
+                />
               </Form.Item>
             </Col>
+          </Row>
+
+          {(showFlow || showReverseTag) && (
+            <Row gutter={16}>
+              {showFlow && (
+                <Col xs={24} md={12}>
+                  <Form.Item label={t('pages.clients.flow')}>
+                    <Select
+                      value={form.flow}
+                      onChange={(v) => update('flow', v)}
+                      options={[
+                        { value: '', label: t('none') },
+                        ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                      ]}
+                    />
+                  </Form.Item>
+                </Col>
+              )}
+              {showReverseTag && (
+                <Col xs={24} md={12}>
+                  <Form.Item label={t('pages.clients.reverseTag')}>
+                    <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
+                      onChange={(e) => update('reverseTag', e.target.value)} />
+                  </Form.Item>
+                </Col>
+              )}
+            </Row>
           )}
-          <Col xs={24} md={tgBotEnable ? 12 : 24}>
-            <Form.Item label={t('pages.clients.comment')}>
-              <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
-            </Form.Item>
-          </Col>
-        </Row>
-
-        <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
-          <Select
-            mode="multiple"
-            value={form.inboundIds}
-            onChange={(v) => update('inboundIds', v)}
-            options={inboundOptions}
-            showSearch
-            placeholder={t('pages.clients.selectInbound')}
-            filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
-          />
-        </Form.Item>
-
-        <Form.Item>
-          <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
-          <span style={{ marginLeft: 8 }}>{t('enable')}</span>
-        </Form.Item>
-
-        {isEdit && ipLimitEnable && (
-          <Form.Item label={t('pages.clients.ipLog')}>
-            <Space style={{ marginBottom: 8 }}>
-              <Button size="small" loading={ipsLoading} onClick={loadIps}>{t('refresh')}</Button>
-              <Button size="small" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
-                {t('pages.clients.clearAll')}
-              </Button>
-            </Space>
-            {clientIps.length > 0 ? (
-              <div>
-                {clientIps.map((ip, idx) => (
-                  <Tag key={idx} color="blue" style={{ marginBottom: 4 }}>{ip}</Tag>
-                ))}
-              </div>
-            ) : (
-              <Tag>{t('tgbot.noIpRecord')}</Tag>
+
+          <Row gutter={16}>
+            {tgBotEnable && (
+              <Col xs={24} md={12}>
+                <Form.Item label={t('pages.clients.telegramId')}>
+                  <InputNumber value={form.tgId} min={0} controls={false}
+                    placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
+                    onChange={(v) => update('tgId', Number(v) || 0)} />
+                </Form.Item>
+              </Col>
             )}
+            <Col xs={24} md={tgBotEnable ? 12 : 24}>
+              <Form.Item label={t('pages.clients.comment')}>
+                <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
+              </Form.Item>
+            </Col>
+          </Row>
+
+          <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
+            <Select
+              mode="multiple"
+              value={form.inboundIds}
+              onChange={(v) => update('inboundIds', v)}
+              options={inboundOptions}
+              placeholder={t('pages.clients.selectInbound')}
+              showSearch={{
+                filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+              }}
+            />
+          </Form.Item>
+
+          <Form.Item>
+            <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
+            <span style={{ marginLeft: 8 }}>{t('enable')}</span>
           </Form.Item>
-        )}
-      </Form>
+
+          {isEdit && ipLimitEnable && (
+            <Form.Item label={t('pages.clients.ipLog')}>
+              <Space style={{ marginBottom: 8 }}>
+                <Button size="small" loading={ipsLoading} onClick={loadIps}>{t('refresh')}</Button>
+                <Button size="small" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
+                  {t('pages.clients.clearAll')}
+                </Button>
+              </Space>
+              {clientIps.length > 0 ? (
+                <div>
+                  {clientIps.map((ip, idx) => (
+                    <Tag key={idx} color="blue" style={{ marginBottom: 4 }}>{ip}</Tag>
+                  ))}
+                </div>
+              ) : (
+                <Tag>{t('tgbot.noIpRecord')}</Tag>
+              )}
+            </Form.Item>
+          )}
+        </Form>
       </Modal>
     </>
   );

+ 61 - 0
frontend/src/pages/clients/ClientInfoModal.css

@@ -37,6 +37,24 @@
   display: flex;
   flex-wrap: wrap;
   gap: 4px;
+  align-items: center;
+}
+
+.chips-stack {
+  flex-direction: column;
+  align-items: flex-start;
+  max-width: 280px;
+  max-height: 280px;
+  overflow-y: auto;
+}
+
+.chip-more {
+  cursor: pointer;
+  user-select: none;
+}
+
+.chip-more:hover {
+  opacity: 0.85;
 }
 
 .link-panel {
@@ -84,3 +102,46 @@
   background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
   text-decoration-color: var(--ant-color-primary);
 }
+
+.link-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border: 1px solid var(--ant-color-border);
+  border-radius: 8px;
+  margin-bottom: 8px;
+}
+
+.link-row-tag {
+  margin: 0;
+  flex-shrink: 0;
+  font-weight: 600;
+  letter-spacing: 0.3px;
+}
+
+.link-row-title {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.link-row-actions {
+  display: flex;
+  gap: 4px;
+  flex-shrink: 0;
+}
+
+.link-row-title-anchor {
+  color: var(--ant-color-primary);
+  text-decoration: underline;
+  text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 35%, transparent);
+  transition: text-decoration-color 120ms ease;
+}
+
+.link-row-title-anchor:hover {
+  text-decoration-color: var(--ant-color-primary);
+}

+ 395 - 166
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -1,18 +1,117 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Modal, Tag, Tooltip, message } from 'antd';
-import { CopyOutlined } from '@ant-design/icons';
+import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
+import { CopyOutlined, QrcodeOutlined } from '@ant-design/icons';
 
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import QrPanel from '@/pages/inbounds/QrPanel';
 import './ClientInfoModal.css';
 
+const PROTOCOL_COLORS: Record<string, string> = {
+  VLESS: 'blue',
+  VMESS: 'geekblue',
+  TROJAN: 'volcano',
+  SS: 'magenta',
+  HYSTERIA: 'cyan',
+  HY2: 'green',
+};
+
+const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
+  vless: 'blue',
+  vmess: 'geekblue',
+  trojan: 'volcano',
+  shadowsocks: 'magenta',
+  hysteria: 'cyan',
+  hysteria2: 'green',
+  wireguard: 'gold',
+  http: 'purple',
+  mixed: 'lime',
+  tunnel: 'orange',
+};
+
+const INBOUND_CHIP_LIMIT = 1;
+
+// Post-quantum keys blow up the encoded URL past what a single QR can
+// hold. In VLESS share links the algorithm names don't appear as plain
+// text — they ride inside query params:
+//   - mldsa65Verify becomes `pqv=<base64>` (sub/subService.go:841)
+//   - ML-KEM-768 becomes `encryption=mlkem768x25519plus.<...>`
+// We also keep the literal substrings so configs that DO embed them
+// directly (e.g. wireguard config text) still match.
+function isPostQuantumLink(link: string): boolean {
+  if (/[?&]pqv=/.test(link)) return true;
+  if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
+  if (link.includes('ML-KEM-768')) return true;
+  return false;
+}
+
+// 3x-ui's genRemark concatenates inbound remark + client email (and an
+// optional extra) using a configurable separator. The email half is
+// redundant in the row title — the modal already names the client by
+// email at the top — so trimEmail strips it back out for the row only.
+// The original remark is preserved for the QR (it's the QR's own name).
+function trimEmail(remark: string, email: string): string {
+  if (!email) return remark;
+  const e = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  return remark
+    .replace(new RegExp(`[-_.\\s|]+${e}$`), '')
+    .replace(new RegExp(`^${e}[-_.\\s|]+`), '')
+    .trim();
+}
+
+// Decode a base64 string as UTF-8. atob() returns a binary string where
+// each char holds one raw byte (Latin-1 interpretation), which mangles
+// any multi-byte UTF-8 sequence in the payload — most commonly the
+// emoji decorations the panel embeds in remarks (📊, ⏳).
+function base64DecodeUtf8(b64: string): string {
+  const binary = atob(b64);
+  const bytes = new Uint8Array(binary.length);
+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+  return new TextDecoder('utf-8').decode(bytes);
+}
+
+function parseLinkMeta(link: string): { protocol: string; remark: string } {
+  const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
+  const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
+  const protocolMap: Record<string, string> = {
+    vless: 'VLESS',
+    vmess: 'VMESS',
+    trojan: 'TROJAN',
+    ss: 'SS',
+    hysteria: 'HYSTERIA',
+    hysteria2: 'HY2',
+    hy2: 'HY2',
+  };
+  const protocol = protocolMap[scheme] ?? scheme.toUpperCase() ?? 'LINK';
+
+  let remark = '';
+  if (scheme === 'vmess') {
+    try {
+      const body = link.slice('vmess://'.length).split('#')[0];
+      const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown };
+      if (typeof json?.ps === 'string') remark = json.ps;
+    } catch { /* fall through to fragment parsing */ }
+  }
+  if (!remark) {
+    const hashIdx = link.indexOf('#');
+    if (hashIdx >= 0) {
+      const raw = link.slice(hashIdx + 1);
+      try { remark = decodeURIComponent(raw); }
+      catch { remark = raw; }
+    }
+  }
+  return { protocol, remark };
+}
+
 interface SubSettings {
   enable: boolean;
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
+  subClashURI: string;
+  subClashEnable: boolean;
 }
 
 interface ClientInfoModalProps {
@@ -29,7 +128,14 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
-const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
+const DEFAULT_SUB: SubSettings = {
+  enable: false,
+  subURI: '',
+  subJsonURI: '',
+  subJsonEnable: false,
+  subClashURI: '',
+  subClashEnable: false,
+};
 
 export default function ClientInfoModal({
   open,
@@ -90,6 +196,12 @@ export default function ClientInfoModal({
     return subSettings.subJsonURI + client.subId;
   }, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
 
+  const subClashLink = useMemo(() => {
+    if (!client?.subId) return '';
+    if (!subSettings?.subClashEnable || !subSettings?.subClashURI) return '';
+    return subSettings.subClashURI + client.subId;
+  }, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
+
   const showSubscription = !!(subSettings?.enable && client?.subId);
 
   async function copyValue(text: string) {
@@ -107,192 +219,309 @@ export default function ClientInfoModal({
         footer={null}
         width={640}
         onCancel={() => onOpenChange(false)}
-    >
-      {client && (
-        <>
-          <table className="info-table block">
-            <tbody>
-              <tr>
-                <td>{t('pages.clients.online')}</td>
-                <td>
-                  {client.enable && isOnline
-                    ? <Tag color="green">{t('pages.clients.online')}</Tag>
-                    : <Tag>{t('pages.clients.offline')}</Tag>}
-                  <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
-                </td>
-              </tr>
-              <tr>
-                <td>{t('status')}</td>
-                <td>
-                  <Tag color={client.enable ? 'green' : 'default'}>
-                    {client.enable ? t('enabled') : t('disabled')}
-                  </Tag>
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.clients.email')}</td>
-                <td>
-                  {client.email
-                    ? <Tag color="green">{client.email}</Tag>
-                    : <Tag color="red">{t('none')}</Tag>}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.clients.subId')}</td>
-                <td>
-                  <Tag className="info-large-tag">{client.subId || '-'}</Tag>
-                  {client.subId && (
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
-                  )}
-                </td>
-              </tr>
-              {client.uuid && (
+      >
+        {client && (
+          <>
+            <table className="info-table block">
+              <tbody>
+                <tr>
+                  <td>{t('pages.clients.online')}</td>
+                  <td>
+                    {client.enable && isOnline
+                      ? <Tag color="green">{t('pages.clients.online')}</Tag>
+                      : <Tag>{t('pages.clients.offline')}</Tag>}
+                    <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('status')}</td>
+                  <td>
+                    <Tag color={client.enable ? 'green' : 'default'}>
+                      {client.enable ? t('enabled') : t('disabled')}
+                    </Tag>
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.email')}</td>
+                  <td>
+                    {client.email
+                      ? <Tag color="green">{client.email}</Tag>
+                      : <Tag color="red">{t('none')}</Tag>}
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.subId')}</td>
+                  <td>
+                    <Tag className="info-large-tag">{client.subId || '-'}</Tag>
+                    {client.subId && (
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
+                    )}
+                  </td>
+                </tr>
+                {client.uuid && (
+                  <tr>
+                    <td>{t('pages.clients.uuid')}</td>
+                    <td>
+                      <Tag className="info-large-tag">{client.uuid}</Tag>
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
+                    </td>
+                  </tr>
+                )}
+                {client.password && (
+                  <tr>
+                    <td>{t('password')}</td>
+                    <td>
+                      <Tag className="info-large-tag">{client.password}</Tag>
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
+                    </td>
+                  </tr>
+                )}
+                {client.auth && (
+                  <tr>
+                    <td>{t('pages.clients.auth')}</td>
+                    <td>
+                      <Tag className="info-large-tag">{client.auth}</Tag>
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
+                    </td>
+                  </tr>
+                )}
                 <tr>
-                  <td>{t('pages.clients.uuid')}</td>
+                  <td>{t('pages.clients.flow')}</td>
                   <td>
-                    <Tag className="info-large-tag">{client.uuid}</Tag>
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
+                    {client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
                   </td>
                 </tr>
-              )}
-              {client.password && (
                 <tr>
-                  <td>{t('password')}</td>
+                  <td>{t('pages.inbounds.traffic')}</td>
                   <td>
-                    <Tag className="info-large-tag">{client.password}</Tag>
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
+                    <Tag>
+                      ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
+                      {' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
+                    </Tag>
+                    <span className="hint">
+                      {SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
+                    </span>
                   </td>
                 </tr>
-              )}
-              {client.auth && (
                 <tr>
-                  <td>{t('pages.clients.auth')}</td>
+                  <td>{t('remained')}</td>
                   <td>
-                    <Tag className="info-large-tag">{client.auth}</Tag>
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
+                    {remaining < 0
+                      ? <Tag color="purple">∞</Tag>
+                      : <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
                   </td>
                 </tr>
-              )}
-              <tr>
-                <td>{t('pages.clients.flow')}</td>
-                <td>
-                  {client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.traffic')}</td>
-                <td>
-                  <Tag>
-                    ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
-                    {' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
-                  </Tag>
-                  <span className="hint">
-                    {SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
-                  </span>
-                </td>
-              </tr>
-              <tr>
-                <td>{t('remained')}</td>
-                <td>
-                  {remaining < 0
-                    ? <Tag color="purple">∞</Tag>
-                    : <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.expireDate')}</td>
-                <td>
-                  {!client.expiryTime
-                    ? <Tag color="purple">∞</Tag>
-                    : <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
-                  {(client.expiryTime ?? 0) > 0 && (
-                    <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
-                  )}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.clients.ipLimit')}</td>
-                <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.createdAt')}</td>
-                <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.updatedAt')}</td>
-                <td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
-              </tr>
-              {client.comment && (
                 <tr>
-                  <td>{t('pages.clients.comment')}</td>
-                  <td><Tag className="info-large-tag">{client.comment}</Tag></td>
+                  <td>{t('pages.inbounds.expireDate')}</td>
+                  <td>
+                    {!client.expiryTime
+                      ? <Tag color="purple">∞</Tag>
+                      : <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
+                    {(client.expiryTime ?? 0) > 0 && (
+                      <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
+                    )}
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.ipLimit')}</td>
+                  <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
+                </tr>
+                <tr>
+                  <td>{t('pages.inbounds.createdAt')}</td>
+                  <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
                 </tr>
-              )}
-              <tr>
-                <td>{t('pages.clients.attachedInbounds')}</td>
-                <td>
-                  <div className="chips">
-                    {(client.inboundIds || []).map((id) => {
-                      const ib = inboundsById[id];
+                <tr>
+                  <td>{t('pages.inbounds.updatedAt')}</td>
+                  <td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
+                </tr>
+                {client.comment && (
+                  <tr>
+                    <td>{t('pages.clients.comment')}</td>
+                    <td><Tag className="info-large-tag">{client.comment}</Tag></td>
+                  </tr>
+                )}
+                <tr>
+                  <td>{t('pages.clients.attachedInbounds')}</td>
+                  <td>
+                    {(() => {
+                      const ids = client.inboundIds || [];
+                      if (ids.length === 0) return <span className="hint">—</span>;
+                      const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
+                      const overflow = ids.slice(INBOUND_CHIP_LIMIT);
+                      const inboundChip = (id: number, compact: boolean) => {
+                        const ib = inboundsById[id];
+                        const proto = (ib?.protocol || '').toLowerCase();
+                        const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
+                        const fullLabel = ib
+                          ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})`
+                          : `#${id}`;
+                        const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
+                        return (
+                          <Tooltip key={id} title={fullLabel}>
+                            <Tag color={color}>{compact ? compactLabel : fullLabel}</Tag>
+                          </Tooltip>
+                        );
+                      };
                       return (
-                        <Tag key={id} color="blue">
-                          {ib ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})` : `#${id}`}
-                        </Tag>
+                        <div className="chips">
+                          {visible.map((id) => inboundChip(id, true))}
+                          {overflow.length > 0 && (
+                            <Popover
+                              trigger="click"
+                              placement="bottomRight"
+                              content={
+                                <div className="chips chips-stack">
+                                  {overflow.map((id) => inboundChip(id, false))}
+                                </div>
+                              }
+                            >
+                              <Tag color="default" className="chip-more">
+                                +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
+                              </Tag>
+                            </Popover>
+                          )}
+                        </div>
                       );
-                    })}
-                    {(!client.inboundIds || client.inboundIds.length === 0) && (
-                      <span className="hint">—</span>
-                    )}
-                  </div>
-                </td>
-              </tr>
-            </tbody>
-          </table>
+                    })()}
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+
+            {links.length > 0 && (
+              <>
+                <Divider>{t('pages.inbounds.copyLink')}</Divider>
+                {links.map((link, idx) => {
+                  const meta = parseLinkMeta(link);
+                  const rowTitle = trimEmail(meta.remark, client.email)
+                    || `${t('pages.clients.link')} ${idx + 1}`;
+                  const qrRemark = client.email
+                    ? `${rowTitle}-${client.email}`
+                    : (meta.remark || `${t('pages.clients.link')} ${idx + 1}`);
+                  const canQr = !isPostQuantumLink(link);
+                  return (
+                    <div key={idx} className="link-row">
+                      <Tag color={PROTOCOL_COLORS[meta.protocol] ?? 'default'} className="link-row-tag">
+                        {meta.protocol}
+                      </Tag>
+                      <span className="link-row-title" title={qrRemark}>{rowTitle}</span>
+                      <div className="link-row-actions">
+                        <Tooltip title={t('copy')}>
+                          <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
+                        </Tooltip>
+                        {canQr && (
+                          <Popover
+                            trigger="click"
+                            placement="left"
+                            destroyOnHidden
+                            content={<QrPanel value={link} remark={qrRemark} size={220} />}
+                          >
+                            <Tooltip title={t('pages.clients.qrCode')}>
+                              <Button size="small" icon={<QrcodeOutlined />} />
+                            </Tooltip>
+                          </Popover>
+                        )}
+                      </div>
+                    </div>
+                  );
+                })}
+              </>
+            )}
 
-          {links.length > 0 && (
-            <>
-              <Divider>{t('pages.inbounds.copyLink')}</Divider>
-              {links.map((link, idx) => (
-                <div key={idx} className="link-panel">
-                  <div className="link-panel-header">
-                    <Tag color="green">{`${t('pages.clients.link')} ${idx + 1}`}</Tag>
+            {showSubscription && subLink && (
+              <>
+                <Divider>{t('subscription.title')}</Divider>
+                <div className="link-row">
+                  <Tag color="green" className="link-row-tag">SUB</Tag>
+                  <a
+                    href={subLink}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="link-row-title link-row-title-anchor"
+                    title={subLink}
+                  >
+                    {client.subId}
+                  </a>
+                  <div className="link-row-actions">
                     <Tooltip title={t('copy')}>
-                      <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
+                      <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
                     </Tooltip>
+                    <Popover
+                      trigger="click"
+                      placement="left"
+                      destroyOnHidden
+                      content={<QrPanel value={subLink} remark={`${client.email} — ${t('subscription.title')}`} size={220} />}
+                    >
+                      <Tooltip title={t('pages.clients.qrCode')}>
+                        <Button size="small" icon={<QrcodeOutlined />} />
+                      </Tooltip>
+                    </Popover>
                   </div>
-                  <code className="link-panel-text">{link}</code>
-                </div>
-              ))}
-            </>
-          )}
-
-          {showSubscription && subLink && (
-            <>
-              <Divider>{t('subscription.title')}</Divider>
-              <div className="link-panel">
-                <div className="link-panel-header">
-                  <Tag color="green">{t('subscription.title')}</Tag>
-                  <Tooltip title={t('copy')}>
-                    <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
-                  </Tooltip>
                 </div>
-                <a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
-              </div>
-              {subJsonLink && (
-                <div className="link-panel">
-                  <div className="link-panel-header">
-                    <Tag color="green">JSON</Tag>
-                    <Tooltip title={t('copy')}>
-                      <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
+                {subJsonLink && (
+                  <div className="link-row">
+                    <Tag color="purple" className="link-row-tag">JSON</Tag>
+                    <a
+                      href={subJsonLink}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="link-row-title link-row-title-anchor"
+                      title={subJsonLink}
+                    >
+                      {client.subId}
+                    </a>
+                    <div className="link-row-actions">
+                      <Tooltip title={t('copy')}>
+                        <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
+                      </Tooltip>
+                      <Popover
+                        trigger="click"
+                        placement="left"
+                        destroyOnHidden
+                        content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
+                      >
+                        <Tooltip title={t('pages.clients.qrCode')}>
+                          <Button size="small" icon={<QrcodeOutlined />} />
+                        </Tooltip>
+                      </Popover>
+                    </div>
+                  </div>
+                )}
+                {subClashLink && (
+                  <div className="link-row">
+                    <Tooltip title="Clash / Mihomo">
+                      <Tag color="gold" className="link-row-tag">CLASH</Tag>
                     </Tooltip>
+                    <a
+                      href={subClashLink}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="link-row-title link-row-title-anchor"
+                      title={subClashLink}
+                    >
+                      {client.subId}
+                    </a>
+                    <div className="link-row-actions">
+                      <Tooltip title={t('copy')}>
+                        <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
+                      </Tooltip>
+                      <Popover
+                        trigger="click"
+                        placement="left"
+                        destroyOnHidden
+                        content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
+                      >
+                        <Tooltip title={t('pages.clients.qrCode')}>
+                          <Button size="small" icon={<QrcodeOutlined />} />
+                        </Tooltip>
+                      </Popover>
+                    </div>
                   </div>
-                  <a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
-                </div>
-              )}
-            </>
-          )}
-        </>
-      )}
+                )}
+              </>
+            )}
+          </>
+        )}
       </Modal>
     </>
   );

+ 60 - 19
frontend/src/pages/clients/ClientsPage.tsx

@@ -72,6 +72,20 @@ interface FilterState {
   inboundFilter?: number;
 }
 
+const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
+  vless: 'blue',
+  vmess: 'geekblue',
+  trojan: 'volcano',
+  shadowsocks: 'magenta',
+  hysteria: 'cyan',
+  hysteria2: 'green',
+  wireguard: 'gold',
+  http: 'purple',
+  mixed: 'lime',
+  tunnel: 'orange',
+};
+const INBOUND_CHIP_LIMIT = 1;
+
 function readFilterState(): FilterState {
   try {
     const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
@@ -103,7 +117,7 @@ export default function ClientsPage() {
     setQuery,
     inbounds, onlines, loading, fetched, subSettings,
     ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, removeMany, bulkAdjust, attach, detach,
+    create, update, remove, bulkDelete, bulkAdjust, attach, detach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     hydrate,
@@ -174,7 +188,7 @@ export default function ClientsPage() {
 
   useEffect(() => {
     if (pageSize > 0) {
-       
+
       setTablePageSize(pageSize);
     }
   }, [pageSize]);
@@ -406,19 +420,13 @@ export default function ClientsPage() {
       okType: 'danger',
       cancelText: t('cancel'),
       onOk: async () => {
-        const results = await removeMany(emails);
+        const msg = await bulkDelete(emails);
         setSelectedRowKeys([]);
-        let ok = 0;
-        let failed = 0;
-        let firstError = '';
-        for (const msg of results) {
-          if (msg?.success) ok++;
-          else {
-            failed++;
-            if (!firstError && msg?.msg) firstError = msg.msg;
-          }
-        }
-        if (failed === 0) {
+        const ok = msg?.obj?.deleted ?? 0;
+        const skipped = msg?.obj?.skipped ?? [];
+        const failed = skipped.length;
+        const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
+        if (failed === 0 && msg?.success) {
           messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
         } else {
           messageApi.warning(firstError
@@ -530,18 +538,52 @@ export default function ClientsPage() {
           <div className="email-cell">
             <span className="email">{record.email}</span>
             {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
+            {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
           </div>
         ),
       }, 'email'),
       sortableCol({
         title: t('pages.clients.attachedInbounds'),
         key: 'inboundIds',
+        width: 170,
         render: (_v, record) => {
           const ids = record.inboundIds || [];
           if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
-          return ids.map((id) => (
-            <Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
-          ));
+          const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
+          const overflow = ids.slice(INBOUND_CHIP_LIMIT);
+          const chip = (id: number, compact: boolean) => {
+            const ib = inboundsById[id];
+            const proto = (ib?.protocol || '').toLowerCase();
+            const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
+            const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
+            return (
+              <Tooltip key={id} title={inboundLabel(id)}>
+                <Tag color={color} style={{ margin: 2 }}>
+                  {compact ? compactLabel : inboundLabel(id)}
+                </Tag>
+              </Tooltip>
+            );
+          };
+          return (
+            <>
+              {visible.map((id) => chip(id, true))}
+              {overflow.length > 0 && (
+                <Popover
+                  trigger="click"
+                  placement="bottomRight"
+                  content={
+                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
+                      {overflow.map((id) => chip(id, false))}
+                    </div>
+                  }
+                >
+                  <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+                    +{overflow.length}
+                  </Tag>
+                </Popover>
+              )}
+            </>
+          );
         },
       }, 'inboundIds'),
       sortableCol({
@@ -750,8 +792,7 @@ export default function ClientsPage() {
                           value={inboundFilter}
                           onChange={(v) => setInboundFilter(v)}
                           allowClear
-                          showSearch
-                          optionFilterProp="label"
+                          showSearch={{ optionFilterProp: 'label' }}
                           placeholder={t('inbounds')}
                           size={isMobile ? 'small' : 'middle'}
                           style={{ minWidth: 160, maxWidth: 240 }}

Fișier diff suprimat deoarece este prea mare
+ 667 - 810
frontend/src/pages/inbounds/InboundFormModal.tsx


+ 201 - 37
frontend/src/pages/inbounds/InboundInfoModal.tsx

@@ -12,12 +12,96 @@ import {
   ClipboardManager,
   FileManager,
 } from '@/utils';
-import { Protocols } from '@/models/inbound';
+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 {
@@ -143,7 +280,7 @@ export default function InboundInfoModal({
   onClose,
   dbInbound,
   clientIndex = 0,
-  remarkModel = '-ieo',
+  remarkModel = '-io',
   expireDiff = 0,
   trafficDiff = 0,
   ipLimitEnable = false,
@@ -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: '-io',
+          hostOverride: nodeAddress,
+          fallbackHostname,
+        }).split('\r\n'),
+      );
+      setWireguardLinks(
+        genWireguardLinks({
+          inbound: inboundForLinks,
+          remark: dbInbound.remark,
+          remarkModel: '-io',
+          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>

+ 55 - 24
frontend/src/pages/inbounds/InboundList.tsx

@@ -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>

+ 47 - 26
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -20,7 +20,9 @@ import {
 } from '@ant-design/icons';
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
-import { Inbound } from '@/models/inbound';
+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,15 +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 {
-          clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
+          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,
@@ -365,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();

+ 39 - 19
frontend/src/pages/inbounds/QrCodeModal.tsx

@@ -3,7 +3,13 @@ import { useTranslation } from 'react-i18next';
 import { Collapse, Modal } from 'antd';
 import type { CollapseProps } from 'antd';
 
-import { Protocols } from '@/models/inbound';
+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;
@@ -47,7 +41,7 @@ export default function QrCodeModal({
   onClose,
   dbInbound,
   client = null,
-  remarkModel = '-ieo',
+  remarkModel = '-io',
   nodeAddress = '',
   subSettings,
 }: QrCodeModalProps) {
@@ -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: '-io',
+          hostOverride: nodeAddress,
+          fallbackHostname,
+        }).split('\r\n'),
+      );
+      setWireguardLinks(
+        genWireguardLinks({
+          inbound,
+          remark: peerRemark,
+          remarkModel: '-io',
+          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([]);
     }

+ 42 - 44
frontend/src/pages/inbounds/useInbounds.ts

@@ -2,10 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 
 import { HttpUtil } from '@/utils';
-import { DBInbound } from '@/models/dbinbound';
-import { Protocols } from '@/models/inbound';
+import { parseMsg } from '@/utils/zodValidate';
+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';
+import { OnlinesSchema } from '@/schemas/client';
+import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
 
 export interface SubSettings {
   enable: boolean;
@@ -27,28 +32,7 @@ interface ClientRollup {
   comments: Map<string, string>;
 }
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  obj?: T;
-  msg?: string;
-}
-
-interface DefaultsPayload {
-  expireDiff?: number;
-  trafficDiff?: number;
-  tgBotEnable?: boolean;
-  subEnable?: boolean;
-  subTitle?: string;
-  subURI?: string;
-  subJsonURI?: string;
-  subJsonEnable?: boolean;
-  pageSize?: number;
-  remarkModel?: string;
-  datepicker?: string;
-  ipLimitEnable?: boolean;
-}
-
-const TRACKED_PROTOCOLS = [
+const TRACKED_PROTOCOLS: readonly string[] = [
   Protocols.VMESS,
   Protocols.VLESS,
   Protocols.TROJAN,
@@ -57,27 +41,31 @@ const TRACKED_PROTOCOLS = [
 ];
 
 async function fetchSlimInbounds(): Promise<unknown[]> {
-  const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
+  const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchOnlineClients(): Promise<string[]> {
-  const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
+  const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchLastOnlineMap(): Promise<Record<string, number>> {
-  const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
+  const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
-  return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
+  const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
+  return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
 }
 
 async function fetchDefaultSettings(): Promise<DefaultsPayload> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
+  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
-  return (msg.obj as DefaultsPayload) || {};
+  const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+  return validated.obj ?? {};
 }
 
 export function useInbounds() {
@@ -113,7 +101,7 @@ export function useInbounds() {
   const tgBotEnable = !!defaults.tgBotEnable;
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const pageSize = defaults.pageSize ?? 0;
-  const remarkModel = defaults.remarkModel || '-ieo';
+  const remarkModel = defaults.remarkModel || '-io';
   const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
 
   const subSettings: SubSettings = useMemo(() => ({
@@ -214,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]);
@@ -232,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;
@@ -258,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() }),
     ]);
@@ -272,8 +269,9 @@ export function useInbounds() {
   const hydrateInbound = useCallback(async (id: number) => {
     const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
     if (!msg?.success || !msg.obj) return null;
-    const full = msg.obj as { id: number; protocol: string };
-    const dbInbound = new DBInbound(full) as DBInboundInstance;
+    const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
+    if (!validated.obj) return null;
+    const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
     setDbInbounds((prev) => {
       const next = prev.map((row) => (
         (row as unknown as { id: number }).id === id ? dbInbound : row

+ 7 - 25
frontend/src/pages/index/CustomGeoFormModal.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Form, Input, message, Modal, Select } from 'antd';
 
 import { HttpUtil } from '@/utils';
+import { CustomGeoFormSchema } from '@/schemas/xray';
 
 export interface CustomGeoRecord {
   id: number;
@@ -46,37 +47,18 @@ export default function CustomGeoFormModal({
     }
   }, [open, record]);
 
-  function validate(): boolean {
-    if (!/^[a-z0-9_-]+$/.test(alias || '')) {
-      messageApi.error(t('pages.index.customGeoValidationAlias'));
-      return false;
-    }
-    const u = (url || '').trim();
-    if (!/^https?:\/\//i.test(u)) {
-      messageApi.error(t('pages.index.customGeoValidationUrl'));
-      return false;
-    }
-    try {
-      const parsed = new URL(u);
-      if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
-        messageApi.error(t('pages.index.customGeoValidationUrl'));
-        return false;
-      }
-    } catch {
-      messageApi.error(t('pages.index.customGeoValidationUrl'));
-      return false;
-    }
-    return true;
-  }
-
   async function submit() {
-    if (!validate()) return;
+    const validated = CustomGeoFormSchema.safeParse({ type, alias, url });
+    if (!validated.success) {
+      messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
+      return;
+    }
     setSaving(true);
     try {
       const apiUrl = editing
         ? `/panel/api/custom-geo/update/${record!.id}`
         : '/panel/api/custom-geo/add';
-      const msg = await HttpUtil.post(apiUrl, { type, alias, url });
+      const msg = await HttpUtil.post(apiUrl, validated.data);
       if (msg?.success) {
         onSaved();
         onClose();

+ 1 - 1
frontend/src/pages/index/CustomGeoSection.tsx

@@ -116,7 +116,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
   async function updateAll() {
     setUpdatingAll(true);
     try {
-      const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
+      const msg = await HttpUtil.post<{ succeeded?: unknown[]; failed?: unknown[] }>('/panel/api/custom-geo/update-all');
       const ok = msg?.obj?.succeeded?.length || 0;
       const failed = msg?.obj?.failed?.length || 0;
       if (msg?.success || ok > 0) {

+ 7 - 5
frontend/src/pages/index/IndexPage.tsx

@@ -86,10 +86,10 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
+    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
       if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
     });
-    HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
+    HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
       if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
     });
   }, []);
@@ -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>

+ 27 - 15
frontend/src/pages/index/LogModal.tsx

@@ -69,7 +69,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
   const refresh = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post(`/panel/api/server/logs/${rows}`, {
+      const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
         level,
         syslog,
       });
@@ -117,20 +117,32 @@ export default function LogModal({ open, onClose }: LogModalProps) {
       <Form layout="inline" className="log-toolbar">
         <Form.Item>
           <Space.Compact>
-            <Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
-              <Select.Option value="10">10</Select.Option>
-              <Select.Option value="20">20</Select.Option>
-              <Select.Option value="50">50</Select.Option>
-              <Select.Option value="100">100</Select.Option>
-              <Select.Option value="500">500</Select.Option>
-            </Select>
-            <Select value={level} size="small" style={{ width: 95 }} onChange={setLevel}>
-              <Select.Option value="debug">Debug</Select.Option>
-              <Select.Option value="info">Info</Select.Option>
-              <Select.Option value="notice">Notice</Select.Option>
-              <Select.Option value="warning">Warning</Select.Option>
-              <Select.Option value="err">Error</Select.Option>
-            </Select>
+            <Select
+              value={rows}
+              size="small"
+              style={{ width: 70 }}
+              onChange={setRows}
+              options={[
+                { value: '10', label: '10' },
+                { value: '20', label: '20' },
+                { value: '50', label: '50' },
+                { value: '100', label: '100' },
+                { value: '500', label: '500' },
+              ]}
+            />
+            <Select
+              value={level}
+              size="small"
+              style={{ width: 95 }}
+              onChange={setLevel}
+              options={[
+                { value: 'debug', label: 'Debug' },
+                { value: 'info', label: 'Info' },
+                { value: 'notice', label: 'Notice' },
+                { value: 'warning', label: 'Warning' },
+                { value: 'err', label: 'Error' },
+              ]}
+            />
           </Space.Compact>
         </Form.Item>
         <Form.Item>

+ 1 - 1
frontend/src/pages/index/VersionModal.tsx

@@ -39,7 +39,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
   const fetchVersions = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
+      const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
       if (msg?.success) setVersions(msg.obj || []);
     } finally {
       setLoading(false);

+ 14 - 8
frontend/src/pages/index/XrayLogModal.tsx

@@ -62,7 +62,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
   const refresh = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows}`, {
+      const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
         filter,
         showDirect,
         showBlocked,
@@ -124,13 +124,19 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
     >
       <Form layout="inline" className="log-toolbar">
         <Form.Item>
-          <Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
-            <Select.Option value="10">10</Select.Option>
-            <Select.Option value="20">20</Select.Option>
-            <Select.Option value="50">50</Select.Option>
-            <Select.Option value="100">100</Select.Option>
-            <Select.Option value="500">500</Select.Option>
-          </Select>
+          <Select
+            value={rows}
+            size="small"
+            style={{ width: 70 }}
+            onChange={setRows}
+            options={[
+              { value: '10', label: '10' },
+              { value: '20', label: '20' },
+              { value: '50', label: '50' },
+              { value: '100', label: '100' },
+              { value: '500', label: '500' },
+            ]}
+          />
         </Form.Item>
         <Form.Item label={t('filter')} className="filter-item">
           <Input

+ 10 - 9
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Tabs, Tag } from 'antd';
 
-import { HttpUtil, SizeFormatter } from '@/utils';
+import { HttpUtil, Msg, SizeFormatter } from '@/utils';
 import Sparkline from '@/components/Sparkline';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import './XrayMetricsModal.css';
@@ -90,7 +90,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
 
-  const applyHistory = useCallback((msg: { success?: boolean; obj?: { t: number; v: number }[] }, currentBucket: number) => {
+  const applyHistory = useCallback((msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
     if (msg?.success && Array.isArray(msg.obj)) {
       const vals: number[] = [];
       const labs: string[] = [];
@@ -112,7 +112,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const fetchState = useCallback(async () => {
     try {
-      const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
+      const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
       if (msg?.success && msg.obj) setState(msg.obj);
     } catch (e) {
       console.error('Failed to fetch xray metrics state', e);
@@ -121,12 +121,13 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const fetchObservatory = useCallback(async () => {
     try {
-      const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
+      const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
       if (msg?.success && Array.isArray(msg.obj)) {
-        setObsTags(msg.obj);
+        const tags = msg.obj;
+        setObsTags(tags);
         setObsActiveTag((prev) => {
-          if (msg.obj.find((tg: ObservatoryTag) => tg.tag === prev)) return prev;
-          return msg.obj[0]?.tag || '';
+          if (tags.find((tg) => tg.tag === prev)) return prev;
+          return tags[0]?.tag || '';
         });
       } else {
         setObsTags([]);
@@ -141,7 +142,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     if (!activeMetric) return;
     try {
       const url = `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`;
-      const msg = await HttpUtil.get(url);
+      const msg = await HttpUtil.get<{ t: number; v: number }[]>(url);
       applyHistory(msg, bucket);
     } catch (e) {
       console.error('Failed to fetch xray metrics bucket', e);
@@ -158,7 +159,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     }
     try {
       const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`;
-      const msg = await HttpUtil.get(url);
+      const msg = await HttpUtil.get<{ t: number; v: number }[]>(url);
       applyHistory(msg, bucket);
     } catch (e) {
       console.error('Failed to fetch observatory bucket', e);

+ 6 - 8
frontend/src/pages/login/LoginPage.tsx

@@ -23,17 +23,15 @@ import {
 } from '@ant-design/icons';
 
 import { HttpUtil, LanguageManager } from '@/utils';
+import { antdRule } from '@/utils/zodForm';
 import { setMessageInstance } from '@/utils/messageBus';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
 import './LoginPage.css';
 
 const HEADLINE_INTERVAL_MS = 2000;
 
-interface LoginForm {
-  username: string;
-  password: string;
-  twoFactorCode?: string;
-}
+type LoginForm = LoginFormValues;
 
 const basePath = window.X_UI_BASE_PATH || '';
 
@@ -191,7 +189,7 @@ export default function LoginPage() {
                   <Form.Item
                     label={t('username')}
                     name="username"
-                    rules={[{ required: true, message: t('username') }]}
+                    rules={[antdRule(LoginFormSchema.shape.username, t)]}
                   >
                     <Input
                       prefix={<UserOutlined />}
@@ -205,7 +203,7 @@ export default function LoginPage() {
                   <Form.Item
                     label={t('password')}
                     name="password"
-                    rules={[{ required: true, message: t('password') }]}
+                    rules={[antdRule(LoginFormSchema.shape.password, t)]}
                   >
                     <Input.Password
                       prefix={<LockOutlined />}
@@ -219,7 +217,7 @@ export default function LoginPage() {
                     <Form.Item
                       label={t('twoFactorCode')}
                       name="twoFactorCode"
-                      rules={[{ required: true, message: t('twoFactorCode') }]}
+                      rules={[antdRule(TwoFactorCodeSchema, t)]}
                     >
                       <Input
                         prefix={<KeyOutlined />}

+ 152 - 180
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -14,44 +14,23 @@ import {
   message,
 } from 'antd';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import type { Msg } from '@/utils';
+import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
+import { antdRule } from '@/utils/zodForm';
 import './NodeFormModal.css';
 
 type Mode = 'add' | 'edit';
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
 interface NodeFormModalProps {
   open: boolean;
   mode: Mode;
   node: NodeRecord | null;
-  testConnection: (payload: Partial<NodeRecord>) => Promise<ApiMsg<{
-    status: string;
-    latencyMs?: number;
-    xrayVersion?: string;
-    error?: string;
-  }>>;
-  save: (payload: Partial<NodeRecord>) => Promise<ApiMsg>;
+  testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
+  save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
   onOpenChange: (open: boolean) => void;
 }
 
-interface FormState {
-  id: number;
-  name: string;
-  remark: string;
-  scheme: 'http' | 'https';
-  address: string;
-  port: number;
-  basePath: string;
-  apiToken: string;
-  enable: boolean;
-  allowPrivateAddress: boolean;
-}
-
-function defaultForm(): FormState {
+function defaultValues(): NodeFormValues {
   return {
     id: 0,
     name: '',
@@ -75,68 +54,59 @@ export default function NodeFormModal({
   onOpenChange,
 }: NodeFormModalProps) {
   const { t } = useTranslation();
+  const [form] = Form.useForm<NodeFormValues>();
   const [messageApi, messageContextHolder] = message.useMessage();
 
-  const [form, setForm] = useState<FormState>(defaultForm);
   const [submitting, setSubmitting] = useState(false);
   const [testing, setTesting] = useState(false);
-  const [testResult, setTestResult] = useState<{
-    status: string;
-    latencyMs?: number;
-    xrayVersion?: string;
-    error?: string;
-  } | null>(null);
+  const [testResult, setTestResult] = useState<ProbeResult | null>(null);
 
   useEffect(() => {
     if (!open) return;
-    const base = defaultForm();
-    const next: FormState = mode === 'edit' && node
+    const base = defaultValues();
+    const next: NodeFormValues = mode === 'edit' && node
       ? {
         ...base,
-        ...(node as unknown as Partial<FormState>),
+        ...(node as unknown as Partial<NodeFormValues>),
         id: node.id,
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
       }
       : base;
-     
-    setForm(next);
+    form.resetFields();
+    form.setFieldsValue(next);
     setTestResult(null);
-     
-  }, [open, mode, node]);
+  }, [open, mode, node, form]);
 
   const title = useMemo(
     () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
     [mode, t],
   );
 
-  function buildPayload(): Partial<NodeRecord> {
+  function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
     return {
-      id: form.id || 0,
-      name: form.name?.trim() || '',
-      remark: form.remark?.trim() || '',
-      scheme: form.scheme || 'https',
-      address: form.address?.trim() || '',
-      port: Number(form.port) || 0,
-      basePath: form.basePath?.trim() || '/',
-      apiToken: form.apiToken?.trim() || '',
-      enable: !!form.enable,
-      allowPrivateAddress: !!form.allowPrivateAddress,
+      id: values.id || 0,
+      name: values.name.trim(),
+      remark: values.remark?.trim() || '',
+      scheme: values.scheme,
+      address: values.address.trim(),
+      port: values.port,
+      basePath: values.basePath.trim() || '/',
+      apiToken: values.apiToken.trim(),
+      enable: values.enable,
+      allowPrivateAddress: values.allowPrivateAddress,
     };
   }
 
-  function update<K extends keyof FormState>(key: K, value: FormState[K]) {
-    setForm((prev) => ({ ...prev, [key]: value }));
-  }
-
   async function onTest() {
+    try {
+      await form.validateFields(['address', 'port']);
+    } catch {
+      return;
+    }
     setTesting(true);
     setTestResult(null);
     try {
-      const payload = buildPayload();
-      if (!payload.address || !payload.port) {
-        messageApi.error(t('pages.nodes.toasts.fillRequired'));
-        return;
-      }
+      const payload = buildPayload(form.getFieldsValue(true));
       const msg = await testConnection(payload);
       if (msg?.success && msg.obj) {
         setTestResult(msg.obj);
@@ -148,15 +118,15 @@ export default function NodeFormModal({
     }
   }
 
-  async function onSave() {
-    const payload = buildPayload();
-    if (!payload.name || !payload.address || !payload.port) {
-      messageApi.error(t('pages.nodes.toasts.fillRequired'));
+  async function onFinish(values: NodeFormValues) {
+    const result = NodeFormSchema.safeParse(values);
+    if (!result.success) {
+      messageApi.error(t(result.error.issues[0]?.message ?? 'pages.nodes.toasts.fillRequired'));
       return;
     }
     setSubmitting(true);
     try {
-      const msg = await save(payload);
+      const msg = await save(buildPayload(result.data));
       if (msg?.success) {
         onOpenChange(false);
       }
@@ -176,125 +146,127 @@ export default function NodeFormModal({
         open={open}
         title={title}
         confirmLoading={submitting}
-      okText={t('save')}
-      cancelText={t('cancel')}
-      mask={{ closable: false }}
-      width="640px"
-      onOk={onSave}
-      onCancel={close}
-    >
-      <Form layout="vertical">
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.name')} required>
-              <Input
-                value={form.name}
-                placeholder={t('pages.nodes.namePlaceholder')}
-                onChange={(e) => update('name', e.target.value)}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.remark')}>
-              <Input value={form.remark} onChange={(e) => update('remark', e.target.value)} />
-            </Form.Item>
-          </Col>
-        </Row>
+        okText={t('save')}
+        cancelText={t('cancel')}
+        mask={{ closable: false }}
+        width="640px"
+        onOk={() => form.submit()}
+        onCancel={close}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          initialValues={defaultValues()}
+          onFinish={onFinish}
+        >
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.nodes.name')}
+                name="name"
+                rules={[antdRule(NodeFormSchema.shape.name, t)]}
+              >
+                <Input placeholder={t('pages.nodes.namePlaceholder')} />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.nodes.remark')} name="remark">
+                <Input />
+              </Form.Item>
+            </Col>
+          </Row>
 
-        <Row gutter={16}>
-          <Col xs={24} md={6}>
-            <Form.Item label={t('pages.nodes.scheme')}>
-              <Select
-                value={form.scheme}
-                onChange={(v) => update('scheme', v)}
-                options={[
-                  { value: 'https', label: 'https' },
-                  { value: 'http', label: 'http' },
-                ]}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.address')} required>
-              <Input
-                value={form.address}
-                placeholder={t('pages.nodes.addressPlaceholder')}
-                onChange={(e) => update('address', e.target.value)}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={6}>
-            <Form.Item label={t('pages.nodes.port')} required>
-              <InputNumber
-                value={form.port}
-                min={1}
-                max={65535}
-                style={{ width: '100%' }}
-                onChange={(v) => update('port', Number(v) || 0)}
-              />
-            </Form.Item>
-          </Col>
-        </Row>
+          <Row gutter={16}>
+            <Col xs={24} md={6}>
+              <Form.Item label={t('pages.nodes.scheme')} name="scheme">
+                <Select
+                  options={[
+                    { value: 'https', label: 'https' },
+                    { value: 'http', label: 'http' },
+                  ]}
+                />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.nodes.address')}
+                name="address"
+                rules={[antdRule(NodeFormSchema.shape.address, t)]}
+              >
+                <Input placeholder={t('pages.nodes.addressPlaceholder')} />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={6}>
+              <Form.Item
+                label={t('pages.nodes.port')}
+                name="port"
+                rules={[antdRule(NodeFormSchema.shape.port, t)]}
+              >
+                <InputNumber min={1} max={65535} style={{ width: '100%' }} />
+              </Form.Item>
+            </Col>
+          </Row>
 
-        <Row gutter={16}>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.basePath')}>
-              <Input
-                value={form.basePath}
-                placeholder="/"
-                onChange={(e) => update('basePath', e.target.value)}
-              />
-            </Form.Item>
-          </Col>
-          <Col xs={24} md={12}>
-            <Form.Item label={t('pages.nodes.enable')}>
-              <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
-            </Form.Item>
-          </Col>
-        </Row>
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.nodes.basePath')} name="basePath">
+                <Input placeholder="/" />
+              </Form.Item>
+            </Col>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.nodes.enable')}
+                name="enable"
+                valuePropName="checked"
+              >
+                <Switch />
+              </Form.Item>
+            </Col>
+          </Row>
 
-        <Form.Item label={t('pages.nodes.allowPrivateAddress')}>
-          <Switch
-            checked={form.allowPrivateAddress}
-            onChange={(v) => update('allowPrivateAddress', v)}
-          />
-          <div className="hint">{t('pages.nodes.allowPrivateAddressHint')}</div>
-        </Form.Item>
+          <Form.Item
+            label={t('pages.nodes.allowPrivateAddress')}
+            name="allowPrivateAddress"
+            valuePropName="checked"
+            extra={t('pages.nodes.allowPrivateAddressHint')}
+          >
+            <Switch />
+          </Form.Item>
 
-        <Form.Item label={t('pages.nodes.apiToken')} required>
-          <Input.Password
-            value={form.apiToken}
-            placeholder={t('pages.nodes.apiTokenPlaceholder')}
-            onChange={(e) => update('apiToken', e.target.value)}
-          />
-          <div className="hint">{t('pages.nodes.apiTokenHint')}</div>
-        </Form.Item>
+          <Form.Item
+            label={t('pages.nodes.apiToken')}
+            name="apiToken"
+            rules={[antdRule(NodeFormSchema.shape.apiToken, t)]}
+            extra={t('pages.nodes.apiTokenHint')}
+          >
+            <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
+          </Form.Item>
 
-        <div className="test-row">
-          <Button type="default" loading={testing} onClick={onTest}>
-            {t('pages.nodes.testConnection')}
-          </Button>
-          {testResult && (
-            <div className="test-result">
-              {testResult.status === 'online' ? (
-                <Alert
-                  type="success"
-                  showIcon
-                  title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
-                  description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
-                />
-              ) : (
-                <Alert
-                  type="error"
-                  showIcon
-                  title={t('pages.nodes.connectionFailed')}
-                  description={testResult.error}
-                />
-              )}
-            </div>
-          )}
-        </div>
-      </Form>
+          <div className="test-row">
+            <Button type="default" loading={testing} onClick={onTest}>
+              {t('pages.nodes.testConnection')}
+            </Button>
+            {testResult && (
+              <div className="test-result">
+                {testResult.status === 'online' ? (
+                  <Alert
+                    type="success"
+                    showIcon
+                    title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
+                    description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
+                  />
+                ) : (
+                  <Alert
+                    type="error"
+                    showIcon
+                    title={t('pages.nodes.connectionFailed')}
+                    description={testResult.error}
+                  />
+                )}
+              </div>
+            )}
+          </div>
+        </Form>
       </Modal>
     </>
   );

+ 15 - 3
frontend/src/pages/settings/SettingsPage.tsx

@@ -29,6 +29,7 @@ import { setMessageInstance } from '@/utils/messageBus';
 import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useAllSettings } from '@/api/queries/useAllSettings';
+import { AllSettingSchema } from '@/schemas/setting';
 import AppSidebar from '@/components/AppSidebar';
 import GeneralTab from './GeneralTab';
 import SecurityTab from './SecurityTab';
@@ -148,6 +149,18 @@ export default function SettingsPage() {
     return url.toString();
   }
 
+  async function onSave() {
+    const result = AllSettingSchema.safeParse(allSetting);
+    if (!result.success) {
+      const issue = result.error.issues[0];
+      const fieldPath = issue?.path.join('.') ?? 'value';
+      const msgKey = issue?.message ?? 'somethingWentWrong';
+      messageApi.error(`${fieldPath}: ${t(msgKey, { defaultValue: msgKey })}`);
+      return;
+    }
+    await saveAll();
+  }
+
   function restartPanel() {
     modal.confirm({
       title: t('pages.settings.restartPanel'),
@@ -280,9 +293,8 @@ export default function SettingsPage() {
                     <Alert
                       type="error"
                       showIcon
-                      closable
+                      closable={{ onClose: () => setAlertVisible(false) }}
                       className="conf-alert"
-                      onClose={() => setAlertVisible(false)}
                       title={t('pages.settings.securityWarnings')}
                       description={(
                         <>
@@ -301,7 +313,7 @@ export default function SettingsPage() {
                         <Row className="header-row">
                           <Col xs={24} sm={10} className="header-actions">
                             <Space>
-                              <Button type="primary" disabled={saveDisabled} onClick={saveAll}>
+                              <Button type="primary" disabled={saveDisabled} onClick={onSave}>
                                 {t('pages.settings.save')}
                               </Button>
                               <Button type="primary" danger disabled={!saveDisabled} onClick={restartPanel}>

+ 9 - 3
frontend/src/pages/settings/TwoFactorModal.tsx

@@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
 import * as OTPAuth from 'otpauth';
 
 import { ClipboardManager } from '@/utils';
+import { TotpCodeSchema } from '@/schemas/login';
 import './TwoFactorModal.css';
 
 type Type = 'set' | 'confirm';
@@ -61,12 +62,17 @@ export default function TwoFactorModal({
   }
 
   function onOk() {
+    const codeOk = TotpCodeSchema.safeParse(enteredCode);
+    if (!codeOk.success) {
+      messageApi.error(t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'));
+      return;
+    }
     if (type === 'confirm' && !token) {
-      close(true, enteredCode);
+      close(true, codeOk.data);
       return;
     }
     if (!totpRef.current) return;
-    if (totpRef.current.generate() === enteredCode) {
+    if (totpRef.current.generate() === codeOk.data) {
       close(true);
     } else {
       messageApi.error(t('pages.settings.security.twoFactorModalError'));
@@ -92,7 +98,7 @@ export default function TwoFactorModal({
         onCancel={onCancel}
       footer={[
         <Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
-        <Button key="ok" type="primary" disabled={enteredCode.length < 6} onClick={onOk}>
+        <Button key="ok" type="primary" disabled={!TotpCodeSchema.safeParse(enteredCode).success} onClick={onOk}>
           {t('confirm')}
         </Button>,
       ]}

+ 48 - 53
frontend/src/pages/sub/SubPage.css

@@ -28,91 +28,86 @@
   margin-top: 8px;
 }
 
-.qr-row {
-  margin-bottom: 12px;
-}
-
-.qr-col {
-  display: flex;
-  justify-content: center;
-}
-
-.qr-box {
-  display: inline-flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 4px;
-  width: 240px;
-}
-
 .qr-tag {
   width: 100%;
   text-align: center;
   margin: 0;
 }
 
-.qr-code {
-  cursor: pointer;
-}
-
 .info-table {
-  margin-top: 12px;
+  margin-top: 4px;
 }
 
 .links-section {
-  margin-top: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
 }
 
-.link-row {
-  position: relative;
-  margin-bottom: 16px;
-  text-align: center;
+.sub-link-anchor {
+  color: inherit;
+  text-decoration: none;
 }
 
-.link-tag {
-  margin-bottom: -10px;
-  position: relative;
-  z-index: 2;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+.sub-link-anchor:hover {
+  text-decoration: underline;
 }
 
-.link-box {
-  cursor: pointer;
-  border-radius: 12px;
-  padding: 22px 18px 14px;
-  margin-top: -10px;
-  word-break: break-all;
-  font-size: 13px;
-  line-height: 1.5;
-  text-align: left;
-  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
-  transition: background 120ms ease, border-color 120ms ease;
-  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08);
+.sub-link-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-radius: 10px;
   background: rgba(0, 0, 0, 0.03);
   border: 1px solid rgba(0, 0, 0, 0.08);
+  transition: background 120ms ease, border-color 120ms ease;
 }
 
-.link-box:hover {
+.sub-link-row:hover {
   background: rgba(0, 0, 0, 0.05);
   border-color: rgba(0, 0, 0, 0.14);
 }
 
-.link-copy-icon {
-  margin-right: 6px;
-  opacity: 0.6;
-}
-
-.is-dark .link-box {
+.is-dark .sub-link-row {
   background: rgba(0, 0, 0, 0.2);
   border-color: rgba(255, 255, 255, 0.1);
-  color: rgba(255, 255, 255, 0.85);
 }
 
-.is-dark .link-box:hover {
+.is-dark .sub-link-row:hover {
   background: rgba(0, 0, 0, 0.3);
   border-color: rgba(255, 255, 255, 0.2);
 }
 
+.sub-link-tag {
+  margin: 0;
+  flex-shrink: 0;
+  font-weight: 600;
+  letter-spacing: 0.3px;
+}
+
+.sub-link-title {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sub-link-actions {
+  display: flex;
+  gap: 4px;
+  flex-shrink: 0;
+}
+
+.sub-link-qr-popover {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6px;
+}
+
 .apps-row {
   margin-top: 24px;
 }

+ 256 - 77
frontend/src/pages/sub/SubPage.tsx

@@ -6,6 +6,7 @@ import {
   Col,
   ConfigProvider,
   Descriptions,
+  Divider,
   Dropdown,
   Layout,
   Menu,
@@ -15,6 +16,7 @@ import {
   Row,
   Space,
   Tag,
+  Tooltip,
 } from 'antd';
 import {
   AndroidOutlined,
@@ -23,6 +25,7 @@ import {
   DownOutlined,
   MoonFilled,
   MoonOutlined,
+  QrcodeOutlined,
   SunOutlined,
   TranslationOutlined,
 } from '@ant-design/icons';
@@ -30,6 +33,7 @@ import {
 import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import SubUsageSummary from './SubUsageSummary';
 import './SubPage.css';
 
 const QR_SIZE = 240;
@@ -51,6 +55,7 @@ const subJsonUrl = subData.subJsonUrl || '';
 const subClashUrl = subData.subClashUrl || '';
 const subTitle = subData.subTitle || '';
 const links: string[] = Array.isArray(subData.links) ? subData.links : [];
+const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
 const datepicker = subData.datepicker || 'gregorian';
 
 const isUnlimited = totalByte <= 0 && expireMs === 0;
@@ -65,18 +70,83 @@ const isActive = (() => {
   return true;
 })();
 
-function linkName(link: string, idx: number): string {
-  if (!link) return `Link ${idx + 1}`;
-  const hashIdx = link.indexOf('#');
-  if (hashIdx >= 0 && hashIdx + 1 < link.length) {
+const PROTOCOL_COLORS: Record<string, string> = {
+  VLESS: 'blue',
+  VMESS: 'geekblue',
+  TROJAN: 'volcano',
+  SS: 'magenta',
+  HYSTERIA: 'cyan',
+  HY2: 'green',
+};
+
+// Same idea as ClientInfoModal.trimEmail — strip the client email
+// suffix from the remark so the row title isn't ugly twice.
+function trimEmail(remark: string, email: string): string {
+  if (!email) return remark;
+  const e = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  return remark
+    .replace(new RegExp(`[-_.\\s|]+${e}$`), '')
+    .replace(new RegExp(`^${e}[-_.\\s|]+`), '')
+    .trim();
+}
+
+// Post-quantum keys blow up the encoded URL past what a single QR can
+// hold. The algorithm names don't appear as plain text in the URL —
+// they ride inside query params: mldsa65Verify → `pqv=<base64>`,
+// ML-KEM-768 → `encryption=mlkem768x25519plus.<...>`. The literal
+// substrings are also matched in case a config (e.g. wireguard) embeds
+// them directly.
+function isPostQuantumLink(link: string): boolean {
+  if (/[?&]pqv=/.test(link)) return true;
+  if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
+  if (link.includes('ML-KEM-768')) return true;
+  return false;
+}
+
+// Decode a base64 string as UTF-8. atob() returns a binary string where
+// each char holds one raw byte (Latin-1 interpretation), which mangles
+// any multi-byte UTF-8 sequence in the payload — most commonly the
+// emoji decorations the panel embeds in remarks (📊, ⏳).
+function base64DecodeUtf8(b64: string): string {
+  const binary = atob(b64);
+  const bytes = new Uint8Array(binary.length);
+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+  return new TextDecoder('utf-8').decode(bytes);
+}
+
+function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } {
+  const fallback = `Link ${idx + 1}`;
+  if (!link) return { protocol: 'LINK', remark: fallback };
+  const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
+  const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
+  const protocolMap: Record<string, string> = {
+    vless: 'VLESS',
+    vmess: 'VMESS',
+    trojan: 'TROJAN',
+    ss: 'SS',
+    hysteria: 'HYSTERIA',
+    hysteria2: 'HY2',
+    hy2: 'HY2',
+  };
+  const protocol = protocolMap[scheme] ?? scheme.toUpperCase() ?? 'LINK';
+
+  let remark = '';
+  if (scheme === 'vmess') {
     try {
-      return decodeURIComponent(link.slice(hashIdx + 1));
-    } catch {
-      return link.slice(hashIdx + 1);
+      const body = link.slice('vmess://'.length).split('#')[0];
+      const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown };
+      if (typeof json?.ps === 'string') remark = json.ps;
+    } catch { /* fall through */ }
+  }
+  if (!remark) {
+    const hashIdx = link.indexOf('#');
+    if (hashIdx >= 0 && hashIdx + 1 < link.length) {
+      const raw = link.slice(hashIdx + 1);
+      try { remark = decodeURIComponent(raw); }
+      catch { remark = raw; }
     }
   }
-  const proto = link.split('://')[0];
-  return `${proto.toUpperCase()} ${idx + 1}`;
+  return { protocol, remark: remark || fallback };
 }
 
 export default function SubPage() {
@@ -277,63 +347,6 @@ export default function SubPage() {
           <Row justify="center">
             <Col xs={24} sm={22} md={18} lg={14} xl={12}>
               <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
-                <Row gutter={[8, 8]} justify="center" className="qr-row">
-                  <Col xs={24} sm={subJsonUrl || subClashUrl ? 12 : 24} className="qr-col">
-                    <div className="qr-box">
-                      <Tag color="purple" className="qr-tag">{t('pages.settings.subSettings')}</Tag>
-                      <QRCode
-                        className="qr-code"
-                        value={subUrl}
-                        size={QR_SIZE}
-                        type="svg"
-                        bordered={false}
-                        color="#000000"
-                        bgColor="#ffffff"
-                        title={t('copy')}
-                        onClick={() => copy(subUrl)}
-                      />
-                    </div>
-                  </Col>
-                  {subJsonUrl && (
-                    <Col xs={24} sm={12} className="qr-col">
-                      <div className="qr-box">
-                        <Tag color="purple" className="qr-tag">
-                          {t('pages.settings.subSettings')} JSON
-                        </Tag>
-                        <QRCode
-                          className="qr-code"
-                          value={subJsonUrl}
-                          size={QR_SIZE}
-                          type="svg"
-                          bordered={false}
-                          color="#000000"
-                          bgColor="#ffffff"
-                          title={t('copy')}
-                          onClick={() => copy(subJsonUrl)}
-                        />
-                      </div>
-                    </Col>
-                  )}
-                  {subClashUrl && (
-                    <Col xs={24} sm={12} className="qr-col">
-                      <div className="qr-box">
-                        <Tag color="purple" className="qr-tag">Clash / Mihomo</Tag>
-                        <QRCode
-                          className="qr-code"
-                          value={subClashUrl}
-                          size={QR_SIZE}
-                          type="svg"
-                          bordered={false}
-                          color="#000000"
-                          bgColor="#ffffff"
-                          title={t('copy')}
-                          onClick={() => copy(subClashUrl)}
-                        />
-                      </div>
-                    </Col>
-                  )}
-                </Row>
-
                 <Descriptions
                   bordered
                   column={1}
@@ -342,18 +355,184 @@ export default function SubPage() {
                   items={descriptionsItems}
                 />
 
-                {links.length > 0 && (
-                  <div className="links-section">
-                    {links.map((link, idx) => (
-                      <div key={link} className="link-row" onClick={() => copy(link)}>
-                        <Tag color="purple" className="link-tag">{linkName(link, idx)}</Tag>
-                        <div className="link-box">
-                          <CopyOutlined className="link-copy-icon" />
-                          {link}
+                <SubUsageSummary
+                  usedByte={Number(subData.usedByte || 0)
+                    || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0))}
+                  totalByte={totalByte}
+                  usedLabel={used}
+                  totalLabel={total}
+                  remainedLabel={remained}
+                  expireMs={expireMs}
+                  isActive={isActive}
+                />
+
+                {(subUrl || subJsonUrl || subClashUrl) && (
+                  <>
+                    <Divider>{t('subscription.title')}</Divider>
+                    <div className="links-section">
+                      {subUrl && (
+                        <div className="sub-link-row">
+                          <Tag color="green" className="sub-link-tag">SUB</Tag>
+                          <a
+                            href={subUrl}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="sub-link-title sub-link-anchor"
+                            title={subUrl}
+                          >
+                            {sId}
+                          </a>
+                          <div className="sub-link-actions">
+                            <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subUrl)} aria-label={t('copy')} title={t('copy')} />
+                            <Popover
+                              trigger="click"
+                              placement="left"
+                              destroyOnHidden
+                              content={
+                                <div className="sub-link-qr-popover">
+                                  <Tag color="green" className="qr-tag">{t('pages.settings.subSettings')}</Tag>
+                                  <QRCode value={subUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
+                                </div>
+                              }
+                            >
+                              <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
+                            </Popover>
+                          </div>
+                        </div>
+                      )}
+                      {subJsonUrl && (
+                        <div className="sub-link-row">
+                          <Tag color="purple" className="sub-link-tag">JSON</Tag>
+                          <a
+                            href={subJsonUrl}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="sub-link-title sub-link-anchor"
+                            title={subJsonUrl}
+                          >
+                            {sId}
+                          </a>
+                          <div className="sub-link-actions">
+                            <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
+                            <Popover
+                              trigger="click"
+                              placement="left"
+                              destroyOnHidden
+                              content={
+                                <div className="sub-link-qr-popover">
+                                  <Tag color="purple" className="qr-tag">{t('pages.settings.subSettings')} JSON</Tag>
+                                  <QRCode value={subJsonUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
+                                </div>
+                              }
+                            >
+                              <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
+                            </Popover>
+                          </div>
+                        </div>
+                      )}
+                      {subClashUrl && (
+                        <div className="sub-link-row">
+                          <Tooltip title="Clash / Mihomo">
+                            <Tag color="gold" className="sub-link-tag">CLASH</Tag>
+                          </Tooltip>
+                          <a
+                            href={subClashUrl}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="sub-link-title sub-link-anchor"
+                            title={subClashUrl}
+                          >
+                            {sId}
+                          </a>
+                          <div className="sub-link-actions">
+                            <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
+                            <Popover
+                              trigger="click"
+                              placement="left"
+                              destroyOnHidden
+                              content={
+                                <div className="sub-link-qr-popover">
+                                  <Tag color="gold" className="qr-tag">Clash / Mihomo</Tag>
+                                  <QRCode value={subClashUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
+                                </div>
+                              }
+                            >
+                              <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
+                            </Popover>
+                          </div>
                         </div>
-                      </div>
-                    ))}
-                  </div>
+                      )}
+                    </div>
+                  </>
+                )}
+
+                {links.length > 0 && (
+                  <>
+                    <Divider>{t('pages.inbounds.copyLink')}</Divider>
+                    <div className="links-section">
+                      {links.map((link, idx) => {
+                        const meta = parseLinkMeta(link, idx);
+                        const rowEmail = linkEmails[idx] || '';
+                        const rowTitle = trimEmail(meta.remark, rowEmail) || meta.remark;
+                        const qrLabel = rowEmail ? `${rowTitle}-${rowEmail}` : meta.remark;
+                        const canQr = !isPostQuantumLink(link);
+                        return (
+                          <div key={link} className="sub-link-row">
+                            <Tag
+                              color={PROTOCOL_COLORS[meta.protocol] ?? 'default'}
+                              className="sub-link-tag"
+                            >
+                              {meta.protocol}
+                            </Tag>
+                            <span className="sub-link-title" title={meta.remark}>
+                              {rowTitle}
+                            </span>
+                            <div className="sub-link-actions">
+                              <Button
+                                size="small"
+                                icon={<CopyOutlined />}
+                                onClick={() => copy(link)}
+                                aria-label={t('copy')}
+                                title={t('copy')}
+                              />
+                              {canQr && (
+                                <Popover
+                                  trigger="click"
+                                  placement="left"
+                                  destroyOnHidden
+                                  content={
+                                    <div className="sub-link-qr-popover">
+                                      <Tag
+                                        color={PROTOCOL_COLORS[meta.protocol] ?? 'default'}
+                                        className="qr-tag"
+                                      >
+                                        {qrLabel}
+                                      </Tag>
+                                      <QRCode
+                                        value={link}
+                                        size={220}
+                                        type="svg"
+                                        bordered={false}
+                                        color="#000000"
+                                        bgColor="#ffffff"
+                                      />
+                                    </div>
+                                  }
+                                >
+                                  <Button
+                                    size="small"
+                                    icon={<QrcodeOutlined />}
+                                    aria-label="QR"
+                                    title="QR"
+                                  />
+                                </Popover>
+                              )}
+                            </div>
+                          </div>
+                        );
+                      })}
+                    </div>
+                  </>
                 )}
 
                 <Row gutter={[8, 8]} justify="center" className="apps-row">

+ 87 - 0
frontend/src/pages/sub/SubUsageSummary.css

@@ -0,0 +1,87 @@
+.usage-summary {
+  margin-top: 12px;
+  padding: 14px 16px;
+  background: var(--ant-color-fill-alter);
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 12px;
+}
+
+.usage-summary.is-inactive {
+  opacity: 0.7;
+  border-color: var(--ant-color-error-border);
+}
+
+.usage-summary-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.usage-summary-labels {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+  font-variant-numeric: tabular-nums;
+  min-width: 0;
+}
+
+.usage-summary-used {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--ant-color-text);
+}
+
+.usage-summary-sep {
+  color: var(--ant-color-text-quaternary);
+  font-size: 16px;
+}
+
+.usage-summary-total {
+  font-size: 14px;
+  color: var(--ant-color-text-secondary);
+  font-weight: 500;
+}
+
+.usage-summary-chips {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+.usage-summary-chips .ant-tag {
+  margin: 0;
+}
+
+.usage-summary-bar.ant-progress {
+  margin-bottom: 6px;
+}
+
+.usage-summary-bar .ant-progress-outer {
+  padding-inline-end: 0;
+}
+
+.usage-summary-bar .ant-progress-inner {
+  background: var(--ant-color-fill-secondary);
+}
+
+.usage-summary-foot {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+  font-variant-numeric: tabular-nums;
+  min-height: 16px;
+}
+
+.usage-summary-remained::before {
+  content: '';
+}
+
+.usage-summary-pct {
+  font-weight: 600;
+  color: var(--ant-color-text-secondary);
+}

+ 96 - 0
frontend/src/pages/sub/SubUsageSummary.tsx

@@ -0,0 +1,96 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Progress, Tag } from 'antd';
+import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
+
+import './SubUsageSummary.css';
+
+interface SubUsageSummaryProps {
+  usedByte: number;
+  totalByte: number;
+  usedLabel: string;
+  totalLabel: string;
+  remainedLabel: string;
+  expireMs: number;
+  isActive: boolean;
+}
+
+function pickStrokeColor(pct: number): { from: string; to: string } {
+  if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
+  if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
+  return { from: '#5fc983', to: '#36b37e' };
+}
+
+function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
+  if (expireMs <= 0) return null;
+  const diff = expireMs - Date.now();
+  if (diff <= 0) return { label: 'Expired', color: 'red' };
+  const days = Math.floor(diff / 86400000);
+  if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
+  const hours = Math.max(1, Math.floor(diff / 3600000));
+  return { label: `${hours}h`, color: 'orange' };
+}
+
+export default function SubUsageSummary({
+  usedByte,
+  totalByte,
+  usedLabel,
+  totalLabel,
+  remainedLabel,
+  expireMs,
+  isActive,
+}: SubUsageSummaryProps) {
+  const { t } = useTranslation();
+  const pct = useMemo(() => {
+    if (totalByte <= 0) return 0;
+    const v = (usedByte / totalByte) * 100;
+    if (!Number.isFinite(v)) return 0;
+    return Math.max(0, Math.min(100, v));
+  }, [usedByte, totalByte]);
+
+  const expiry = formatExpiryChip(expireMs);
+  const isUnlimited = totalByte <= 0;
+  const stroke = pickStrokeColor(pct);
+
+  return (
+    <div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
+      <div className="usage-summary-head">
+        <div className="usage-summary-labels">
+          <span className="usage-summary-used">{usedLabel}</span>
+          <span className="usage-summary-sep">/</span>
+          <span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
+        </div>
+        <div className="usage-summary-chips">
+          {isUnlimited && (
+            <Tag color="purple" icon={<ThunderboltOutlined />}>
+              {t('subscription.unlimited')}
+            </Tag>
+          )}
+          {expiry && (
+            <Tag color={expiry.color} icon={<ClockCircleOutlined />}>
+              {expiry.label}
+            </Tag>
+          )}
+        </div>
+      </div>
+      {!isUnlimited && (
+        <Progress
+          percent={pct}
+          showInfo={false}
+          strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
+          trailColor="var(--ant-color-fill-secondary)"
+          strokeWidth={10}
+          className="usage-summary-bar"
+        />
+      )}
+      <div className="usage-summary-foot">
+        {!isUnlimited && (
+          <>
+            <span className="usage-summary-remained">{remainedLabel}</span>
+            <span className="usage-summary-pct">{pct.toFixed(1)}%</span>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}

+ 202 - 67
frontend/src/pages/xray/BalancerFormModal.tsx

@@ -1,13 +1,20 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Modal, Select } from 'antd';
+import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
 
-export interface BalancerFormValue {
-  tag: string;
-  strategy: string;
-  selector: string[];
-  fallbackTag: string;
-}
+import InputAddon from '@/components/InputAddon';
+import {
+  BalancerFormSchema,
+  type BalancerFormValues,
+} from '@/schemas/xray';
+import {
+  BalancerStrategyTypeSchema,
+  type BalancerStrategySettings,
+  type BalancerStrategyType,
+} from '@/schemas/routing';
+
+export type BalancerFormValue = BalancerFormValues;
 
 interface BalancerFormModalProps {
   open: boolean;
@@ -18,12 +25,38 @@ interface BalancerFormModalProps {
   onConfirm: (value: BalancerFormValue) => void;
 }
 
-const STRATEGIES = [
-  { value: 'random', label: 'Random' },
-  { value: 'roundRobin', label: 'Round robin' },
-  { value: 'leastLoad', label: 'Least load' },
-  { value: 'leastPing', label: 'Least ping' },
-];
+const STRATEGY_LABELS: Record<string, string> = {
+  random: 'Random',
+  roundRobin: 'Round robin',
+  leastLoad: 'Least load',
+  leastPing: 'Least ping',
+};
+
+const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
+  value,
+  label: STRATEGY_LABELS[value] ?? value,
+}));
+
+interface FormState {
+  tag: string;
+  strategy: BalancerStrategyType;
+  selector: string[];
+  fallbackTag: string;
+  settings?: BalancerStrategySettings;
+}
+
+function initialState(balancer: BalancerFormValue | null): FormState {
+  if (!balancer) {
+    return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
+  }
+  return {
+    tag: balancer.tag ?? '',
+    strategy: (balancer.strategy ?? 'random') as BalancerStrategyType,
+    selector: [...(balancer.selector ?? [])],
+    fallbackTag: balancer.fallbackTag ?? '',
+    settings: balancer.settings,
+  };
+}
 
 export default function BalancerFormModal({
   open,
@@ -34,98 +67,200 @@ export default function BalancerFormModal({
   onConfirm,
 }: BalancerFormModalProps) {
   const { t } = useTranslation();
-  const [tag, setTag] = useState(() => balancer?.tag || '');
-  const [strategy, setStrategy] = useState(() => balancer?.strategy || 'random');
-  const [selector, setSelector] = useState<string[]>(() => [...(balancer?.selector || [])]);
-  const [fallbackTag, setFallbackTag] = useState(() => balancer?.fallbackTag || '');
-
+  const [state, setState] = useState<FormState>(() => initialState(balancer));
   const isEdit = balancer != null;
 
-  useEffect(() => {
-    if (!open) return;
-    if (balancer) {
-      setTag(balancer.tag || '');
-      setStrategy(balancer.strategy || 'random');
-      setSelector([...(balancer.selector || [])]);
-      setFallbackTag(balancer.fallbackTag || '');
-    } else {
-      setTag('');
-      setStrategy('random');
-      setSelector([]);
-      setFallbackTag('');
+  const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
+    setState((prev) => ({ ...prev, [key]: value }));
+
+  const parsed = useMemo(
+    () => BalancerFormSchema.safeParse(state),
+    [state],
+  );
+  const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
+  const issues = useMemo(() => {
+    const map: Record<string, string> = {};
+    if (!parsed.success) {
+      for (const issue of parsed.error.issues) {
+        const key = String(issue.path[0] ?? '');
+        if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message });
+      }
     }
-  }, [open, balancer]);
-
-  const tagEmpty = !tag.trim();
-  const duplicateTag = !!tag && otherTags.includes(tag.trim());
-  const emptySelector = selector.length === 0;
-  const isValid = !tagEmpty && !duplicateTag && !emptySelector;
-
-  const tagValidateStatus: 'error' | 'warning' | 'success' = tagEmpty
-    ? 'error'
-    : duplicateTag
-      ? 'warning'
-      : 'success';
-  const tagHelp = tagEmpty
-    ? 'Tag is required'
-    : duplicateTag
-      ? 'Tag already used by another balancer'
-      : '';
-
-  const selectorValidateStatus: 'error' | 'success' = emptySelector ? 'error' : 'success';
-  const selectorHelp = emptySelector ? 'Pick at least one outbound' : '';
+    return map;
+  }, [parsed, t]);
 
   function submit() {
-    if (!isValid) return;
-    onConfirm({ tag, strategy, selector, fallbackTag });
+    if (!parsed.success || duplicateTag) return;
+    const values = { ...parsed.data };
+    if (values.strategy !== 'leastLoad') delete values.settings;
+    onConfirm(values);
   }
 
-  const title = isEdit
-    ? `${t('edit')} ${t('pages.xray.Balancers')}`
-    : `+ ${t('pages.xray.Balancers')}`;
-  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
+  const settings = state.settings;
+  const updateSetting = <K extends keyof BalancerStrategySettings>(
+    key: K,
+    value: BalancerStrategySettings[K],
+  ) => {
+    setState((prev) => ({
+      ...prev,
+      settings: { ...(prev.settings ?? {}), [key]: value },
+    }));
+  };
+  const updateBaselines = (next: string[]) => updateSetting('baselines', next);
+  const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => updateSetting('costs', next);
+
+  const baselines = settings?.baselines ?? [];
+  const costs = settings?.costs ?? [];
 
   const fallbackOptions = useMemo(
     () => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
     [outboundTags, t],
   );
 
+  const title = isEdit
+    ? `${t('edit')} ${t('pages.xray.Balancers')}`
+    : `+ ${t('pages.xray.Balancers')}`;
+  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
+
   return (
     <Modal
       open={open}
       title={title}
       okText={okText}
       cancelText={t('close')}
-      okButtonProps={{ disabled: !isValid }}
+      okButtonProps={{ disabled: !parsed.success || duplicateTag }}
       mask={{ closable: false }}
-      destroyOnHidden
       onOk={submit}
       onCancel={onClose}
     >
       <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-        <Form.Item label="Tag" validateStatus={tagValidateStatus} help={tagHelp} hasFeedback>
-          <Input value={tag} onChange={(e) => setTag(e.target.value)} placeholder="unique balancer tag" />
+        <Form.Item
+          label="Tag"
+          required
+          validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
+          help={issues.tag || (duplicateTag ? 'Tag already used by another balancer' : '')}
+          hasFeedback
+        >
+          <Input
+            value={state.tag}
+            onChange={(e) => update('tag', e.target.value)}
+            placeholder="unique balancer tag"
+          />
         </Form.Item>
         <Form.Item label="Strategy">
-          <Select value={strategy} onChange={setStrategy} options={STRATEGIES} />
+          <Select
+            value={state.strategy}
+            onChange={(v) => update('strategy', v)}
+            options={STRATEGIES}
+          />
         </Form.Item>
         <Form.Item
           label="Selector"
-          validateStatus={selectorValidateStatus}
-          help={selectorHelp}
+          required
+          validateStatus={issues.selector ? 'error' : ''}
+          help={issues.selector || ''}
           hasFeedback
         >
           <Select
             mode="tags"
-            value={selector}
-            onChange={setSelector}
+            value={state.selector}
+            onChange={(v) => update('selector', v)}
             tokenSeparators={[',']}
             options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
           />
         </Form.Item>
         <Form.Item label="Fallback">
-          <Select value={fallbackTag} onChange={setFallbackTag} allowClear options={fallbackOptions} />
+          <Select
+            value={state.fallbackTag}
+            onChange={(v) => update('fallbackTag', v ?? '')}
+            allowClear
+            options={fallbackOptions}
+          />
         </Form.Item>
+
+        {state.strategy === 'leastLoad' && (
+          <>
+            <Form.Item label="Expected">
+              <InputNumber
+                value={settings?.expected}
+                onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
+                min={0}
+                placeholder="optimal node count"
+                style={{ width: '100%' }}
+              />
+            </Form.Item>
+            <Form.Item label="Max RTT">
+              <Input
+                value={settings?.maxRTT ?? ''}
+                onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
+                placeholder="e.g. 1s"
+              />
+            </Form.Item>
+            <Form.Item label="Tolerance">
+              <InputNumber
+                value={settings?.tolerance}
+                onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
+                min={0}
+                max={1}
+                step={0.01}
+                placeholder="0.01 = 1%"
+                style={{ width: '100%' }}
+              />
+            </Form.Item>
+            <Form.Item label="Baselines">
+              <Button
+                size="small"
+                type="primary"
+                icon={<PlusOutlined />}
+                onClick={() => updateBaselines([...baselines, ''])}
+              />
+              {baselines.map((b, idx) => (
+                <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+                  <Input
+                    value={b}
+                    placeholder="e.g. 1s"
+                    onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
+                  />
+                  <InputAddon onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+            <Form.Item label="Costs">
+              <Button
+                size="small"
+                type="primary"
+                icon={<PlusOutlined />}
+                onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
+              />
+              {costs.map((c, idx) => (
+                <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+                  <Switch
+                    checked={c.regexp}
+                    checkedChildren="re"
+                    unCheckedChildren="lit"
+                    onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
+                  />
+                  <Input
+                    value={c.match}
+                    placeholder="tag pattern"
+                    onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
+                  />
+                  <InputNumber
+                    value={c.value}
+                    placeholder="weight"
+                    style={{ width: 100 }}
+                    onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
+                  />
+                  <InputAddon onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          </>
+        )}
       </Form>
     </Modal>
   );

+ 86 - 84
frontend/src/pages/xray/BalancersTab.tsx

@@ -8,6 +8,11 @@ import BalancerFormModal from './BalancerFormModal';
 import type { BalancerFormValue } from './BalancerFormModal';
 import JsonEditor from '@/components/JsonEditor';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
+import type {
+  BalancerObject,
+  BalancerStrategySettings,
+  BalancerStrategyType,
+} from '@/schemas/routing';
 
 interface BalancersTabProps {
   templateSettings: XraySettingsValue | null;
@@ -16,19 +21,15 @@ interface BalancersTabProps {
   isMobile: boolean;
 }
 
-interface BalancerRecord {
-  tag: string;
-  selector?: string[];
-  fallbackTag?: string;
-  strategy?: { type?: string };
-}
+type BalancerRecord = BalancerObject;
 
 interface BalancerRow {
   key: number;
   tag: string;
-  strategy: string;
+  strategy: BalancerStrategyType;
   selector: string[];
   fallbackTag: string;
+  settings?: BalancerStrategySettings;
 }
 
 const STRATEGY_LABELS: Record<string, string> = {
@@ -102,9 +103,10 @@ export default function BalancersTab({
     return list.map((b, idx) => ({
       key: idx,
       tag: b.tag || '',
-      strategy: b.strategy?.type || 'random',
+      strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
       selector: b.selector || [],
       fallbackTag: b.fallbackTag || '',
+      settings: b.strategy?.settings,
     }));
   }, [templateSettings?.routing?.balancers]);
 
@@ -159,6 +161,9 @@ export default function BalancersTab({
       };
       if (form.strategy && form.strategy !== 'random') {
         wire.strategy = { type: form.strategy };
+        if (form.strategy === 'leastLoad' && form.settings) {
+          wire.strategy.settings = form.settings;
+        }
       }
       if (editingIndex == null) {
         list.push(wire);
@@ -192,84 +197,80 @@ export default function BalancersTab({
     });
   }
 
-  const columns: ColumnsType<BalancerRow> = useMemo(
-    () => [
-      {
-        title: '#',
-        key: 'action',
-        align: 'center',
-        width: 100,
-        render: (_v, _record, index) => (
-          <div className="action-cell">
-            <span className="row-index">{index + 1}</span>
-            <div className={!isMobile ? 'action-buttons' : ''}>
-              {!isMobile && (
-                <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
-              )}
-              <Dropdown
-                trigger={['click']}
-                menu={{
-                  items: [
-                    ...(isMobile
-                      ? [
-                          {
-                            key: 'edit',
-                            label: (
-                              <>
-                                <EditOutlined /> {t('edit')}
-                              </>
-                            ),
-                            onClick: () => openEdit(index),
-                          },
-                        ]
-                      : []),
-                    {
-                      key: 'del',
-                      danger: true,
-                      label: (
-                        <>
-                          <DeleteOutlined /> {t('delete')}
-                        </>
-                      ),
-                      onClick: () => confirmDelete(index),
-                    },
-                  ],
-                }}
-              >
-                <Button shape="circle" size="small" icon={<MoreOutlined />} />
-              </Dropdown>
-            </div>
+  const columns: ColumnsType<BalancerRow> = [
+    {
+      title: '#',
+      key: 'action',
+      align: 'center',
+      width: 100,
+      render: (_v, _record, index) => (
+        <div className="action-cell">
+          <span className="row-index">{index + 1}</span>
+          <div className={!isMobile ? 'action-buttons' : ''}>
+            {!isMobile && (
+              <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
+            )}
+            <Dropdown
+              trigger={['click']}
+              menu={{
+                items: [
+                  ...(isMobile
+                    ? [
+                        {
+                          key: 'edit',
+                          label: (
+                            <>
+                              <EditOutlined /> {t('edit')}
+                            </>
+                          ),
+                          onClick: () => openEdit(index),
+                        },
+                      ]
+                    : []),
+                  {
+                    key: 'del',
+                    danger: true,
+                    label: (
+                      <>
+                        <DeleteOutlined /> {t('delete')}
+                      </>
+                    ),
+                    onClick: () => confirmDelete(index),
+                  },
+                ],
+              }}
+            >
+              <Button shape="circle" size="small" icon={<MoreOutlined />} />
+            </Dropdown>
           </div>
-        ),
-      },
-      { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
-      {
-        title: 'Strategy',
-        key: 'strategy',
-        align: 'center',
-        width: 140,
-        render: (_v, record) => (
-          <Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
-            {STRATEGY_LABELS[record.strategy] || record.strategy}
+        </div>
+      ),
+    },
+    { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
+    {
+      title: 'Strategy',
+      key: 'strategy',
+      align: 'center',
+      width: 140,
+      render: (_v, record) => (
+        <Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
+          {STRATEGY_LABELS[record.strategy] || record.strategy}
+        </Tag>
+      ),
+    },
+    {
+      title: 'Selector',
+      key: 'selector',
+      align: 'center',
+      render: (_v, record) =>
+        (record.selector || []).map((sel) => (
+          <Tag key={sel} className="info-large-tag">
+            {sel}
           </Tag>
-        ),
-      },
-      {
-        title: 'Selector',
-        key: 'selector',
-        align: 'center',
-        render: (_v, record) =>
-          (record.selector || []).map((sel) => (
-            <Tag key={sel} className="info-large-tag">
-              {sel}
-            </Tag>
-          )),
-      },
-      { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
-    ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, isMobile],
-  );
+        )),
+    },
+    { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
+  ];
 
   const hasObservatory = !!templateSettings?.observatory;
   const hasBurstObservatory = !!templateSettings?.burstObservatory;
@@ -354,6 +355,7 @@ export default function BalancersTab({
       </Space>
 
       <BalancerFormModal
+        key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
         open={modalOpen}
         balancer={editingBalancer}
         outboundTags={outboundTags}

+ 2 - 2
frontend/src/pages/xray/BasicsTab.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
 import { CloudOutlined, ApiOutlined } from '@ant-design/icons';
 
-import { OutboundDomainStrategies } from '@/models/outbound';
+import { OutboundDomainStrategies } from '@/schemas/primitives';
 import SettingListItem from '@/components/SettingListItem';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import './BasicsTab.css';
@@ -217,7 +217,7 @@ export default function BasicsTab({
               <Select
                 value={freedomStrategy}
                 style={{ width: '100%' }}
-                options={(OutboundDomainStrategies as string[]).map((s) => ({ value: s, label: s }))}
+                options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
                 onChange={(next) => mutate((tt) => {
                   if (!tt.outbounds) tt.outbounds = [];
                   const idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');

+ 175 - 157
frontend/src/pages/xray/DnsServerModal.tsx

@@ -1,29 +1,23 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
-import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
 import InputAddon from '@/components/InputAddon';
+import {
+  DnsQueryStrategySchema,
+  DnsServerObjectInnerSchema,
+  DnsServerObjectSchema,
+  type DnsServerObject,
+} from '@/schemas/dns';
+import { antdRule } from '@/utils/zodForm';
 
 export type DnsServerValue =
   | string
-  | {
-      address: string;
-      port?: number;
-      domains?: string[];
-      expectedIPs?: string[];
+  | (DnsServerObject & {
       expectIPs?: string[];
-      unexpectedIPs?: string[];
-      queryStrategy?: string;
-      skipFallback?: boolean;
-      disableCache?: boolean;
-      finalQuery?: boolean;
-      tag?: string;
-      clientIP?: string;
-      serveStale?: boolean;
-      serveExpiredTTL?: number;
-      timeoutMs?: number;
       [key: string]: unknown;
-    };
+    });
 
 interface DnsServerModalProps {
   open: boolean;
@@ -33,9 +27,9 @@ interface DnsServerModalProps {
   onConfirm: (value: DnsServerValue) => void;
 }
 
-const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
+const STRATEGIES = DnsQueryStrategySchema.options;
 
-interface DnsForm {
+type DnsServerForm = {
   address: string;
   port: number;
   domains: string[];
@@ -50,9 +44,9 @@ interface DnsForm {
   serveStale: boolean;
   serveExpiredTTL: number;
   timeoutMs: number;
-}
+};
 
-function defaultServer(): DnsForm {
+function defaultFormValues(): DnsServerForm {
   return {
     address: 'localhost',
     port: 53,
@@ -71,6 +65,68 @@ function defaultServer(): DnsForm {
   };
 }
 
+function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
+  if (server == null) return defaultFormValues();
+  if (typeof server === 'string') return { ...defaultFormValues(), address: server };
+  const parsed = DnsServerObjectSchema.safeParse(server);
+  const data = parsed.success ? parsed.data : null;
+  return {
+    ...defaultFormValues(),
+    ...(data ?? {}),
+    address: (data?.address ?? server.address) || 'localhost',
+    domains: data?.domains ?? server.domains ?? [],
+    expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
+    unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
+    queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
+    skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
+    disableCache: data?.disableCache ?? server.disableCache ?? false,
+    finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
+    tag: data?.tag ?? server.tag ?? '',
+    clientIP: data?.clientIP ?? server.clientIP ?? '',
+    serveStale: data?.serveStale ?? server.serveStale ?? false,
+    serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
+    timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
+  };
+}
+
+function valuesToWire(values: DnsServerForm): DnsServerValue {
+  const isPlain
+    = values.domains.length === 0
+    && values.expectedIPs.length === 0
+    && values.unexpectedIPs.length === 0
+    && values.port === 53
+    && values.queryStrategy === 'UseIP'
+    && values.skipFallback === false
+    && values.disableCache === false
+    && values.finalQuery === false
+    && !values.tag
+    && !values.clientIP
+    && values.serveStale === false
+    && values.serveExpiredTTL === 0
+    && values.timeoutMs === 4000;
+  if (isPlain) return values.address;
+
+  const out: Record<string, unknown> = {
+    address: values.address,
+    port: values.port,
+    domains: values.domains.filter(Boolean),
+    expectedIPs: values.expectedIPs.filter(Boolean),
+    unexpectedIPs: values.unexpectedIPs.filter(Boolean),
+    queryStrategy: values.queryStrategy,
+    skipFallback: values.skipFallback,
+    disableCache: values.disableCache,
+    finalQuery: values.finalQuery,
+    serveStale: values.serveStale,
+    serveExpiredTTL: values.serveExpiredTTL,
+    timeoutMs: values.timeoutMs,
+  };
+  if (values.tag) out.tag = values.tag;
+  if (values.clientIP) out.clientIP = values.clientIP;
+  return out as DnsServerValue;
+}
+
+const shape = DnsServerObjectInnerSchema.shape;
+
 export default function DnsServerModal({
   open,
   server,
@@ -79,74 +135,16 @@ export default function DnsServerModal({
   onConfirm,
 }: DnsServerModalProps) {
   const { t } = useTranslation();
-  const [form, setForm] = useState<DnsForm>(defaultServer());
+  const [form] = Form.useForm<DnsServerForm>();
 
   useEffect(() => {
     if (!open) return;
-    if (server == null) {
-      setForm(defaultServer());
-      return;
-    }
-    if (typeof server === 'string') {
-      setForm({ ...defaultServer(), address: server });
-      return;
-    }
-    setForm({
-      ...defaultServer(),
-      ...server,
-      domains: [...(server.domains || [])],
-      expectedIPs: [...(server.expectedIPs || server.expectIPs || [])],
-      unexpectedIPs: [...(server.unexpectedIPs || [])],
-    });
-  }, [open, server]);
-
-  const update = <K extends keyof DnsForm>(key: K, value: DnsForm[K]) =>
-    setForm((prev) => ({ ...prev, [key]: value }));
-
-  function updateList(key: 'domains' | 'expectedIPs' | 'unexpectedIPs', mutator: (next: string[]) => void) {
-    setForm((prev) => {
-      const next = [...prev[key]];
-      mutator(next);
-      return { ...prev, [key]: next };
-    });
-  }
+    form.setFieldsValue(valuesFromServer(server));
+  }, [open, server, form]);
 
-  function submit() {
-    const isPlain =
-      form.domains.length === 0 &&
-      form.expectedIPs.length === 0 &&
-      form.unexpectedIPs.length === 0 &&
-      form.port === 53 &&
-      form.queryStrategy === 'UseIP' &&
-      form.skipFallback === false &&
-      form.disableCache === false &&
-      form.finalQuery === false &&
-      !form.tag &&
-      !form.clientIP &&
-      form.serveStale === false &&
-      form.serveExpiredTTL === 0 &&
-      form.timeoutMs === 4000;
-    if (isPlain) {
-      onConfirm(form.address);
-      return;
-    }
-    const out: Record<string, unknown> = {
-      address: form.address,
-      port: form.port,
-      domains: form.domains.filter(Boolean),
-      expectedIPs: form.expectedIPs.filter(Boolean),
-      unexpectedIPs: form.unexpectedIPs.filter(Boolean),
-      queryStrategy: form.queryStrategy,
-      skipFallback: form.skipFallback,
-      disableCache: form.disableCache,
-      finalQuery: form.finalQuery,
-      serveStale: form.serveStale,
-      serveExpiredTTL: form.serveExpiredTTL,
-      timeoutMs: form.timeoutMs,
-    };
-    if (form.tag) out.tag = form.tag;
-    if (form.clientIP) out.clientIP = form.clientIP;
-    onConfirm(out as DnsServerValue);
+  async function submit() {
+    const values = await form.validateFields();
+    onConfirm(valuesToWire(values));
   }
 
   const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
@@ -161,99 +159,119 @@ export default function DnsServerModal({
       onOk={submit}
       onCancel={onClose}
     >
-      <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-        <Form.Item label={t('pages.inbounds.address')}>
-          <Input value={form.address} onChange={(e) => update('address', e.target.value)} />
+      <Form
+        form={form}
+        colon={false}
+        labelCol={{ md: { span: 8 } }}
+        wrapperCol={{ md: { span: 14 } }}
+        initialValues={defaultFormValues()}
+      >
+        <Form.Item
+          label={t('pages.inbounds.address')}
+          name="address"
+          rules={[antdRule(shape.address, t)]}
+        >
+          <Input />
         </Form.Item>
-        <Form.Item label={t('pages.inbounds.port')}>
-          <InputNumber value={form.port} min={1} max={65535} onChange={(v) => update('port', Number(v) || 53)} />
+        <Form.Item
+          label={t('pages.inbounds.port')}
+          name="port"
+          rules={[antdRule(shape.port, t)]}
+        >
+          <InputNumber min={1} max={65535} />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.tag')}>
-          <Input value={form.tag} onChange={(e) => update('tag', e.target.value)} />
+        <Form.Item label={t('pages.xray.dns.tag')} name="tag">
+          <Input />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.clientIp')}>
-          <Input value={form.clientIP} onChange={(e) => update('clientIP', e.target.value)} />
+        <Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP">
+          <Input />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.strategy')}>
+        <Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy">
           <Select
-            value={form.queryStrategy}
-            onChange={(v) => update('queryStrategy', v)}
             style={{ width: '100%' }}
             options={STRATEGIES.map((s) => ({ value: s, label: s }))}
           />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.timeoutMs')}>
-          <InputNumber value={form.timeoutMs} min={0} step={500} onChange={(v) => update('timeoutMs', Number(v) || 0)} />
+        <Form.Item
+          label={t('pages.xray.dns.timeoutMs')}
+          name="timeoutMs"
+          rules={[antdRule(shape.timeoutMs, t)]}
+        >
+          <InputNumber min={0} step={500} />
         </Form.Item>
 
         <Divider style={{ margin: '5px 0' }} />
 
-        <Form.Item label={t('pages.xray.dns.domains')}>
-          <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
-          {form.domains.map((value, idx) => (
-            <Space.Compact key={`d${idx}`} block style={{ marginTop: 4 }}>
-              <Input
-                value={value}
-                onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
-              />
-              <InputAddon onClick={() => updateList('domains', (d) => d.splice(idx, 1))}>
-                <MinusOutlined />
-              </InputAddon>
-            </Space.Compact>
-          ))}
-        </Form.Item>
+        <Form.List name="domains">
+          {(fields, { add, remove }) => (
+            <Form.Item label={t('pages.xray.dns.domains')}>
+              <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
+              {fields.map((field) => (
+                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
+                  <Form.Item name={field.name} noStyle>
+                    <Input />
+                  </Form.Item>
+                  <InputAddon onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </Form.List>
 
-        <Form.Item label={t('pages.xray.dns.expectIPs')}>
-          <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
-          {form.expectedIPs.map((value, idx) => (
-            <Space.Compact key={`e${idx}`} block style={{ marginTop: 4 }}>
-              <Input
-                value={value}
-                onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
-              />
-              <InputAddon onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))}>
-                <MinusOutlined />
-              </InputAddon>
-            </Space.Compact>
-          ))}
-        </Form.Item>
+        <Form.List name="expectedIPs">
+          {(fields, { add, remove }) => (
+            <Form.Item label={t('pages.xray.dns.expectIPs')}>
+              <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
+              {fields.map((field) => (
+                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
+                  <Form.Item name={field.name} noStyle>
+                    <Input />
+                  </Form.Item>
+                  <InputAddon onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </Form.List>
 
-        <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
-          <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
-          {form.unexpectedIPs.map((value, idx) => (
-            <Space.Compact key={`u${idx}`} block style={{ marginTop: 4 }}>
-              <Input
-                value={value}
-                onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
-              />
-              <InputAddon onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
-                <MinusOutlined />
-              </InputAddon>
-            </Space.Compact>
-          ))}
-        </Form.Item>
+        <Form.List name="unexpectedIPs">
+          {(fields, { add, remove }) => (
+            <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
+              <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
+              {fields.map((field) => (
+                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
+                  <Form.Item name={field.name} noStyle>
+                    <Input />
+                  </Form.Item>
+                  <InputAddon onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </Form.List>
 
         <Divider style={{ margin: '5px 0' }} />
 
-        <Form.Item label={t('pages.xray.dns.skipFallback')}>
-          <Switch checked={form.skipFallback} onChange={(v) => update('skipFallback', v)} />
+        <Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.finalQuery')}>
-          <Switch checked={form.finalQuery} onChange={(v) => update('finalQuery', v)} />
+        <Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.disableCache')}>
-          <Switch checked={form.disableCache} onChange={(v) => update('disableCache', v)} />
+        <Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.serveStale')}>
-          <Switch checked={form.serveStale} onChange={(v) => update('serveStale', v)} />
+        <Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.serveExpiredTTL')}>
-          <InputNumber
-            value={form.serveExpiredTTL}
-            min={0}
-            step={60}
-            onChange={(v) => update('serveExpiredTTL', Number(v) || 0)}
-          />
+        <Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
+          <InputNumber min={0} step={60} />
         </Form.Item>
       </Form>
     </Modal>

+ 3 - 15
frontend/src/pages/xray/DnsTab.tsx

@@ -9,6 +9,7 @@ import DnsServerModal from './DnsServerModal';
 import type { DnsServerValue } from './DnsServerModal';
 import DnsPresetsModal from './DnsPresetsModal';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
+import { DnsQueryStrategySchema, type DnsObject } from '@/schemas/dns';
 import './DnsTab.css';
 
 interface DnsTabProps {
@@ -16,23 +17,10 @@ interface DnsTabProps {
   setTemplateSettings: SetTemplate;
 }
 
-const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
+const STRATEGIES = DnsQueryStrategySchema.options;
 const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
 
-interface DnsConfig {
-  tag?: string;
-  clientIp?: string;
-  queryStrategy?: string;
-  disableCache?: boolean;
-  disableFallback?: boolean;
-  disableFallbackIfMatch?: boolean;
-  enableParallelQuery?: boolean;
-  useSystemHosts?: boolean;
-  serveStale?: boolean;
-  serveExpiredTTL?: number;
-  hosts?: Record<string, string | string[]>;
-  servers?: DnsServerValue[];
-}
+type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
 
 interface HostRow {
   domain: string;

+ 12 - 15
frontend/src/pages/xray/NordModal.tsx

@@ -86,14 +86,14 @@ export default function NordModal({
   }, [filteredServers]);
 
   const fetchCountries = useCallback(async () => {
-    const msg = await HttpUtil.post('/panel/xray/nord/countries');
-    if (msg?.success) setCountries(JSON.parse(msg.obj));
+    const msg = await HttpUtil.post<string>('/panel/xray/nord/countries');
+    if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
   }, []);
 
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/data');
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/data');
       if (msg?.success) {
         const next = msg.obj ? JSON.parse(msg.obj) : null;
         setNordData(next);
@@ -111,8 +111,8 @@ export default function NordModal({
   async function login() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/reg', { token });
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token });
+      if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
       }
@@ -124,8 +124,8 @@ export default function NordModal({
   async function saveKey() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey });
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey });
+      if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
       }
@@ -164,8 +164,8 @@ export default function NordModal({
     setServerId(null);
     setCityId(null);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: newCountryId });
-      if (!msg?.success) return;
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId });
+      if (!msg?.success || !msg.obj) return;
       const data = JSON.parse(msg.obj);
       const locations = data.locations || [];
       const locToCity: Record<number, City> = {};
@@ -318,8 +318,7 @@ export default function NordModal({
             <Form.Item label="Country">
               <Select
                 value={countryId ?? undefined}
-                showSearch
-                optionFilterProp="label"
+                showSearch={{ optionFilterProp: 'label' }}
                 onChange={(v) => fetchServers(v)}
                 options={countries.map((c) => ({
                   value: c.id,
@@ -332,8 +331,7 @@ export default function NordModal({
               <Form.Item label="City">
                 <Select
                   value={cityId}
-                  showSearch
-                  optionFilterProp="label"
+                  showSearch={{ optionFilterProp: 'label' }}
                   onChange={setCityId}
                   options={[{ value: null, label: 'All cities' }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
                 />
@@ -344,8 +342,7 @@ export default function NordModal({
               <Form.Item label="Server">
                 <Select
                   value={serverId}
-                  showSearch
-                  optionFilterProp="label"
+                  showSearch={{ optionFilterProp: 'label' }}
                   onChange={setServerId}
                   options={filteredServers.map((s) => ({
                     value: s.id,

+ 2141 - 1368
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -1,42 +1,72 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
   Form,
   Input,
   InputNumber,
-  message,
   Modal,
   Radio,
   Select,
   Space,
   Switch,
   Tabs,
-  Checkbox,
+  message,
 } from 'antd';
-import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons';
+import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
 
-import { Wireguard } from '@/utils';
+import FinalMaskForm from '@/components/FinalMaskForm';
+import HeaderMapEditor from '@/components/HeaderMapEditor';
 import InputAddon from '@/components/InputAddon';
+import JsonEditor from '@/components/JsonEditor';
+import { Wireguard } from '@/utils';
+import {
+  formValuesToWirePayload,
+  rawOutboundToFormValues,
+} from '@/lib/xray/outbound-form-adapter';
+import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
+import {
+  OutboundFormBaseSchema,
+  ShadowsocksOutboundFormSettingsSchema,
+  TrojanOutboundFormSettingsSchema,
+  VlessOutboundFormSettingsSchema,
+  VmessOutboundFormSettingsSchema,
+  type OutboundFormValues,
+} from '@/schemas/forms/outbound-form';
 import {
-  Outbound,
-  Protocols,
-  SSMethods,
-  TLS_FLOW_CONTROL,
-  UTLS_FINGERPRINT,
   ALPN_OPTION,
+  Address_Port_Strategy,
+  DNSRuleActions,
+  DOMAIN_STRATEGY_OPTION,
+  MODE_OPTION,
+  OutboundDomainStrategies,
+  OutboundProtocols as Protocols,
   SNIFFING_OPTION,
+  TCP_CONGESTION_OPTION,
+  TLS_FLOW_CONTROL,
   USERS_SECURITY,
-  OutboundDomainStrategies,
+  UTLS_FINGERPRINT,
   WireguardDomainStrategy,
-  Address_Port_Strategy,
-  MODE_OPTION,
-  DNSRuleActions,
-} from '@/models/outbound';
-import FinalMaskForm from '@/components/FinalMaskForm';
-import JsonEditor from '@/components/JsonEditor';
+} from '@/schemas/primitives';
+import {
+  HappyEyeballsSchema,
+  SockoptStreamSettingsSchema,
+} from '@/schemas/protocols/stream/sockopt';
+import {
+  canEnableReality,
+  canEnableStream,
+  canEnableTls,
+  canEnableTlsFlow,
+} from '@/lib/xray/protocol-capabilities';
+import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks';
+import { antdRule } from '@/utils/zodForm';
 import './OutboundFormModal.css';
 
+// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
+// file so the build stays green section-by-section. The atomic swap at
+// the end of the rewrite replaces the legacy file in one commit
+// (per Core Decision 7 in the migration spec).
+
 interface OutboundFormModalProps {
   open: boolean;
   outbound: Record<string, unknown> | null;
@@ -45,20 +75,104 @@ interface OutboundFormModalProps {
   onConfirm: (outbound: Record<string, unknown>) => void;
 }
 
-const PROTOCOL_OPTIONS = Object.values(Protocols) as string[];
-const SECURITY_OPTIONS = Object.values(USERS_SECURITY) as string[];
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL) as string[];
-const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT) as string[];
-const ALPN_OPTIONS = Object.values(ALPN_OPTION) as string[];
-const NETWORKS = ['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp'];
-const NETWORK_LABELS: Record<string, string> = {
-  tcp: 'TCP (RAW)',
-  kcp: 'mKCP',
-  ws: 'WebSocket',
-  grpc: 'gRPC',
-  httpupgrade: 'HTTPUpgrade',
-  xhttp: 'XHTTP',
-};
+const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
+const SECURITY_OPTIONS = Object.values(USERS_SECURITY).map((v) => ({ value: v, label: v }));
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL).map((v) => ({ value: v, label: v }));
+const SS_METHOD_OPTIONS = SSMethodSchema.options.map((v) => ({ value: v, label: v }));
+const MODE_OPTIONS = Object.values(MODE_OPTION).map((v) => ({ value: v, label: v }));
+const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v }));
+const ALPN_OPTIONS = Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v }));
+const ADDRESS_PORT_STRATEGY_OPTIONS = Object.values(Address_Port_Strategy).map((v) => ({
+  value: v,
+  label: v,
+}));
+
+// canEnableMux mirrors the adapter's helper but lives here so the modal
+// can show/hide the Mux section without going through the adapter.
+const MUX_PROTOCOLS = new Set<string>(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
+function isMuxAllowed(protocol: string, flow: string, network: string): boolean {
+  if (!MUX_PROTOCOLS.has(protocol)) return false;
+  if (protocol === 'vless' && flow) return false;
+  if (network === 'xhttp') return false;
+  return true;
+}
+
+const NETWORK_OPTIONS: { value: string; label: string }[] = [
+  { value: 'tcp', label: 'TCP (RAW)' },
+  { value: 'kcp', label: 'mKCP' },
+  { value: 'ws', label: 'WebSocket' },
+  { value: 'grpc', label: 'gRPC' },
+  { value: 'httpupgrade', label: 'HTTPUpgrade' },
+  { value: 'xhttp', label: 'XHTTP' },
+];
+
+// Hysteria appends an extra `hysteria` network branch to the selector
+// — only when the parent protocol is hysteria. Wire-side this matches
+// the legacy modal's `isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS`.
+const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
+
+// Per-network bootstrap. Mirrors the legacy class constructors so the
+// initial state for each transport matches what xray-core expects.
+function newStreamSlice(network: string): Record<string, unknown> {
+  switch (network) {
+    case 'tcp':
+      return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
+    case 'kcp':
+      return {
+        network: 'kcp',
+        kcpSettings: {
+          mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
+          cwndMultiplier: 1, maxSendingWindow: 2097152,
+        },
+      };
+    case 'ws':
+      return {
+        network: 'ws',
+        wsSettings: { path: '/', host: '', headers: {}, heartbeatPeriod: 0 },
+      };
+    case 'grpc':
+      return {
+        network: 'grpc',
+        grpcSettings: { serviceName: '', authority: '', multiMode: false },
+      };
+    case 'httpupgrade':
+      return {
+        network: 'httpupgrade',
+        httpupgradeSettings: { path: '/', host: '', headers: {} },
+      };
+    case 'xhttp':
+      return {
+        network: 'xhttp',
+        xhttpSettings: {
+          path: '/', host: '', mode: '', headers: [],
+          xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
+        },
+      };
+    case 'hysteria':
+      return {
+        network: 'hysteria',
+        hysteriaSettings: {
+          version: 2,
+          auth: '',
+          udpIdleTimeout: 60,
+        },
+      };
+    default:
+      return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
+  }
+}
+
+// Protocols whose form schema carries a flat connect target — these all
+// get the shared "server" sub-block (address + port) at the top of the
+// protocol section. Wireguard has an address but no port. DNS/freedom/
+// blackhole/loopback have no connect target.
+const SERVER_PROTOCOLS = new Set<string>([
+  'vmess', 'vless', 'trojan', 'shadowsocks', 'socks', 'http', 'hysteria',
+]);
+
+function buildAddModeValues(): OutboundFormValues {
+  return rawOutboundToFormValues({});
+}
 
 export default function OutboundFormModal({
   open,
@@ -69,203 +183,227 @@ export default function OutboundFormModal({
 }: OutboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const outboundRef = useRef<any>(null);
-  const [, setTick] = useState(0);
+  const [form] = Form.useForm<OutboundFormValues>();
   const [activeKey, setActiveKey] = useState('1');
+  const [jsonText, setJsonText] = useState('');
+  const [jsonDirty, setJsonDirty] = useState(false);
   const [linkInput, setLinkInput] = useState('');
-  const [advancedJson, setAdvancedJson] = useState('');
-  const revertingTabRef = useRef(false);
 
-  const isEdit = outboundProp != null;
-
-  const refresh = useCallback(() => setTick((n) => n + 1), []);
-
-  const primeAdvancedJson = useCallback(() => {
-    const ob = outboundRef.current;
-    if (!ob) {
-      setAdvancedJson('');
+  // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
+  // hysteria2://) and replace form state with the result. The current
+  // tag is preserved when the parsed link doesn't carry one.
+  function importLink() {
+    const link = linkInput.trim();
+    if (!link) return;
+    const parsed = parseOutboundLink(link);
+    if (!parsed) {
+      messageApi.error('Wrong Link!');
       return;
     }
-    try {
-      setAdvancedJson(JSON.stringify(ob.toJson(), null, 2));
-    } catch {
-      setAdvancedJson('');
-    }
-  }, []);
+    const currentTag = form.getFieldValue('tag') as string | undefined;
+    if (!parsed.tag && currentTag) parsed.tag = currentTag;
+    const next = rawOutboundToFormValues(parsed);
+    form.resetFields();
+    form.setFieldsValue(next);
+    setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
+    setJsonDirty(false);
+    setLinkInput('');
+    messageApi.success('Link imported successfully');
+    switchTab('1');
+  }
+
+  const isEdit = outboundProp != null;
+  const title = isEdit
+    ? `${t('edit')} ${t('pages.xray.Outbounds')}`
+    : `+ ${t('pages.xray.Outbounds')}`;
+  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
 
   useEffect(() => {
     if (!open) return;
-    outboundRef.current = outboundProp
-      ? Outbound.fromJson(outboundProp)
-      : new Outbound();
+    const initial = outboundProp
+      ? rawOutboundToFormValues(outboundProp)
+      : buildAddModeValues();
+    form.resetFields();
+    form.setFieldsValue(initial);
     setActiveKey('1');
-    setLinkInput('');
-    primeAdvancedJson();
-    refresh();
+    setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
+    setJsonDirty(false);
+  }, [open, outboundProp, form]);
+
+  const tag = Form.useWatch('tag', form) ?? '';
+  const protocol = (Form.useWatch('protocol', form) ?? 'vless') 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 } });
+  const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
+  const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
+
+  // Seed streamSettings when the user picks a protocol that supports
+  // streams but the form does not yet have a stream slice (new outbound,
+  // or wire payload arrived without streamSettings).
+  useEffect(() => {
+    if (!streamAllowed) return;
+    if (network) return;
+    form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open, outboundProp]);
+  }, [streamAllowed, network]);
 
-  function applyAdvancedJsonToForm(): boolean {
-    const raw = advancedJson.trim();
-    if (!raw) return true;
-    const ob = outboundRef.current;
-    let currentJson = '';
+  // Wireguard pubKey is a UI-only field derived from secretKey on every
+  // edit. The legacy modal did the same on every keystroke. We re-derive
+  // here so paste-in secret keys immediately surface the matching pub.
+  const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
+  useEffect(() => {
+    if (protocol !== 'wireguard') return;
+    const sk = (wgSecretKey ?? '').trim();
+    if (!sk) {
+      form.setFieldValue(['settings', 'pubKey'], '');
+      return;
+    }
     try {
-      currentJson = JSON.stringify(ob?.toJson() ?? {}, null, 2);
+      const { publicKey } = Wireguard.generateKeypair(sk);
+      form.setFieldValue(['settings', 'pubKey'], publicKey);
     } catch {
-      /* ignore */
+      form.setFieldValue(['settings', 'pubKey'], '');
     }
-    if (raw === currentJson.trim()) return true;
-    let parsed;
-    try {
-      parsed = JSON.parse(raw);
-    } catch (e) {
-      messageApi.error(`JSON: ${(e as Error).message}`);
-      return false;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [protocol, wgSecretKey]);
+
+  // Switching protocol resets the settings sub-object to fresh defaults
+  // so leftover fields from the previous protocol do not bleed through.
+  // The adapter's rawOutboundToFormValues seeds whatever the new protocol
+  // expects (vless flat shape, vmess flat shape, wireguard with secretKey
+  // placeholder, etc.).
+  function onValuesChange(changed: Partial<OutboundFormValues>) {
+    if ('protocol' in changed && changed.protocol) {
+      const next = rawOutboundToFormValues({ protocol: changed.protocol });
+      form.setFieldValue('settings', next.settings);
+    }
+  }
+
+  // Security change cascade: swap the security sub-key so the DU branch
+  // matches. Seed default field values when entering tls/reality so the
+  // sub-forms render without `undefined` field references.
+  function onSecurityChange(next: string) {
+    const stream = form.getFieldValue('streamSettings') ?? {};
+    const cleaned = { ...stream } as Record<string, unknown>;
+    delete cleaned.tlsSettings;
+    delete cleaned.realitySettings;
+    if (next === 'tls') {
+      cleaned.tlsSettings = {
+        serverName: '',
+        alpn: [],
+        fingerprint: '',
+        echConfigList: '',
+        verifyPeerCertByName: '',
+        pinnedPeerCertSha256: '',
+      };
+    } else if (next === 'reality') {
+      cleaned.realitySettings = {
+        publicKey: '',
+        fingerprint: 'chrome',
+        serverName: '',
+        shortId: '',
+        spiderX: '',
+        mldsa65Verify: '',
+      };
     }
+    cleaned.security = next;
+    form.setFieldValue('streamSettings', cleaned);
+  }
+
+  // Network change cascade: swap the per-network sub-key (tcpSettings,
+  // wsSettings, etc.) so the DU branch matches. Preserve security if
+  // the new network supports it, otherwise force back to 'none'.
+  function onNetworkChange(next: string) {
+    const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
+    const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
+    const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
+    const newSecurity =
+      currentSecurity === 'tls' && !stillAllowed
+        ? 'none'
+        : currentSecurity === 'reality' && !stillReality
+          ? 'none'
+          : currentSecurity;
+    form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity });
+  }
+
+  const duplicateTag = useMemo(() => {
+    const myTag = tag.trim();
+    if (!myTag) return false;
+    if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
+    return (existingTags || []).includes(myTag);
+  }, [tag, existingTags, isEdit, outboundProp]);
+
+  // Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
+  // any edits into form state. When entering JSON tab, snapshot current
+  // form values so the user sees the live shape.
+  function applyJsonToForm(): boolean {
+    if (!jsonDirty) return true;
+    const raw = jsonText.trim();
+    if (!raw) return true;
+    let parsed: Record<string, unknown>;
     try {
-      const fallbackTag = ob?.tag;
-      const next = Outbound.fromJson(parsed);
-      if (!next.tag && fallbackTag) next.tag = fallbackTag;
-      outboundRef.current = next;
-      refresh();
-      return true;
+      parsed = JSON.parse(raw) as Record<string, unknown>;
     } catch (e) {
       messageApi.error(`JSON: ${(e as Error).message}`);
       return false;
     }
+    const next = rawOutboundToFormValues(parsed);
+    form.resetFields();
+    form.setFieldsValue(next);
+    setJsonDirty(false);
+    return true;
   }
 
-  function onTabChange(key: string) {
-    if (document.activeElement instanceof HTMLElement) {
-      document.activeElement.blur();
+  // Wrap every tab switch with a blur of the active element. AntD marks
+  // the outgoing panel `aria-hidden="true"` synchronously when the
+  // controlled activeKey flips; if a focused input is still inside that
+  // panel (e.g. Input.Search on the JSON tab after user hits Enter to
+  // import), Chrome logs a WAI-ARIA warning. Doing the blur right
+  // before setActiveKey ensures the panel is unfocused by the time
+  // AntD applies the attribute.
+  function switchTab(key: string) {
+    if (typeof document !== 'undefined') {
+      (document.activeElement as HTMLElement | null)?.blur?.();
     }
-    if (revertingTabRef.current) {
-      revertingTabRef.current = false;
-      setActiveKey(key);
+    setActiveKey(key);
+  }
+
+  function onTabChange(key: string) {
+    if (key === '2') {
+      const values = form.getFieldsValue(true) as OutboundFormValues;
+      setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
+      setJsonDirty(false);
+      switchTab(key);
       return;
     }
-    const prev = activeKey;
-    if (key === '2') {
-      primeAdvancedJson();
-      setActiveKey(key);
-    } else if (key === '1' && prev === '2') {
-      if (!applyAdvancedJsonToForm()) {
-        revertingTabRef.current = true;
-        setActiveKey('2');
-      } else {
-        setActiveKey(key);
-      }
-    } else {
-      setActiveKey(key);
+    if (key === '1' && activeKey === '2') {
+      if (!applyJsonToForm()) return;
     }
+    switchTab(key);
   }
 
-  const ob = outboundRef.current;
-
-  const proto = ob?.protocol;
-  const isVMess = proto === Protocols.VMess;
-  const isVLESS = proto === Protocols.VLESS;
-  const isVMessOrVLess = isVMess || isVLESS;
-  const isTrojan = proto === Protocols.Trojan;
-  const isShadowsocks = proto === Protocols.Shadowsocks;
-  const isFreedom = proto === Protocols.Freedom;
-  const isBlackhole = proto === Protocols.Blackhole;
-  const isDNS = proto === Protocols.DNS;
-  const isWireguard = proto === Protocols.Wireguard;
-  const isHysteria = proto === Protocols.Hysteria;
-  const isLoopback = proto === Protocols.Loopback;
-
-  function onProtocolChange(next: string) {
-    if (!ob) return;
-    ob.protocol = next;
-    refresh();
-  }
-
-  function streamNetworkChange(next: string) {
-    if (!ob?.stream) return;
-    ob.stream.network = next;
-    if (!ob.canEnableTls()) ob.stream.security = 'none';
-    refresh();
-  }
-
-  function regenerateWgKeys() {
-    if (!ob?.settings) return;
-    const pair = Wireguard.generateKeypair();
-    ob.settings.secretKey = pair.privateKey;
-    ob.settings.pubKey = pair.publicKey;
-    refresh();
-  }
-
-  const duplicateTag = useMemo(() => {
-    if (!ob?.tag) return false;
-    const myTag = ob.tag.trim();
-    if (!myTag) return false;
-    if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
-    return (existingTags || []).includes(myTag);
-  }, [ob?.tag, existingTags, isEdit, outboundProp]);
-
-  const tagEmpty = !ob?.tag?.trim();
-
-  const tagValidateStatus: 'error' | 'warning' | 'success' = tagEmpty
-    ? 'error'
-    : duplicateTag
-      ? 'warning'
-      : 'success';
-
-  const tagHelp = tagEmpty
-    ? 'Tag is required'
-    : duplicateTag
-      ? 'Tag already used by another outbound'
-      : '';
-
-  function onOk() {
-    if (!ob) return;
-    if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
-    if (!ob.tag?.trim()) {
-      messageApi.error('Tag is required');
+  async function onOk() {
+    if (activeKey === '2' && !applyJsonToForm()) return;
+    let values: OutboundFormValues;
+    try {
+      values = await form.validateFields();
+    } catch {
       return;
     }
     if (duplicateTag) {
       messageApi.error('Tag already used by another outbound');
       return;
     }
-    onConfirm(ob.toJson());
-  }
-
-  function convertLink() {
-    const link = linkInput.trim();
-    if (!link) return;
-    try {
-      const next = Outbound.fromLink(link);
-      if (!next) {
-        messageApi.error('Wrong Link!');
-        return;
-      }
-      outboundRef.current = next;
-      primeAdvancedJson();
-      setLinkInput('');
-      messageApi.success('Link imported successfully...');
-      setActiveKey('1');
-      refresh();
-    } catch (e) {
-      messageApi.error(`Link parse: ${(e as Error).message}`);
-    }
-  }
-
-  const title = isEdit
-    ? `${t('edit')} ${t('pages.xray.Outbounds')}`
-    : `+ ${t('pages.xray.Outbounds')}`;
-  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
-
-  if (!ob) {
-    return (
-      <>
-        {messageContextHolder}
-        <Modal open={open} title={title} footer={null} onCancel={onClose} />
-      </>
-    );
+    onConfirm(formValuesToWirePayload(values));
   }
 
   return (
@@ -280,1192 +418,1827 @@ export default function OutboundFormModal({
         width={780}
         onOk={onOk}
         onCancel={onClose}
+        destroyOnHidden
       >
-      <Tabs
-        activeKey={activeKey}
-        onChange={onTabChange}
-        items={[
-          {
-            key: '1',
-            label: t('pages.xray.basicTemplate'),
-            children: (
-              <>
-              <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-                <Form.Item label={t('protocol')}>
-                  <Select
-                    value={proto}
-                    onChange={onProtocolChange}
-                    options={PROTOCOL_OPTIONS.map((p) => ({ value: p, label: p }))}
-                  />
-                </Form.Item>
-
-                <Form.Item label="Tag" validateStatus={tagValidateStatus} help={tagHelp} hasFeedback>
-                  <Input
-                    value={ob.tag}
-                    placeholder="unique-tag"
-                    onChange={(e) => {
-                      ob.tag = e.target.value;
-                      refresh();
-                    }}
-                  />
-                </Form.Item>
-
-                <Form.Item label="Send through">
-                  <Input
-                    value={ob.sendThrough || ''}
-                    placeholder="local IP"
-                    onChange={(e) => {
-                      ob.sendThrough = e.target.value;
-                      refresh();
-                    }}
-                  />
-                </Form.Item>
-
-                {isFreedom && <FreedomFields ob={ob} refresh={refresh} />}
-                {isBlackhole && (
-                  <Form.Item label="Response Type">
-                    <Select
-                      value={ob.settings.type || ''}
-                      onChange={(v) => { ob.settings.type = v; refresh(); }}
-                      options={[
-                        { value: '', label: '(empty)' },
-                        { value: 'none', label: 'none' },
-                        { value: 'http', label: 'http' },
-                      ]}
-                    />
-                  </Form.Item>
-                )}
-                {isLoopback && (
-                  <Form.Item label="Inbound tag">
-                    <Input
-                      value={ob.settings.inboundTag || ''}
-                      placeholder="inbound tag using in routing rules"
-                      onChange={(e) => { ob.settings.inboundTag = e.target.value; refresh(); }}
-                    />
-                  </Form.Item>
-                )}
-                {isDNS && <DnsFields ob={ob} refresh={refresh} t={t} />}
-                {isWireguard && <WireguardFields ob={ob} refresh={refresh} regenerate={regenerateWgKeys} t={t} />}
-
-                {ob.hasAddressPort() && (
+        <Form
+          form={form}
+          colon={false}
+          labelCol={{ md: { span: 8 } }}
+          wrapperCol={{ md: { span: 14 } }}
+          onValuesChange={onValuesChange}
+        >
+          <Tabs
+            activeKey={activeKey}
+            onChange={onTabChange}
+            items={[
+              {
+                key: '1',
+                label: t('pages.xray.basicTemplate'),
+                children: (
                   <>
-                    <Form.Item label={t('pages.inbounds.address')}>
-                      <Input
-                        value={ob.settings.address || ''}
-                        onChange={(e) => { ob.settings.address = e.target.value; refresh(); }}
-                      />
-                    </Form.Item>
-                    <Form.Item label={t('pages.inbounds.port')}>
-                      <InputNumber
-                        value={ob.settings.port || 0}
-                        min={1}
-                        max={65535}
-                        onChange={(v) => { ob.settings.port = Number(v) || 0; refresh(); }}
-                      />
+                    <Form.Item
+                      label={t('protocol')}
+                      name="protocol"
+                      rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
+                    >
+                      <Select options={PROTOCOL_OPTIONS} />
                     </Form.Item>
-                  </>
-                )}
-
-                {isVMessOrVLess && (
-                  <VMessVLessFields ob={ob} refresh={refresh} isVMess={isVMess} isVLESS={isVLESS} t={t} />
-                )}
 
-                {(isTrojan || isShadowsocks) && (
-                  <Form.Item label={t('password')}>
-                    <Input
-                      value={ob.settings.password || ''}
-                      onChange={(e) => { ob.settings.password = e.target.value; refresh(); }}
-                    />
-                  </Form.Item>
-                )}
-
-                {isShadowsocks && (
-                  <>
-                    <Form.Item label={t('encryption')}>
-                      <Select
-                        value={ob.settings.method}
-                        onChange={(v) => { ob.settings.method = v; refresh(); }}
-                        options={Object.entries(SSMethods).map(([k, v]) => ({ value: v as string, label: k }))}
-                      />
-                    </Form.Item>
-                    <Form.Item label="UDP over TCP">
-                      <Switch checked={!!ob.settings.uot} onChange={(v) => { ob.settings.uot = v; refresh(); }} />
-                    </Form.Item>
-                    <Form.Item label="UoT version">
-                      <InputNumber
-                        value={ob.settings.UoTVersion ?? 1}
-                        min={1}
-                        max={2}
-                        onChange={(v) => { ob.settings.UoTVersion = Number(v) || 1; refresh(); }}
-                      />
+                    <Form.Item
+                      label="Tag"
+                      name="tag"
+                      validateStatus={duplicateTag ? 'warning' : undefined}
+                      help={duplicateTag ? 'Tag already used by another outbound' : undefined}
+                      rules={[
+                        { required: true, message: 'Tag is required' },
+                      ]}
+                    >
+                      <Input placeholder="unique-tag" />
                     </Form.Item>
-                  </>
-                )}
 
-                {ob.hasUsername() && (
-                  <>
-                    <Form.Item label={t('username')}>
-                      <Input
-                        value={ob.settings.user || ''}
-                        onChange={(e) => { ob.settings.user = e.target.value; refresh(); }}
-                      />
-                    </Form.Item>
-                    <Form.Item label={t('password')}>
-                      <Input
-                        value={ob.settings.pass || ''}
-                        onChange={(e) => { ob.settings.pass = e.target.value; refresh(); }}
-                      />
+                    <Form.Item label="Send through" name="sendThrough">
+                      <Input placeholder="local IP" />
                     </Form.Item>
-                  </>
-                )}
-
-                {isHysteria && (
-                  <Form.Item label="Version">
-                    <InputNumber value={ob.settings.version || 2} min={2} max={2} disabled />
-                  </Form.Item>
-                )}
-
-                {ob.canEnableStream() && (
-                  <StreamFields ob={ob} refresh={refresh} streamNetworkChange={streamNetworkChange} isHysteria={isHysteria} t={t} />
-                )}
-
-                {ob.canEnableTls() && <TlsFields ob={ob} refresh={refresh} t={t} />}
-
-                {ob.stream && <SockoptFields ob={ob} refresh={refresh} />}
-
-                {ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />}
-              </Form>
-              {ob.stream && ob.canEnableStream() && (
-                <FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
-              )}
-              </>
-            ),
-          },
-          {
-            key: '2',
-            label: 'JSON',
-            children: (
-              <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
-                <Input.Search
-                  value={linkInput}
-                  placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
-                  enterButton="Convert"
-                  onChange={(e) => setLinkInput(e.target.value)}
-                  onSearch={convertLink}
-                />
-                <JsonEditor
-                  value={advancedJson}
-                  onChange={setAdvancedJson}
-                  minHeight="360px"
-                  maxHeight="600px"
-                />
-              </Space>
-            ),
-          },
-        ]}
-      />
-      </Modal>
-    </>
-  );
-}
-
-type OB = Outbound;
-
-interface FieldProps {
-  ob: OB;
-  refresh: () => void;
-}
-
-interface TFieldProps extends FieldProps {
-  t: (k: string) => string;
-}
-
-function FreedomFields({ ob, refresh }: FieldProps) {
-  const fragment = (ob.settings.fragment || {}) as Record<string, string>;
-  const noises = (ob.settings.noises || []) as Array<{ type: string; packet: string; delay: string; applyTo: string }>;
-  const finalRules = (ob.settings.finalRules || []) as Array<{ action: string; network?: string; port?: string; ip?: string[]; blockDelay?: string }>;
 
-  return (
-    <>
-      <Form.Item label="Strategy">
-        <Select
-          value={ob.settings.domainStrategy}
-          onChange={(v) => { ob.settings.domainStrategy = v; refresh(); }}
-          options={(OutboundDomainStrategies as string[]).map((s) => ({ value: s, label: s }))}
-        />
-      </Form.Item>
-      <Form.Item label="Redirect">
-        <Input
-          value={ob.settings.redirect || ''}
-          onChange={(e) => { ob.settings.redirect = e.target.value; refresh(); }}
-        />
-      </Form.Item>
-
-      <Form.Item label="Fragment">
-        <Switch
-          checked={!!ob.settings.fragment && Object.keys(ob.settings.fragment).length > 0}
-          onChange={(checked) => {
-            ob.settings.fragment = checked
-              ? { packets: 'tlshello', length: '100-200', interval: '10-20', maxSplit: '300-400' }
-              : {};
-            refresh();
-          }}
-        />
-      </Form.Item>
-      {ob.settings.fragment && Object.keys(ob.settings.fragment).length > 0 && (
-        <>
-          <Form.Item label="Packets">
-            <Select
-              value={fragment.packets}
-              onChange={(v) => { (ob.settings.fragment as Record<string, string>).packets = v; refresh(); }}
-              options={[
-                { value: '1-3', label: '1-3' },
-                { value: 'tlshello', label: 'tlshello' },
-              ]}
-            />
-          </Form.Item>
-          {(['length', 'interval', 'maxSplit'] as const).map((field) => (
-            <Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field[0].toUpperCase() + field.slice(1)}>
-              <Input
-                value={fragment[field] || ''}
-                onChange={(e) => { (ob.settings.fragment as Record<string, string>)[field] = e.target.value; refresh(); }}
-              />
-            </Form.Item>
-          ))}
-        </>
-      )}
-
-      <Form.Item label="Noises">
-        <Switch
-          checked={noises.length > 0}
-          onChange={(checked) => {
-            ob.settings.noises = checked ? [new Outbound.FreedomSettings.Noise()] : [];
-            refresh();
-          }}
-        />
-        {noises.length > 0 && (
-          <Button
-            size="small"
-            type="primary"
-            className="ml-8"
-            icon={<PlusOutlined />}
-            onClick={() => { (ob.settings.noises as unknown[]).push(new Outbound.FreedomSettings.Noise()); refresh(); }}
-          />
-        )}
-      </Form.Item>
-      {noises.map((noise, index) => (
-        <div key={index}>
-          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-            <div className="item-heading">
-              <span>Noise {index + 1}</span>
-              {noises.length > 1 && (
-                <DeleteOutlined
-                  className="danger-icon"
-                  onClick={() => { (ob.settings.noises as unknown[]).splice(index, 1); refresh(); }}
-                />
-              )}
-            </div>
-          </Form.Item>
-          <Form.Item label="Type">
-            <Select
-              value={noise.type}
-              onChange={(v) => { noise.type = v; refresh(); }}
-              options={['rand', 'base64', 'str', 'hex'].map((x) => ({ value: x, label: x }))}
-            />
-          </Form.Item>
-          <Form.Item label="Packet">
-            <Input value={noise.packet} onChange={(e) => { noise.packet = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Delay (ms)">
-            <Input value={noise.delay} onChange={(e) => { noise.delay = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Apply to">
-            <Select
-              value={noise.applyTo}
-              onChange={(v) => { noise.applyTo = v; refresh(); }}
-              options={['ip', 'ipv4', 'ipv6'].map((x) => ({ value: x, label: x }))}
-            />
-          </Form.Item>
-        </div>
-      ))}
-
-      <Form.Item label="Final Rules">
-        <Button
-          size="small"
-          type="primary"
-          icon={<PlusOutlined />}
-          onClick={() => { ob.settings.addFinalRule('allow'); refresh(); }}
-        />
-        <span className="ml-8" style={{ opacity: 0.6 }}>
-          Override Xray&apos;s default private-IP block (needed for LAN access through proxy)
-        </span>
-      </Form.Item>
-      {finalRules.map((rule, index) => (
-        <div key={`fr-${index}`}>
-          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-            <div className="item-heading">
-              <span>Rule {index + 1}</span>
-              <DeleteOutlined
-                className="danger-icon"
-                onClick={() => { ob.settings.delFinalRule(index); refresh(); }}
-              />
-            </div>
-          </Form.Item>
-          <Form.Item label="Action">
-            <Select
-              value={rule.action}
-              onChange={(v) => { rule.action = v; refresh(); }}
-              options={['allow', 'block'].map((x) => ({ value: x, label: x }))}
-            />
-          </Form.Item>
-          <Form.Item label="Network">
-            <Select
-              value={rule.network}
-              allowClear
-              placeholder="(any)"
-              onChange={(v) => { rule.network = v; refresh(); }}
-              options={['tcp', 'udp', 'tcp,udp'].map((x) => ({ value: x, label: x }))}
-            />
-          </Form.Item>
-          <Form.Item label="Port">
-            <Input
-              value={rule.port}
-              placeholder="e.g. 80,443 or 1000-2000"
-              onChange={(e) => { rule.port = e.target.value; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="IP / CIDR / geoip">
-            <Select
-              mode="tags"
-              value={rule.ip || []}
-              tokenSeparators={[',', ' ']}
-              placeholder="e.g. 10.0.0.0/8, geoip:private, ext:cn.dat:cn"
-              onChange={(v) => { rule.ip = v as string[]; refresh(); }}
-            />
-          </Form.Item>
-          {rule.action === 'block' && (
-            <Form.Item label="Block delay (ms)">
-              <Input
-                value={rule.blockDelay}
-                placeholder="optional: 5000-10000"
-                onChange={(e) => { rule.blockDelay = e.target.value; refresh(); }}
-              />
-            </Form.Item>
-          )}
-        </div>
-      ))}
-    </>
-  );
-}
-
-function DnsFields({ ob, refresh, t }: TFieldProps) {
-  const rules = (ob.settings.rules || []) as Array<{ action: string; qtype?: string; domain?: string }>;
-  return (
-    <>
-      <Form.Item label="Rewrite network">
-        <Select
-          value={ob.settings.rewriteNetwork}
-          allowClear
-          placeholder="(unchanged)"
-          onChange={(v) => { ob.settings.rewriteNetwork = v; refresh(); }}
-          options={['udp', 'tcp'].map((x) => ({ value: x, label: x }))}
-        />
-      </Form.Item>
-      <Form.Item label="Rewrite address">
-        <Input
-          value={ob.settings.rewriteAddress || ''}
-          placeholder="(unchanged) e.g. 1.1.1.1"
-          onChange={(e) => { ob.settings.rewriteAddress = e.target.value; refresh(); }}
-        />
-      </Form.Item>
-      <Form.Item label="Rewrite port">
-        <InputNumber
-          value={ob.settings.rewritePort || undefined}
-          min={0}
-          max={65535}
-          style={{ width: '100%' }}
-          placeholder="(unchanged)"
-          onChange={(v) => { ob.settings.rewritePort = Number(v) || 0; refresh(); }}
-        />
-      </Form.Item>
-      <Form.Item label="User level">
-        <InputNumber
-          value={ob.settings.userLevel || 0}
-          min={0}
-          style={{ width: '100%' }}
-          onChange={(v) => { ob.settings.userLevel = Number(v) || 0; refresh(); }}
-        />
-      </Form.Item>
-      <Form.Item label="Rules">
-        <Button
-          size="small"
-          type="primary"
-          icon={<PlusOutlined />}
-          onClick={() => { (ob.settings.rules || (ob.settings.rules = [])).push(new Outbound.DNSRule()); refresh(); }}
-        />
-      </Form.Item>
-      {rules.map((rule, index) => (
-        <div key={index}>
-          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-            <div className="item-heading">
-              <span>Rule {index + 1}</span>
-              <DeleteOutlined
-                className="danger-icon"
-                onClick={() => { (ob.settings.rules as unknown[]).splice(index, 1); refresh(); }}
-              />
-            </div>
-          </Form.Item>
-          <Form.Item label="Action">
-            <Select
-              value={rule.action}
-              onChange={(v) => { rule.action = v; refresh(); }}
-              options={(DNSRuleActions as string[]).map((a) => ({ value: a, label: a }))}
-            />
-          </Form.Item>
-          <Form.Item label="QType">
-            <Input
-              value={rule.qtype}
-              placeholder="1,3,23-24"
-              onChange={(e) => { rule.qtype = e.target.value; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label={t('domainName')}>
-            <Input
-              value={rule.domain}
-              placeholder="domain:example.com"
-              onChange={(e) => { rule.domain = e.target.value; refresh(); }}
-            />
-          </Form.Item>
-        </div>
-      ))}
-    </>
-  );
-}
-
-function WireguardFields({ ob, refresh, regenerate, t }: TFieldProps & { regenerate: () => void }) {
-  const peers = (ob.settings.peers || []) as Array<{ endpoint?: string; publicKey?: string; psk?: string; allowedIPs?: string[]; keepAlive?: number }>;
-  return (
-    <>
-      <Form.Item label={t('pages.inbounds.address')}>
-        <Input
-          value={ob.settings.address || ''}
-          onChange={(e) => { ob.settings.address = e.target.value; refresh(); }}
-        />
-      </Form.Item>
-      <Form.Item
-        label={
-          <>
-            {t('pages.inbounds.privatekey')}
-            <SyncOutlined className="random-icon" onClick={regenerate} />
-          </>
-        }
-      >
-        <Input
-          value={ob.settings.secretKey || ''}
-          onChange={(e) => { ob.settings.secretKey = e.target.value; refresh(); }}
-        />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.publicKey')}>
-        <Input value={ob.settings.pubKey || ''} disabled />
-      </Form.Item>
-      <Form.Item label="Domain strategy">
-        <Select
-          value={ob.settings.domainStrategy || ''}
-          onChange={(v) => { ob.settings.domainStrategy = v; refresh(); }}
-          options={['', ...(WireguardDomainStrategy as string[])].map((x) => ({ value: x, label: x || `(${t('none')})` }))}
-        />
-      </Form.Item>
-      <Form.Item label="MTU">
-        <InputNumber value={ob.settings.mtu || 0} min={0} onChange={(v) => { ob.settings.mtu = Number(v) || 0; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Workers">
-        <InputNumber value={ob.settings.workers || 0} min={0} onChange={(v) => { ob.settings.workers = Number(v) || 0; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="No-kernel TUN">
-        <Switch checked={!!ob.settings.noKernelTun} onChange={(v) => { ob.settings.noKernelTun = v; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Reserved">
-        <Input value={ob.settings.reserved || ''} onChange={(e) => { ob.settings.reserved = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Peers">
-        <Button
-          size="small"
-          type="primary"
-          icon={<PlusOutlined />}
-          onClick={() => { (ob.settings.peers || (ob.settings.peers = [])).push(new Outbound.WireguardSettings.Peer()); refresh(); }}
-        />
-      </Form.Item>
-      {peers.map((peer, index) => (
-        <div key={index}>
-          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-            <div className="item-heading">
-              <span>Peer {index + 1}</span>
-              {peers.length > 1 && (
-                <DeleteOutlined
-                  className="danger-icon"
-                  onClick={() => { (ob.settings.peers as unknown[]).splice(index, 1); refresh(); }}
-                />
-              )}
-            </div>
-          </Form.Item>
-          <Form.Item label="Endpoint">
-            <Input value={peer.endpoint} onChange={(e) => { peer.endpoint = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label={t('pages.inbounds.publicKey')}>
-            <Input value={peer.publicKey} onChange={(e) => { peer.publicKey = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="PSK">
-            <Input value={peer.psk} onChange={(e) => { peer.psk = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Allowed IPs">
-            {(peer.allowedIPs || []).map((ip, idx) => (
-              <Space.Compact key={idx} block style={{ marginBottom: 4 }}>
-                <Input
-                  value={ip}
-                  onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
-                />
-                {(peer.allowedIPs || []).length > 1 && (
-                  <InputAddon onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }}>
-                    <MinusOutlined />
-                  </InputAddon>
-                )}
-              </Space.Compact>
-            ))}
-            <Button
-              size="small"
-              icon={<PlusOutlined />}
-              onClick={() => { (peer.allowedIPs = peer.allowedIPs || []).push(''); refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="Keep alive">
-            <InputNumber value={peer.keepAlive || 0} min={0} onChange={(v) => { peer.keepAlive = Number(v) || 0; refresh(); }} />
-          </Form.Item>
-        </div>
-      ))}
-    </>
-  );
-}
-
-function VMessVLessFields({ ob, refresh, isVMess, isVLESS, t }: TFieldProps & { isVMess: boolean; isVLESS: boolean }) {
-  const rev = ob.settings.reverseSniffing || {};
-  return (
-    <>
-      <Form.Item label="ID">
-        <Input value={ob.settings.id || ''} onChange={(e) => { ob.settings.id = e.target.value; refresh(); }} />
-      </Form.Item>
-      {isVMess && (
-        <Form.Item label={t('security')}>
-          <Select
-            value={ob.settings.security}
-            onChange={(v) => { ob.settings.security = v; refresh(); }}
-            options={SECURITY_OPTIONS.map((s) => ({ value: s, label: s }))}
-          />
-        </Form.Item>
-      )}
-      {isVLESS && (
-        <Form.Item label={t('encryption')}>
-          <Input
-            value={ob.settings.encryption || ''}
-            onChange={(e) => { ob.settings.encryption = e.target.value; refresh(); }}
-          />
-        </Form.Item>
-      )}
-      {isVLESS && (
-        <Form.Item label="Reverse tag">
-          <Input
-            value={ob.settings.reverseTag || ''}
-            placeholder="optional"
-            onChange={(e) => { ob.settings.reverseTag = e.target.value; refresh(); }}
-          />
-        </Form.Item>
-      )}
-      {isVLESS && ob.settings.reverseTag && (
-        <>
-          <Form.Item label="Reverse Sniffing">
-            <Switch checked={!!rev.enabled} onChange={(v) => { rev.enabled = v; refresh(); }} />
-          </Form.Item>
-          {rev.enabled && (
-            <>
-              <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-                <Checkbox.Group
-                  className="sniffing-options"
-                  value={rev.destOverride || []}
-                  onChange={(v) => { rev.destOverride = v as string[]; refresh(); }}
-                  options={Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ label, value: value as string }))}
-                />
-              </Form.Item>
-              <Form.Item label="Metadata Only">
-                <Switch checked={!!rev.metadataOnly} onChange={(v) => { rev.metadataOnly = v; refresh(); }} />
-              </Form.Item>
-              <Form.Item label="Route Only">
-                <Switch checked={!!rev.routeOnly} onChange={(v) => { rev.routeOnly = v; refresh(); }} />
-              </Form.Item>
-              <Form.Item label="IPs Excluded">
-                <Select
-                  mode="tags"
-                  value={rev.ipsExcluded || []}
-                  tokenSeparators={[',']}
-                  placeholder="IP/CIDR/geoip:*/ext:*"
-                  style={{ width: '100%' }}
-                  onChange={(v) => { rev.ipsExcluded = v as string[]; refresh(); }}
-                />
-              </Form.Item>
-              <Form.Item label="Domains Excluded">
-                <Select
-                  mode="tags"
-                  value={rev.domainsExcluded || []}
-                  tokenSeparators={[',']}
-                  placeholder="domain:*/ext:*"
-                  style={{ width: '100%' }}
-                  onChange={(v) => { rev.domainsExcluded = v as string[]; refresh(); }}
-                />
-              </Form.Item>
-            </>
-          )}
-        </>
-      )}
-      {ob.canEnableTlsFlow() && (
-        <Form.Item label="Flow">
-          <Select
-            value={ob.settings.flow || ''}
-            onChange={(v) => { ob.settings.flow = v; refresh(); }}
-            options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS.map((k) => ({ value: k, label: k }))]}
-          />
-        </Form.Item>
-      )}
-    </>
-  );
-}
+                    {/* Shared connect target (address + port) for protocols
+                        whose form schema carries them flat at settings root.
+                        Hidden for freedom/blackhole/dns/loopback/wireguard. */}
+                    {SERVER_PROTOCOLS.has(protocol) && (
+                      <>
+                        <Form.Item
+                          label={t('pages.inbounds.address')}
+                          name={['settings', 'address']}
+                          rules={[{ required: true, message: 'Address is required' }]}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item
+                          label={t('pages.inbounds.port')}
+                          name={['settings', 'port']}
+                          rules={[{ required: true, message: 'Port is required' }]}
+                        >
+                          <InputNumber min={1} max={65535} style={{ width: '100%' }} />
+                        </Form.Item>
+                      </>
+                    )}
+
+                    {(protocol === 'vmess' || protocol === 'vless') && (
+                      <Form.Item
+                        label="ID"
+                        name={['settings', 'id']}
+                        rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
+                      >
+                        <Input placeholder="UUID" />
+                      </Form.Item>
+                    )}
+                    {protocol === 'vmess' && (
+                      <Form.Item
+                        label={t('security')}
+                        name={['settings', 'security']}
+                        rules={[antdRule(VmessOutboundFormSettingsSchema.shape.security, t)]}
+                      >
+                        <Select options={SECURITY_OPTIONS} />
+                      </Form.Item>
+                    )}
+                    {protocol === 'vless' && (
+                      <>
+                        <Form.Item
+                          label={t('encryption')}
+                          name={['settings', 'encryption']}
+                          rules={[antdRule(VlessOutboundFormSettingsSchema.shape.encryption, t)]}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item label="Reverse tag" name={['settings', 'reverseTag']}>
+                          <Input placeholder="optional" />
+                        </Form.Item>
+                      </>
+                    )}
+
+                    {(protocol === 'trojan' || protocol === 'shadowsocks') && (
+                      <Form.Item
+                        label={t('password')}
+                        name={['settings', 'password']}
+                        rules={[
+                          antdRule(
+                            protocol === 'trojan'
+                              ? TrojanOutboundFormSettingsSchema.shape.password
+                              : ShadowsocksOutboundFormSettingsSchema.shape.password,
+                            t,
+                          ),
+                        ]}
+                      >
+                        <Input />
+                      </Form.Item>
+                    )}
+
+                    {protocol === 'shadowsocks' && (
+                      <>
+                        <Form.Item
+                          label={t('encryption')}
+                          name={['settings', 'method']}
+                          rules={[antdRule(SSMethodSchema, t)]}
+                        >
+                          <Select options={SS_METHOD_OPTIONS} />
+                        </Form.Item>
+                        <Form.Item
+                          label="UDP over TCP"
+                          name={['settings', 'uot']}
+                          valuePropName="checked"
+                        >
+                          <Switch />
+                        </Form.Item>
+                        <Form.Item label="UoT version" name={['settings', 'UoTVersion']}>
+                          <InputNumber min={1} max={2} />
+                        </Form.Item>
+                      </>
+                    )}
+
+                    {(protocol === 'socks' || protocol === 'http') && (
+                      <>
+                        <Form.Item label={t('username')} name={['settings', 'user']}>
+                          <Input />
+                        </Form.Item>
+                        <Form.Item label={t('password')} name={['settings', 'pass']}>
+                          <Input />
+                        </Form.Item>
+                      </>
+                    )}
+
+                    {protocol === 'hysteria' && (
+                      <Form.Item label="Version" name={['settings', 'version']}>
+                        <InputNumber min={2} max={2} disabled />
+                      </Form.Item>
+                    )}
+
+                    {protocol === 'loopback' && (
+                      <Form.Item label="Inbound tag" name={['settings', 'inboundTag']}>
+                        <Input placeholder="inbound tag used in routing rules" />
+                      </Form.Item>
+                    )}
+
+                    {protocol === 'blackhole' && (
+                      <Form.Item label="Response type" name={['settings', 'type']}>
+                        <Select
+                          options={[
+                            { value: '', label: '(empty)' },
+                            { value: 'none', label: 'none' },
+                            { value: 'http', label: 'http' },
+                          ]}
+                        />
+                      </Form.Item>
+                    )}
+
+                    {protocol === 'dns' && (
+                      <>
+                        <Form.Item label="Rewrite network" name={['settings', 'rewriteNetwork']}>
+                          <Select
+                            allowClear
+                            placeholder="(unchanged)"
+                            options={[
+                              { value: 'udp', label: 'udp' },
+                              { value: 'tcp', label: 'tcp' },
+                            ]}
+                          />
+                        </Form.Item>
+                        <Form.Item label="Rewrite address" name={['settings', 'rewriteAddress']}>
+                          <Input placeholder="(unchanged) e.g. 1.1.1.1" />
+                        </Form.Item>
+                        <Form.Item label="Rewrite port" name={['settings', 'rewritePort']}>
+                          <InputNumber min={0} max={65535} style={{ width: '100%' }} />
+                        </Form.Item>
+                        <Form.Item label="User level" name={['settings', 'userLevel']}>
+                          <InputNumber min={0} style={{ width: '100%' }} />
+                        </Form.Item>
+                        <Form.List name={['settings', 'rules']}>
+                          {(fields, { add, remove }) => (
+                            <>
+                              <Form.Item label="Rules">
+                                <Button
+                                  size="small"
+                                  type="primary"
+                                  icon={<PlusOutlined />}
+                                  onClick={() => add({ action: 'direct', qtype: '', domain: '' })}
+                                />
+                              </Form.Item>
+                              {fields.map((field, index) => (
+                                <div key={field.key}>
+                                  <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+                                    <div className="item-heading">
+                                      <span>Rule {index + 1}</span>
+                                      <DeleteOutlined
+                                        className="danger-icon"
+                                        onClick={() => remove(field.name)}
+                                      />
+                                    </div>
+                                  </Form.Item>
+                                  <Form.Item label="Action" name={[field.name, 'action']}>
+                                    <Select
+                                      options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item label="QType" name={[field.name, 'qtype']}>
+                                    <Input placeholder="1,3,23-24" />
+                                  </Form.Item>
+                                  <Form.Item label={t('domainName')} name={[field.name, 'domain']}>
+                                    <Input placeholder="domain:example.com" />
+                                  </Form.Item>
+                                </div>
+                              ))}
+                            </>
+                          )}
+                        </Form.List>
+                      </>
+                    )}
+
+                    {protocol === 'freedom' && (
+                      <>
+                        <Form.Item label="Strategy" name={['settings', 'domainStrategy']}>
+                          <Select
+                            options={[
+                              { value: '', label: `(${t('none')})` },
+                              ...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
+                            ]}
+                          />
+                        </Form.Item>
+                        <Form.Item label="Redirect" name={['settings', 'redirect']}>
+                          <Input />
+                        </Form.Item>
+
+                        <Form.Item label="Fragment" shouldUpdate noStyle>
+                          {() => {
+                            const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as {
+                              packets?: string;
+                              length?: string;
+                              interval?: string;
+                              maxSplit?: string;
+                            };
+                            const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
+                            return (
+                              <>
+                                <Form.Item label="Fragment">
+                                  <Switch
+                                    checked={enabled}
+                                    onChange={(checked) => {
+                                      form.setFieldValue(
+                                        ['settings', 'fragment'],
+                                        checked
+                                          ? {
+                                              packets: 'tlshello',
+                                              length: '100-200',
+                                              interval: '10-20',
+                                              maxSplit: '300-400',
+                                            }
+                                          : { packets: '', length: '', interval: '', maxSplit: '' },
+                                      );
+                                    }}
+                                  />
+                                </Form.Item>
+                                {enabled && (
+                                  <>
+                                    <Form.Item
+                                      label="Packets"
+                                      name={['settings', 'fragment', 'packets']}
+                                    >
+                                      <Select
+                                        options={[
+                                          { value: '1-3', label: '1-3' },
+                                          { value: 'tlshello', label: 'tlshello' },
+                                        ]}
+                                      />
+                                    </Form.Item>
+                                    <Form.Item label="Length" name={['settings', 'fragment', 'length']}>
+                                      <Input />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Interval"
+                                      name={['settings', 'fragment', 'interval']}
+                                    >
+                                      <Input />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Max Split"
+                                      name={['settings', 'fragment', 'maxSplit']}
+                                    >
+                                      <Input />
+                                    </Form.Item>
+                                  </>
+                                )}
+                              </>
+                            );
+                          }}
+                        </Form.Item>
+
+                        <Form.List name={['settings', 'noises']}>
+                          {(fields, { add, remove }) => (
+                            <>
+                              <Form.Item label="Noises">
+                                <Switch
+                                  checked={fields.length > 0}
+                                  onChange={(checked) => {
+                                    if (checked) {
+                                      add({
+                                        type: 'rand',
+                                        packet: '10-20',
+                                        delay: '10-16',
+                                        applyTo: 'ip',
+                                      });
+                                    } else {
+                                      // remove() with no arg is not supported;
+                                      // walk fields in reverse and drop each.
+                                      for (let i = fields.length - 1; i >= 0; i--) {
+                                        remove(fields[i].name);
+                                      }
+                                    }
+                                  }}
+                                />
+                                {fields.length > 0 && (
+                                  <Button
+                                    size="small"
+                                    type="primary"
+                                    className="ml-8"
+                                    icon={<PlusOutlined />}
+                                    onClick={() =>
+                                      add({
+                                        type: 'rand',
+                                        packet: '10-20',
+                                        delay: '10-16',
+                                        applyTo: 'ip',
+                                      })
+                                    }
+                                  />
+                                )}
+                              </Form.Item>
+                              {fields.map((field, index) => (
+                                <div key={field.key}>
+                                  <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+                                    <div className="item-heading">
+                                      <span>Noise {index + 1}</span>
+                                      {fields.length > 1 && (
+                                        <DeleteOutlined
+                                          className="danger-icon"
+                                          onClick={() => remove(field.name)}
+                                        />
+                                      )}
+                                    </div>
+                                  </Form.Item>
+                                  <Form.Item label="Type" name={[field.name, 'type']}>
+                                    <Select
+                                      options={['rand', 'base64', 'str', 'hex'].map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item label="Packet" name={[field.name, 'packet']}>
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item label="Delay (ms)" name={[field.name, 'delay']}>
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item label="Apply to" name={[field.name, 'applyTo']}>
+                                    <Select
+                                      options={['ip', 'ipv4', 'ipv6'].map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                </div>
+                              ))}
+                            </>
+                          )}
+                        </Form.List>
+
+                        <Form.List name={['settings', 'finalRules']}>
+                          {(fields, { add, remove }) => (
+                            <>
+                              <Form.Item label="Final Rules">
+                                <Button
+                                  size="small"
+                                  type="primary"
+                                  icon={<PlusOutlined />}
+                                  onClick={() =>
+                                    add({
+                                      action: 'allow',
+                                      network: '',
+                                      port: '',
+                                      ip: [],
+                                      blockDelay: '',
+                                    })
+                                  }
+                                />
+                                <span className="ml-8" style={{ opacity: 0.6 }}>
+                                  Override Xray&apos;s default private-IP block
+                                </span>
+                              </Form.Item>
+                              {fields.map((field, index) => (
+                                <div key={field.key}>
+                                  <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+                                    <div className="item-heading">
+                                      <span>Rule {index + 1}</span>
+                                      <DeleteOutlined
+                                        className="danger-icon"
+                                        onClick={() => remove(field.name)}
+                                      />
+                                    </div>
+                                  </Form.Item>
+                                  <Form.Item label="Action" name={[field.name, 'action']}>
+                                    <Select
+                                      options={['allow', 'block'].map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item label="Network" name={[field.name, 'network']}>
+                                    <Select
+                                      allowClear
+                                      placeholder="(any)"
+                                      options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item label="Port" name={[field.name, 'port']}>
+                                    <Input placeholder="e.g. 80,443 or 1000-2000" />
+                                  </Form.Item>
+                                  <Form.Item label="IP / CIDR / geoip" name={[field.name, 'ip']}>
+                                    <Select
+                                      mode="tags"
+                                      tokenSeparators={[',', ' ']}
+                                      placeholder="e.g. 10.0.0.0/8, geoip:private"
+                                    />
+                                  </Form.Item>
+                                  <Form.Item shouldUpdate noStyle>
+                                    {() => {
+                                      const ruleAction = form.getFieldValue([
+                                        'settings',
+                                        'finalRules',
+                                        field.name,
+                                        'action',
+                                      ]);
+                                      if (ruleAction !== 'block') return null;
+                                      return (
+                                        <Form.Item
+                                          label="Block delay (ms)"
+                                          name={[field.name, 'blockDelay']}
+                                        >
+                                          <Input placeholder="optional: 5000-10000" />
+                                        </Form.Item>
+                                      );
+                                    }}
+                                  </Form.Item>
+                                </div>
+                              ))}
+                            </>
+                          )}
+                        </Form.List>
+                      </>
+                    )}
+
+                    {protocol === 'vless' && (
+                      <Form.Item shouldUpdate noStyle>
+                        {() => {
+                          const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
+                          if (!reverseTag) return null;
+                          const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
+                            enabled?: boolean;
+                          };
+                          return (
+                            <>
+                              <Form.Item
+                                label="Reverse Sniffing"
+                                name={['settings', 'reverseSniffing', 'enabled']}
+                                valuePropName="checked"
+                              >
+                                <Switch />
+                              </Form.Item>
+                              {sniff.enabled && (
+                                <>
+                                  <Form.Item
+                                    wrapperCol={{ md: { span: 14, offset: 8 } }}
+                                    name={['settings', 'reverseSniffing', 'destOverride']}
+                                  >
+                                    <Select
+                                      mode="multiple"
+                                      className="sniffing-options"
+                                      options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
+                                        value: v,
+                                        label: k,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Metadata Only"
+                                    name={['settings', 'reverseSniffing', 'metadataOnly']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Route Only"
+                                    name={['settings', 'reverseSniffing', 'routeOnly']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="IPs Excluded"
+                                    name={['settings', 'reverseSniffing', 'ipsExcluded']}
+                                  >
+                                    <Select
+                                      mode="tags"
+                                      tokenSeparators={[',']}
+                                      placeholder="IP/CIDR/geoip:*"
+                                    />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Domains Excluded"
+                                    name={['settings', 'reverseSniffing', 'domainsExcluded']}
+                                  >
+                                    <Select
+                                      mode="tags"
+                                      tokenSeparators={[',']}
+                                      placeholder="domain:*"
+                                    />
+                                  </Form.Item>
+                                </>
+                              )}
+                            </>
+                          );
+                        }}
+                      </Form.Item>
+                    )}
+
+                    {protocol === 'wireguard' && (
+                      <>
+                        <Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}>
+                          <Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
+                        </Form.Item>
+                        <Form.Item
+                          label={
+                            <>
+                              {t('pages.inbounds.privatekey')}
+                              <SyncOutlined
+                                className="random-icon"
+                                onClick={() => {
+                                  const pair = Wireguard.generateKeypair();
+                                  form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
+                                  form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
+                                }}
+                              />
+                            </>
+                          }
+                          name={['settings', 'secretKey']}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
+                          <Input disabled />
+                        </Form.Item>
+                        <Form.Item label="Domain strategy" name={['settings', 'domainStrategy']}>
+                          <Select
+                            options={[
+                              { value: '', label: `(${t('none')})` },
+                              ...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
+                            ]}
+                          />
+                        </Form.Item>
+                        <Form.Item label="MTU" name={['settings', 'mtu']}>
+                          <InputNumber min={0} />
+                        </Form.Item>
+                        <Form.Item label="Workers" name={['settings', 'workers']}>
+                          <InputNumber min={0} />
+                        </Form.Item>
+                        <Form.Item
+                          label="No-kernel TUN"
+                          name={['settings', 'noKernelTun']}
+                          valuePropName="checked"
+                        >
+                          <Switch />
+                        </Form.Item>
+                        <Form.Item label="Reserved" name={['settings', 'reserved']}>
+                          <Input placeholder="comma-separated bytes, e.g. 1,2,3" />
+                        </Form.Item>
+                        <Form.List name={['settings', 'peers']}>
+                          {(fields, { add, remove }) => (
+                            <>
+                              <Form.Item label="Peers">
+                                <Button
+                                  size="small"
+                                  type="primary"
+                                  icon={<PlusOutlined />}
+                                  onClick={() =>
+                                    add({
+                                      publicKey: '',
+                                      psk: '',
+                                      allowedIPs: ['0.0.0.0/0', '::/0'],
+                                      endpoint: '',
+                                      keepAlive: 0,
+                                    })
+                                  }
+                                />
+                              </Form.Item>
+                              {fields.map((field, index) => (
+                                <div key={field.key}>
+                                  <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+                                    <div className="item-heading">
+                                      <span>Peer {index + 1}</span>
+                                      {fields.length > 1 && (
+                                        <DeleteOutlined
+                                          className="danger-icon"
+                                          onClick={() => remove(field.name)}
+                                        />
+                                      )}
+                                    </div>
+                                  </Form.Item>
+                                  <Form.Item label="Endpoint" name={[field.name, 'endpoint']}>
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label={t('pages.inbounds.publicKey')}
+                                    name={[field.name, 'publicKey']}
+                                  >
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item label="PSK" name={[field.name, 'psk']}>
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item label="Allowed IPs">
+                                    <Form.List name={[field.name, 'allowedIPs']}>
+                                      {(ipFields, { add: addIp, remove: removeIp }) => (
+                                        <>
+                                          {ipFields.map((ipField, ipIdx) => (
+                                            <Space.Compact
+                                              key={ipField.key}
+                                              block
+                                              style={{ marginBottom: 4 }}
+                                            >
+                                              <Form.Item noStyle name={ipField.name}>
+                                                <Input />
+                                              </Form.Item>
+                                              {ipFields.length > 1 && (
+                                                <InputAddon onClick={() => removeIp(ipIdx)}>
+                                                  <MinusOutlined />
+                                                </InputAddon>
+                                              )}
+                                            </Space.Compact>
+                                          ))}
+                                          <Button
+                                            size="small"
+                                            icon={<PlusOutlined />}
+                                            onClick={() => addIp('')}
+                                          />
+                                        </>
+                                      )}
+                                    </Form.List>
+                                  </Form.Item>
+                                  <Form.Item label="Keep alive" name={[field.name, 'keepAlive']}>
+                                    <InputNumber min={0} />
+                                  </Form.Item>
+                                </div>
+                              ))}
+                            </>
+                          )}
+                        </Form.List>
+                      </>
+                    )}
+
+                    {streamAllowed && network && (
+                      <>
+                        <Form.Item
+                          label={t('transmission')}
+                          name={['streamSettings', 'network']}
+                        >
+                          <Select
+                            value={network}
+                            onChange={onNetworkChange}
+                            options={
+                              protocol === 'hysteria'
+                                ? [...NETWORK_OPTIONS, HYSTERIA_NETWORK_OPTION]
+                                : NETWORK_OPTIONS
+                            }
+                          />
+                        </Form.Item>
+
+                        {network === 'tcp' && (
+                          <Form.Item shouldUpdate noStyle>
+                            {() => {
+                              const type =
+                                form.getFieldValue([
+                                  'streamSettings',
+                                  'tcpSettings',
+                                  'header',
+                                  'type',
+                                ]) ?? 'none';
+                              return (
+                                <>
+                                  <Form.Item label={`HTTP ${t('camouflage')}`}>
+                                    <Switch
+                                      checked={type === 'http'}
+                                      onChange={(checked) =>
+                                        form.setFieldValue(
+                                          ['streamSettings', 'tcpSettings', 'header'],
+                                          checked
+                                            ? {
+                                                type: 'http',
+                                                request: {
+                                                  version: '1.1',
+                                                  method: 'GET',
+                                                  path: ['/'],
+                                                  headers: {},
+                                                },
+                                                response: {
+                                                  version: '1.1',
+                                                  status: '200',
+                                                  reason: 'OK',
+                                                  headers: {},
+                                                },
+                                              }
+                                            : { type: 'none' },
+                                        )
+                                      }
+                                    />
+                                  </Form.Item>
+                                  {type === 'http' && (
+                                    <>
+                                      <Form.Item
+                                        label="Request method"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'request', 'method',
+                                        ]}
+                                      >
+                                        <Input placeholder="GET" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label="Request version"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'request', 'version',
+                                        ]}
+                                      >
+                                        <Input placeholder="1.1" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label={t('host')}
+                                        name={[
+                                          'streamSettings',
+                                          'tcpSettings',
+                                          'header',
+                                          'request',
+                                          'headers',
+                                          'Host',
+                                        ]}
+                                        normalize={(v: unknown) =>
+                                          typeof v === 'string'
+                                            ? v.split(',').map((s) => s.trim()).filter(Boolean)
+                                            : Array.isArray(v) ? v : []
+                                        }
+                                        getValueProps={(v: unknown) => ({
+                                          value: Array.isArray(v) ? v.join(',') : '',
+                                        })}
+                                      >
+                                        <Input placeholder="example.com,cdn.example.com" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label={t('path')}
+                                        name={[
+                                          'streamSettings',
+                                          'tcpSettings',
+                                          'header',
+                                          'request',
+                                          'path',
+                                        ]}
+                                        normalize={(v: unknown) =>
+                                          typeof v === 'string'
+                                            ? v.split(',').map((s) => s.trim()).filter(Boolean)
+                                            : Array.isArray(v) ? v : ['/']
+                                        }
+                                        getValueProps={(v: unknown) => ({
+                                          value: Array.isArray(v) ? v.join(',') : '/',
+                                        })}
+                                      >
+                                        <Input placeholder="/,/api,/static" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label="Request headers"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'request', 'headers',
+                                        ]}
+                                      >
+                                        <HeaderMapEditor mode="v2" />
+                                      </Form.Item>
+
+                                      <Form.Item
+                                        label="Response version"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'response', 'version',
+                                        ]}
+                                      >
+                                        <Input placeholder="1.1" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label="Response status"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'response', 'status',
+                                        ]}
+                                      >
+                                        <Input placeholder="200" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label="Response reason"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'response', 'reason',
+                                        ]}
+                                      >
+                                        <Input placeholder="OK" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label="Response headers"
+                                        name={[
+                                          'streamSettings', 'tcpSettings', 'header',
+                                          'response', 'headers',
+                                        ]}
+                                      >
+                                        <HeaderMapEditor mode="v2" />
+                                      </Form.Item>
+                                    </>
+                                  )}
+                                </>
+                              );
+                            }}
+                          </Form.Item>
+                        )}
+
+                        {network === 'kcp' && (
+                          <>
+                            <Form.Item label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
+                              <InputNumber min={0} />
+                            </Form.Item>
+                            <Form.Item label="TTI (ms)" name={['streamSettings', 'kcpSettings', 'tti']}>
+                              <InputNumber min={0} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Uplink (MB/s)"
+                              name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
+                            >
+                              <InputNumber min={0} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Downlink (MB/s)"
+                              name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
+                            >
+                              <InputNumber min={0} />
+                            </Form.Item>
+                            <Form.Item
+                              label="CWND multiplier"
+                              name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
+                            >
+                              <InputNumber min={1} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Max sending window"
+                              name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
+                            >
+                              <InputNumber min={0} />
+                            </Form.Item>
+                          </>
+                        )}
+
+                        {network === 'ws' && (
+                          <>
+                            <Form.Item label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
+                              <Input />
+                            </Form.Item>
+                            <Form.Item label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Heartbeat (s)"
+                              name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
+                            >
+                              <InputNumber min={0} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Headers"
+                              name={['streamSettings', 'wsSettings', 'headers']}
+                            >
+                              <HeaderMapEditor mode="v1" />
+                            </Form.Item>
+                          </>
+                        )}
+
+                        {network === 'grpc' && (
+                          <>
+                            <Form.Item
+                              label="Service name"
+                              name={['streamSettings', 'grpcSettings', 'serviceName']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Authority"
+                              name={['streamSettings', 'grpcSettings', 'authority']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Multi mode"
+                              name={['streamSettings', 'grpcSettings', 'multiMode']}
+                              valuePropName="checked"
+                            >
+                              <Switch />
+                            </Form.Item>
+                          </>
+                        )}
+
+                        {network === 'httpupgrade' && (
+                          <>
+                            <Form.Item
+                              label={t('host')}
+                              name={['streamSettings', 'httpupgradeSettings', 'host']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label={t('path')}
+                              name={['streamSettings', 'httpupgradeSettings', 'path']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Headers"
+                              name={['streamSettings', 'httpupgradeSettings', 'headers']}
+                            >
+                              <HeaderMapEditor mode="v1" />
+                            </Form.Item>
+                          </>
+                        )}
+
+                        {network === 'xhttp' && (
+                          <>
+                            <Form.Item
+                              label={t('host')}
+                              name={['streamSettings', 'xhttpSettings', 'host']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label={t('path')}
+                              name={['streamSettings', 'xhttpSettings', 'path']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Mode"
+                              name={['streamSettings', 'xhttpSettings', 'mode']}
+                            >
+                              <Select options={MODE_OPTIONS} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Padding Bytes"
+                              name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Headers"
+                              name={['streamSettings', 'xhttpSettings', 'headers']}
+                            >
+                              <HeaderMapEditor mode="v1" />
+                            </Form.Item>
+
+                            {/* Padding obfs sub-section: gated by a Switch.
+                                When on, four extra knobs (key/header/placement/
+                                method) tune how Xray injects random padding to
+                                disguise the post body shape. */}
+                            <Form.Item
+                              label="Padding obfs mode"
+                              name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
+                              valuePropName="checked"
+                            >
+                              <Switch />
+                            </Form.Item>
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                const obfs = !!form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
+                                ]);
+                                if (!obfs) return null;
+                                return (
+                                  <>
+                                    <Form.Item
+                                      label="Padding key"
+                                      name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
+                                    >
+                                      <Input placeholder="x_padding" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Padding header"
+                                      name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
+                                    >
+                                      <Input placeholder="X-Padding" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Padding placement"
+                                      name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
+                                    >
+                                      <Select
+                                        options={[
+                                          { value: '', label: 'Default (queryInHeader)' },
+                                          { value: 'queryInHeader', label: 'queryInHeader' },
+                                          { value: 'header', label: 'header' },
+                                          { value: 'cookie', label: 'cookie' },
+                                          { value: 'query', label: 'query' },
+                                        ]}
+                                      />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Padding method"
+                                      name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
+                                    >
+                                      <Select
+                                        options={[
+                                          { value: '', label: 'Default (repeat-x)' },
+                                          { value: 'repeat-x', label: 'repeat-x' },
+                                          { value: 'tokenish', label: 'tokenish' },
+                                        ]}
+                                      />
+                                    </Form.Item>
+                                  </>
+                                );
+                              }}
+                            </Form.Item>
+
+                            <Form.Item
+                              noStyle
+                              shouldUpdate={(prev, curr) =>
+                                prev?.streamSettings?.xhttpSettings?.mode !==
+                                curr?.streamSettings?.xhttpSettings?.mode
+                              }
+                            >
+                              {() => {
+                                const mode = form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'mode',
+                                ]);
+                                return (
+                                  <Form.Item
+                                    label="Uplink HTTP method"
+                                    name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
+                                  >
+                                    <Select
+                                      placeholder="Default (POST)"
+                                      options={[
+                                        { value: '', label: 'Default (POST)' },
+                                        { value: 'POST', label: 'POST' },
+                                        { value: 'PUT', label: 'PUT' },
+                                        { value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
+                                      ]}
+                                    />
+                                  </Form.Item>
+                                );
+                              }}
+                            </Form.Item>
+
+                            {/* Session + sequence + uplinkData placements:
+                                three orthogonal slots Xray uses to thread
+                                request metadata through the transport
+                                (path / header / cookie / query). Key field
+                                only matters when placement is not 'path'. */}
+                            <Form.Item
+                              label="Session placement"
+                              name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
+                            >
+                              <Select
+                                placeholder="Default (path)"
+                                options={[
+                                  { value: '', label: 'Default (path)' },
+                                  { value: 'path', label: 'path' },
+                                  { value: 'header', label: 'header' },
+                                  { value: 'cookie', label: 'cookie' },
+                                  { value: 'query', label: 'query' },
+                                ]}
+                              />
+                            </Form.Item>
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                const placement = form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'sessionPlacement',
+                                ]);
+                                if (!placement || placement === 'path') return null;
+                                return (
+                                  <Form.Item
+                                    label="Session key"
+                                    name={['streamSettings', 'xhttpSettings', 'sessionKey']}
+                                  >
+                                    <Input placeholder="x_session" />
+                                  </Form.Item>
+                                );
+                              }}
+                            </Form.Item>
+                            <Form.Item
+                              label="Sequence placement"
+                              name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
+                            >
+                              <Select
+                                placeholder="Default (path)"
+                                options={[
+                                  { value: '', label: 'Default (path)' },
+                                  { value: 'path', label: 'path' },
+                                  { value: 'header', label: 'header' },
+                                  { value: 'cookie', label: 'cookie' },
+                                  { value: 'query', label: 'query' },
+                                ]}
+                              />
+                            </Form.Item>
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                const placement = form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'seqPlacement',
+                                ]);
+                                if (!placement || placement === 'path') return null;
+                                return (
+                                  <Form.Item
+                                    label="Sequence key"
+                                    name={['streamSettings', 'xhttpSettings', 'seqKey']}
+                                  >
+                                    <Input placeholder="x_seq" />
+                                  </Form.Item>
+                                );
+                              }}
+                            </Form.Item>
+
+                            {/* Mode-conditional sub-sections. */}
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                const mode = form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'mode',
+                                ]);
+                                if (mode !== 'packet-up') return null;
+                                return (
+                                  <>
+                                    <Form.Item
+                                      label="Min upload interval (ms)"
+                                      name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
+                                    >
+                                      <Input placeholder="30" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Max upload size (bytes)"
+                                      name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
+                                    >
+                                      <Input placeholder="1000000" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Uplink data placement"
+                                      name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
+                                    >
+                                      <Select
+                                        options={[
+                                          { value: '', label: 'Default (body)' },
+                                          { value: 'body', label: 'body' },
+                                          { value: 'header', label: 'header' },
+                                          { value: 'cookie', label: 'cookie' },
+                                          { value: 'query', label: 'query' },
+                                        ]}
+                                      />
+                                    </Form.Item>
+                                    <Form.Item shouldUpdate noStyle>
+                                      {() => {
+                                        const place = form.getFieldValue([
+                                          'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
+                                        ]);
+                                        if (!place || place === 'body') return null;
+                                        return (
+                                          <>
+                                            <Form.Item
+                                              label="Uplink data key"
+                                              name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
+                                            >
+                                              <Input placeholder="x_data" />
+                                            </Form.Item>
+                                            <Form.Item
+                                              label="Uplink chunk size"
+                                              name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
+                                            >
+                                              <InputNumber
+                                                min={0}
+                                                placeholder="0 (unlimited)"
+                                                style={{ width: '100%' }}
+                                              />
+                                            </Form.Item>
+                                          </>
+                                        );
+                                      }}
+                                    </Form.Item>
+                                  </>
+                                );
+                              }}
+                            </Form.Item>
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                const mode = form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'mode',
+                                ]);
+                                if (mode !== 'stream-up' && mode !== 'stream-one') return null;
+                                return (
+                                  <Form.Item
+                                    label="No gRPC header"
+                                    name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                );
+                              }}
+                            </Form.Item>
+
+                            {/* XMUX is the connection-multiplexing layer
+                                xHTTP uses to fan out parallel requests over
+                                a small pool of upstream connections. UI-only
+                                toggle (enableXmux) hides the 6 nested knobs
+                                when off. */}
+                            <Form.Item
+                              label="XMUX"
+                              name={['streamSettings', 'xhttpSettings', 'enableXmux']}
+                              valuePropName="checked"
+                            >
+                              <Switch />
+                            </Form.Item>
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                if (!form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'enableXmux',
+                                ])) return null;
+                                return (
+                                  <>
+                                    <Form.Item
+                                      label="Max concurrency"
+                                      name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+                                    >
+                                      <Input placeholder="16-32" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Max connections"
+                                      name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+                                    >
+                                      <Input placeholder="0" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Max reuse times"
+                                      name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
+                                    >
+                                      <Input />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Max request times"
+                                      name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
+                                    >
+                                      <Input placeholder="600-900" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Max reusable secs"
+                                      name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
+                                    >
+                                      <Input placeholder="1800-3000" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="Keep alive period"
+                                      name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
+                                    >
+                                      <InputNumber min={0} style={{ width: '100%' }} />
+                                    </Form.Item>
+                                  </>
+                                );
+                              }}
+                            </Form.Item>
+                          </>
+                        )}
+
+                        {network === 'hysteria' && (
+                          <>
+                            <Form.Item
+                              label="Auth password"
+                              name={['streamSettings', 'hysteriaSettings', 'auth']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="UDP idle timeout (s)"
+                              name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
+                            >
+                              <InputNumber min={1} style={{ width: '100%' }} />
+                            </Form.Item>
+                          </>
+                        )}
+                      </>
+                    )}
+
+                    {tlsFlowAllowed && (
+                      <Form.Item label="Flow" name={['settings', 'flow']}>
+                        <Select
+                          allowClear
+                          placeholder={t('none')}
+                          options={FLOW_OPTIONS}
+                        />
+                      </Form.Item>
+                    )}
+
+                    {/* Vision seed knobs only meaningful for the exact
+                        xtls-rprx-vision flow, on TCP+(tls|reality). The
+                        legacy class gated this on `canEnableVisionSeed()`
+                        — same condition encoded inline here. */}
+                    <Form.Item shouldUpdate noStyle>
+                      {() => {
+                        const flow =
+                          (form.getFieldValue(['settings', 'flow']) ?? '') as string;
+                        if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
+                        return (
+                          <>
+                            <Form.Item label="Vision testpre" name={['settings', 'testpre']}>
+                              <InputNumber min={0} style={{ width: '100%' }} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Vision testseed"
+                              name={['settings', 'testseed']}
+                              normalize={(v: unknown) =>
+                                Array.isArray(v)
+                                  ? v
+                                      .map((x) => Number(x))
+                                      .filter((n) => Number.isInteger(n) && n > 0)
+                                  : []
+                              }
+                            >
+                              <Select
+                                mode="tags"
+                                tokenSeparators={[',', ' ']}
+                                placeholder="four positive integers"
+                              />
+                            </Form.Item>
+                          </>
+                        );
+                      }}
+                    </Form.Item>
 
-function StreamFields({ ob, refresh, streamNetworkChange, isHysteria, t }: TFieldProps & { streamNetworkChange: (next: string) => void; isHysteria: boolean }) {
-  const networks = isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS;
-  return (
-    <>
-      <Form.Item label={t('transmission')}>
-        <Select
-          value={ob.stream.network}
-          onChange={streamNetworkChange}
-          options={networks.map((net) => ({ value: net, label: NETWORK_LABELS[net] || net }))}
-        />
-      </Form.Item>
-
-      {ob.stream.network === 'tcp' && (
-        <>
-          <Form.Item label={`HTTP ${t('camouflage')}`}>
-            <Switch
-              checked={ob.stream.tcp.type === 'http'}
-              onChange={(checked) => { ob.stream.tcp.type = checked ? 'http' : 'none'; refresh(); }}
-            />
-          </Form.Item>
-          {ob.stream.tcp.type === 'http' && (
-            <>
-              <Form.Item label={t('host')}>
-                <Input value={ob.stream.tcp.host || ''} onChange={(e) => { ob.stream.tcp.host = e.target.value; refresh(); }} />
-              </Form.Item>
-              <Form.Item label={t('path')}>
-                <Input value={ob.stream.tcp.path || ''} onChange={(e) => { ob.stream.tcp.path = e.target.value; refresh(); }} />
-              </Form.Item>
-            </>
-          )}
-        </>
-      )}
-
-      {ob.stream.network === 'kcp' && (
-        <>
-          {(
-            [
-              ['mtu', 'MTU', 0],
-              ['tti', 'TTI (ms)', 0],
-              ['upCap', 'Uplink (MB/s)', 0],
-              ['downCap', 'Downlink (MB/s)', 0],
-              ['cwndMultiplier', 'CWND multiplier', 1],
-              ['maxSendingWindow', 'Max sending window', 0],
-            ] as const
-          ).map(([field, label, min]) => (
-            <Form.Item key={field} label={label}>
-              <InputNumber
-                value={ob.stream.kcp[field] ?? 0}
-                min={min}
-                onChange={(v) => { ob.stream.kcp[field] = Number(v) || 0; refresh(); }}
-              />
-            </Form.Item>
-          ))}
-        </>
-      )}
-
-      {ob.stream.network === 'ws' && (
-        <>
-          <Form.Item label={t('host')}>
-            <Input value={ob.stream.ws.host || ''} onChange={(e) => { ob.stream.ws.host = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label={t('path')}>
-            <Input value={ob.stream.ws.path || ''} onChange={(e) => { ob.stream.ws.path = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Heartbeat (s)">
-            <InputNumber
-              value={ob.stream.ws.heartbeatPeriod || 0}
-              min={0}
-              onChange={(v) => { ob.stream.ws.heartbeatPeriod = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-        </>
-      )}
-
-      {ob.stream.network === 'grpc' && (
-        <>
-          <Form.Item label="Service name">
-            <Input value={ob.stream.grpc.serviceName || ''} onChange={(e) => { ob.stream.grpc.serviceName = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Authority">
-            <Input value={ob.stream.grpc.authority || ''} onChange={(e) => { ob.stream.grpc.authority = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Multi mode">
-            <Switch checked={!!ob.stream.grpc.multiMode} onChange={(v) => { ob.stream.grpc.multiMode = v; refresh(); }} />
-          </Form.Item>
-        </>
-      )}
-
-      {ob.stream.network === 'httpupgrade' && (
-        <>
-          <Form.Item label={t('host')}>
-            <Input value={ob.stream.httpupgrade.host || ''} onChange={(e) => { ob.stream.httpupgrade.host = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label={t('path')}>
-            <Input value={ob.stream.httpupgrade.path || ''} onChange={(e) => { ob.stream.httpupgrade.path = e.target.value; refresh(); }} />
-          </Form.Item>
-        </>
-      )}
-
-      {ob.stream.network === 'xhttp' && <XhttpFields ob={ob} refresh={refresh} t={t} />}
-
-      {ob.stream.network === 'hysteria' && <HysteriaTransportFields ob={ob} refresh={refresh} />}
-    </>
-  );
-}
+                    {streamAllowed && network && (
+                      <Form.Item label={t('security')}>
+                        <Radio.Group
+                          value={security}
+                          buttonStyle="solid"
+                          onChange={(e) => onSecurityChange(e.target.value as string)}
+                        >
+                          <Radio.Button value="none">{t('none')}</Radio.Button>
+                          {tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
+                          {realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
+                        </Radio.Group>
+                      </Form.Item>
+                    )}
+
+                    {security === 'tls' && tlsAllowed && (
+                      <>
+                        <Form.Item
+                          label="SNI"
+                          name={['streamSettings', 'tlsSettings', 'serverName']}
+                        >
+                          <Input placeholder="server name" />
+                        </Form.Item>
+                        <Form.Item
+                          label="uTLS"
+                          name={['streamSettings', 'tlsSettings', 'fingerprint']}
+                        >
+                          <Select
+                            allowClear
+                            placeholder={t('none')}
+                            options={UTLS_OPTIONS}
+                          />
+                        </Form.Item>
+                        <Form.Item
+                          label="ALPN"
+                          name={['streamSettings', 'tlsSettings', 'alpn']}
+                        >
+                          <Select mode="multiple" options={ALPN_OPTIONS} />
+                        </Form.Item>
+                        <Form.Item
+                          label="ECH"
+                          name={['streamSettings', 'tlsSettings', 'echConfigList']}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item
+                          label="Verify peer name"
+                          name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
+                        >
+                          <Input placeholder="cloudflare-dns.com" />
+                        </Form.Item>
+                        <Form.Item
+                          label="Pinned SHA256"
+                          name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
+                        >
+                          <Input placeholder="base64 SHA256" />
+                        </Form.Item>
+                      </>
+                    )}
+
+                    {security === 'reality' && realityAllowed && (
+                      <>
+                        <Form.Item
+                          label="SNI"
+                          name={['streamSettings', 'realitySettings', 'serverName']}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item
+                          label="uTLS"
+                          name={['streamSettings', 'realitySettings', 'fingerprint']}
+                        >
+                          <Select options={UTLS_OPTIONS} />
+                        </Form.Item>
+                        <Form.Item
+                          label="Short ID"
+                          name={['streamSettings', 'realitySettings', 'shortId']}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item
+                          label="SpiderX"
+                          name={['streamSettings', 'realitySettings', 'spiderX']}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item
+                          label={t('pages.inbounds.publicKey')}
+                          name={['streamSettings', 'realitySettings', 'publicKey']}
+                        >
+                          <Input.TextArea autoSize={{ minRows: 2 }} />
+                        </Form.Item>
+                        <Form.Item
+                          label="mldsa65 verify"
+                          name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
+                        >
+                          <Input.TextArea autoSize={{ minRows: 2 }} />
+                        </Form.Item>
+                      </>
+                    )}
+
+                    {((streamAllowed && network) || !streamAllowed) && (
+                      <Form.Item shouldUpdate noStyle>
+                        {() => {
+                          const hasSockopt = !!form.getFieldValue([
+                            'streamSettings',
+                            'sockopt',
+                          ]);
+                          return (
+                            <>
+                              <Form.Item label="Sockopts">
+                                <Switch
+                                  checked={hasSockopt}
+                                  onChange={(checked) => {
+                                    form.setFieldValue(
+                                      ['streamSettings', 'sockopt'],
+                                      checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
+                                    );
+                                  }}
+                                />
+                              </Form.Item>
+                              {hasSockopt && (
+                                <>
+                                  <Form.Item
+                                    label="Dialer proxy"
+                                    name={['streamSettings', 'sockopt', 'dialerProxy']}
+                                  >
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Domain strategy"
+                                    name={['streamSettings', 'sockopt', 'domainStrategy']}
+                                  >
+                                    <Select
+                                      options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Address+port strategy"
+                                    name={['streamSettings', 'sockopt', 'addressPortStrategy']}
+                                  >
+                                    <Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Keep alive interval"
+                                    name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
+                                  >
+                                    <InputNumber min={0} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TCP Fast Open"
+                                    name={['streamSettings', 'sockopt', 'tcpFastOpen']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Multipath TCP"
+                                    name={['streamSettings', 'sockopt', 'tcpMptcp']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Penetrate"
+                                    name={['streamSettings', 'sockopt', 'penetrate']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Mark (fwmark)"
+                                    name={['streamSettings', 'sockopt', 'mark']}
+                                  >
+                                    <InputNumber min={0} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Interface"
+                                    name={['streamSettings', 'sockopt', 'interfaceName']}
+                                  >
+                                    <Input />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TProxy"
+                                    name={['streamSettings', 'sockopt', 'tproxy']}
+                                  >
+                                    <Select
+                                      options={[
+                                        { value: 'off', label: 'off' },
+                                        { value: 'redirect', label: 'redirect' },
+                                        { value: 'tproxy', label: 'tproxy' },
+                                      ]}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TCP congestion"
+                                    name={['streamSettings', 'sockopt', 'tcpcongestion']}
+                                  >
+                                    <Select
+                                      options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="IPv6 only"
+                                    name={['streamSettings', 'sockopt', 'V6Only']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Accept proxy protocol"
+                                    name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
+                                    valuePropName="checked"
+                                  >
+                                    <Switch />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TCP user timeout (ms)"
+                                    name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
+                                  >
+                                    <InputNumber min={0} style={{ width: '100%' }} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TCP keep-alive idle (s)"
+                                    name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
+                                  >
+                                    <InputNumber min={0} style={{ width: '100%' }} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TCP max segment"
+                                    name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
+                                  >
+                                    <InputNumber min={0} style={{ width: '100%' }} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="TCP window clamp"
+                                    name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
+                                  >
+                                    <InputNumber min={0} style={{ width: '100%' }} />
+                                  </Form.Item>
+                                  <Form.Item
+                                    label="Trusted X-Forwarded-For"
+                                    name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
+                                  >
+                                    <Select
+                                      mode="tags"
+                                      tokenSeparators={[',', ' ']}
+                                      placeholder="trusted-proxy.example,10.0.0.0/8"
+                                    />
+                                  </Form.Item>
+                                  <Form.Item shouldUpdate noStyle>
+                                    {() => {
+                                      const he = form.getFieldValue([
+                                        'streamSettings', 'sockopt', 'happyEyeballs',
+                                      ]);
+                                      const hasHe = he != null;
+                                      return (
+                                        <>
+                                          <Form.Item label="Happy Eyeballs">
+                                            <Switch
+                                              checked={hasHe}
+                                              onChange={(v) => {
+                                                form.setFieldValue(
+                                                  ['streamSettings', 'sockopt', 'happyEyeballs'],
+                                                  v ? HappyEyeballsSchema.parse({}) : undefined,
+                                                );
+                                              }}
+                                            />
+                                          </Form.Item>
+                                          {hasHe && (
+                                            <>
+                                              <Form.Item
+                                                label="Try delay (ms)"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
+                                              >
+                                                <InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
+                                              </Form.Item>
+                                              <Form.Item
+                                                label="Prioritize IPv6"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
+                                                valuePropName="checked"
+                                              >
+                                                <Switch />
+                                              </Form.Item>
+                                              <Form.Item
+                                                label="Interleave"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
+                                              >
+                                                <InputNumber min={1} style={{ width: '100%' }} />
+                                              </Form.Item>
+                                              <Form.Item
+                                                label="Max concurrent try"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
+                                              >
+                                                <InputNumber min={0} style={{ width: '100%' }} />
+                                              </Form.Item>
+                                            </>
+                                          )}
+                                        </>
+                                      );
+                                    }}
+                                  </Form.Item>
+                                  <Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
+                                    {(fields, { add, remove }) => (
+                                      <>
+                                        <Form.Item label="Custom sockopt">
+                                          <Button
+                                            type="dashed"
+                                            size="small"
+                                            onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
+                                          >
+                                            + Add custom option
+                                          </Button>
+                                        </Form.Item>
+                                        {fields.map((field) => (
+                                          <Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
+                                            <Form.Item name={[field.name, 'system']} noStyle>
+                                              <Select
+                                                placeholder="all"
+                                                allowClear
+                                                style={{ width: 100 }}
+                                                options={[
+                                                  { value: 'linux', label: 'linux' },
+                                                  { value: 'windows', label: 'windows' },
+                                                  { value: 'darwin', label: 'darwin' },
+                                                ]}
+                                              />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'type']} noStyle>
+                                              <Select
+                                                style={{ width: 80 }}
+                                                options={[
+                                                  { value: 'int', label: 'int' },
+                                                  { value: 'str', label: 'str' },
+                                                ]}
+                                              />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'level']} noStyle>
+                                              <Input placeholder="level (6=TCP)" style={{ width: 100 }} />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'opt']} noStyle>
+                                              <Input placeholder="opt (decimal)" style={{ width: 120 }} />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'value']} noStyle>
+                                              <Input placeholder="value" style={{ flex: 1 }} />
+                                            </Form.Item>
+                                            <Button danger onClick={() => remove(field.name)}>−</Button>
+                                          </Space.Compact>
+                                        ))}
+                                      </>
+                                    )}
+                                  </Form.List>
+                                </>
+                              )}
+                            </>
+                          );
+                        }}
+                      </Form.Item>
+                    )}
+
+                    <FinalMaskForm
+                      name={['streamSettings', 'finalmask']}
+                      network={network}
+                      protocol={protocol}
+                      form={form}
+                    />
 
-function XhttpFields({ ob, refresh, t }: TFieldProps) {
-  const xh = ob.stream.xhttp;
-  return (
-    <>
-      <Form.Item label={t('host')}>
-        <Input value={xh.host || ''} onChange={(e) => { xh.host = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label={t('path')}>
-        <Input value={xh.path || ''} onChange={(e) => { xh.path = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}>
-        <Button size="small" icon={<PlusOutlined />} onClick={() => { xh.addHeader('', ''); refresh(); }} />
-      </Form.Item>
-      <Form.Item wrapperCol={{ span: 24 }}>
-        {(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => (
-          <Space.Compact key={idx} block className="mb-8">
-            <InputAddon>{`${idx + 1}`}</InputAddon>
-            <Input
-              value={header.name}
-              placeholder="Name"
-              onChange={(e) => { header.name = e.target.value; refresh(); }}
-            />
-            <Input
-              value={header.value}
-              placeholder="Value"
-              onChange={(e) => { header.value = e.target.value; refresh(); }}
-            />
-            <Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} />
-          </Space.Compact>
-        ))}
-      </Form.Item>
-
-      <Form.Item label="Mode">
-        <Select
-          value={xh.mode}
-          onChange={(v) => { xh.mode = v; refresh(); }}
-          options={Object.values(MODE_OPTION).map((m) => ({ value: m as string, label: m as string }))}
-        />
-      </Form.Item>
-      {xh.mode === 'packet-up' && (
-        <>
-          <Form.Item label="Max Upload Size (Byte)">
-            <Input value={xh.scMaxEachPostBytes} onChange={(e) => { xh.scMaxEachPostBytes = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Min Upload Interval (Ms)">
-            <Input value={xh.scMinPostsIntervalMs} onChange={(e) => { xh.scMinPostsIntervalMs = e.target.value; refresh(); }} />
-          </Form.Item>
-        </>
-      )}
-
-      <Form.Item label="Padding Bytes">
-        <Input value={xh.xPaddingBytes} onChange={(e) => { xh.xPaddingBytes = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Padding Obfs Mode">
-        <Switch checked={!!xh.xPaddingObfsMode} onChange={(v) => { xh.xPaddingObfsMode = v; refresh(); }} />
-      </Form.Item>
-      {xh.xPaddingObfsMode && (
-        <>
-          <Form.Item label="Padding Key">
-            <Input value={xh.xPaddingKey} placeholder="x_padding" onChange={(e) => { xh.xPaddingKey = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Padding Header">
-            <Input value={xh.xPaddingHeader} placeholder="X-Padding" onChange={(e) => { xh.xPaddingHeader = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Padding Placement">
-            <Select
-              value={xh.xPaddingPlacement || ''}
-              onChange={(v) => { xh.xPaddingPlacement = v; refresh(); }}
-              options={[
-                { value: '', label: 'Default (queryInHeader)' },
-                { value: 'queryInHeader', label: 'queryInHeader' },
-                { value: 'header', label: 'header' },
-                { value: 'cookie', label: 'cookie' },
-                { value: 'query', label: 'query' },
-              ]}
-            />
-          </Form.Item>
-          <Form.Item label="Padding Method">
-            <Select
-              value={xh.xPaddingMethod || ''}
-              onChange={(v) => { xh.xPaddingMethod = v; refresh(); }}
-              options={[
-                { value: '', label: 'Default (repeat-x)' },
-                { value: 'repeat-x', label: 'repeat-x' },
-                { value: 'tokenish', label: 'tokenish' },
-              ]}
-            />
-          </Form.Item>
-        </>
-      )}
-
-      <Form.Item label="Uplink HTTP Method">
-        <Select
-          value={xh.uplinkHTTPMethod || ''}
-          onChange={(v) => { xh.uplinkHTTPMethod = v; refresh(); }}
-          options={[
-            { value: '', label: 'Default (POST)' },
-            { value: 'POST', label: 'POST' },
-            { value: 'PUT', label: 'PUT' },
-            { value: 'GET', label: 'GET (packet-up only)', disabled: xh.mode !== 'packet-up' },
-          ]}
-        />
-      </Form.Item>
-
-      <Form.Item label="Session Placement">
-        <Select
-          value={xh.sessionPlacement || ''}
-          onChange={(v) => { xh.sessionPlacement = v; refresh(); }}
-          options={[
-            { value: '', label: 'Default (path)' },
-            { value: 'path', label: 'path' },
-            { value: 'header', label: 'header' },
-            { value: 'cookie', label: 'cookie' },
-            { value: 'query', label: 'query' },
-          ]}
-        />
-      </Form.Item>
-      {xh.sessionPlacement && xh.sessionPlacement !== 'path' && (
-        <Form.Item label="Session Key">
-          <Input value={xh.sessionKey} placeholder="x_session" onChange={(e) => { xh.sessionKey = e.target.value; refresh(); }} />
-        </Form.Item>
-      )}
-
-      <Form.Item label="Sequence Placement">
-        <Select
-          value={xh.seqPlacement || ''}
-          onChange={(v) => { xh.seqPlacement = v; refresh(); }}
-          options={[
-            { value: '', label: 'Default (path)' },
-            { value: 'path', label: 'path' },
-            { value: 'header', label: 'header' },
-            { value: 'cookie', label: 'cookie' },
-            { value: 'query', label: 'query' },
-          ]}
-        />
-      </Form.Item>
-      {xh.seqPlacement && xh.seqPlacement !== 'path' && (
-        <Form.Item label="Sequence Key">
-          <Input value={xh.seqKey} placeholder="x_seq" onChange={(e) => { xh.seqKey = e.target.value; refresh(); }} />
-        </Form.Item>
-      )}
-
-      {xh.mode === 'packet-up' && (
-        <Form.Item label="Uplink Data Placement">
-          <Select
-            value={xh.uplinkDataPlacement || ''}
-            onChange={(v) => { xh.uplinkDataPlacement = v; refresh(); }}
-            options={[
-              { value: '', label: 'Default (body)' },
-              { value: 'body', label: 'body' },
-              { value: 'header', label: 'header' },
-              { value: 'cookie', label: 'cookie' },
-              { value: 'query', label: 'query' },
+                    {(() => {
+                      const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
+                      if (!isMuxAllowed(protocol, flow, network)) return null;
+                      return (
+                        <Form.Item shouldUpdate noStyle>
+                          {() => {
+                            const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
+                            return (
+                              <>
+                                <Form.Item
+                                  label={t('pages.settings.mux')}
+                                  name={['mux', 'enabled']}
+                                  valuePropName="checked"
+                                >
+                                  <Switch />
+                                </Form.Item>
+                                {muxEnabled && (
+                                  <>
+                                    <Form.Item
+                                      label="Concurrency"
+                                      name={['mux', 'concurrency']}
+                                    >
+                                      <InputNumber min={-1} max={1024} />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="xudp concurrency"
+                                      name={['mux', 'xudpConcurrency']}
+                                    >
+                                      <InputNumber min={-1} max={1024} />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="xudp UDP 443"
+                                      name={['mux', 'xudpProxyUDP443']}
+                                    >
+                                      <Select
+                                        options={['reject', 'allow', 'skip'].map((v) => ({
+                                          value: v,
+                                          label: v,
+                                        }))}
+                                      />
+                                    </Form.Item>
+                                  </>
+                                )}
+                              </>
+                            );
+                          }}
+                        </Form.Item>
+                      );
+                    })()}
+                  </>
+                ),
+              },
+              {
+                key: '2',
+                label: 'JSON',
+                children: (
+                  <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
+                    <Input.Search
+                      value={linkInput}
+                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
+                      enterButton="Import"
+                      onChange={(e) => setLinkInput(e.target.value)}
+                      onSearch={importLink}
+                    />
+                    <JsonEditor
+                      value={jsonText}
+                      onChange={(next) => {
+                        setJsonText(next);
+                        setJsonDirty(true);
+                      }}
+                      minHeight="360px"
+                      maxHeight="600px"
+                    />
+                  </Space>
+                ),
+              },
             ]}
           />
-        </Form.Item>
-      )}
-      {xh.mode === 'packet-up' && xh.uplinkDataPlacement && xh.uplinkDataPlacement !== 'body' && (
-        <>
-          <Form.Item label="Uplink Data Key">
-            <Input value={xh.uplinkDataKey} placeholder="x_data" onChange={(e) => { xh.uplinkDataKey = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Uplink Chunk Size">
-            <InputNumber
-              value={xh.uplinkChunkSize}
-              min={0}
-              placeholder="0 (unlimited)"
-              onChange={(v) => { xh.uplinkChunkSize = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-        </>
-      )}
-
-      {(xh.mode === 'stream-up' || xh.mode === 'stream-one') && (
-        <Form.Item label="No gRPC Header">
-          <Switch checked={!!xh.noGRPCHeader} onChange={(v) => { xh.noGRPCHeader = v; refresh(); }} />
-        </Form.Item>
-      )}
-
-      <Form.Item label="XMUX">
-        <Switch checked={!!xh.enableXmux} onChange={(v) => { xh.enableXmux = v; refresh(); }} />
-      </Form.Item>
-      {xh.enableXmux && (
-        <>
-          {!xh.xmux.maxConnections && (
-            <Form.Item label="Max Concurrency">
-              <Input value={xh.xmux.maxConcurrency} onChange={(e) => { xh.xmux.maxConcurrency = e.target.value; refresh(); }} />
-            </Form.Item>
-          )}
-          {!xh.xmux.maxConcurrency && (
-            <Form.Item label="Max Connections">
-              <Input value={xh.xmux.maxConnections} onChange={(e) => { xh.xmux.maxConnections = e.target.value; refresh(); }} />
-            </Form.Item>
-          )}
-          <Form.Item label="Max Reuse Times">
-            <Input value={xh.xmux.cMaxReuseTimes} onChange={(e) => { xh.xmux.cMaxReuseTimes = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Max Request Times">
-            <Input value={xh.xmux.hMaxRequestTimes} onChange={(e) => { xh.xmux.hMaxRequestTimes = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Max Reusable Secs">
-            <Input value={xh.xmux.hMaxReusableSecs} onChange={(e) => { xh.xmux.hMaxReusableSecs = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Keep Alive Period">
-            <InputNumber
-              value={xh.xmux.hKeepAlivePeriod}
-              min={0}
-              onChange={(v) => { xh.xmux.hKeepAlivePeriod = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-        </>
-      )}
-    </>
-  );
-}
-
-function HysteriaTransportFields({ ob, refresh }: FieldProps) {
-  const h = ob.stream.hysteria;
-  return (
-    <>
-      <Form.Item label="Auth password">
-        <Input value={h.auth || ''} onChange={(e) => { h.auth = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Congestion">
-        <Select
-          value={h.congestion || ''}
-          onChange={(v) => { h.congestion = v; refresh(); }}
-          options={[
-            { value: '', label: 'BBR (auto)' },
-            { value: 'brutal', label: 'Brutal' },
-          ]}
-        />
-      </Form.Item>
-      <Form.Item label="Upload">
-        <Input value={h.up} placeholder="100 mbps" onChange={(e) => { h.up = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Download">
-        <Input value={h.down} placeholder="100 mbps" onChange={(e) => { h.down = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="UDP hop port">
-        <Input value={h.udphopPort} placeholder="1145-1919" onChange={(e) => { h.udphopPort = e.target.value; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Max idle (s)">
-        <InputNumber value={h.maxIdleTimeout} min={4} max={120} onChange={(v) => { h.maxIdleTimeout = Number(v) || 0; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Keep alive (s)">
-        <InputNumber value={h.keepAlivePeriod} min={2} max={60} onChange={(v) => { h.keepAlivePeriod = Number(v) || 0; refresh(); }} />
-      </Form.Item>
-      <Form.Item label="Disable Path MTU">
-        <Switch checked={!!h.disablePathMTUDiscovery} onChange={(v) => { h.disablePathMTUDiscovery = v; refresh(); }} />
-      </Form.Item>
-    </>
-  );
-}
-
-function TlsFields({ ob, refresh, t }: TFieldProps) {
-  return (
-    <>
-      <Form.Item label={t('security')}>
-        <Radio.Group
-          value={ob.stream.security}
-          buttonStyle="solid"
-          onChange={(e) => { ob.stream.security = e.target.value; refresh(); }}
-        >
-          <Radio.Button value="none">{t('none')}</Radio.Button>
-          <Radio.Button value="tls">TLS</Radio.Button>
-          {ob.canEnableReality() && <Radio.Button value="reality">Reality</Radio.Button>}
-        </Radio.Group>
-      </Form.Item>
-
-      {ob.stream.isTls && (
-        <>
-          <Form.Item label="SNI">
-            <Input value={ob.stream.tls.serverName} placeholder="server name" onChange={(e) => { ob.stream.tls.serverName = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="uTLS">
-            <Select
-              value={ob.stream.tls.fingerprint || ''}
-              onChange={(v) => { ob.stream.tls.fingerprint = v; refresh(); }}
-              options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS.map((k) => ({ value: k, label: k }))]}
-            />
-          </Form.Item>
-          <Form.Item label="ALPN">
-            <Select
-              mode="multiple"
-              value={ob.stream.tls.alpn || []}
-              onChange={(v) => { ob.stream.tls.alpn = v; refresh(); }}
-              options={ALPN_OPTIONS.map((alpn) => ({ value: alpn, label: alpn }))}
-            />
-          </Form.Item>
-          <Form.Item label="ECH">
-            <Input value={ob.stream.tls.echConfigList} onChange={(e) => { ob.stream.tls.echConfigList = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Verify peer name">
-            <Input value={ob.stream.tls.verifyPeerCertByName} placeholder="cloudflare-dns.com" onChange={(e) => { ob.stream.tls.verifyPeerCertByName = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Pinned SHA256">
-            <Input value={ob.stream.tls.pinnedPeerCertSha256} placeholder="base64 SHA256" onChange={(e) => { ob.stream.tls.pinnedPeerCertSha256 = e.target.value; refresh(); }} />
-          </Form.Item>
-        </>
-      )}
-
-      {ob.stream.isReality && (
-        <>
-          <Form.Item label="SNI">
-            <Input value={ob.stream.reality.serverName} onChange={(e) => { ob.stream.reality.serverName = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="uTLS">
-            <Select
-              value={ob.stream.reality.fingerprint}
-              onChange={(v) => { ob.stream.reality.fingerprint = v; refresh(); }}
-              options={UTLS_OPTIONS.map((k) => ({ value: k, label: k }))}
-            />
-          </Form.Item>
-          <Form.Item label="Short ID">
-            <Input value={ob.stream.reality.shortId} onChange={(e) => { ob.stream.reality.shortId = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="SpiderX">
-            <Input value={ob.stream.reality.spiderX} onChange={(e) => { ob.stream.reality.spiderX = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label={t('pages.inbounds.publicKey')}>
-            <Input.TextArea
-              value={ob.stream.reality.publicKey}
-              autoSize={{ minRows: 2 }}
-              onChange={(e) => { ob.stream.reality.publicKey = e.target.value; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="mldsa65 verify">
-            <Input.TextArea
-              value={ob.stream.reality.mldsa65Verify}
-              autoSize={{ minRows: 2 }}
-              onChange={(e) => { ob.stream.reality.mldsa65Verify = e.target.value; refresh(); }}
-            />
-          </Form.Item>
-        </>
-      )}
-    </>
-  );
-}
-
-function SockoptFields({ ob, refresh }: FieldProps) {
-  return (
-    <>
-      <Form.Item label="Sockopts">
-        <Switch checked={!!ob.stream.sockoptSwitch} onChange={(v) => { ob.stream.sockoptSwitch = v; refresh(); }} />
-      </Form.Item>
-      {ob.stream.sockoptSwitch && (
-        <>
-          <Form.Item label="Dialer proxy">
-            <Input value={ob.stream.sockopt.dialerProxy || ''} onChange={(e) => { ob.stream.sockopt.dialerProxy = e.target.value; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Address+Port strategy">
-            <Select
-              value={ob.stream.sockopt.addressPortStrategy}
-              onChange={(v) => { ob.stream.sockopt.addressPortStrategy = v; refresh(); }}
-              options={Object.values(Address_Port_Strategy).map((k) => ({ value: k as string, label: k as string }))}
-            />
-          </Form.Item>
-          <Form.Item label="Keep alive interval">
-            <InputNumber
-              value={ob.stream.sockopt.tcpKeepAliveInterval}
-              min={0}
-              onChange={(v) => { ob.stream.sockopt.tcpKeepAliveInterval = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="TCP Fast Open">
-            <Switch checked={!!ob.stream.sockopt.tcpFastOpen} onChange={(v) => { ob.stream.sockopt.tcpFastOpen = v; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Multipath TCP">
-            <Switch checked={!!ob.stream.sockopt.tcpMptcp} onChange={(v) => { ob.stream.sockopt.tcpMptcp = v; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Penetrate">
-            <Switch checked={!!ob.stream.sockopt.penetrate} onChange={(v) => { ob.stream.sockopt.penetrate = v; refresh(); }} />
-          </Form.Item>
-          <Form.Item label="Mark (fwmark)">
-            <InputNumber
-              value={ob.stream.sockopt.mark}
-              min={0}
-              onChange={(v) => { ob.stream.sockopt.mark = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="Interface">
-            <Input value={ob.stream.sockopt.interfaceName} onChange={(e) => { ob.stream.sockopt.interfaceName = e.target.value; refresh(); }} />
-          </Form.Item>
-        </>
-      )}
-    </>
-  );
-}
-
-function MuxFields({ ob, refresh, t }: TFieldProps) {
-  return (
-    <>
-      <Form.Item label={t('pages.settings.mux')}>
-        <Switch checked={!!ob.mux.enabled} onChange={(v) => { ob.mux.enabled = v; refresh(); }} />
-      </Form.Item>
-      {ob.mux.enabled && (
-        <>
-          <Form.Item label="Concurrency">
-            <InputNumber
-              value={ob.mux.concurrency}
-              min={-1}
-              max={1024}
-              onChange={(v) => { ob.mux.concurrency = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="xudp concurrency">
-            <InputNumber
-              value={ob.mux.xudpConcurrency}
-              min={-1}
-              max={1024}
-              onChange={(v) => { ob.mux.xudpConcurrency = Number(v) || 0; refresh(); }}
-            />
-          </Form.Item>
-          <Form.Item label="xudp UDP 443">
-            <Select
-              value={ob.mux.xudpProxyUDP443}
-              onChange={(v) => { ob.mux.xudpProxyUDP443 = v; refresh(); }}
-              options={['reject', 'allow', 'skip'].map((x) => ({ value: x, label: x }))}
-            />
-          </Form.Item>
-        </>
-      )}
+        </Form>
+      </Modal>
     </>
   );
 }

+ 2 - 2
frontend/src/pages/xray/OutboundsTab.tsx

@@ -34,7 +34,7 @@ import {
 import type { ColumnsType } from 'antd/es/table';
 
 import { SizeFormatter } from '@/utils';
-import { Protocols } from '@/models/outbound';
+import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import OutboundFormModal from './OutboundFormModal';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
@@ -130,7 +130,7 @@ export default function OutboundsTab({
   const [existingTags, setExistingTags] = useState<string[]>([]);
 
   const outbounds = useMemo(
-    () => (templateSettings?.outbounds || []) as OutboundRow[],
+    () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
     [templateSettings?.outbounds],
   );
 

+ 4 - 2
frontend/src/pages/xray/RoutingTab.tsx

@@ -17,6 +17,7 @@ import type { ColumnsType } from 'antd/es/table';
 import RuleFormModal from './RuleFormModal';
 import type { RoutingRule } from './RuleFormModal';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
+import type { RuleObject } from '@/schemas/routing';
 import './RoutingTab.css';
 
 interface RoutingTabProps {
@@ -182,8 +183,9 @@ export default function RoutingTab({
     mutate((tt) => {
       if (!tt.routing) tt.routing = { rules: [] };
       if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
-      if (editingIndex == null) tt.routing.rules.push(rule);
-      else tt.routing.rules[editingIndex] = rule;
+      const typed = rule as unknown as RuleObject;
+      if (editingIndex == null) tt.routing.rules.push(typed);
+      else tt.routing.rules[editingIndex] = typed;
     });
     setRuleModalOpen(false);
   }

+ 18 - 28
frontend/src/pages/xray/RuleFormModal.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import InputAddon from '@/components/InputAddon';
+import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
 
 export interface RoutingRule {
   type?: string;
@@ -32,21 +33,7 @@ interface RuleFormModalProps {
   onConfirm: (rule: Record<string, unknown>) => void;
 }
 
-interface FormState {
-  domain: string;
-  ip: string;
-  port: string;
-  sourcePort: string;
-  vlessRoute: string;
-  network: string;
-  sourceIP: string;
-  user: string;
-  inboundTag: string[];
-  protocol: string[];
-  attrs: [string, string][];
-  outboundTag: string;
-  balancerTag: string;
-}
+type FormState = RuleFormValues;
 
 const initialForm = (): FormState => ({
   domain: '',
@@ -112,21 +99,24 @@ export default function RuleFormModal({
     setForm((prev) => ({ ...prev, [key]: value }));
 
   function submit() {
+    const validated = RuleFormSchema.safeParse(form);
+    if (!validated.success) return;
+    const v = validated.data;
     const built: Record<string, unknown> = {
       type: 'field',
-      domain: csv(form.domain),
-      ip: csv(form.ip),
-      port: form.port,
-      sourcePort: form.sourcePort,
-      vlessRoute: form.vlessRoute,
-      network: form.network,
-      sourceIP: csv(form.sourceIP),
-      user: csv(form.user),
-      inboundTag: form.inboundTag,
-      protocol: form.protocol,
-      attrs: Object.fromEntries(form.attrs.filter(([k]) => k)),
-      outboundTag: form.outboundTag === '' ? undefined : form.outboundTag,
-      balancerTag: form.balancerTag === '' ? undefined : form.balancerTag,
+      domain: csv(v.domain),
+      ip: csv(v.ip),
+      port: v.port,
+      sourcePort: v.sourcePort,
+      vlessRoute: v.vlessRoute,
+      network: v.network,
+      sourceIP: csv(v.sourceIP),
+      user: csv(v.user),
+      inboundTag: v.inboundTag,
+      protocol: v.protocol,
+      attrs: Object.fromEntries(v.attrs.filter(([k]) => k)),
+      outboundTag: v.outboundTag === '' ? undefined : v.outboundTag,
+      balancerTag: v.balancerTag === '' ? undefined : v.balancerTag,
     };
     const out: Record<string, unknown> = {};
     for (const [k, v] of Object.entries(built)) {

+ 7 - 7
frontend/src/pages/xray/WarpModal.tsx

@@ -108,7 +108,7 @@ export default function WarpModal({
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/data');
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/data');
       if (msg?.success) {
         const raw = msg.obj;
         setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
@@ -130,8 +130,8 @@ export default function WarpModal({
     setLoading(true);
     try {
       const keys = Wireguard.generateKeypair();
-      const msg = await HttpUtil.post('/panel/xray/warp/reg', keys);
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys);
+      if (msg?.success && msg.obj) {
         const resp = JSON.parse(msg.obj);
         setWarpData(resp.data);
         setWarpConfig(resp.config);
@@ -145,8 +145,8 @@ export default function WarpModal({
   async function getConfig() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/config');
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/config');
+      if (msg?.success && msg.obj) {
         const parsed = JSON.parse(msg.obj);
         setWarpConfig(parsed);
         collectConfig(warpData, parsed);
@@ -161,8 +161,8 @@ export default function WarpModal({
     setLoading(true);
     setLicenseError('');
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus });
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus });
+      if (msg?.success && msg.obj) {
         setWarpData(JSON.parse(msg.obj));
         setWarpConfig(null);
         setWarpPlus('');

+ 10 - 0
frontend/src/schemas/_envelope.ts

@@ -0,0 +1,10 @@
+import { z } from 'zod';
+
+export const msgSchema = <T extends z.ZodType>(obj: T) =>
+  z.object({
+    success: z.boolean(),
+    msg: z.string().default(''),
+    obj: obj.nullable(),
+  });
+
+export type MsgOf<S extends z.ZodType> = z.infer<ReturnType<typeof msgSchema<S>>>;

+ 64 - 0
frontend/src/schemas/api/inbound.ts

@@ -0,0 +1,64 @@
+import { z } from 'zod';
+
+import { PortSchema, SniffingSchema } from '@/schemas/primitives';
+import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
+import { SecuritySettingsSchema } from '@/schemas/protocols/security';
+import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
+
+// Top-level inbound shape on the wire. Composes:
+//   - Per-protocol settings via the InboundSettingsSchema discriminated
+//     union (10 protocols, tagged-wrapper {protocol, settings}).
+//   - StreamSettings as an intersection of the network DU (6 branches),
+//     security DU (3 branches), and the orthogonal extras (finalmask,
+//     sockopt, externalProxy). Zod 4 supports DU intersection — each
+//     branch validates its slice of the same input object.
+//
+// The id/up/down/total/expiryTime fields are int64 on the Go side but
+// the panel ships them as JS numbers. Numbers above Number.MAX_SAFE_INTEGER
+// (~9e15) lose precision; the panel works around this for the traffic
+// counters by stringifying them at the API edge. Not modeled here.
+
+export const StreamSettingsSchema = NetworkSettingsSchema
+  .and(SecuritySettingsSchema)
+  .and(StreamExtrasSchema);
+export type StreamSettings = z.infer<typeof StreamSettingsSchema>;
+
+export const InboundCoreSchema = z.object({
+  id: z.number().int().optional(),
+  up: z.number().int().min(0).default(0),
+  down: z.number().int().min(0).default(0),
+  total: z.number().int().min(0).default(0),
+  remark: z.string().default(''),
+  enable: z.boolean().default(true),
+  expiryTime: z.number().int().default(0),
+  listen: z.string().default(''),
+  port: PortSchema,
+  tag: z.string().default(''),
+  sniffing: SniffingSchema.default({
+    enabled: false,
+    destOverride: ['http', 'tls', 'quic', 'fakedns'],
+    metadataOnly: false,
+    routeOnly: false,
+    ipsExcluded: [],
+    domainsExcluded: [],
+  }),
+  streamSettings: StreamSettingsSchema.optional(),
+  clientStats: z.string().optional(),
+});
+export type InboundCore = z.infer<typeof InboundCoreSchema>;
+
+// Full Inbound = core fields + the protocol/settings discriminated union.
+// Consumers narrow on `.protocol` to access the matching `.settings`
+// branch with full type safety.
+export const InboundSchema = InboundCoreSchema.and(InboundSettingsSchema);
+export type Inbound = z.infer<typeof InboundSchema>;
+
+// SlimInbound is the list-view projection — same shape minus settings
+// and streamSettings (the list endpoint omits both to keep payload
+// small). Used by InboundsPage list rendering.
+export const SlimInboundSchema = InboundCoreSchema.omit({
+  streamSettings: true,
+}).extend({
+  protocol: z.string(),
+});
+export type SlimInbound = z.infer<typeof SlimInboundSchema>;

+ 158 - 0
frontend/src/schemas/client.ts

@@ -0,0 +1,158 @@
+import { z } from 'zod';
+
+const nullableStringArray = z.array(z.string()).nullable().transform((v) => v ?? []);
+const nullableNumberArray = z.array(z.number()).nullable().transform((v) => v ?? []);
+
+export const ClientTrafficSchema = z.object({
+  up: z.number().optional(),
+  down: z.number().optional(),
+  total: z.number().optional(),
+  expiryTime: z.number().optional(),
+  enable: z.boolean().optional(),
+  lastOnline: z.number().optional(),
+});
+
+export const ClientRecordSchema = z.object({
+  id: z.number().optional(),
+  email: z.string(),
+  subId: z.string().optional(),
+  uuid: z.string().optional(),
+  password: z.string().optional(),
+  auth: z.string().optional(),
+  flow: z.string().optional(),
+  security: z.string().optional(),
+  totalGB: z.number().optional(),
+  expiryTime: z.number().optional(),
+  limitIp: z.number().optional(),
+  tgId: z.union([z.number(), z.string()]).optional(),
+  comment: z.string().optional(),
+  enable: z.boolean().optional(),
+  reset: z.number().optional(),
+  inboundIds: nullableNumberArray.optional(),
+  traffic: ClientTrafficSchema.nullable().optional(),
+  reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
+  createdAt: z.number().optional(),
+  updatedAt: z.number().optional(),
+}).loose();
+
+export const InboundOptionSchema = z.object({
+  id: z.number(),
+  remark: z.string().optional(),
+  protocol: z.string().optional(),
+  port: z.number().optional(),
+  tlsFlowCapable: z.boolean().optional(),
+}).loose();
+
+export const InboundOptionsSchema = z.array(InboundOptionSchema);
+
+export const ClientsSummarySchema = z.object({
+  total: z.number(),
+  active: z.number(),
+  online: nullableStringArray,
+  depleted: nullableStringArray,
+  expiring: nullableStringArray,
+  deactive: nullableStringArray,
+});
+
+const nullableClientArray = z.array(ClientRecordSchema).nullable().transform((v) => v ?? []);
+
+export const ClientPageResponseSchema = z.object({
+  items: nullableClientArray,
+  total: z.number(),
+  filtered: z.number(),
+  page: z.number(),
+  pageSize: z.number(),
+  summary: ClientsSummarySchema.nullable().optional(),
+});
+
+export const ClientHydrateSchema = z.object({
+  client: ClientRecordSchema,
+  inboundIds: nullableNumberArray,
+});
+
+export const BulkAdjustResultSchema = z.object({
+  adjusted: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
+export const BulkDeleteResultSchema = z.object({
+  deleted: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
+export const BulkCreateResultSchema = z.object({
+  created: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
+export const DelDepletedResultSchema = z.object({
+  deleted: z.number().optional(),
+});
+
+export const OnlinesSchema = nullableStringArray;
+
+export const ClientFormSchema = z.object({
+  email: z.string().trim().min(1, 'pages.clients.email'),
+  subId: z.string(),
+  uuid: z.string(),
+  password: z.string(),
+  auth: z.string(),
+  flow: z.string(),
+  reverseTag: z.string(),
+  totalGB: z.number().min(0),
+  delayedStart: z.boolean(),
+  delayedDays: z.number().int().min(0),
+  limitIp: z.number().int().min(0),
+  tgId: z.number().int().min(0),
+  comment: z.string(),
+  enable: z.boolean(),
+  inboundIds: z.array(z.number()),
+});
+
+export const ClientCreateFormSchema = ClientFormSchema.extend({
+  inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
+});
+
+export const ClientBulkAdjustFormSchema = z
+  .object({
+    addDays: z.number().int(),
+    addGB: z.number(),
+  })
+  .refine((v) => v.addDays !== 0 || v.addGB !== 0, {
+    message: 'pages.clients.bulkAdjustNothing',
+  });
+
+export const ClientBulkAddFormSchema = z.object({
+  emailMethod: z.number().int().min(0).max(4),
+  firstNum: z.number().int().min(1),
+  lastNum: z.number().int().min(1),
+  emailPrefix: z.string(),
+  emailPostfix: z.string(),
+  quantity: z.number().int().min(1).max(100),
+  subId: z.string(),
+  comment: z.string(),
+  flow: z.string(),
+  limitIp: z.number().int().min(0),
+  totalGB: z.number().min(0),
+  expiryTime: z.number(),
+  inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
+});
+
+export type ClientRecord = z.infer<typeof ClientRecordSchema>;
+export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
+export type InboundOption = z.infer<typeof InboundOptionSchema>;
+export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
+export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
+export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
+export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
+export type BulkDeleteResult = z.infer<typeof BulkDeleteResultSchema>;
+export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
+export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
+export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
+export type ClientFormValues = z.infer<typeof ClientFormSchema>;

+ 20 - 0
frontend/src/schemas/defaults.ts

@@ -0,0 +1,20 @@
+import { z } from 'zod';
+
+export const DefaultsPayloadSchema = z.object({
+  expireDiff: z.number().optional(),
+  trafficDiff: z.number().optional(),
+  tgBotEnable: z.boolean().optional(),
+  subEnable: z.boolean().optional(),
+  subTitle: z.string().optional(),
+  subURI: z.string().optional(),
+  subJsonURI: z.string().optional(),
+  subJsonEnable: z.boolean().optional(),
+  subClashURI: z.string().optional(),
+  subClashEnable: z.boolean().optional(),
+  pageSize: z.number().optional(),
+  remarkModel: z.string().optional(),
+  datepicker: z.enum(['gregorian', 'jalalian']).optional(),
+  ipLimitEnable: z.boolean().optional(),
+}).loose();
+
+export type DefaultsPayload = z.infer<typeof DefaultsPayloadSchema>;

+ 64 - 0
frontend/src/schemas/dns.ts

@@ -0,0 +1,64 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+export const DnsQueryStrategySchema = z.enum([
+  'UseIP',
+  'UseIPv4',
+  'UseIPv6',
+  'UseSystem',
+]);
+export type DnsQueryStrategy = z.infer<typeof DnsQueryStrategySchema>;
+
+const DnsHostValueSchema = z.union([z.string(), z.array(z.string())]);
+export const DnsHostsSchema = z.record(z.string(), DnsHostValueSchema);
+export type DnsHosts = z.infer<typeof DnsHostsSchema>;
+
+export const DnsServerObjectInnerSchema = z.object({
+  address: z.string(),
+  port: PortSchema.default(53),
+  domains: z.array(z.string()).optional(),
+  expectedIPs: z.array(z.string()).optional(),
+  unexpectedIPs: z.array(z.string()).optional(),
+  skipFallback: z.boolean().optional(),
+  finalQuery: z.boolean().optional(),
+  tag: z.string().optional(),
+  clientIP: z.string().optional(),
+  queryStrategy: DnsQueryStrategySchema.optional(),
+  disableCache: z.boolean().optional(),
+  timeoutMs: z.number().int().min(0).default(4000),
+  serveStale: z.boolean().optional(),
+  serveExpiredTTL: z.number().int().min(0).optional(),
+});
+
+export const DnsServerObjectSchema = z.preprocess(
+  (val) => {
+    if (typeof val !== 'object' || val === null || Array.isArray(val)) return val;
+    const v = val as Record<string, unknown>;
+    if (v.expectIPs && !v.expectedIPs) {
+      return { ...v, expectedIPs: v.expectIPs };
+    }
+    return val;
+  },
+  DnsServerObjectInnerSchema,
+);
+export type DnsServerObject = z.infer<typeof DnsServerObjectSchema>;
+
+export const DnsServerEntrySchema = z.union([z.string(), DnsServerObjectSchema]);
+export type DnsServerEntry = z.infer<typeof DnsServerEntrySchema>;
+
+export const DnsObjectSchema = z.object({
+  tag: z.string().optional(),
+  hosts: DnsHostsSchema.optional(),
+  servers: z.array(DnsServerEntrySchema).optional(),
+  clientIp: z.string().optional(),
+  queryStrategy: DnsQueryStrategySchema.default('UseIP'),
+  disableCache: z.boolean().default(false),
+  disableFallback: z.boolean().default(false),
+  disableFallbackIfMatch: z.boolean().default(false),
+  enableParallelQuery: z.boolean().default(false),
+  useSystemHosts: z.boolean().default(false),
+  serveStale: z.boolean().default(false),
+  serveExpiredTTL: z.number().int().min(0).default(0),
+});
+export type DnsObject = z.infer<typeof DnsObjectSchema>;

+ 83 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -0,0 +1,83 @@
+import { z } from 'zod';
+
+import { PortSchema, SniffingSchema } from '@/schemas/primitives';
+import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
+import { SecuritySettingsSchema } from '@/schemas/protocols/security';
+import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
+
+// InboundFormValues = the values shape Form.useForm<T>() carries in
+// InboundFormModal. Mirrors the wire shape (so submission can hand
+// values straight to Schema.parse + POST) plus the DB-side fields that
+// the panel's /panel/api/inbounds/add endpoint expects alongside.
+//
+// Differences from schemas/api/inbound.ts InboundSchema:
+//   - settings/streamSettings/sniffing are nested OBJECTS here, not the
+//     JSON strings the endpoint accepts. The form holds typed data; the
+//     submit handler stringifies right before POSTing.
+//   - Adds DB fields not in InboundSchema: up, down, total, trafficReset,
+//     lastTrafficResetTime, nodeId. These flow through the DBInbound row,
+//     not the xray-config slice.
+
+export const InboundStreamFormSchema = NetworkSettingsSchema
+  .and(SecuritySettingsSchema)
+  .and(StreamExtrasSchema);
+export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
+
+export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
+export type TrafficReset = z.infer<typeof TrafficResetSchema>;
+
+// Db-side fields layered on top of the xray slice. These mirror the
+// DBInbound model — they live in the SQL row, not in xray's config.
+export const InboundDbFieldsSchema = z.object({
+  up: z.number().int().min(0).default(0),
+  down: z.number().int().min(0).default(0),
+  total: z.number().int().min(0).default(0),
+  trafficReset: TrafficResetSchema.default('never'),
+  lastTrafficResetTime: z.number().int().default(0),
+  nodeId: z.number().int().nullable().optional(),
+});
+export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
+
+// Base fields that apply to every inbound regardless of protocol or
+// transport. The protocol-specific `settings` and the transport-specific
+// `streamSettings` are layered on via intersection below.
+export const InboundFormBaseSchema = z.object({
+  remark: z.string().default(''),
+  enable: z.boolean().default(true),
+  port: PortSchema,
+  listen: z.string().default(''),
+  tag: z.string().default(''),
+  expiryTime: z.number().int().default(0),
+  clientStats: z.string().optional(),
+  sniffing: SniffingSchema.default({
+    enabled: false,
+    destOverride: ['http', 'tls', 'quic', 'fakedns'],
+    metadataOnly: false,
+    routeOnly: false,
+    ipsExcluded: [],
+    domainsExcluded: [],
+  }),
+  streamSettings: InboundStreamFormSchema.optional(),
+});
+export type InboundFormBase = z.infer<typeof InboundFormBaseSchema>;
+
+// Full form values = base + db fields + protocol-discriminated settings.
+// Consumers narrow on `.protocol` to access the matching settings branch.
+export const InboundFormSchema = InboundFormBaseSchema
+  .and(InboundDbFieldsSchema)
+  .and(InboundSettingsSchema);
+export type InboundFormValues = z.infer<typeof InboundFormSchema>;
+
+// Fallback rows ride alongside the inbound submission for VLESS/Trojan
+// hosts. They're saved via a separate endpoint after the main inbound
+// POST returns, so the schema lives here but is not part of the wire
+// inbound payload.
+export const FallbackRowSchema = z.object({
+  rowKey: z.string(),
+  childId: z.number().int().nullable(),
+  name: z.string().default(''),
+  alpn: z.string().default(''),
+  path: z.string().default(''),
+  xver: z.number().int().min(0).max(2).default(0),
+});
+export type FallbackRow = z.infer<typeof FallbackRowSchema>;

+ 265 - 0
frontend/src/schemas/forms/outbound-form.ts

@@ -0,0 +1,265 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+import { VmessSecuritySchema } from '@/schemas/protocols/inbound/vmess';
+import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks';
+import { SecuritySettingsSchema } from '@/schemas/protocols/security';
+import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
+import {
+  BlackholeResponseTypeSchema,
+  DNSRuleActionSchema,
+  FreedomFinalRuleActionSchema,
+  FreedomFragmentSchema,
+  FreedomNoiseSchema,
+  OutboundDomainStrategySchema,
+  WireguardDomainStrategySchema,
+} from '@/schemas/protocols/outbound';
+
+// OutboundFormValues = the shape Form.useForm<T>() carries inside
+// OutboundFormModal. Differences from schemas/api wire schemas:
+//
+//   - vmess vnext / trojan-ss-socks-http servers are FLATTENED into
+//     {address, port, ...auth} at settings root. The adapter handles
+//     nesting on submit.
+//   - wireguard `address` (string[] wire) and `reserved` (number[] wire)
+//     are comma-joined STRINGS in the form. The adapter splits + coerces.
+//   - wireguard `pubKey` is a UI-only field derived from `secretKey`. Not
+//     emitted on the wire — the adapter strips it.
+//   - VLESS `reverseTag` and `reverseSniffing` are flat at settings root;
+//     the adapter wraps them as { reverse: { tag, sniffing } } on the wire.
+//   - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it
+//     as { response: { type } } on the wire (omitted when empty).
+//   - DNS rules carry `qtype` and `domain` as comma-joined strings (matches
+//     the legacy DNSRule UI). The adapter normalizes them on submit.
+//
+// All flat-form settings types are documented inline so the adapter has a
+// single source of truth for the shape it converts between.
+
+// VMess outbound: connect target (address+port) + first user (id+security).
+// Wire: { vnext: [{ address, port, users: [{ id, security }] }] }.
+export const VmessOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  id: z.string().default(''),
+  security: VmessSecuritySchema.default('auto'),
+});
+export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
+
+// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults
+// match legacy ReverseSniffing constructor.
+export const ReverseSniffingFormSchema = z.object({
+  enabled: z.boolean().default(false),
+  destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']),
+  metadataOnly: z.boolean().default(false),
+  routeOnly: z.boolean().default(false),
+  ipsExcluded: z.array(z.string()).default([]),
+  domainsExcluded: z.array(z.string()).default([]),
+});
+export type ReverseSniffingForm = z.infer<typeof ReverseSniffingFormSchema>;
+
+// VLESS outbound: flat connect target + auth + Vision-specific knobs +
+// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed.
+export const VlessOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  id: z.string().default(''),
+  flow: z.string().default(''),
+  encryption: z.string().min(1).default('none'),
+  reverseTag: z.string().default(''),
+  reverseSniffing: ReverseSniffingFormSchema.default({
+    enabled: false,
+    destOverride: ['http', 'tls', 'quic', 'fakedns'],
+    metadataOnly: false,
+    routeOnly: false,
+    ipsExcluded: [],
+    domainsExcluded: [],
+  }),
+  testpre: z.number().int().min(0).default(0),
+  testseed: z.array(z.number().int().positive()).default([]),
+});
+export type VlessOutboundFormSettings = z.infer<typeof VlessOutboundFormSettingsSchema>;
+
+export const TrojanOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  password: z.string().default(''),
+});
+export type TrojanOutboundFormSettings = z.infer<typeof TrojanOutboundFormSettingsSchema>;
+
+export const ShadowsocksOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  password: z.string().default(''),
+  method: SSMethodSchema.default('2022-blake3-aes-128-gcm'),
+  uot: z.boolean().default(false),
+  UoTVersion: z.number().int().min(1).max(2).default(1),
+});
+export type ShadowsocksOutboundFormSettings = z.infer<typeof ShadowsocksOutboundFormSettingsSchema>;
+
+// SOCKS / HTTP: panel only supports a single server, with optionally one
+// user (the adapter emits users: [] when user is empty).
+export const SocksOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(1080),
+  user: z.string().default(''),
+  pass: z.string().default(''),
+});
+export type SocksOutboundFormSettings = z.infer<typeof SocksOutboundFormSettingsSchema>;
+
+export const HttpOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(8080),
+  user: z.string().default(''),
+  pass: z.string().default(''),
+});
+export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
+
+// Wireguard peer mirrors the legacy Outbound.WireguardSettings.Peer class.
+// `psk` (form) <-> `preSharedKey` (wire) — adapter renames.
+export const WireguardOutboundFormPeerSchema = z.object({
+  publicKey: z.string().default(''),
+  psk: z.string().default(''),
+  allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']),
+  endpoint: z.string().default(''),
+  keepAlive: z.number().int().min(0).default(0),
+});
+export type WireguardOutboundFormPeer = z.infer<typeof WireguardOutboundFormPeerSchema>;
+
+// Wireguard: `address` and `reserved` are comma-joined strings in the form
+// (the legacy UI binds them to a single Input). pubKey is UI-only — the
+// modal derives it from secretKey via Wireguard.generateKeypair() and
+// displays it disabled; the adapter strips it.
+export const WireguardOutboundFormSettingsSchema = z.object({
+  mtu: z.number().int().min(0).default(1420),
+  secretKey: z.string().default(''),
+  pubKey: z.string().default(''),
+  address: z.string().default(''),
+  workers: z.number().int().min(0).default(2),
+  domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
+  reserved: z.string().default(''),
+  peers: z.array(WireguardOutboundFormPeerSchema).default([]),
+  noKernelTun: z.boolean().default(false),
+});
+export type WireguardOutboundFormSettings = z.infer<typeof WireguardOutboundFormSettingsSchema>;
+
+// Hysteria outbound carries the connect target only; transport-layer knobs
+// (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria.
+export const HysteriaOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  version: z.literal(2).default(2),
+});
+export type HysteriaOutboundFormSettings = z.infer<typeof HysteriaOutboundFormSettingsSchema>;
+
+// FinalRule (freedom): network/port are strings; ip is string[]; blockDelay
+// is only meaningful when action === 'block'. The adapter omits empty
+// fields from the wire payload.
+export const FreedomFinalRuleFormSchema = z.object({
+  action: FreedomFinalRuleActionSchema.default('block'),
+  network: z.string().default(''),
+  port: z.string().default(''),
+  ip: z.array(z.string()).default([]),
+  blockDelay: z.string().default(''),
+});
+export type FreedomFinalRuleForm = z.infer<typeof FreedomFinalRuleFormSchema>;
+
+export const FreedomOutboundFormSettingsSchema = z.object({
+  domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
+  redirect: z.string().default(''),
+  fragment: FreedomFragmentSchema.default({
+    packets: '1-3',
+    length: '',
+    interval: '',
+    maxSplit: '',
+  }),
+  noises: z.array(FreedomNoiseSchema).default([]),
+  finalRules: z.array(FreedomFinalRuleFormSchema).default([]),
+});
+export type FreedomOutboundFormSettings = z.infer<typeof FreedomOutboundFormSettingsSchema>;
+
+// Blackhole: legacy form keeps `type` as a flat string ('' | 'none' | 'http');
+// adapter wraps as { response: { type } } on the wire and omits when empty.
+export const BlackholeOutboundFormSettingsSchema = z.object({
+  type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''),
+});
+export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
+
+// DNS rules: form holds qtype + domain as joined strings (the legacy UI
+// binds to <Input>). Adapter parses them on submit per the DNSRule class.
+export const DnsRuleFormSchema = z.object({
+  action: DNSRuleActionSchema.default('direct'),
+  qtype: z.string().default(''),
+  domain: z.string().default(''),
+});
+export type DnsRuleForm = z.infer<typeof DnsRuleFormSchema>;
+
+export const DnsOutboundFormSettingsSchema = z.object({
+  rewriteNetwork: z.union([z.enum(['udp', 'tcp']), z.literal('')]).default(''),
+  rewriteAddress: z.string().default(''),
+  rewritePort: z.number().int().min(0).max(65535).default(53),
+  userLevel: z.number().int().min(0).default(0),
+  rules: z.array(DnsRuleFormSchema).default([]),
+});
+export type DnsOutboundFormSettings = z.infer<typeof DnsOutboundFormSettingsSchema>;
+
+export const LoopbackOutboundFormSettingsSchema = z.object({
+  inboundTag: z.string().default(''),
+});
+export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
+
+// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
+// inbound side: each branch is { protocol: literal, settings: <flat> }.
+export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
+  z.object({ protocol: z.literal('vmess'),       settings: VmessOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('vless'),       settings: VlessOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('trojan'),      settings: TrojanOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('socks'),       settings: SocksOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('http'),        settings: HttpOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('wireguard'),   settings: WireguardOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('hysteria'),    settings: HysteriaOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('freedom'),     settings: FreedomOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('blackhole'),   settings: BlackholeOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('dns'),         settings: DnsOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('loopback'),    settings: LoopbackOutboundFormSettingsSchema }),
+]);
+export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
+
+// Mux ride: only emitted when enabled. The adapter respects canEnableMux
+// (gated by protocol + flow + network).
+export const MuxFormSchema = z.object({
+  enabled: z.boolean().default(false),
+  concurrency: z.number().int().default(8),
+  xudpConcurrency: z.number().int().default(16),
+  xudpProxyUDP443: z.enum(['reject', 'allow', 'skip']).default('reject'),
+});
+export type MuxForm = z.infer<typeof MuxFormSchema>;
+
+// Stream form mirrors the inbound side: NetworkSettings DU + SecuritySettings
+// DU + extras (sockopt). Hysteria gets a side-channel branch in the modal
+// (legacy ob.stream.hysteria) — keeping the DU strict for now and routing
+// hysteria transport knobs through the Advanced JSON tab if needed.
+export const OutboundStreamFormSchema = NetworkSettingsSchema
+  .and(SecuritySettingsSchema)
+  .and(StreamExtrasSchema);
+export type OutboundStreamFormValues = z.infer<typeof OutboundStreamFormSchema>;
+
+// Top-level form base: identity (tag, sendThrough), then the per-protocol
+// settings DU, then the stream sub-form, then mux.
+export const OutboundFormBaseSchema = z.object({
+  tag: z.string().default(''),
+  sendThrough: z.string().default(''),
+  streamSettings: OutboundStreamFormSchema.optional(),
+  mux: MuxFormSchema.default({
+    enabled: false,
+    concurrency: 8,
+    xudpConcurrency: 16,
+    xudpProxyUDP443: 'reject',
+  }),
+});
+export type OutboundFormBase = z.infer<typeof OutboundFormBaseSchema>;
+
+// Full form values = base + protocol-discriminated settings. Consumers
+// narrow on `.protocol` to access the matching settings branch.
+export const OutboundFormSchema = OutboundFormBaseSchema.and(OutboundFormSettingsSchema);
+export type OutboundFormValues = z.infer<typeof OutboundFormSchema>;

+ 32 - 0
frontend/src/schemas/inbound.ts

@@ -0,0 +1,32 @@
+import { z } from 'zod';
+
+export const SlimInboundSchema = z.object({
+  id: z.number(),
+  protocol: z.string(),
+}).loose();
+
+export const SlimInboundListSchema = z.array(SlimInboundSchema);
+
+export const InboundDetailSchema = z.object({
+  id: z.number(),
+  protocol: z.string(),
+}).loose();
+
+export const LastOnlineMapSchema = z.record(z.string(), z.number());
+
+export const InboundFormSchema = z.object({
+  remark: z.string(),
+  enable: z.boolean(),
+  port: z
+    .number({ error: 'pages.inbounds.toasts.portRequired' })
+    .int()
+    .min(1, 'pages.inbounds.toasts.portRange')
+    .max(65535, 'pages.inbounds.toasts.portRange'),
+  listen: z.string(),
+  protocol: z.string().min(1, 'pages.inbounds.toasts.protocolRequired'),
+});
+
+export type SlimInbound = z.infer<typeof SlimInboundSchema>;
+export type InboundDetail = z.infer<typeof InboundDetailSchema>;
+export type LastOnlineMap = z.infer<typeof LastOnlineMapSchema>;
+export type InboundFormValues = z.infer<typeof InboundFormSchema>;

+ 2 - 0
frontend/src/schemas/index.ts

@@ -0,0 +1,2 @@
+export * from './primitives';
+export * from './protocols';

+ 15 - 0
frontend/src/schemas/login.ts

@@ -0,0 +1,15 @@
+import { z } from 'zod';
+
+export const LoginFormSchema = z.object({
+  username: z.string().min(1, 'username'),
+  password: z.string().min(1, 'password'),
+  twoFactorCode: z.string().optional(),
+});
+
+export const TwoFactorCodeSchema = z.string().min(1, 'twoFactorCode');
+
+export const TotpCodeSchema = z
+  .string()
+  .regex(/^\d{6}$/, 'pages.settings.security.twoFactorModalError');
+
+export type LoginFormValues = z.infer<typeof LoginFormSchema>;

+ 53 - 0
frontend/src/schemas/node.ts

@@ -0,0 +1,53 @@
+import { z } from 'zod';
+
+export const NodeRecordSchema = z.object({
+  id: z.number(),
+  name: z.string().optional(),
+  remark: z.string().optional(),
+  scheme: z.string().optional(),
+  address: z.string().optional(),
+  port: z.number().optional(),
+  basePath: z.string().optional(),
+  apiToken: z.string().optional(),
+  enable: z.boolean().optional(),
+  status: z.string().optional(),
+  latencyMs: z.number().optional(),
+  cpuPct: z.number().optional(),
+  memPct: z.number().optional(),
+  xrayVersion: z.string().optional(),
+  panelVersion: z.string().optional(),
+  uptimeSecs: z.number().optional(),
+  inboundCount: z.number().optional(),
+  clientCount: z.number().optional(),
+  onlineCount: z.number().optional(),
+  depletedCount: z.number().optional(),
+  lastHeartbeat: z.number().optional(),
+  lastError: z.string().optional(),
+  allowPrivateAddress: z.boolean().optional(),
+}).loose();
+
+export const NodeListSchema = z.array(NodeRecordSchema);
+
+export const ProbeResultSchema = z.object({
+  status: z.string(),
+  latencyMs: z.number().optional(),
+  xrayVersion: z.string().optional(),
+  error: z.string().optional(),
+}).loose();
+
+export const NodeFormSchema = z.object({
+  id: z.number().optional(),
+  name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
+  remark: z.string().optional(),
+  scheme: z.enum(['http', 'https']),
+  address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
+  port: z.number().int().min(1).max(65535),
+  basePath: z.string(),
+  apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
+  enable: z.boolean(),
+  allowPrivateAddress: z.boolean(),
+});
+
+export type NodeRecord = z.infer<typeof NodeRecordSchema>;
+export type ProbeResult = z.infer<typeof ProbeResultSchema>;
+export type NodeFormValues = z.infer<typeof NodeFormSchema>;

+ 16 - 0
frontend/src/schemas/primitives/flow.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+export const FlowSchema = z.enum([
+  '',
+  'xtls-rprx-vision',
+  'xtls-rprx-vision-udp443',
+]);
+export type Flow = z.infer<typeof FlowSchema>;
+
+// Const map matching the legacy models/inbound.ts `TLS_FLOW_CONTROL`
+// export. The empty-string default isn't keyed here — the legacy never
+// carried a NONE key and call sites compare against the two real flows.
+export const TLS_FLOW_CONTROL = Object.freeze({
+  VISION: 'xtls-rprx-vision',
+  VISION_UDP443: 'xtls-rprx-vision-udp443',
+}) satisfies Record<string, Exclude<Flow, ''>>;

+ 6 - 0
frontend/src/schemas/primitives/index.ts

@@ -0,0 +1,6 @@
+export * from './port';
+export * from './protocol';
+export * from './outbound-protocol';
+export * from './sniffing';
+export * from './flow';
+export * from './options';

+ 111 - 0
frontend/src/schemas/primitives/options.ts

@@ -0,0 +1,111 @@
+export const UTLS_FINGERPRINT = Object.freeze({
+  UTLS_CHROME: 'chrome',
+  UTLS_FIREFOX: 'firefox',
+  UTLS_SAFARI: 'safari',
+  UTLS_IOS: 'ios',
+  UTLS_android: 'android',
+  UTLS_EDGE: 'edge',
+  UTLS_360: '360',
+  UTLS_QQ: 'qq',
+  UTLS_RANDOM: 'random',
+  UTLS_RANDOMIZED: 'randomized',
+  UTLS_RONDOMIZEDNOALPN: 'randomizednoalpn',
+  UTLS_UNSAFE: 'unsafe',
+});
+
+export const ALPN_OPTION = Object.freeze({
+  H3: 'h3',
+  H2: 'h2',
+  HTTP1: 'http/1.1',
+});
+
+export const SNIFFING_OPTION = Object.freeze({
+  HTTP: 'http',
+  TLS: 'tls',
+  QUIC: 'quic',
+  FAKEDNS: 'fakedns',
+});
+
+export const USERS_SECURITY = Object.freeze({
+  AES_128_GCM: 'aes-128-gcm',
+  CHACHA20_POLY1305: 'chacha20-poly1305',
+  AUTO: 'auto',
+  NONE: 'none',
+  ZERO: 'zero',
+});
+
+export const MODE_OPTION = Object.freeze({
+  AUTO: 'auto',
+  PACKET_UP: 'packet-up',
+  STREAM_UP: 'stream-up',
+  STREAM_ONE: 'stream-one',
+});
+
+export const WireguardDomainStrategy = Object.freeze([
+  'ForceIP',
+  'ForceIPv4',
+  'ForceIPv4v6',
+  'ForceIPv6',
+  'ForceIPv6v4',
+] as const);
+
+export const Address_Port_Strategy = Object.freeze({
+  NONE: 'none',
+  SRV_PORT_ONLY: 'SrvPortOnly',
+  SRV_ADDRESS_ONLY: 'SrvAddressOnly',
+  SRV_PORT_AND_ADDRESS: 'SrvPortAndAddress',
+  TXT_PORT_ONLY: 'TxtPortOnly',
+  TXT_ADDRESS_ONLY: 'TxtAddressOnly',
+  TXT_PORT_AND_ADDRESS: 'TxtPortAndAddress',
+});
+
+export const DNSRuleActions = Object.freeze(['direct', 'drop', 'reject', 'hijack'] as const);
+
+export const TLS_VERSION_OPTION = Object.freeze({
+  TLS10: '1.0',
+  TLS11: '1.1',
+  TLS12: '1.2',
+  TLS13: '1.3',
+});
+
+export const TLS_CIPHER_OPTION = Object.freeze({
+  AES_128_GCM: 'TLS_AES_128_GCM_SHA256',
+  AES_256_GCM: 'TLS_AES_256_GCM_SHA384',
+  CHACHA20_POLY1305: 'TLS_CHACHA20_POLY1305_SHA256',
+  ECDHE_ECDSA_AES_128_CBC: 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA',
+  ECDHE_ECDSA_AES_256_CBC: 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA',
+  ECDHE_RSA_AES_128_CBC: 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA',
+  ECDHE_RSA_AES_256_CBC: 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
+  ECDHE_ECDSA_AES_128_GCM: 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
+  ECDHE_ECDSA_AES_256_GCM: 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384',
+  ECDHE_RSA_AES_128_GCM: 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
+  ECDHE_RSA_AES_256_GCM: 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
+  ECDHE_ECDSA_CHACHA20_POLY1305: 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
+  ECDHE_RSA_CHACHA20_POLY1305: 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
+});
+
+export const USAGE_OPTION = Object.freeze({
+  ENCIPHERMENT: 'encipherment',
+  VERIFY: 'verify',
+  ISSUE: 'issue',
+});
+
+export const DOMAIN_STRATEGY_OPTION = Object.freeze({
+  AS_IS: 'AsIs',
+  USE_IP: 'UseIP',
+  USE_IPV6V4: 'UseIPv6v4',
+  USE_IPV6: 'UseIPv6',
+  USE_IPV4V6: 'UseIPv4v6',
+  USE_IPV4: 'UseIPv4',
+  FORCE_IP: 'ForceIP',
+  FORCE_IPV6V4: 'ForceIPv6v4',
+  FORCE_IPV6: 'ForceIPv6',
+  FORCE_IPV4V6: 'ForceIPv4v6',
+  FORCE_IPV4: 'ForceIPv4',
+});
+
+export const TCP_CONGESTION_OPTION = Object.freeze({
+  BBR: 'bbr',
+  CUBIC: 'cubic',
+  RENO: 'reno',
+});

+ 30 - 0
frontend/src/schemas/primitives/outbound-protocol.ts

@@ -0,0 +1,30 @@
+export const OutboundProtocols = Object.freeze({
+  Freedom: 'freedom',
+  Blackhole: 'blackhole',
+  DNS: 'dns',
+  VMess: 'vmess',
+  VLESS: 'vless',
+  Trojan: 'trojan',
+  Shadowsocks: 'shadowsocks',
+  Wireguard: 'wireguard',
+  Hysteria: 'hysteria',
+  Socks: 'socks',
+  HTTP: 'http',
+  Loopback: 'loopback',
+});
+
+export const OutboundDomainStrategies = Object.freeze([
+  'AsIs',
+  'UseIP',
+  'UseIPv4',
+  'UseIPv6',
+  'UseIPv6v4',
+  'UseIPv4v6',
+  'ForceIP',
+  'ForceIPv6v4',
+  'ForceIPv6',
+  'ForceIPv4v6',
+  'ForceIPv4',
+] as const);
+
+export type OutboundDomainStrategy = (typeof OutboundDomainStrategies)[number];

+ 4 - 0
frontend/src/schemas/primitives/port.ts

@@ -0,0 +1,4 @@
+import { z } from 'zod';
+
+export const PortSchema = z.number().int().min(1).max(65535);
+export type Port = z.infer<typeof PortSchema>;

+ 34 - 0
frontend/src/schemas/primitives/protocol.ts

@@ -0,0 +1,34 @@
+import { z } from 'zod';
+
+export const ProtocolSchema = z.enum([
+  'vmess',
+  'vless',
+  'trojan',
+  'shadowsocks',
+  'wireguard',
+  'hysteria',
+  'http',
+  'mixed',
+  'tunnel',
+  'tun',
+]);
+export type Protocol = z.infer<typeof ProtocolSchema>;
+
+// Const map matching the legacy models/inbound.ts `Protocols` export so
+// call sites can swap the import without touching `Protocols.VLESS`-style
+// references throughout the codebase. Frozen so downstream code can't
+// mutate the dispatch table. TUN is kept here for parity even though the
+// Go backend's validator no longer accepts it — existing panel deployments
+// may still have TUN inbounds saved that we want to render.
+export const Protocols = Object.freeze({
+  VMESS: 'vmess',
+  VLESS: 'vless',
+  TROJAN: 'trojan',
+  SHADOWSOCKS: 'shadowsocks',
+  WIREGUARD: 'wireguard',
+  HYSTERIA: 'hysteria',
+  HTTP: 'http',
+  MIXED: 'mixed',
+  TUNNEL: 'tunnel',
+  TUN: 'tun',
+});

+ 16 - 0
frontend/src/schemas/primitives/sniffing.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+export const SniffingDestSchema = z.enum(['http', 'tls', 'quic', 'fakedns']);
+export type SniffingDest = z.infer<typeof SniffingDestSchema>;
+
+export const SniffingSchema = z.object({
+  enabled: z.boolean().default(false),
+  destOverride: z
+    .array(SniffingDestSchema)
+    .default(['http', 'tls', 'quic', 'fakedns']),
+  metadataOnly: z.boolean().default(false),
+  routeOnly: z.boolean().default(false),
+  ipsExcluded: z.array(z.string()).default([]),
+  domainsExcluded: z.array(z.string()).default([]),
+});
+export type Sniffing = z.infer<typeof SniffingSchema>;

+ 17 - 0
frontend/src/schemas/protocols/inbound/http.ts

@@ -0,0 +1,17 @@
+import { z } from 'zod';
+
+// HTTP proxy inbound — a classic forward proxy. Accounts are user/pass pairs;
+// `allowTransparent` exposes Xray's option to forward requests with the
+// original Host header. No client tracking (no email/limits) at the Xray
+// settings level — the panel doesn't model HTTP users as billable clients.
+export const HttpAccountSchema = z.object({
+  user: z.string().min(1),
+  pass: z.string().min(1),
+});
+export type HttpAccount = z.infer<typeof HttpAccountSchema>;
+
+export const HttpInboundSettingsSchema = z.object({
+  accounts: z.array(HttpAccountSchema).default([]),
+  allowTransparent: z.boolean().default(false),
+});
+export type HttpInboundSettings = z.infer<typeof HttpInboundSettingsSchema>;

+ 26 - 0
frontend/src/schemas/protocols/inbound/hysteria.ts

@@ -0,0 +1,26 @@
+import { z } from 'zod';
+
+// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
+// panel defaults to v2). Each client supplies an `auth` token instead of a
+// UUID/password.
+export const HysteriaClientSchema = z.object({
+  auth: z.string().min(1),
+  email: z.string().min(1),
+  limitIp: z.number().int().min(0).default(0),
+  totalGB: z.number().int().min(0).default(0),
+  expiryTime: z.number().int().default(0),
+  enable: z.boolean().default(true),
+  tgId: z.number().int().default(0),
+  subId: z.string().default(''),
+  comment: z.string().default(''),
+  reset: z.number().int().min(0).default(0),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
+
+export const HysteriaInboundSettingsSchema = z.object({
+  version: z.number().int().min(1).default(2),
+  clients: z.array(HysteriaClientSchema).default([]),
+});
+export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;

+ 42 - 0
frontend/src/schemas/protocols/inbound/index.ts

@@ -0,0 +1,42 @@
+import { z } from 'zod';
+
+import { HttpInboundSettingsSchema } from './http';
+import { HysteriaInboundSettingsSchema } from './hysteria';
+import { MixedInboundSettingsSchema } from './mixed';
+import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
+import { TrojanInboundSettingsSchema } from './trojan';
+import { TunInboundSettingsSchema } from './tun';
+import { TunnelInboundSettingsSchema } from './tunnel';
+import { VlessInboundSettingsSchema } from './vless';
+import { VmessInboundSettingsSchema } from './vmess';
+import { WireguardInboundSettingsSchema } from './wireguard';
+
+export * from './http';
+export * from './hysteria';
+export * from './mixed';
+export * from './shadowsocks';
+export * from './trojan';
+export * from './tun';
+export * from './tunnel';
+export * from './vless';
+export * from './vmess';
+export * from './wireguard';
+
+// Tagged-wrapper discriminated union. The discriminator (`protocol`) lives on
+// the wrapper, not inside `settings`, mirroring the wire format Xray emits:
+//   { protocol: 'vless', settings: { clients: [...], ... }, ... }
+// Consumers narrow on `.protocol` and TypeScript narrows `.settings` to the
+// matching leaf type.
+export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
+  z.object({ protocol: z.literal('vmess'),       settings: VmessInboundSettingsSchema }),
+  z.object({ protocol: z.literal('vless'),       settings: VlessInboundSettingsSchema }),
+  z.object({ protocol: z.literal('trojan'),      settings: TrojanInboundSettingsSchema }),
+  z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksInboundSettingsSchema }),
+  z.object({ protocol: z.literal('wireguard'),   settings: WireguardInboundSettingsSchema }),
+  z.object({ protocol: z.literal('hysteria'),    settings: HysteriaInboundSettingsSchema }),
+  z.object({ protocol: z.literal('http'),        settings: HttpInboundSettingsSchema }),
+  z.object({ protocol: z.literal('mixed'),       settings: MixedInboundSettingsSchema }),
+  z.object({ protocol: z.literal('tunnel'),      settings: TunnelInboundSettingsSchema }),
+  z.object({ protocol: z.literal('tun'),         settings: TunInboundSettingsSchema }),
+]);
+export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

+ 21 - 0
frontend/src/schemas/protocols/inbound/mixed.ts

@@ -0,0 +1,21 @@
+import { z } from 'zod';
+
+export const MixedAuthSchema = z.enum(['password', 'noauth']);
+export type MixedAuth = z.infer<typeof MixedAuthSchema>;
+
+// SOCKS/HTTP combined inbound. When auth==='noauth' the `accounts` field is
+// omitted from the wire payload (the panel writes `undefined`), so we accept
+// either an array or absence here.
+export const MixedAccountSchema = z.object({
+  user: z.string().min(1),
+  pass: z.string().min(1),
+});
+export type MixedAccount = z.infer<typeof MixedAccountSchema>;
+
+export const MixedInboundSettingsSchema = z.object({
+  auth: MixedAuthSchema.default('password'),
+  accounts: z.array(MixedAccountSchema).optional(),
+  udp: z.boolean().default(false),
+  ip: z.string().default('127.0.0.1'),
+});
+export type MixedInboundSettings = z.infer<typeof MixedInboundSettingsSchema>;

+ 45 - 0
frontend/src/schemas/protocols/inbound/shadowsocks.ts

@@ -0,0 +1,45 @@
+import { z } from 'zod';
+
+export const SSMethodSchema = z.enum([
+  'aes-256-gcm',
+  'chacha20-poly1305',
+  'chacha20-ietf-poly1305',
+  'xchacha20-ietf-poly1305',
+  '2022-blake3-aes-128-gcm',
+  '2022-blake3-aes-256-gcm',
+  '2022-blake3-chacha20-poly1305',
+]);
+export type SSMethod = z.infer<typeof SSMethodSchema>;
+
+export const SSNetworkSchema = z.enum(['tcp', 'udp', 'tcp,udp']);
+export type SSNetwork = z.infer<typeof SSNetworkSchema>;
+
+// On a single-user shadowsocks inbound the client carries no method/password
+// of its own — the inbound-level method+password are authoritative. On a
+// 2022-blake3 multi-user setup each client provides its own password (and
+// optionally a per-client method).
+export const ShadowsocksClientSchema = z.object({
+  method: z.string().default(''),
+  password: z.string().default(''),
+  email: z.string().min(1),
+  limitIp: z.number().int().min(0).default(0),
+  totalGB: z.number().int().min(0).default(0),
+  expiryTime: z.number().int().default(0),
+  enable: z.boolean().default(true),
+  tgId: z.number().int().default(0),
+  subId: z.string().default(''),
+  comment: z.string().default(''),
+  reset: z.number().int().min(0).default(0),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type ShadowsocksClient = z.infer<typeof ShadowsocksClientSchema>;
+
+export const ShadowsocksInboundSettingsSchema = z.object({
+  method: SSMethodSchema.default('2022-blake3-aes-256-gcm'),
+  password: z.string().default(''),
+  network: SSNetworkSchema.default('tcp'),
+  clients: z.array(ShadowsocksClientSchema).default([]),
+  ivCheck: z.boolean().default(false),
+});
+export type ShadowsocksInboundSettings = z.infer<typeof ShadowsocksInboundSettingsSchema>;

+ 32 - 0
frontend/src/schemas/protocols/inbound/trojan.ts

@@ -0,0 +1,32 @@
+import { z } from 'zod';
+
+export const TrojanFallbackSchema = z.object({
+  name: z.string().default(''),
+  alpn: z.string().default(''),
+  path: z.string().default(''),
+  dest: z.union([z.string(), z.number()]).default(''),
+  xver: z.number().int().min(0).default(0),
+});
+export type TrojanFallback = z.infer<typeof TrojanFallbackSchema>;
+
+export const TrojanClientSchema = z.object({
+  password: z.string().min(1),
+  email: z.string().min(1),
+  limitIp: z.number().int().min(0).default(0),
+  totalGB: z.number().int().min(0).default(0),
+  expiryTime: z.number().int().default(0),
+  enable: z.boolean().default(true),
+  tgId: z.number().int().default(0),
+  subId: z.string().default(''),
+  comment: z.string().default(''),
+  reset: z.number().int().min(0).default(0),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type TrojanClient = z.infer<typeof TrojanClientSchema>;
+
+export const TrojanInboundSettingsSchema = z.object({
+  clients: z.array(TrojanClientSchema).default([]),
+  fallbacks: z.array(TrojanFallbackSchema).default([]),
+});
+export type TrojanInboundSettings = z.infer<typeof TrojanInboundSettingsSchema>;

+ 12 - 0
frontend/src/schemas/protocols/inbound/tun.ts

@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+export const TunInboundSettingsSchema = z.object({
+  name: z.string().default('xray0'),
+  mtu: z.number().int().min(0).default(1500),
+  gateway: z.array(z.string()).default([]),
+  dns: z.array(z.string()).default([]),
+  userLevel: z.number().int().min(0).default(0),
+  autoSystemRoutingTable: z.array(z.string()).default([]),
+  autoOutboundsInterface: z.string().default('auto'),
+});
+export type TunInboundSettings = z.infer<typeof TunInboundSettingsSchema>;

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff