1
0

71 Ревизии 31845fa8f6 ... bfdaf7a8f8

Автор SHA1 Съобщение Дата
  MHSanaei bfdaf7a8f8 docs(frontend): record FinalMaskForm rewrite + hookup in status doc преди 8 часа
  MHSanaei e978428ca3 feat(frontend): FinalMaskForm rewrite to Pattern A + wire into both modals преди 8 часа
  MHSanaei 34590dc327 feat(frontend): round-trip XHTTP padding-obfs + remaining advanced knobs преди 8 часа
  MHSanaei 2f1a146f45 feat(frontend): round-trip XHTTP advanced fields in outbound link parser преди 8 часа
  MHSanaei 9f84859ff6 feat(frontend): outbound TCP HTTP camouflage parity with inbound преди 8 часа
  MHSanaei a7166988ca feat(frontend): complete outbound sockopt section with remaining knobs преди 9 часа
  MHSanaei 5c902ca298 feat(frontend): inbound Hysteria stream sub-form (auth + udpIdleTimeout + masquerade) преди 9 часа
  MHSanaei 9de527b35f feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) преди 9 часа
  MHSanaei 01991e74b1 feat(frontend): inbound TCP HTTP camouflage response fields + request headers преди 9 часа
  MHSanaei e01acae843 feat(frontend): XHTTP advanced fields on outbound modal преди 9 часа
  MHSanaei f4a49862a0 feat(frontend): fallbacks polish — move up/down + Add all button преди 9 часа
  MHSanaei 19204f9e04 feat(frontend): Hysteria stream sub-form (schema branch + outbound UI) преди 9 часа
  MHSanaei 7442486a58 feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers преди 10 часа
  MHSanaei e62ad84bb7 feat(frontend): symmetric TCP HTTP host/path + extra sockopt knobs преди 10 часа
  MHSanaei ad3d3937b0 feat(frontend): OutboundFormModal deferred features (Vision seed / TCP host+path / WG pubKey derive) преди 10 часа
  MHSanaei 1702b544f1 chore(frontend): enforce no-explicit-any: error + add typecheck/test to CI преди 10 часа
  MHSanaei 71631fd4dc test(frontend): convert legacy-class parity tests to snapshot baselines преди 10 часа
  MHSanaei eac50b4e80 feat(frontend): atomic swap OutboundFormModal to Pattern A преди 10 часа
  MHSanaei 7765fb39fe feat(frontend): OutboundFormModal.new.tsx sockopt + mux sections преди 10 часа
  MHSanaei bfc9c12c05 feat(frontend): OutboundFormModal.new.tsx security tab (TLS + Reality + Flow) преди 10 часа
  MHSanaei 8e9c82f56b feat(frontend): OutboundFormModal.new.tsx stream tab (TCP/KCP/WS/gRPC/HTTPUpgrade) преди 10 часа
  MHSanaei e8721a207c feat(frontend): OutboundFormModal.new.tsx DNS + Freedom + VLESS reverse-sniffing преди 10 часа
  MHSanaei b6d996d1b1 feat(frontend): OutboundFormModal.new.tsx socks/http/hysteria/loopback/blackhole/wireguard sections преди 10 часа
  MHSanaei a3857cff6a feat(frontend): OutboundFormModal.new.tsx vmess/vless/trojan/ss sections преди 10 часа
  MHSanaei e64d1a9bef feat(frontend): OutboundFormModal.new.tsx skeleton (Pattern A) преди 10 часа
  MHSanaei b554bb6b75 feat(frontend): outbound form schema + wire adapter foundation преди 10 часа
  MHSanaei ec18ee4290 fix(frontend): finish InboundFormModal rename after atomic swap преди 11 часа
  MHSanaei 1aef7171e3 feat(frontend): atomic swap InboundFormModal to Pattern A преди 11 часа
  MHSanaei ab24871669 feat(frontend): fallbacks card on InboundFormModal.new.tsx (Pattern A) преди 11 часа
  MHSanaei d6d0c3bb41 feat(frontend): advanced JSON tab on InboundFormModal.new.tsx (Pattern A) преди 11 часа
  MHSanaei 40d17b5e59 feat(frontend): security tab TLS certificates list (Pattern A) преди 11 часа
  MHSanaei 8db1be8592 feat(frontend): security tab Reality + ECH + mldsa65 controls (Pattern A) преди 20 часа
  MHSanaei 534e954954 feat(frontend): security tab base + TLS section (Pattern A) преди 20 часа
  MHSanaei 6f0bcaf97d feat(frontend): stream tab external-proxy + sockopt sections (Pattern A) преди 20 часа
  MHSanaei 54a2d32343 feat(frontend): stream tab XHTTP section (Pattern A) преди 20 часа
  MHSanaei 72c717bffd feat(frontend): stream tab WS + gRPC + HTTPUpgrade sections (Pattern A) преди 20 часа
  MHSanaei 985e647d6e feat(frontend): stream tab skeleton with TCP + KCP (Pattern A) преди 20 часа
  MHSanaei b1ccf915db feat(frontend): protocol tab Wireguard section (Pattern A) преди 20 часа
  MHSanaei e53f87ce30 feat(frontend): protocol tab TUN section (Pattern A) преди 20 часа
  MHSanaei d59c002a46 feat(frontend): protocol tab Tunnel section (Pattern A) преди 20 часа
  MHSanaei ecd751c310 feat(frontend): protocol tab HTTP and Mixed sections (Pattern A) преди 20 часа
  MHSanaei 591a03ff96 feat(frontend): protocol tab Shadowsocks section (Pattern A) преди 20 часа
  MHSanaei 102465f9d1 feat(frontend): protocol tab VLESS auth on InboundFormModal.new.tsx преди 20 часа
  MHSanaei 74a2813fb4 feat(frontend): sniffing tab on InboundFormModal.new.tsx (Pattern A) преди 20 часа
  MHSanaei bf70743589 feat(frontend): basic tab on InboundFormModal.new.tsx (Pattern A) преди 20 часа
  MHSanaei b10e0d0acd feat(frontend): InboundFormModal.new.tsx skeleton (Pattern A) преди 20 часа
  MHSanaei e2784fcf3f feat(frontend): outbound settings factories + dispatcher преди 20 часа
  MHSanaei 142ed97cc0 feat(frontend): protocol capability predicates as pure functions преди 20 часа
  MHSanaei 629567db72 feat(frontend): adapter between raw inbound rows and InboundFormValues преди 21 часа
  MHSanaei d2f3a7baa7 feat(frontend): InboundFormValues schema for Pattern A rewrite преди 21 часа
  MHSanaei f79e486f9f refactor(frontend): swap InboundFormModal option dicts to schemas/primitives преди 21 часа
  MHSanaei 2d74dbe7ad refactor(frontend): lift outbound option dictionaries to schemas/primitives преди 21 часа
  MHSanaei 40ca58d42e refactor(frontend): lift OutboundProtocols + OutboundDomainStrategies to schemas/primitives преди 21 часа
  MHSanaei 4ce2503c1e refactor(frontend): lift Protocols + TLS_FLOW_CONTROL consts to schemas/primitives преди 22 часа
  MHSanaei bd03f1a117 refactor(frontend): swap InboundsPage clone fallback off Inbound.Settings.getSettings преди 22 часа
  MHSanaei 5d07185438 refactor(frontend): extract share-link orchestrator to lib/xray/inbound-link преди 22 часа
  MHSanaei a7ca8c5b10 refactor(frontend): extract genHysteriaLink + Wireguard link/config to lib/xray преди 22 часа
  MHSanaei 1e2845306c refactor(frontend): extract genTrojanLink + genShadowsocksLink to lib/xray преди 22 часа
  MHSanaei 79c076ee11 refactor(frontend): extract genVlessLink to lib/xray/inbound-link преди 22 часа
  MHSanaei 5cdb71ec7d test(frontend): refresh inbound-full snapshot with vmess-tcp-tls fixture преди 22 часа
  MHSanaei 24c5c80bc3 refactor(frontend): extract genVmessLink to lib/xray/inbound-link.ts преди 22 часа
  MHSanaei d14eb6923f feat(frontend): stream extras + full InboundSchema with DU intersection преди 22 часа
  MHSanaei c4f5d841b0 refactor(frontend): add getHeaderValue wire-shape lookup to lib/xray/headers преди 22 часа
  MHSanaei e79ca42407 refactor(frontend): add createDefault*InboundSettings factories for all 10 protocols преди 23 часа
  MHSanaei 8d5d11cafc refactor(frontend): extract createDefault*Client factories to lib/xray преди 23 часа
  MHSanaei 922a442264 refactor(frontend): extract toHeaders + toV2Headers to lib/xray/headers.ts преди 23 часа
  MHSanaei a7a8041b13 test(frontend): shadow-parse harness asserting legacy class and Zod converge преди 23 часа
  MHSanaei 2176e816f0 test(frontend): broaden golden coverage to remaining inbounds + stream + security DUs преди 23 часа
  MHSanaei a9359e921b test(frontend): vitest harness with golden-file fixtures for inbound protocols преди 23 часа
  MHSanaei 9721dae2b6 feat(frontend): stream and security Zod families with discriminated unions преди 23 часа
  MHSanaei 8d45cd8c68 feat(frontend): protocol-leaf Zod schemas with discriminated unions преди 23 часа
променени са 100 файла, в които са добавени 11655 реда и са изтрити 2737 реда
  1. 6 0
      .github/workflows/ci.yml
  2. 132 0
      frontend/ZOD_MIGRATION_STATUS.md
  3. 6 0
      frontend/eslint.config.js
  4. 363 1
      frontend/package-lock.json
  5. 4 1
      frontend/package.json
  6. 444 525
      frontend/src/components/FinalMaskForm.tsx
  7. 122 0
      frontend/src/components/HeaderMapEditor.tsx
  8. 78 0
      frontend/src/lib/xray/headers.ts
  9. 260 0
      frontend/src/lib/xray/inbound-defaults.ts
  10. 135 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  11. 924 0
      frontend/src/lib/xray/inbound-link.ts
  12. 174 0
      frontend/src/lib/xray/outbound-defaults.ts
  13. 627 0
      frontend/src/lib/xray/outbound-form-adapter.ts
  14. 400 0
      frontend/src/lib/xray/outbound-link-parser.ts
  15. 74 0
      frontend/src/lib/xray/protocol-capabilities.ts
  16. 1 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  17. 1 1
      frontend/src/pages/clients/ClientFormModal.tsx
  18. 425 832
      frontend/src/pages/inbounds/InboundFormModal.tsx
  19. 1 1
      frontend/src/pages/inbounds/InboundInfoModal.tsx
  20. 3 2
      frontend/src/pages/inbounds/InboundsPage.tsx
  21. 1 1
      frontend/src/pages/inbounds/QrCodeModal.tsx
  22. 2 2
      frontend/src/pages/inbounds/useInbounds.ts
  23. 2 2
      frontend/src/pages/xray/BasicsTab.tsx
  24. 2126 1367
      frontend/src/pages/xray/OutboundFormModal.tsx
  25. 1 1
      frontend/src/pages/xray/OutboundsTab.tsx
  26. 64 0
      frontend/src/schemas/api/inbound.ts
  27. 83 0
      frontend/src/schemas/forms/inbound-form.ts
  28. 265 0
      frontend/src/schemas/forms/outbound-form.ts
  29. 2 0
      frontend/src/schemas/index.ts
  30. 16 0
      frontend/src/schemas/primitives/flow.ts
  31. 6 0
      frontend/src/schemas/primitives/index.ts
  32. 111 0
      frontend/src/schemas/primitives/options.ts
  33. 30 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  34. 4 0
      frontend/src/schemas/primitives/port.ts
  35. 35 0
      frontend/src/schemas/primitives/protocol.ts
  36. 16 0
      frontend/src/schemas/primitives/sniffing.ts
  37. 17 0
      frontend/src/schemas/protocols/inbound/http.ts
  38. 26 0
      frontend/src/schemas/protocols/inbound/hysteria.ts
  39. 13 0
      frontend/src/schemas/protocols/inbound/hysteria2.ts
  40. 42 0
      frontend/src/schemas/protocols/inbound/index.ts
  41. 21 0
      frontend/src/schemas/protocols/inbound/mixed.ts
  42. 45 0
      frontend/src/schemas/protocols/inbound/shadowsocks.ts
  43. 32 0
      frontend/src/schemas/protocols/inbound/trojan.ts
  44. 19 0
      frontend/src/schemas/protocols/inbound/tunnel.ts
  45. 50 0
      frontend/src/schemas/protocols/inbound/vless.ts
  46. 32 0
      frontend/src/schemas/protocols/inbound/vmess.ts
  47. 23 0
      frontend/src/schemas/protocols/inbound/wireguard.ts
  48. 13 0
      frontend/src/schemas/protocols/index.ts
  49. 13 0
      frontend/src/schemas/protocols/outbound/blackhole.ts
  50. 27 0
      frontend/src/schemas/protocols/outbound/dns.ts
  51. 59 0
      frontend/src/schemas/protocols/outbound/freedom.ts
  52. 25 0
      frontend/src/schemas/protocols/outbound/http.ts
  53. 12 0
      frontend/src/schemas/protocols/outbound/hysteria.ts
  54. 12 0
      frontend/src/schemas/protocols/outbound/hysteria2.ts
  55. 50 0
      frontend/src/schemas/protocols/outbound/index.ts
  56. 8 0
      frontend/src/schemas/protocols/outbound/loopback.ts
  57. 21 0
      frontend/src/schemas/protocols/outbound/shadowsocks.ts
  58. 24 0
      frontend/src/schemas/protocols/outbound/socks.ts
  59. 18 0
      frontend/src/schemas/protocols/outbound/trojan.ts
  60. 22 0
      frontend/src/schemas/protocols/outbound/vless.ts
  61. 25 0
      frontend/src/schemas/protocols/outbound/vmess.ts
  62. 36 0
      frontend/src/schemas/protocols/outbound/wireguard.ts
  63. 23 0
      frontend/src/schemas/protocols/security/index.ts
  64. 7 0
      frontend/src/schemas/protocols/security/none.ts
  65. 41 0
      frontend/src/schemas/protocols/security/reality.ts
  66. 72 0
      frontend/src/schemas/protocols/security/tls.ts
  67. 23 0
      frontend/src/schemas/protocols/stream/external-proxy.ts
  68. 83 0
      frontend/src/schemas/protocols/stream/finalmask.ts
  69. 11 0
      frontend/src/schemas/protocols/stream/grpc.ts
  70. 14 0
      frontend/src/schemas/protocols/stream/httpupgrade.ts
  71. 64 0
      frontend/src/schemas/protocols/stream/hysteria.ts
  72. 59 0
      frontend/src/schemas/protocols/stream/index.ts
  73. 14 0
      frontend/src/schemas/protocols/stream/kcp.ts
  74. 53 0
      frontend/src/schemas/protocols/stream/sockopt.ts
  75. 47 0
      frontend/src/schemas/protocols/stream/tcp.ts
  76. 17 0
      frontend/src/schemas/protocols/stream/ws.ts
  77. 63 0
      frontend/src/schemas/protocols/stream/xhttp.ts
  78. 118 0
      frontend/src/test/__snapshots__/headers.test.ts.snap
  79. 158 0
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  80. 517 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  81. 60 0
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  82. 1681 0
      frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap
  83. 219 0
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  84. 72 0
      frontend/src/test/__snapshots__/security.test.ts.snap
  85. 34 0
      frontend/src/test/__snapshots__/stream.test.ts.snap
  86. 67 0
      frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json
  87. 49 0
      frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json
  88. 73 0
      frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json
  89. 67 0
      frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json
  90. 76 0
      frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json
  91. 69 0
      frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json
  92. 34 0
      frontend/src/test/golden/fixtures/inbound-full/wireguard-server.json
  93. 10 0
      frontend/src/test/golden/fixtures/inbound/http-basic.json
  94. 20 0
      frontend/src/test/golden/fixtures/inbound/hysteria-basic.json
  95. 20 0
      frontend/src/test/golden/fixtures/inbound/hysteria2-basic.json
  96. 11 0
      frontend/src/test/golden/fixtures/inbound/mixed-basic.json
  97. 24 0
      frontend/src/test/golden/fixtures/inbound/shadowsocks-2022.json
  98. 20 0
      frontend/src/test/golden/fixtures/inbound/trojan-basic.json
  99. 13 0
      frontend/src/test/golden/fixtures/inbound/tunnel-basic.json
  100. 23 0
      frontend/src/test/golden/fixtures/inbound/vless-tcp-none.json

+ 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

+ 132 - 0
frontend/ZOD_MIGRATION_STATUS.md

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

+ 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',

+ 363 - 1
frontend/package-lock.json

@@ -40,7 +40,8 @@
         "globals": "^17.6.0",
         "typescript": "^6.0.3",
         "typescript-eslint": "^8.59.4",
-        "vite": "8.0.13"
+        "vite": "8.0.13",
+        "vitest": "^4.1.7"
       },
       "engines": {
         "node": ">=22.0.0",
@@ -2639,6 +2640,17 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
     "node_modules/@types/d3-array": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2702,6 +2714,13 @@
       "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
       "license": "MIT"
     },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/esrecurse": {
       "version": "4.3.1",
       "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -3065,6 +3084,119 @@
         }
       }
     },
+    "node_modules/@vitest/expect": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
+      "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.1.0",
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "4.1.7",
+        "@vitest/utils": "4.1.7",
+        "chai": "^6.2.2",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+      "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "4.1.7",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.21"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
+      "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
+      "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "4.1.7",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
+      "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.7",
+        "@vitest/utils": "4.1.7",
+        "magic-string": "^0.30.21",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
+      "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
+      "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.7",
+        "convert-source-map": "^2.0.0",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
     "node_modules/acorn": {
       "version": "8.16.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3192,6 +3324,16 @@
       "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
       "license": "Python-2.0"
     },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3414,6 +3556,16 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/chai": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/character-entities": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -3856,6 +4008,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es-module-lexer": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+      "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/es-object-atoms": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -4078,6 +4237,16 @@
         "node": ">=4.0"
       }
     },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
     "node_modules/esutils": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4094,6 +4263,16 @@
       "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
       "license": "MIT"
     },
+    "node_modules/expect-type": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+      "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5171,6 +5350,16 @@
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5328,6 +5517,17 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/obug": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+      "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT"
+    },
     "node_modules/openapi-path-templating": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz",
@@ -5459,6 +5659,13 @@
         "node": ">=8"
       }
     },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/persian-calendar-suite": {
       "version": "1.5.5",
       "resolved": "https://registry.npmjs.org/persian-calendar-suite/-/persian-calendar-suite-1.5.5.tgz",
@@ -6221,6 +6428,13 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6247,6 +6461,20 @@
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+      "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/string-convert": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
@@ -6355,6 +6583,23 @@
       "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
       "license": "MIT"
     },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
+      "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.16",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -6372,6 +6617,16 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tinyrainbow": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+      "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/to-buffer": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -6707,6 +6962,96 @@
         }
       }
     },
+    "node_modules/vitest": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
+      "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "4.1.7",
+        "@vitest/mocker": "4.1.7",
+        "@vitest/pretty-format": "4.1.7",
+        "@vitest/runner": "4.1.7",
+        "@vitest/snapshot": "4.1.7",
+        "@vitest/spy": "4.1.7",
+        "@vitest/utils": "4.1.7",
+        "es-module-lexer": "^2.0.0",
+        "expect-type": "^1.3.0",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "std-env": "^4.0.0-rc.1",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^1.0.2",
+        "tinyglobby": "^0.2.15",
+        "tinyrainbow": "^3.1.0",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "4.1.7",
+        "@vitest/browser-preview": "4.1.7",
+        "@vitest/browser-webdriverio": "4.1.7",
+        "@vitest/coverage-istanbul": "4.1.7",
+        "@vitest/coverage-v8": "4.1.7",
+        "@vitest/ui": "4.1.7",
+        "happy-dom": "*",
+        "jsdom": "*",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/coverage-istanbul": {
+          "optional": true
+        },
+        "@vitest/coverage-v8": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        },
+        "vite": {
+          "optional": false
+        }
+      }
+    },
     "node_modules/void-elements": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -6766,6 +7111,23 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/word-wrap": {
       "version": "1.2.5",
       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

+ 4 - 1
frontend/package.json

@@ -14,6 +14,8 @@
     "preview": "vite preview",
     "lint": "eslint src",
     "typecheck": "tsc --noEmit",
+    "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"
   },
@@ -50,7 +52,8 @@
     "globals": "^17.6.0",
     "typescript": "^6.0.3",
     "typescript-eslint": "^8.59.4",
-    "vite": "8.0.13"
+    "vite": "8.0.13",
+    "vitest": "^4.1.7"
   },
   "overrides": {
     "react-copy-to-clipboard": "^5.1.1",

Файловите разлики са ограничени, защото са твърде много
+ 444 - 525
frontend/src/components/FinalMaskForm.tsx


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

@@ -0,0 +1,122 @@
+import { useMemo } 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) {
+  const rows = useMemo(() => mapToRows(value), [value]);
+
+  function commit(next: HeaderRow[]) {
+    onChange?.(rowsToMap(next, mode));
+  }
+
+  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>
+    </>
+  );
+}

+ 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;
+}

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

@@ -0,0 +1,260 @@
+import { RandomUtil, Wireguard } from '@/utils';
+
+import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
+import type { Hysteria2InboundSettings } from '@/schemas/protocols/inbound/hysteria2';
+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 { 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 createDefaultHysteria2InboundSettings(): Hysteria2InboundSettings {
+  return { 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 interface WireguardInboundSeed {
+  mtu?: number;
+  secretKey?: string;
+  noKernelTun?: boolean;
+}
+
+export function createDefaultWireguardInboundSettings(
+  seed: WireguardInboundSeed = {},
+): WireguardInboundSettings {
+  return {
+    mtu: seed.mtu ?? 1420,
+    secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
+    peers: [],
+    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
+  | Hysteria2InboundSettings
+  | HttpInboundSettings
+  | MixedInboundSettings
+  | 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 'hysteria2':   return createDefaultHysteria2InboundSettings();
+    case 'http':        return createDefaultHttpInboundSettings();
+    case 'mixed':       return createDefaultMixedInboundSettings();
+    case 'tunnel':      return createDefaultTunnelInboundSettings();
+    case 'wireguard':   return createDefaultWireguardInboundSettings();
+    default:            return null;
+  }
+}

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

@@ -0,0 +1,135 @@
+import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
+import type { InboundSettings } from '@/schemas/protocols/inbound';
+import type { StreamSettings } from '@/schemas/api/inbound';
+import type { Sniffing } from '@/schemas/primitives';
+
+// 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';
+}
+
+// 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;
+  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;
+}
+
+export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
+  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(values.settings ?? {}),
+    streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '',
+    sniffing: JSON.stringify(values.sniffing ?? {}),
+    tag: values.tag,
+  };
+  if (values.nodeId != null) payload.nodeId = values.nodeId;
+  return payload;
+}

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

@@ -0,0 +1,924 @@
+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://${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' && inbound.protocol !== 'hysteria2') 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':
+    case 'hysteria2':
+      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':
+    case 'hysteria2':
+      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 '-ieo'
+// (dash-separated, inbound + email + proxy).
+export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
+  const {
+    inbound,
+    remark = '',
+    remarkModel = '-ieo',
+    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 = '-ieo',
+    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 = '-ieo', 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 = '-ieo', 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');
+}

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

@@ -0,0 +1,174 @@
+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 { Hysteria2OutboundSettings } from '@/schemas/protocols/outbound/hysteria2';
+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 function createDefaultHysteria2OutboundSettings(): Hysteria2OutboundSettings {
+  return { address: '', port: 443, version: 2 };
+}
+
+export type AnyOutboundSettings =
+  | BlackholeOutboundSettings
+  | DNSOutboundSettings
+  | FreedomOutboundSettings
+  | HttpOutboundSettings
+  | HysteriaOutboundSettings
+  | Hysteria2OutboundSettings
+  | 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 'hysteria2':   return createDefaultHysteria2OutboundSettings();
+    case 'loopback':    return createDefaultLoopbackOutboundSettings();
+    default:            return null;
+  }
+}

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

@@ -0,0 +1,627 @@
+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';
+
+// Adapter between the wire-shape outbound JSON the panel stores in
+// templateSettings.outbounds[] and the typed OutboundFormValues the modal
+// holds in Form.useForm<T>. No dependency on the legacy Outbound class
+// hierarchy — the modal hands a wire-shape object in, takes typed values
+// out, and on submit calls formValuesToWirePayload() to get a plain JS
+// object ready to pass to onConfirm().
+
+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;
+}
+
+// Convert wire-shape outbound (the object stored in
+// templateSettings.outbounds[]) into typed form values. Stream + mux are
+// minimal placeholders for now — the modal will fold the real stream sub-
+// form in when those sections come online.
+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);
+  // Leave streamSettings undefined when missing or empty — the modal's
+  // stream tab seeds it when the user opens the relevant section. This
+  // keeps Form.useForm from receiving a value that doesn't match the
+  // NetworkSettings DU.
+  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']);
+
+// Strip UI-only fields the form layered into streamSettings (e.g. the
+// XHTTP modal's enableXmux toggle that controls section visibility but
+// has no meaning on the wire). xray-core would ignore unknown fields
+// anyway but the panel reads back its own emitted JSON, so we keep
+// the wire shape clean.
+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 = 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;
+  if (values.mux.enabled && muxAllowed(values)) {
+    result.mux = values.mux;
+  }
+  return result;
+}

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

@@ -0,0 +1,400 @@
+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 {
+  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;
+  }
+}
+
+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);
+  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);
+  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, congestion: '', up: '0', down: '0',
+      initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608,
+      initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520,
+      maxIdleTimeout: 30, keepAlivePeriod: 2, disablePathMTUDiscovery: false,
+    },
+    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;
+}

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
-import { TLS_FLOW_CONTROL } from '@/models/inbound';
+import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import DateTimePicker from '@/components/DateTimePicker';
 import type { InboundOption } from '@/hooks/useClients';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';

+ 1 - 1
frontend/src/pages/clients/ClientFormModal.tsx

@@ -19,7 +19,7 @@ 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 { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 import './ClientFormModal.css';

Файловите разлики са ограничени, защото са твърде много
+ 425 - 832
frontend/src/pages/inbounds/InboundFormModal.tsx


+ 1 - 1
frontend/src/pages/inbounds/InboundInfoModal.tsx

@@ -12,7 +12,7 @@ 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 type { SubSettings } from './useInbounds';

+ 3 - 2
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -20,7 +20,7 @@ import {
 } from '@ant-design/icons';
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
-import { Inbound } from '@/models/inbound';
+import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -354,7 +354,8 @@ export default function InboundsPage() {
           raw.clients = [];
           clonedSettings = JSON.stringify(raw);
         } catch {
-          clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
+          const fallback = createDefaultInboundSettings(baseInbound.protocol);
+          clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
         }
         const data = {
           up: 0,

+ 1 - 1
frontend/src/pages/inbounds/QrCodeModal.tsx

@@ -3,7 +3,7 @@ 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 QrPanel from './QrPanel';
 import type { SubSettings } from './useInbounds';
 

+ 2 - 2
frontend/src/pages/inbounds/useInbounds.ts

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { HttpUtil } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { DBInbound } from '@/models/dbinbound';
-import { Protocols } from '@/models/inbound';
+import { Protocols } from '@/schemas/primitives';
 import { setDatepicker } from '@/hooks/useDatepicker';
 import { keys } from '@/api/queryKeys';
 import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
@@ -31,7 +31,7 @@ interface ClientRollup {
   comments: Map<string, string>;
 }
 
-const TRACKED_PROTOCOLS = [
+const TRACKED_PROTOCOLS: readonly string[] = [
   Protocols.VMESS,
   Protocols.VLESS,
   Protocols.TROJAN,

+ 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');

+ 2126 - 1367
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -1,43 +1,67 @@
-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,
+  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';
-import { OutboundTagSchema } from '@/schemas/xray';
+} from '@/schemas/primitives';
+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;
@@ -46,20 +70,113 @@ 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: '',
+          congestion: '',
+          up: '0',
+          down: '0',
+          initStreamReceiveWindow: 8388608,
+          maxStreamReceiveWindow: 8388608,
+          initConnectionReceiveWindow: 20971520,
+          maxConnectionReceiveWindow: 20971520,
+          maxIdleTimeout: 30,
+          keepAlivePeriod: 2,
+          disablePathMTUDiscovery: false,
+        },
+      };
+    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,
@@ -70,205 +187,210 @@ 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');
+    setActiveKey('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;
+  const network = (Form.useWatch(['streamSettings', 'network'], form) ?? '') as string;
+  const security = (Form.useWatch(['streamSettings', 'security'], form) ?? '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();
     }
-    if (revertingTabRef.current) {
-      revertingTabRef.current = false;
+    if (key === '2') {
+      const values = form.getFieldsValue(true) as OutboundFormValues;
+      setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
+      setJsonDirty(false);
       setActiveKey(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;
     }
+    setActiveKey(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;
-    const tagOk = OutboundTagSchema.safeParse(ob.tag);
-    if (!tagOk.success) {
-      const msgKey = tagOk.error.issues[0]?.message ?? 'Tag is required';
-      messageApi.error(t(msgKey, { defaultValue: '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 (
@@ -283,1192 +405,1829 @@ 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 label="Send through" name="sendThrough">
+                      <Input placeholder="local IP" />
                     </Form.Item>
-                    <Form.Item label={t('password')}>
-                      <Input
-                        value={ob.settings.pass || ''}
-                        onChange={(e) => { ob.settings.pass = e.target.value; refresh(); }}
-                      />
-                    </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
+                              label="Uplink HTTP method"
+                              name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
+                            >
+                              <Form.Item shouldUpdate noStyle>
+                                {() => {
+                                  const mode = form.getFieldValue([
+                                    'streamSettings', 'xhttpSettings', 'mode',
+                                  ]);
+                                  return (
+                                    <Select
+                                      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
+                                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
+                                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="Congestion"
+                              name={['streamSettings', 'hysteriaSettings', 'congestion']}
+                            >
+                              <Select
+                                options={[
+                                  { value: '', label: 'BBR (auto)' },
+                                  { value: 'brutal', label: 'Brutal' },
+                                ]}
+                              />
+                            </Form.Item>
+                            <Form.Item
+                              label="Upload"
+                              name={['streamSettings', 'hysteriaSettings', 'up']}
+                            >
+                              <Input placeholder="100 mbps" />
+                            </Form.Item>
+                            <Form.Item
+                              label="Download"
+                              name={['streamSettings', 'hysteriaSettings', 'down']}
+                            >
+                              <Input placeholder="100 mbps" />
+                            </Form.Item>
+                            <Form.Item label="UDP hop">
+                              <Form.Item
+                                shouldUpdate
+                                noStyle
+                              >
+                                {() => {
+                                  const udphop = form.getFieldValue([
+                                    'streamSettings', 'hysteriaSettings', 'udphop',
+                                  ]) as { port?: string } | undefined;
+                                  return (
+                                    <Switch
+                                      checked={!!udphop}
+                                      onChange={(checked) =>
+                                        form.setFieldValue(
+                                          ['streamSettings', 'hysteriaSettings', 'udphop'],
+                                          checked
+                                            ? { port: '', intervalMin: 30, intervalMax: 30 }
+                                            : undefined,
+                                        )
+                                      }
+                                    />
+                                  );
+                                }}
+                              </Form.Item>
+                            </Form.Item>
+                            <Form.Item shouldUpdate noStyle>
+                              {() => {
+                                const udphop = form.getFieldValue([
+                                  'streamSettings', 'hysteriaSettings', 'udphop',
+                                ]) as { port?: string } | undefined;
+                                if (!udphop) return null;
+                                return (
+                                  <>
+                                    <Form.Item
+                                      label="UDP hop port"
+                                      name={['streamSettings', 'hysteriaSettings', 'udphop', 'port']}
+                                    >
+                                      <Input placeholder="1145-1919" />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="UDP hop interval min (s)"
+                                      name={[
+                                        'streamSettings', 'hysteriaSettings',
+                                        'udphop', 'intervalMin',
+                                      ]}
+                                    >
+                                      <InputNumber min={1} />
+                                    </Form.Item>
+                                    <Form.Item
+                                      label="UDP hop interval max (s)"
+                                      name={[
+                                        'streamSettings', 'hysteriaSettings',
+                                        'udphop', 'intervalMax',
+                                      ]}
+                                    >
+                                      <InputNumber min={1} />
+                                    </Form.Item>
+                                  </>
+                                );
+                              }}
+                            </Form.Item>
+                            <Form.Item
+                              label="Max idle (s)"
+                              name={['streamSettings', 'hysteriaSettings', 'maxIdleTimeout']}
+                            >
+                              <InputNumber min={1} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Keep alive (s)"
+                              name={['streamSettings', 'hysteriaSettings', 'keepAlivePeriod']}
+                            >
+                              <InputNumber min={1} />
+                            </Form.Item>
+                            <Form.Item
+                              label="Disable Path MTU"
+                              name={['streamSettings', 'hysteriaSettings', 'disablePathMTUDiscovery']}
+                              valuePropName="checked"
+                            >
+                              <Switch />
+                            </Form.Item>
+                            <div style={{ marginTop: 4, opacity: 0.6, fontStyle: 'italic' }}>
+                              Receive-window tuning (init/maxStreamReceiveWindow,
+                              init/maxConnectionReceiveWindow) is rarely changed
+                              — edit via the JSON tab if needed.
+                            </div>
+                          </>
+                        )}
+                      </>
+                    )}
+
+                    {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 && (
+                      <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
+                                        ? {
+                                            acceptProxyProtocol: false,
+                                            tcpFastOpen: false,
+                                            mark: 0,
+                                            tproxy: 'off',
+                                            tcpMptcp: false,
+                                            penetrate: false,
+                                            domainStrategy: 'UseIP',
+                                            tcpMaxSeg: 1440,
+                                            dialerProxy: '',
+                                            tcpKeepAliveInterval: 0,
+                                            tcpKeepAliveIdle: 300,
+                                            tcpUserTimeout: 10000,
+                                            tcpcongestion: 'bbr',
+                                            V6Only: false,
+                                            tcpWindowClamp: 600,
+                                            interfaceName: '',
+                                            trustedXForwardedFor: [],
+                                          }
+                                        : 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={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>
+                    )}
+
+                    <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>
     </>
   );
 }

+ 1 - 1
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';

+ 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>;

+ 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.literal('none').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>;

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

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

+ 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',
+  SrvPortOnly: 'srvportonly',
+  SrvAddressOnly: 'srvaddressonly',
+  SrvPortAndAddress: 'srvportandaddress',
+  TxtPortOnly: 'txtportonly',
+  TxtAddressOnly: 'txtaddressonly',
+  TxtPortAndAddress: '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>;

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

@@ -0,0 +1,35 @@
+import { z } from 'zod';
+
+export const ProtocolSchema = z.enum([
+  'vmess',
+  'vless',
+  'trojan',
+  'shadowsocks',
+  'wireguard',
+  'hysteria',
+  'hysteria2',
+  'http',
+  'mixed',
+  'tunnel',
+]);
+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',
+  HYSTERIA2: 'hysteria2',
+  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>;

+ 13 - 0
frontend/src/schemas/protocols/inbound/hysteria2.ts

@@ -0,0 +1,13 @@
+import { z } from 'zod';
+
+import { HysteriaClientSchema } from '@/schemas/protocols/inbound/hysteria';
+
+// hysteria2 is wire-distinct from hysteria (different parent protocol literal,
+// different Go validate tag) but the panel's settings payload is structurally
+// identical — same client shape, same auth-based clients. We pin `version` to
+// the literal 2 here so a hysteria2 inbound can never silently downgrade.
+export const Hysteria2InboundSettingsSchema = z.object({
+  version: z.literal(2).default(2),
+  clients: z.array(HysteriaClientSchema).default([]),
+});
+export type Hysteria2InboundSettings = z.infer<typeof Hysteria2InboundSettingsSchema>;

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

@@ -0,0 +1,42 @@
+import { z } from 'zod';
+
+import { HttpInboundSettingsSchema } from './http';
+import { Hysteria2InboundSettingsSchema } from './hysteria2';
+import { HysteriaInboundSettingsSchema } from './hysteria';
+import { MixedInboundSettingsSchema } from './mixed';
+import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
+import { TrojanInboundSettingsSchema } from './trojan';
+import { TunnelInboundSettingsSchema } from './tunnel';
+import { VlessInboundSettingsSchema } from './vless';
+import { VmessInboundSettingsSchema } from './vmess';
+import { WireguardInboundSettingsSchema } from './wireguard';
+
+export * from './http';
+export * from './hysteria';
+export * from './hysteria2';
+export * from './mixed';
+export * from './shadowsocks';
+export * from './trojan';
+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('hysteria2'),   settings: Hysteria2InboundSettingsSchema }),
+  z.object({ protocol: z.literal('http'),        settings: HttpInboundSettingsSchema }),
+  z.object({ protocol: z.literal('mixed'),       settings: MixedInboundSettingsSchema }),
+  z.object({ protocol: z.literal('tunnel'),      settings: TunnelInboundSettingsSchema }),
+]);
+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>;

+ 19 - 0
frontend/src/schemas/protocols/inbound/tunnel.ts

@@ -0,0 +1,19 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+export const TunnelNetworkSchema = z.enum(['tcp', 'udp', 'tcp,udp']);
+export type TunnelNetwork = z.infer<typeof TunnelNetworkSchema>;
+
+// Tunnel inbound (Xray's `dokodemo-door`-style transparent forwarder).
+// `portMap` is persisted as Record<string, string> on the wire — the panel
+// flattens an internal array-of-{name,value} into that map via toV2Headers
+// with arr=false.
+export const TunnelInboundSettingsSchema = z.object({
+  rewriteAddress: z.string().optional(),
+  rewritePort: PortSchema.optional(),
+  portMap: z.record(z.string(), z.string()).default({}),
+  allowedNetwork: TunnelNetworkSchema.default('tcp,udp'),
+  followRedirect: z.boolean().default(false),
+});
+export type TunnelInboundSettings = z.infer<typeof TunnelInboundSettingsSchema>;

+ 50 - 0
frontend/src/schemas/protocols/inbound/vless.ts

@@ -0,0 +1,50 @@
+import { z } from 'zod';
+
+import { FlowSchema, SniffingSchema } from '@/schemas/primitives';
+
+export const VlessFallbackSchema = 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 VlessFallback = z.infer<typeof VlessFallbackSchema>;
+
+export const VlessClientSchema = z.object({
+  id: z.uuid(),
+  email: z.string().min(1),
+  flow: FlowSchema.default(''),
+  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),
+  // VLESS simple reverse-proxy: which reverse tag this client routes to,
+  // plus an optional sniffing override for that path. Distinct from the
+  // inbound-level `fallbacks` feature.
+  reverse: z
+    .object({
+      tag: z.string(),
+      sniffing: SniffingSchema.optional(),
+    })
+    .optional(),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type VlessClient = z.infer<typeof VlessClientSchema>;
+
+export const VlessInboundSettingsSchema = z.object({
+  clients: z.array(VlessClientSchema).default([]),
+  decryption: z.literal('none').default('none'),
+  encryption: z.literal('none').default('none'),
+  fallbacks: z.array(VlessFallbackSchema).default([]),
+  // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
+  // exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses
+  // safe defaults when omitted.
+  testseed: z.array(z.number().int().positive()).length(4).optional(),
+});
+export type VlessInboundSettings = z.infer<typeof VlessInboundSettingsSchema>;

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

@@ -0,0 +1,32 @@
+import { z } from 'zod';
+
+export const VmessSecuritySchema = z.enum([
+  'aes-128-gcm',
+  'chacha20-poly1305',
+  'auto',
+  'none',
+  'zero',
+]);
+export type VmessSecurity = z.infer<typeof VmessSecuritySchema>;
+
+export const VmessClientSchema = z.object({
+  id: z.uuid(),
+  security: VmessSecuritySchema.default('auto'),
+  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 VmessClient = z.infer<typeof VmessClientSchema>;
+
+export const VmessInboundSettingsSchema = z.object({
+  clients: z.array(VmessClientSchema).default([]),
+});
+export type VmessInboundSettings = z.infer<typeof VmessInboundSettingsSchema>;

+ 23 - 0
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -0,0 +1,23 @@
+import { z } from 'zod';
+
+// Wireguard inbound is peer-based (no clients). Each peer is a client device
+// the server accepts; secretKey is the server-side private key and pubKey is
+// derived from it at runtime (not persisted on the wire). Inbound peers
+// optionally store the client's privateKey so the panel can render configs
+// for the user — outbound peers never have a privateKey.
+export const WireguardInboundPeerSchema = z.object({
+  privateKey: z.string().optional(),
+  publicKey: z.string().min(1),
+  preSharedKey: z.string().optional(),
+  allowedIPs: z.array(z.string()).default([]),
+  keepAlive: z.number().int().min(0).optional(),
+});
+export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
+
+export const WireguardInboundSettingsSchema = z.object({
+  mtu: z.number().int().min(1).optional(),
+  secretKey: z.string().min(1),
+  peers: z.array(WireguardInboundPeerSchema).default([]),
+  noKernelTun: z.boolean().default(false),
+});
+export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;

+ 13 - 0
frontend/src/schemas/protocols/index.ts

@@ -0,0 +1,13 @@
+export * as Inbound from './inbound';
+export * as Outbound from './outbound';
+export * as Stream from './stream';
+export * as Security from './security';
+
+export { InboundSettingsSchema } from './inbound';
+export type { InboundSettings } from './inbound';
+export { OutboundSettingsSchema } from './outbound';
+export type { OutboundSettings } from './outbound';
+export { NetworkSchema, NetworkSettingsSchema } from './stream';
+export type { Network, NetworkSettings } from './stream';
+export { SecuritySchema, SecuritySettingsSchema } from './security';
+export type { Security as SecurityKind, SecuritySettings } from './security';

+ 13 - 0
frontend/src/schemas/protocols/outbound/blackhole.ts

@@ -0,0 +1,13 @@
+import { z } from 'zod';
+
+export const BlackholeResponseTypeSchema = z.enum(['none', 'http']);
+export type BlackholeResponseType = z.infer<typeof BlackholeResponseTypeSchema>;
+
+// Blackhole drops traffic. `response.type` is the only knob — when set, Xray
+// returns the canned 403 HTTP response before closing; when omitted it
+// silently drops. The panel stores it as { response: { type } } or omits the
+// whole `response` key when type is empty.
+export const BlackholeOutboundSettingsSchema = z.object({
+  response: z.object({ type: BlackholeResponseTypeSchema }).optional(),
+});
+export type BlackholeOutboundSettings = z.infer<typeof BlackholeOutboundSettingsSchema>;

+ 27 - 0
frontend/src/schemas/protocols/outbound/dns.ts

@@ -0,0 +1,27 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+export const DNSRuleActionSchema = z.enum(['direct', 'reject', 'rejectIPv4', 'rejectIPv6']);
+
+// On the wire `qtype` is either a number (DNS type code) or a string like
+// "A"/"AAAA"/"TXT"; the panel normalizes numeric strings to numbers in
+// toJson. `domain` is a string[] (split from a comma-joined input).
+export const DNSRuleSchema = z.object({
+  action: DNSRuleActionSchema.default('direct'),
+  qtype: z.union([z.string(), z.number().int()]).optional(),
+  domain: z.array(z.string()).optional(),
+});
+export type DNSRule = z.infer<typeof DNSRuleSchema>;
+
+// DNS outbound rewrites DNS queries onto a different transport. All five
+// fields are emitted conditionally — empty/zero values are omitted from the
+// wire payload entirely (handled at the caller, not here).
+export const DNSOutboundSettingsSchema = z.object({
+  rewriteNetwork: z.string().optional(),
+  rewriteAddress: z.string().optional(),
+  rewritePort: PortSchema.optional(),
+  userLevel: z.number().int().min(0).optional(),
+  rules: z.array(DNSRuleSchema).optional(),
+});
+export type DNSOutboundSettings = z.infer<typeof DNSOutboundSettingsSchema>;

+ 59 - 0
frontend/src/schemas/protocols/outbound/freedom.ts

@@ -0,0 +1,59 @@
+import { z } from 'zod';
+
+export const OutboundDomainStrategySchema = z.enum([
+  'AsIs',
+  'UseIP',
+  'UseIPv4',
+  'UseIPv6',
+  'UseIPv6v4',
+  'UseIPv4v6',
+  'ForceIP',
+  'ForceIPv6v4',
+  'ForceIPv6',
+  'ForceIPv4v6',
+  'ForceIPv4',
+]);
+export type OutboundDomainStrategy = z.infer<typeof OutboundDomainStrategySchema>;
+
+// Fragment knobs are TCP-level splitting controls; all four fields are
+// dash-range strings (e.g. '1-3', '10-20').
+export const FreedomFragmentSchema = z.object({
+  packets: z.string().default('1-3'),
+  length: z.string().default(''),
+  interval: z.string().default(''),
+  maxSplit: z.string().default(''),
+});
+export type FreedomFragment = z.infer<typeof FreedomFragmentSchema>;
+
+export const FreedomNoiseTypeSchema = z.enum(['rand', 'str', 'base64', 'hex']);
+export const FreedomNoiseApplyToSchema = z.enum(['ip', 'host', 'all']);
+
+export const FreedomNoiseSchema = z.object({
+  type: FreedomNoiseTypeSchema.default('rand'),
+  packet: z.string().default('10-20'),
+  delay: z.string().default('10-16'),
+  applyTo: FreedomNoiseApplyToSchema.default('ip'),
+});
+export type FreedomNoise = z.infer<typeof FreedomNoiseSchema>;
+
+export const FreedomFinalRuleActionSchema = z.enum(['allow', 'block']);
+
+// Final rules express the legacy ipsBlocked behavior plus generalized
+// allow/block per network+port+ip combinations.
+export const FreedomFinalRuleSchema = z.object({
+  action: FreedomFinalRuleActionSchema.default('block'),
+  network: z.string().optional(),
+  port: z.string().optional(),
+  ip: z.array(z.string()).default([]),
+  blockDelay: z.string().optional(),
+});
+export type FreedomFinalRule = z.infer<typeof FreedomFinalRuleSchema>;
+
+export const FreedomOutboundSettingsSchema = z.object({
+  domainStrategy: OutboundDomainStrategySchema.optional(),
+  redirect: z.string().optional(),
+  fragment: FreedomFragmentSchema.optional(),
+  noises: z.array(FreedomNoiseSchema).optional(),
+  finalRules: z.array(FreedomFinalRuleSchema).optional(),
+});
+export type FreedomOutboundSettings = z.infer<typeof FreedomOutboundSettingsSchema>;

+ 25 - 0
frontend/src/schemas/protocols/outbound/http.ts

@@ -0,0 +1,25 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+// HTTP outbound persists in Xray's `servers[].users[]` shape. The panel only
+// supports a single server with at most one user (the constructor reads
+// servers[0] / users[0]). We model the wire shape rather than the panel's
+// flattened class fields so saves round-trip exactly.
+export const HttpOutboundUserSchema = z.object({
+  user: z.string().min(1),
+  pass: z.string().min(1),
+});
+export type HttpOutboundUser = z.infer<typeof HttpOutboundUserSchema>;
+
+export const HttpOutboundServerSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  users: z.array(HttpOutboundUserSchema).default([]),
+});
+export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
+
+export const HttpOutboundSettingsSchema = z.object({
+  servers: z.array(HttpOutboundServerSchema).min(1),
+});
+export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;

+ 12 - 0
frontend/src/schemas/protocols/outbound/hysteria.ts

@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+// Hysteria outbound is a thin connect-target descriptor — the actual auth and
+// transport knobs live on the stream/transport layer, not in settings.
+export const HysteriaOutboundSettingsSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  version: z.number().int().min(1).default(2),
+});
+export type HysteriaOutboundSettings = z.infer<typeof HysteriaOutboundSettingsSchema>;

+ 12 - 0
frontend/src/schemas/protocols/outbound/hysteria2.ts

@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+// Outbound counterpart to hysteria2 — same {address, port} connect descriptor
+// as hysteria, but version locked to 2.
+export const Hysteria2OutboundSettingsSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  version: z.literal(2).default(2),
+});
+export type Hysteria2OutboundSettings = z.infer<typeof Hysteria2OutboundSettingsSchema>;

+ 50 - 0
frontend/src/schemas/protocols/outbound/index.ts

@@ -0,0 +1,50 @@
+import { z } from 'zod';
+
+import { BlackholeOutboundSettingsSchema } from './blackhole';
+import { DNSOutboundSettingsSchema } from './dns';
+import { FreedomOutboundSettingsSchema } from './freedom';
+import { HttpOutboundSettingsSchema } from './http';
+import { Hysteria2OutboundSettingsSchema } from './hysteria2';
+import { HysteriaOutboundSettingsSchema } from './hysteria';
+import { LoopbackOutboundSettingsSchema } from './loopback';
+import { ShadowsocksOutboundSettingsSchema } from './shadowsocks';
+import { SocksOutboundSettingsSchema } from './socks';
+import { TrojanOutboundSettingsSchema } from './trojan';
+import { VlessOutboundSettingsSchema } from './vless';
+import { VmessOutboundSettingsSchema } from './vmess';
+import { WireguardOutboundSettingsSchema } from './wireguard';
+
+export * from './blackhole';
+export * from './dns';
+export * from './freedom';
+export * from './http';
+export * from './hysteria';
+export * from './hysteria2';
+export * from './loopback';
+export * from './shadowsocks';
+export * from './socks';
+export * from './trojan';
+export * from './vless';
+export * from './vmess';
+export * from './wireguard';
+
+// Outbound discriminated union spans 13 protocols (mixed/tunnel are
+// inbound-only; freedom/blackhole/dns/loopback are outbound-only). The wire
+// shape is `{ protocol, settings }` — same wrapper pattern as the inbound
+// union, even though some leaf schemas (freedom, blackhole) are sparse.
+export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
+  z.object({ protocol: z.literal('vmess'),       settings: VmessOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('vless'),       settings: VlessOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('trojan'),      settings: TrojanOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('wireguard'),   settings: WireguardOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('hysteria'),    settings: HysteriaOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('hysteria2'),   settings: Hysteria2OutboundSettingsSchema }),
+  z.object({ protocol: z.literal('http'),        settings: HttpOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('socks'),       settings: SocksOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('freedom'),     settings: FreedomOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('blackhole'),   settings: BlackholeOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('dns'),         settings: DNSOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('loopback'),    settings: LoopbackOutboundSettingsSchema }),
+]);
+export type OutboundSettings = z.infer<typeof OutboundSettingsSchema>;

+ 8 - 0
frontend/src/schemas/protocols/outbound/loopback.ts

@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+// Loopback outbound reinjects traffic back into a named inbound for chained
+// routing. The single `inboundTag` field references an inbound tag by name.
+export const LoopbackOutboundSettingsSchema = z.object({
+  inboundTag: z.string().optional(),
+});
+export type LoopbackOutboundSettings = z.infer<typeof LoopbackOutboundSettingsSchema>;

+ 21 - 0
frontend/src/schemas/protocols/outbound/shadowsocks.ts

@@ -0,0 +1,21 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks';
+
+// Shadowsocks outbound persists as { servers: [{ ... }] }, with UDP-over-TCP
+// knobs (uot, UoTVersion) attached per-server when the user enabled them.
+export const ShadowsocksOutboundServerSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  password: z.string().min(1),
+  method: SSMethodSchema,
+  uot: z.boolean().optional(),
+  UoTVersion: z.number().int().min(1).max(2).optional(),
+});
+export type ShadowsocksOutboundServer = z.infer<typeof ShadowsocksOutboundServerSchema>;
+
+export const ShadowsocksOutboundSettingsSchema = z.object({
+  servers: z.array(ShadowsocksOutboundServerSchema).min(1),
+});
+export type ShadowsocksOutboundSettings = z.infer<typeof ShadowsocksOutboundSettingsSchema>;

+ 24 - 0
frontend/src/schemas/protocols/outbound/socks.ts

@@ -0,0 +1,24 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+// SOCKS outbound persists in Xray's `servers[].users[]` shape — wire-identical
+// to HTTP outbound but with `socks` as the parent protocol literal. The panel
+// only supports a single server with at most one user.
+export const SocksOutboundUserSchema = z.object({
+  user: z.string().min(1),
+  pass: z.string().min(1),
+});
+export type SocksOutboundUser = z.infer<typeof SocksOutboundUserSchema>;
+
+export const SocksOutboundServerSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  users: z.array(SocksOutboundUserSchema).default([]),
+});
+export type SocksOutboundServer = z.infer<typeof SocksOutboundServerSchema>;
+
+export const SocksOutboundSettingsSchema = z.object({
+  servers: z.array(SocksOutboundServerSchema).min(1),
+});
+export type SocksOutboundSettings = z.infer<typeof SocksOutboundSettingsSchema>;

+ 18 - 0
frontend/src/schemas/protocols/outbound/trojan.ts

@@ -0,0 +1,18 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+// Trojan outbound persists as { servers: [{ address, port, password }] }
+// — distinct from VLESS outbound which stores the connect target flat at
+// the settings root. The wrapping mirrors what Xray expects.
+export const TrojanOutboundServerSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  password: z.string().min(1),
+});
+export type TrojanOutboundServer = z.infer<typeof TrojanOutboundServerSchema>;
+
+export const TrojanOutboundSettingsSchema = z.object({
+  servers: z.array(TrojanOutboundServerSchema).min(1),
+});
+export type TrojanOutboundSettings = z.infer<typeof TrojanOutboundSettingsSchema>;

+ 22 - 0
frontend/src/schemas/protocols/outbound/vless.ts

@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+import { FlowSchema, SniffingSchema } from '@/schemas/primitives';
+
+export const VlessOutboundSettingsSchema = z.object({
+  address: z.string(),
+  port: z.number().int().min(1).max(65535),
+  id: z.uuid(),
+  flow: FlowSchema.default(''),
+  encryption: z.literal('none').default('none'),
+  reverse: z
+    .object({
+      tag: z.string(),
+      sniffing: SniffingSchema.optional(),
+    })
+    .optional(),
+  testpre: z.number().int().min(0).optional(),
+  // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
+  // exists.
+  testseed: z.array(z.number().int().positive()).length(4).optional(),
+});
+export type VlessOutboundSettings = z.infer<typeof VlessOutboundSettingsSchema>;

+ 25 - 0
frontend/src/schemas/protocols/outbound/vmess.ts

@@ -0,0 +1,25 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+import { VmessSecuritySchema } from '@/schemas/protocols/inbound/vmess';
+
+// Vmess outbound persists in the standard Xray `vnext` shape:
+// { vnext: [{ address, port, users: [{ id, security }] }] }
+// — distinct from VLESS outbound which the panel stores flat.
+export const VmessOutboundUserSchema = z.object({
+  id: z.uuid(),
+  security: VmessSecuritySchema.default('auto'),
+});
+export type VmessOutboundUser = z.infer<typeof VmessOutboundUserSchema>;
+
+export const VmessOutboundServerSchema = z.object({
+  address: z.string().min(1),
+  port: PortSchema,
+  users: z.array(VmessOutboundUserSchema).min(1),
+});
+export type VmessOutboundServer = z.infer<typeof VmessOutboundServerSchema>;
+
+export const VmessOutboundSettingsSchema = z.object({
+  vnext: z.array(VmessOutboundServerSchema).min(1),
+});
+export type VmessOutboundSettings = z.infer<typeof VmessOutboundSettingsSchema>;

+ 36 - 0
frontend/src/schemas/protocols/outbound/wireguard.ts

@@ -0,0 +1,36 @@
+import { z } from 'zod';
+
+export const WireguardDomainStrategySchema = z.enum([
+  'ForceIP',
+  'ForceIPv4',
+  'ForceIPv4v6',
+  'ForceIPv6',
+  'ForceIPv6v4',
+]);
+export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
+
+// Outbound peer is the remote server we connect to: no privateKey, but an
+// `endpoint` (host:port) the inbound side does not need.
+export const WireguardOutboundPeerSchema = z.object({
+  publicKey: z.string().min(1),
+  preSharedKey: z.string().optional(),
+  allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']),
+  endpoint: z.string().min(1),
+  keepAlive: z.number().int().min(0).optional(),
+});
+export type WireguardOutboundPeer = z.infer<typeof WireguardOutboundPeerSchema>;
+
+// Wire format: address is a string[] (Xray expects an array even though the
+// panel UI stores it comma-joined); reserved is number[] (panel splits the
+// comma string and Number()-coerces each entry).
+export const WireguardOutboundSettingsSchema = z.object({
+  mtu: z.number().int().min(1).optional(),
+  secretKey: z.string().min(1),
+  address: z.array(z.string()).default([]),
+  workers: z.number().int().min(1).optional(),
+  domainStrategy: WireguardDomainStrategySchema.optional(),
+  reserved: z.array(z.number().int()).optional(),
+  peers: z.array(WireguardOutboundPeerSchema).min(1),
+  noKernelTun: z.boolean().default(false),
+});
+export type WireguardOutboundSettings = z.infer<typeof WireguardOutboundSettingsSchema>;

+ 23 - 0
frontend/src/schemas/protocols/security/index.ts

@@ -0,0 +1,23 @@
+import { z } from 'zod';
+
+import { RealityStreamSettingsSchema } from './reality';
+import { TlsStreamSettingsSchema } from './tls';
+
+export * from './none';
+export * from './reality';
+export * from './tls';
+
+export const SecuritySchema = z.enum(['none', 'tls', 'reality']);
+export type Security = z.infer<typeof SecuritySchema>;
+
+// Tagged-wrapper DU on `security`. Wire shape: when security==='tls' only
+// `tlsSettings` is present, when 'reality' only `realitySettings`, when
+// 'none' neither key appears. The Xray panel's StreamSettings class emits
+// `undefined` for the inactive branch which strips the key during JSON
+// serialization, so this DU faithfully describes what's on disk.
+export const SecuritySettingsSchema = z.discriminatedUnion('security', [
+  z.object({ security: z.literal('none') }),
+  z.object({ security: z.literal('tls'),     tlsSettings:     TlsStreamSettingsSchema }),
+  z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
+]);
+export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;

+ 7 - 0
frontend/src/schemas/protocols/security/none.ts

@@ -0,0 +1,7 @@
+import { z } from 'zod';
+
+// `security: 'none'` carries no payload — the streamSettings root just omits
+// both `tlsSettings` and `realitySettings`. This empty leaf is kept for
+// symmetry so the discriminated union has a branch for every security value.
+export const NoneSecuritySettingsSchema = z.object({});
+export type NoneSecuritySettings = z.infer<typeof NoneSecuritySettingsSchema>;

+ 41 - 0
frontend/src/schemas/protocols/security/reality.ts

@@ -0,0 +1,41 @@
+import { z } from 'zod';
+
+import { UtlsFingerprintSchema } from '@/schemas/protocols/security/tls';
+
+// Reality client-side handshake config (sits under the inbound's
+// realitySettings.settings on the wire — the panel's class names the field
+// `settings` even though it's the "client" half of Reality).
+export const RealityClientSettingsSchema = z.object({
+  publicKey: z.string().default(''),
+  fingerprint: UtlsFingerprintSchema.default('chrome'),
+  serverName: z.string().default(''),
+  spiderX: z.string().default('/'),
+  mldsa65Verify: z.string().default(''),
+});
+export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
+
+// Reality stream payload. `serverNames` and `shortIds` are stored as
+// comma-joined strings in the panel class but ship as string[] on the wire
+// — fixtures round-trip through the array form. `target` is the dest host
+// Reality piggybacks on; the panel auto-generates random target+SNI when
+// blank.
+export const RealityStreamSettingsSchema = z.object({
+  show: z.boolean().default(false),
+  xver: z.number().int().min(0).default(0),
+  target: z.string().default(''),
+  serverNames: z.array(z.string()).default([]),
+  privateKey: z.string().default(''),
+  minClientVer: z.string().default(''),
+  maxClientVer: z.string().default(''),
+  maxTimediff: z.number().int().min(0).default(0),
+  shortIds: z.array(z.string()).default([]),
+  mldsa65Seed: z.string().default(''),
+  settings: RealityClientSettingsSchema.default({
+    publicKey: '',
+    fingerprint: 'chrome',
+    serverName: '',
+    spiderX: '/',
+    mldsa65Verify: '',
+  }),
+});
+export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;

+ 72 - 0
frontend/src/schemas/protocols/security/tls.ts

@@ -0,0 +1,72 @@
+import { z } from 'zod';
+
+export const TlsVersionSchema = z.enum(['1.0', '1.1', '1.2', '1.3']);
+export type TlsVersion = z.infer<typeof TlsVersionSchema>;
+
+// Xray's uTLS fingerprints — used both for TLS and Reality. Kept here (not
+// in primitives/) because the only consumer is security/tls.ts and
+// security/reality.ts via re-import.
+export const UtlsFingerprintSchema = z.enum([
+  'chrome',
+  'firefox',
+  'safari',
+  'ios',
+  'android',
+  'edge',
+  '360',
+  'qq',
+  'random',
+  'randomized',
+  'randomizednoalpn',
+  'unsafe',
+]);
+export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
+
+export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
+export type Alpn = z.infer<typeof AlpnSchema>;
+
+export const TlsCertUsageSchema = z.enum(['encipherment', 'verify', 'issue']);
+export type TlsCertUsage = z.infer<typeof TlsCertUsageSchema>;
+
+// TLS certs on the wire come in two shapes — file-backed or inline. The
+// panel class collapses them into one with a `useFile` boolean; we model
+// the wire shape as a DU so saves round-trip without the boolean leaking.
+export const TlsCertFileSchema = z.object({
+  certificateFile: z.string().min(1),
+  keyFile: z.string().min(1),
+  oneTimeLoading: z.boolean().default(false),
+  usage: TlsCertUsageSchema.default('encipherment'),
+  buildChain: z.boolean().default(false),
+});
+export const TlsCertInlineSchema = z.object({
+  certificate: z.array(z.string()),
+  key: z.array(z.string()),
+  oneTimeLoading: z.boolean().default(false),
+  usage: TlsCertUsageSchema.default('encipherment'),
+  buildChain: z.boolean().default(false),
+});
+export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
+export type TlsCert = z.infer<typeof TlsCertSchema>;
+
+export const TlsClientSettingsSchema = z.object({
+  fingerprint: UtlsFingerprintSchema.default('chrome'),
+  echConfigList: z.string().default(''),
+});
+export type TlsClientSettings = z.infer<typeof TlsClientSettingsSchema>;
+
+// `serverName` is the SNI; the class field is `sni` internally but on the
+// wire stays `serverName` to match Xray's config schema.
+export const TlsStreamSettingsSchema = z.object({
+  serverName: z.string().default(''),
+  minVersion: TlsVersionSchema.default('1.2'),
+  maxVersion: TlsVersionSchema.default('1.3'),
+  cipherSuites: z.string().default(''),
+  rejectUnknownSni: z.boolean().default(false),
+  disableSystemRoot: z.boolean().default(false),
+  enableSessionResumption: z.boolean().default(false),
+  certificates: z.array(TlsCertSchema).default([]),
+  alpn: z.array(AlpnSchema).default(['h2', 'http/1.1']),
+  echServerKeys: z.string().default(''),
+  settings: TlsClientSettingsSchema.default({ fingerprint: 'chrome', echConfigList: '' }),
+});
+export type TlsStreamSettings = z.infer<typeof TlsStreamSettingsSchema>;

+ 23 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -0,0 +1,23 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+import { AlpnSchema, UtlsFingerprintSchema } from '@/schemas/protocols/security/tls';
+
+export const ExternalProxyForceTlsSchema = z.enum(['same', 'tls', 'none']);
+export type ExternalProxyForceTls = z.infer<typeof ExternalProxyForceTlsSchema>;
+
+// An inbound can advertise external proxy fronts (CDN edges, mirror nodes)
+// that share its config but vary the dest+port+SNI for the share link. The
+// panel form ships rows of this shape; link generators iterate them when
+// stream.externalProxy is non-empty.
+export const ExternalProxyEntrySchema = z.object({
+  forceTls: ExternalProxyForceTlsSchema.default('same'),
+  dest: z.string().default(''),
+  port: PortSchema.default(443),
+  remark: z.string().default(''),
+  sni: z.string().optional(),
+  fingerprint: UtlsFingerprintSchema.optional(),
+  alpn: z.array(AlpnSchema).optional(),
+});
+export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 83 - 0
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -0,0 +1,83 @@
+import { z } from 'zod';
+
+// FinalMask is xray-core's late-layer obfuscation wrapper applied AFTER
+// the network/security layers. It models per-type masks on TCP and UDP
+// plus optional QUIC tuning. The `settings` sub-object is polymorphic on
+// `type`; we model the wire-faithful shape with a permissive
+// record-of-unknown for `settings` and leave per-type tightening to
+// Step 6 — there are ~13 UDP mask types plus 3 TCP mask types, each with
+// distinct setting fields, and modeling them all as discriminated unions
+// here would dwarf the rest of the stream module without buying anything
+// the safety net doesn't already cover.
+
+export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom']);
+export type TcpMaskType = z.infer<typeof TcpMaskTypeSchema>;
+
+export const TcpMaskSchema = z.object({
+  type: TcpMaskTypeSchema,
+  settings: z.record(z.string(), z.unknown()).optional(),
+});
+export type TcpMask = z.infer<typeof TcpMaskSchema>;
+
+export const UdpMaskTypeSchema = z.enum([
+  'salamander',
+  'mkcp-aes128gcm',
+  'mkcp-original',
+  'header-dns',
+  'header-dtls',
+  'header-srtp',
+  'header-utp',
+  'header-wechat',
+  'header-wireguard',
+  'header-custom',
+  'xdns',
+  'xicmp',
+  'noise',
+]);
+export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
+
+export const UdpMaskSchema = z.object({
+  type: UdpMaskTypeSchema,
+  settings: z.record(z.string(), z.unknown()).optional(),
+});
+export type UdpMask = z.infer<typeof UdpMaskSchema>;
+
+export const QuicCongestionSchema = z.enum(['bbr', 'cubic', 'reno', 'brutal', 'force-brutal']);
+export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
+
+// udpHop randomizes the QUIC port between a range every `interval` seconds
+// to dodge port-based blocking. Both fields are dash-range strings on the
+// wire (e.g. '20000-50000', '5-10').
+export const QuicUdpHopSchema = z.object({
+  ports: z.string().default('20000-50000'),
+  interval: z.string().default('5-10'),
+});
+export type QuicUdpHop = z.infer<typeof QuicUdpHopSchema>;
+
+export const QuicParamsSchema = z.object({
+  congestion: QuicCongestionSchema.default('bbr'),
+  debug: z.boolean().optional(),
+  brutalUp: z.number().int().min(0).optional(),
+  brutalDown: z.number().int().min(0).optional(),
+  udpHop: QuicUdpHopSchema.optional(),
+  initStreamReceiveWindow: z.number().int().min(0).optional(),
+  maxStreamReceiveWindow: z.number().int().min(0).optional(),
+  initConnectionReceiveWindow: z.number().int().min(0).optional(),
+  maxConnectionReceiveWindow: z.number().int().min(0).optional(),
+  maxIdleTimeout: z.number().int().min(0).optional(),
+  keepAlivePeriod: z.number().int().min(0).optional(),
+  disablePathMTUDiscovery: z.boolean().optional(),
+  maxIncomingStreams: z.number().int().min(0).optional(),
+});
+export type QuicParams = z.infer<typeof QuicParamsSchema>;
+
+// `tcp` and `udp` are omitted from the wire entirely when their arrays
+// are empty (legacy toJson() drops them). Our default([]) here mirrors
+// the parsed-in shape; the shadow harness already treats empty arrays as
+// equivalent to absence so both pipelines converge.
+export const FinalMaskStreamSettingsSchema = z.object({
+  tcp: z.array(TcpMaskSchema).default([]),
+  udp: z.array(UdpMaskSchema).default([]),
+  quicParams: QuicParamsSchema.optional(),
+});
+export type FinalMaskStreamSettings = z.infer<typeof FinalMaskStreamSettingsSchema>;

+ 11 - 0
frontend/src/schemas/protocols/stream/grpc.ts

@@ -0,0 +1,11 @@
+import { z } from 'zod';
+
+// gRPC stream is the lightest transport — three booleans/strings, no
+// header obfuscation. `multiMode` enables multi-stream gRPC (multiple
+// concurrent streams over one connection).
+export const GrpcStreamSettingsSchema = z.object({
+  serviceName: z.string().default(''),
+  authority: z.string().default(''),
+  multiMode: z.boolean().default(false),
+});
+export type GrpcStreamSettings = z.infer<typeof GrpcStreamSettingsSchema>;

+ 14 - 0
frontend/src/schemas/protocols/stream/httpupgrade.ts

@@ -0,0 +1,14 @@
+import { z } from 'zod';
+
+import { WsHeaderMapSchema } from '@/schemas/protocols/stream/ws';
+
+// HTTP Upgrade transport reuses the flat WS-style header map (string values,
+// not arrays — toV2Headers with arr=false). No heartbeat field — that's
+// websocket-specific.
+export const HttpUpgradeStreamSettingsSchema = z.object({
+  acceptProxyProtocol: z.boolean().default(false),
+  path: z.string().default('/'),
+  host: z.string().default(''),
+  headers: WsHeaderMapSchema.default({}),
+});
+export type HttpUpgradeStreamSettings = z.infer<typeof HttpUpgradeStreamSettingsSchema>;

+ 64 - 0
frontend/src/schemas/protocols/stream/hysteria.ts

@@ -0,0 +1,64 @@
+import { z } from 'zod';
+
+// Hysteria stream transport — the hysteria-specific knobs that ride
+// alongside the connect target on outbound (and the inbound side too,
+// where the listening peer needs matching auth / congestion / obfs).
+// Wire shape mirrors xray-core's HysteriaConfig, with udphop nested
+// when port-hopping is on and omitted otherwise.
+
+export const HysteriaUdphopSchema = z.object({
+  port: z.string().default(''),
+  intervalMin: z.number().int().min(1).default(30),
+  intervalMax: z.number().int().min(1).default(30),
+});
+export type HysteriaUdphop = z.infer<typeof HysteriaUdphopSchema>;
+
+// `congestion` is `''` (BBR, the default) or `'brutal'`. Both empty and
+// missing are equivalent on the wire so we accept either.
+export const HysteriaCongestionSchema = z.union([z.literal(''), z.literal('brutal')]);
+
+// Inbound-only masquerade sub-object. Xray's hysteria inbound can disguise
+// itself as an HTTP server by serving static files (`type: 'file'`),
+// reverse-proxying upstream traffic (`type: 'proxy'`), or returning a
+// fixed string body (`type: 'string'`). Fields are loose-typed strings
+// because the panel writes them as free-form input.
+export const HysteriaMasqueradeSchema = z.object({
+  type: z.enum(['proxy', 'file', 'string']).default('proxy'),
+  dir: z.string().default(''),
+  url: z.string().default(''),
+  rewriteHost: z.boolean().default(false),
+  insecure: z.boolean().default(false),
+  content: z.string().default(''),
+  headers: z.record(z.string(), z.string()).default({}),
+  statusCode: z.number().int().min(0).default(0),
+});
+export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
+
+export const HysteriaStreamSettingsSchema = z.object({
+  // Outbound-side fields. The version field is shared with inbound and
+  // typically locked to 2.
+  version: z.literal(2).default(2),
+  auth: z.string().default(''),
+  congestion: HysteriaCongestionSchema.default(''),
+  // up / down are dash-separated bandwidth strings like '100 mbps' / '1 gbps'.
+  // The panel stores them as free-form strings and Xray parses on the
+  // server side; no client-side validation.
+  up: z.string().default('0'),
+  down: z.string().default('0'),
+  udphop: HysteriaUdphopSchema.optional(),
+  initStreamReceiveWindow: z.number().int().min(0).default(8388608),
+  maxStreamReceiveWindow: z.number().int().min(0).default(8388608),
+  initConnectionReceiveWindow: z.number().int().min(0).default(20971520),
+  maxConnectionReceiveWindow: z.number().int().min(0).default(20971520),
+  maxIdleTimeout: z.number().int().min(1).default(30),
+  keepAlivePeriod: z.number().int().min(1).default(2),
+  disablePathMTUDiscovery: z.boolean().default(false),
+  // Inbound-side fields. xray-core's HysteriaConfig accepts both sets in
+  // the same struct; outbound emits the bandwidth/udphop block, inbound
+  // emits the protocol/udpIdleTimeout/masquerade block. The panel can
+  // round-trip both shapes through this single schema.
+  protocol: z.string().optional(),
+  udpIdleTimeout: z.number().int().min(1).optional(),
+  masquerade: HysteriaMasqueradeSchema.optional(),
+});
+export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;

+ 59 - 0
frontend/src/schemas/protocols/stream/index.ts

@@ -0,0 +1,59 @@
+import { z } from 'zod';
+
+import { ExternalProxyEntrySchema } from './external-proxy';
+import { FinalMaskStreamSettingsSchema } from './finalmask';
+import { GrpcStreamSettingsSchema } from './grpc';
+import { HttpUpgradeStreamSettingsSchema } from './httpupgrade';
+import { HysteriaStreamSettingsSchema } from './hysteria';
+import { KcpStreamSettingsSchema } from './kcp';
+import { SockoptStreamSettingsSchema } from './sockopt';
+import { TcpStreamSettingsSchema } from './tcp';
+import { WsStreamSettingsSchema } from './ws';
+import { XHttpStreamSettingsSchema } from './xhttp';
+
+export * from './external-proxy';
+export * from './finalmask';
+export * from './grpc';
+export * from './httpupgrade';
+export * from './hysteria';
+export * from './kcp';
+export * from './sockopt';
+export * from './tcp';
+export * from './ws';
+export * from './xhttp';
+
+export const NetworkSchema = z.enum([
+  'tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp', 'hysteria',
+]);
+export type Network = z.infer<typeof NetworkSchema>;
+
+// Tagged-wrapper DU on `network`. The wire shape uses an asymmetric per-
+// network key (`tcpSettings`, `wsSettings`, ...) rather than a single
+// `settings` object — same pattern Xray ships and the panel's StreamSettings
+// class flattens via toJson. Each branch carries only the matching key so
+// fixtures round-trip byte-identical.
+//
+// `hysteria` is only valid when the parent protocol is hysteria — the
+// network selector hides it for other protocols. xray-core enforces
+// the constraint server-side too.
+export const NetworkSettingsSchema = z.discriminatedUnion('network', [
+  z.object({ network: z.literal('tcp'),         tcpSettings:         TcpStreamSettingsSchema }),
+  z.object({ network: z.literal('kcp'),         kcpSettings:         KcpStreamSettingsSchema }),
+  z.object({ network: z.literal('ws'),          wsSettings:          WsStreamSettingsSchema }),
+  z.object({ network: z.literal('grpc'),        grpcSettings:        GrpcStreamSettingsSchema }),
+  z.object({ network: z.literal('httpupgrade'), httpupgradeSettings: HttpUpgradeStreamSettingsSchema }),
+  z.object({ network: z.literal('xhttp'),       xhttpSettings:       XHttpStreamSettingsSchema }),
+  z.object({ network: z.literal('hysteria'),    hysteriaSettings:    HysteriaStreamSettingsSchema }),
+]);
+export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
+
+// Orthogonal extras that ride alongside the network and security branches.
+// All optional on the wire — legacy toJson() omits any field whose value
+// is empty. The shadow harness treats absent and empty-array as the same
+// canonical state.
+export const StreamExtrasSchema = z.object({
+  externalProxy: z.array(ExternalProxyEntrySchema).optional(),
+  finalmask: FinalMaskStreamSettingsSchema.optional(),
+  sockopt: SockoptStreamSettingsSchema.optional(),
+});
+export type StreamExtras = z.infer<typeof StreamExtrasSchema>;

+ 14 - 0
frontend/src/schemas/protocols/stream/kcp.ts

@@ -0,0 +1,14 @@
+import { z } from 'zod';
+
+// mKCP transport (Xray's reliable UDP). The panel renames upCap/downCap on
+// the JS side back to uplinkCapacity/downlinkCapacity on the wire. Defaults
+// match xray-core's recommended values.
+export const KcpStreamSettingsSchema = z.object({
+  mtu: z.number().int().min(576).max(1460).default(1350),
+  tti: z.number().int().min(10).max(100).default(20),
+  uplinkCapacity: z.number().int().min(0).default(5),
+  downlinkCapacity: z.number().int().min(0).default(20),
+  cwndMultiplier: z.number().int().min(1).default(1),
+  maxSendingWindow: z.number().int().min(0).default(2097152),
+});
+export type KcpStreamSettings = z.infer<typeof KcpStreamSettingsSchema>;

+ 53 - 0
frontend/src/schemas/protocols/stream/sockopt.ts

@@ -0,0 +1,53 @@
+import { z } from 'zod';
+
+export const SockoptDomainStrategySchema = z.enum([
+  'AsIs',
+  'UseIP',
+  'UseIPv6v4',
+  'UseIPv6',
+  'UseIPv4v6',
+  'UseIPv4',
+  'ForceIP',
+  'ForceIPv6v4',
+  'ForceIPv6',
+  'ForceIPv4v6',
+  'ForceIPv4',
+]);
+export type SockoptDomainStrategy = z.infer<typeof SockoptDomainStrategySchema>;
+
+export const TcpCongestionSchema = z.enum(['bbr', 'cubic', 'reno']);
+export type TcpCongestion = z.infer<typeof TcpCongestionSchema>;
+
+export const TproxyModeSchema = z.enum(['off', 'redirect', 'tproxy']);
+export type TproxyMode = z.infer<typeof TproxyModeSchema>;
+
+// Sockopt knobs are an orthogonal layer on streamSettings — they tune
+// the underlying socket (TCP keepalive, TFO, mark, tproxy, dialer proxy,
+// IPv6-only, MPTCP). The wire field is `interface` (single word) but the
+// panel class names it `interfaceName` internally to avoid the JS
+// reserved keyword. We use `interfaceName` here too and document the
+// renames; serializers writing back to wire must rename.
+//
+// trustedXForwardedFor is omitted from the wire payload when empty
+// (legacy toJson() filters it); our default([]) lets parsing succeed but
+// the shadow canonicalize step treats [] and absence as equivalent.
+export const SockoptStreamSettingsSchema = z.object({
+  acceptProxyProtocol: z.boolean().default(false),
+  tcpFastOpen: z.boolean().default(false),
+  mark: z.number().int().min(0).default(0),
+  tproxy: TproxyModeSchema.default('off'),
+  tcpMptcp: z.boolean().default(false),
+  penetrate: z.boolean().default(false),
+  domainStrategy: SockoptDomainStrategySchema.default('UseIP'),
+  tcpMaxSeg: z.number().int().min(0).default(1440),
+  dialerProxy: z.string().default(''),
+  tcpKeepAliveInterval: z.number().int().min(0).default(0),
+  tcpKeepAliveIdle: z.number().int().min(0).default(300),
+  tcpUserTimeout: z.number().int().min(0).default(10000),
+  tcpcongestion: TcpCongestionSchema.default('bbr'),
+  V6Only: z.boolean().default(false),
+  tcpWindowClamp: z.number().int().min(0).default(600),
+  interfaceName: z.string().default(''),
+  trustedXForwardedFor: z.array(z.string()).default([]),
+});
+export type SockoptStreamSettings = z.infer<typeof SockoptStreamSettingsSchema>;

+ 47 - 0
frontend/src/schemas/protocols/stream/tcp.ts

@@ -0,0 +1,47 @@
+import { z } from 'zod';
+
+// Xray's V2-style header map: { Host: ['example.com', ...], ... }. Each
+// header name maps to a string[] because HTTP allows repeated headers
+// (Accept, Cookie, etc.). The panel renders these as a flat name/value
+// table internally and flattens to this map on save via toV2Headers.
+export const V2HeaderMapSchema = z.record(z.string(), z.array(z.string()));
+export type V2HeaderMap = z.infer<typeof V2HeaderMapSchema>;
+
+export const TcpRequestSchema = z.object({
+  version: z.string().default('1.1'),
+  method: z.string().default('GET'),
+  path: z.array(z.string()).min(1).default(['/']),
+  headers: V2HeaderMapSchema.default({}),
+});
+export type TcpRequest = z.infer<typeof TcpRequestSchema>;
+
+export const TcpResponseSchema = z.object({
+  version: z.string().default('1.1'),
+  status: z.string().default('200'),
+  reason: z.string().default('OK'),
+  headers: V2HeaderMapSchema.default({}),
+});
+export type TcpResponse = z.infer<typeof TcpResponseSchema>;
+
+// TCP stream `header` is the obfuscation header. type='none' (the wire
+// representation just omits `header` entirely) or type='http' (HTTP-1.1
+// camouflage with request/response sub-objects).
+export const TcpHeaderHttpSchema = z.object({
+  type: z.literal('http'),
+  request: TcpRequestSchema.optional(),
+  response: TcpResponseSchema.optional(),
+});
+export const TcpHeaderNoneSchema = z.object({ type: z.literal('none') });
+export const TcpHeaderSchema = z.discriminatedUnion('type', [
+  TcpHeaderNoneSchema,
+  TcpHeaderHttpSchema,
+]);
+export type TcpHeader = z.infer<typeof TcpHeaderSchema>;
+
+// Top-level TCP stream payload. `acceptProxyProtocol` only appears on the
+// wire when true (panel omits it when false), so we treat it as optional.
+export const TcpStreamSettingsSchema = z.object({
+  acceptProxyProtocol: z.literal(true).optional(),
+  header: TcpHeaderSchema.optional(),
+});
+export type TcpStreamSettings = z.infer<typeof TcpStreamSettingsSchema>;

+ 17 - 0
frontend/src/schemas/protocols/stream/ws.ts

@@ -0,0 +1,17 @@
+import { z } from 'zod';
+
+// WebSocket stream uses the flat V1-style header map (string values only,
+// not arrays — the panel calls toV2Headers with arr=false). `path` and
+// `host` are the WS request line / Host header overrides. `heartbeatPeriod`
+// in seconds; 0 disables heartbeats.
+export const WsHeaderMapSchema = z.record(z.string(), z.string());
+export type WsHeaderMap = z.infer<typeof WsHeaderMapSchema>;
+
+export const WsStreamSettingsSchema = z.object({
+  acceptProxyProtocol: z.boolean().default(false),
+  path: z.string().default('/'),
+  host: z.string().default(''),
+  headers: WsHeaderMapSchema.default({}),
+  heartbeatPeriod: z.number().int().min(0).default(0),
+});
+export type WsStreamSettings = z.infer<typeof WsStreamSettingsSchema>;

+ 63 - 0
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -0,0 +1,63 @@
+import { z } from 'zod';
+
+import { WsHeaderMapSchema } from '@/schemas/protocols/stream/ws';
+
+export const XHttpModeSchema = z.enum(['auto', 'packet-up', 'stream-up', 'stream-one']);
+export type XHttpMode = z.infer<typeof XHttpModeSchema>;
+
+// xHTTP (SplitHTTPConfig) is xray-core's modern stream-multiplexed transport.
+// The field set is large because the schema mirrors what the server-side
+// listener reads — plus a few client-only fields (`uplinkHTTPMethod`,
+// `headers`) the panel embeds into share-link `extra` blobs even though the
+// server ignores them at runtime. Outbound has additional fields (uplinkChunk
+// sizes, noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) which
+// belong on the outbound class instead, not modeled here.
+// XMUX is the connection-multiplexing layer xHTTP uses to fan out
+// parallel requests over a small pool of upstream connections. Fields
+// are strings because they accept dash-range values like '16-32'.
+export const XHttpXmuxSchema = z.object({
+  maxConcurrency: z.string().default('16-32'),
+  maxConnections: z.union([z.string(), z.number()]).default(0),
+  cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
+  hMaxRequestTimes: z.string().default('600-900'),
+  hMaxReusableSecs: z.string().default('1800-3000'),
+  hKeepAlivePeriod: z.number().int().min(0).default(0),
+});
+export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
+
+export const XHttpStreamSettingsSchema = z.object({
+  path: z.string().default('/'),
+  host: z.string().default(''),
+  mode: XHttpModeSchema.default('auto'),
+  xPaddingBytes: z.string().default('100-1000'),
+  xPaddingObfsMode: z.boolean().default(false),
+  xPaddingKey: z.string().default(''),
+  xPaddingHeader: z.string().default(''),
+  xPaddingPlacement: z.string().default(''),
+  xPaddingMethod: z.string().default(''),
+  sessionPlacement: z.string().default(''),
+  sessionKey: z.string().default(''),
+  seqPlacement: z.string().default(''),
+  seqKey: z.string().default(''),
+  uplinkDataPlacement: z.string().default(''),
+  uplinkDataKey: z.string().default(''),
+  scMaxEachPostBytes: z.string().default('1000000'),
+  noSSEHeader: z.boolean().default(false),
+  scMaxBufferedPosts: z.number().int().min(0).default(30),
+  scStreamUpServerSecs: z.string().default('20-80'),
+  serverMaxHeaderBytes: z.number().int().min(0).default(0),
+  uplinkHTTPMethod: z.string().default(''),
+  headers: WsHeaderMapSchema.default({}),
+  // Outbound-only fields. Server (inbound) listener ignores these. The
+  // panel embeds them in share-link `extra` blobs so the same xhttp
+  // config can roundtrip on both sides.
+  scMinPostsIntervalMs: z.string().default('30'),
+  uplinkChunkSize: z.number().int().min(0).default(0),
+  noGRPCHeader: z.boolean().default(false),
+  xmux: XHttpXmuxSchema.optional(),
+  // UI-only toggle controlling whether the XMUX sub-form is expanded.
+  // Never present on the wire — outbound modal strips it via the
+  // form-to-wire adapter.
+  enableXmux: z.boolean().default(false),
+});
+export type XHttpStreamSettings = z.infer<typeof XHttpStreamSettingsSchema>;

+ 118 - 0
frontend/src/test/__snapshots__/headers.test.ts.snap

@@ -0,0 +1,118 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`toHeaders > empty 1`] = `[]`;
+
+exports[`toHeaders > mixed 1`] = `
+[
+  {
+    "name": "Host",
+    "value": "a.example.test",
+  },
+  {
+    "name": "X-Trace",
+    "value": "1",
+  },
+  {
+    "name": "X-Trace",
+    "value": "2",
+  },
+]
+`;
+
+exports[`toHeaders > multi array 1`] = `
+[
+  {
+    "name": "Accept",
+    "value": "text/html",
+  },
+  {
+    "name": "Accept",
+    "value": "application/json",
+  },
+]
+`;
+
+exports[`toHeaders > null 1`] = `[]`;
+
+exports[`toHeaders > primitive 1`] = `[]`;
+
+exports[`toHeaders > single array 1`] = `
+[
+  {
+    "name": "Host",
+    "value": "a.example.test",
+  },
+]
+`;
+
+exports[`toHeaders > single string 1`] = `
+[
+  {
+    "name": "Host",
+    "value": "example.test",
+  },
+]
+`;
+
+exports[`toHeaders > undefined 1`] = `[]`;
+
+exports[`toV2Headers (arr=false) > duplicate name 1`] = `
+{
+  "Accept": "application/json",
+}
+`;
+
+exports[`toV2Headers (arr=false) > empty 1`] = `{}`;
+
+exports[`toV2Headers (arr=false) > empty name skipped 1`] = `
+{
+  "X-Real": "kept",
+}
+`;
+
+exports[`toV2Headers (arr=false) > empty value skipped 1`] = `
+{
+  "X-Real": "kept",
+}
+`;
+
+exports[`toV2Headers (arr=false) > single 1`] = `
+{
+  "Host": "example.test",
+}
+`;
+
+exports[`toV2Headers (arr=true) > duplicate name 1`] = `
+{
+  "Accept": [
+    "text/html",
+    "application/json",
+  ],
+}
+`;
+
+exports[`toV2Headers (arr=true) > empty 1`] = `{}`;
+
+exports[`toV2Headers (arr=true) > empty name skipped 1`] = `
+{
+  "X-Real": [
+    "kept",
+  ],
+}
+`;
+
+exports[`toV2Headers (arr=true) > empty value skipped 1`] = `
+{
+  "X-Real": [
+    "kept",
+  ],
+}
+`;
+
+exports[`toV2Headers (arr=true) > single 1`] = `
+{
+  "Host": [
+    "example.test",
+  ],
+}
+`;

+ 158 - 0
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -0,0 +1,158 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`createDefault*InboundSettings factories > http 1`] = `
+{
+  "accounts": [],
+  "allowTransparent": false,
+}
+`;
+
+exports[`createDefault*InboundSettings factories > hysteria (v1, defaults to v2 wire version) 1`] = `
+{
+  "clients": [],
+  "version": 2,
+}
+`;
+
+exports[`createDefault*InboundSettings factories > hysteria2 1`] = `
+{
+  "clients": [],
+  "version": 2,
+}
+`;
+
+exports[`createDefault*InboundSettings factories > mixed 1`] = `
+{
+  "accounts": [],
+  "auth": "password",
+  "ip": "127.0.0.1",
+  "udp": false,
+}
+`;
+
+exports[`createDefault*InboundSettings factories > shadowsocks 1`] = `
+{
+  "clients": [],
+  "ivCheck": false,
+  "method": "2022-blake3-aes-256-gcm",
+  "network": "tcp",
+  "password": "ZmFrZS1zcy1zZWVk",
+}
+`;
+
+exports[`createDefault*InboundSettings factories > trojan 1`] = `
+{
+  "clients": [],
+  "fallbacks": [],
+}
+`;
+
+exports[`createDefault*InboundSettings factories > tunnel 1`] = `
+{
+  "allowedNetwork": "tcp,udp",
+  "followRedirect": false,
+  "portMap": {},
+}
+`;
+
+exports[`createDefault*InboundSettings factories > vless 1`] = `
+{
+  "clients": [],
+  "decryption": "none",
+  "encryption": "none",
+  "fallbacks": [],
+}
+`;
+
+exports[`createDefault*InboundSettings factories > vmess 1`] = `
+{
+  "clients": [],
+}
+`;
+
+exports[`createDefault*InboundSettings factories > wireguard 1`] = `
+{
+  "mtu": 1420,
+  "noKernelTun": false,
+  "peers": [],
+  "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+}
+`;
+
+exports[`createDefaultHysteriaClient > produces a Zod-valid client 1`] = `
+{
+  "auth": "fixed-hyst-auth",
+  "comment": "",
+  "email": "[email protected]",
+  "enable": true,
+  "expiryTime": 0,
+  "limitIp": 0,
+  "reset": 0,
+  "subId": "fixed-sub-id-1234",
+  "tgId": 0,
+  "totalGB": 0,
+}
+`;
+
+exports[`createDefaultShadowsocksClient > produces a Zod-valid client 1`] = `
+{
+  "comment": "",
+  "email": "[email protected]",
+  "enable": true,
+  "expiryTime": 0,
+  "limitIp": 0,
+  "method": "",
+  "password": "ZmFrZS1zcy1wYXNzd29yZA==",
+  "reset": 0,
+  "subId": "fixed-sub-id-1234",
+  "tgId": 0,
+  "totalGB": 0,
+}
+`;
+
+exports[`createDefaultTrojanClient > produces a Zod-valid client 1`] = `
+{
+  "comment": "",
+  "email": "[email protected]",
+  "enable": true,
+  "expiryTime": 0,
+  "limitIp": 0,
+  "password": "fixed-trojan-pw",
+  "reset": 0,
+  "subId": "fixed-sub-id-1234",
+  "tgId": 0,
+  "totalGB": 0,
+}
+`;
+
+exports[`createDefaultVlessClient > produces a Zod-valid client 1`] = `
+{
+  "comment": "",
+  "email": "[email protected]",
+  "enable": true,
+  "expiryTime": 0,
+  "flow": "",
+  "id": "11111111-2222-4333-8444-555555555555",
+  "limitIp": 0,
+  "reset": 0,
+  "subId": "fixed-sub-id-1234",
+  "tgId": 0,
+  "totalGB": 0,
+}
+`;
+
+exports[`createDefaultVmessClient > produces a Zod-valid client 1`] = `
+{
+  "comment": "",
+  "email": "[email protected]",
+  "enable": true,
+  "expiryTime": 0,
+  "id": "aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee",
+  "limitIp": 0,
+  "reset": 0,
+  "security": "auto",
+  "subId": "fixed-sub-id-1234",
+  "tgId": 0,
+  "totalGB": 0,
+}
+`;

+ 517 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -0,0 +1,517 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 21,
+  "listen": "",
+  "port": 36715,
+  "protocol": "hysteria",
+  "remark": "gina-hysteria-v1",
+  "settings": {
+    "clients": [
+      {
+        "auth": "hyst-v1-auth-XYZ",
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "hy1-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "version": 1,
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": false,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "security": "tls",
+    "tcpSettings": {},
+    "tlsSettings": {
+      "alpn": [
+        "h3",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/hysteria.crt",
+          "keyFile": "/etc/ssl/private/hysteria.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "hysteria.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+      },
+    },
+  },
+  "tag": "inbound-hysteria-v1",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 17,
+  "listen": "",
+  "port": 8388,
+  "protocol": "shadowsocks",
+  "remark": "frank-ss-tcp-2022",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "method": "",
+        "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==",
+        "reset": 0,
+        "subId": "ss-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "ivCheck": false,
+    "method": "2022-blake3-aes-256-gcm",
+    "network": "tcp,udp",
+    "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "security": "none",
+    "tcpSettings": {
+      "header": {
+        "type": "none",
+      },
+    },
+  },
+  "tag": "inbound-ss-2022",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 13,
+  "listen": "",
+  "port": 443,
+  "protocol": "trojan",
+  "remark": "eve-trojan-ws-tls",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "password": "trojan-test-pw-XYZ",
+        "reset": 0,
+        "subId": "trj-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "fallbacks": [],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "ws",
+    "security": "tls",
+    "tlsSettings": {
+      "alpn": [
+        "h2",
+        "http/1.1",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/trojan.crt",
+          "keyFile": "/etc/ssl/private/trojan.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "trojan.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+      },
+    },
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "headers": {},
+      "heartbeatPeriod": 0,
+      "host": "trojan.example.test",
+      "path": "/trojan",
+    },
+  },
+  "tag": "inbound-trojan-ws",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 9,
+  "listen": "",
+  "port": 443,
+  "protocol": "vless",
+  "remark": "dave-vless-tcp-reality",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "flow": "xtls-rprx-vision",
+        "id": "22222222-3333-4444-9555-666666666666",
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "vless-reality-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": [],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "realitySettings": {
+      "maxClientVer": "",
+      "maxTimediff": 0,
+      "minClientVer": "",
+      "mldsa65Seed": "",
+      "privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
+      "serverNames": [
+        "yahoo.com",
+        "www.yahoo.com",
+      ],
+      "settings": {
+        "fingerprint": "chrome",
+        "mldsa65Verify": "",
+        "publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
+        "serverName": "",
+        "spiderX": "/",
+      },
+      "shortIds": [
+        "a3f1",
+        "b8c2",
+      ],
+      "show": false,
+      "target": "yahoo.com:443",
+      "xver": 0,
+    },
+    "security": "reality",
+    "tcpSettings": {
+      "header": {
+        "type": "none",
+      },
+    },
+  },
+  "tag": "inbound-vless-reality",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 42,
+  "listen": "",
+  "port": 443,
+  "protocol": "vless",
+  "remark": "alice-vless-ws-tls",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "flow": "",
+        "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02",
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "abc123def",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": [],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "ws",
+    "security": "tls",
+    "tlsSettings": {
+      "alpn": [
+        "h2",
+        "http/1.1",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
+          "keyFile": "/etc/ssl/private/cdn.example.test.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "cdn.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+      },
+    },
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "headers": {},
+      "heartbeatPeriod": 0,
+      "host": "cdn.example.test",
+      "path": "/ws",
+    },
+  },
+  "tag": "inbound-vless-1",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 7,
+  "listen": "",
+  "port": 8443,
+  "protocol": "vmess",
+  "remark": "carol-vmess-tcp-tls",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "id": "11111111-2222-4333-8444-555555555555",
+        "limitIp": 0,
+        "reset": 0,
+        "security": "auto",
+        "subId": "vmess-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "security": "tls",
+    "tcpSettings": {
+      "header": {
+        "type": "none",
+      },
+    },
+    "tlsSettings": {
+      "alpn": [
+        "h2",
+        "http/1.1",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/vmess.crt",
+          "keyFile": "/etc/ssl/private/vmess.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "vmess.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+      },
+    },
+  },
+  "tag": "inbound-vmess-1",
+  "total": 0,
+  "up": 0,
+}
+`;
+
+exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 25,
+  "listen": "",
+  "port": 51820,
+  "protocol": "wireguard",
+  "remark": "wg-server",
+  "settings": {
+    "mtu": 1420,
+    "noKernelTun": false,
+    "peers": [
+      {
+        "allowedIPs": [
+          "10.0.0.2/32",
+        ],
+        "keepAlive": 25,
+        "privateKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+        "publicKey": "DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=",
+      },
+    ],
+    "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": false,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "tag": "inbound-wg-1",
+  "total": 0,
+  "up": 0,
+}
+`;

+ 60 - 0
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -0,0 +1,60 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
+
+exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test-gina%40example.test"`;
+
+exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@override.test:8388?type=tcp#parity-test-frank%40example.test"`;
+
+exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test-eve%40example.test"`;
+
+exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test-dave%40example.test"`;
+
+exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test-alice%40example.test"`;
+
+exports[`genInboundLinks orchestrator > vmess-tcp-tls: byte-stable 1`] = `"vmess://ewogICJ2IjogIjIiLAogICJwcyI6ICJwYXJpdHktdGVzdC1jYXJvbEBleGFtcGxlLnRlc3QiLAogICJhZGQiOiAib3ZlcnJpZGUudGVzdCIsCiAgInBvcnQiOiA4NDQzLAogICJpZCI6ICIxMTExMTExMS0yMjIyLTQzMzMtODQ0NC01NTU1NTU1NTU1NTUiLAogICJzY3kiOiAiYXV0byIsCiAgIm5ldCI6ICJ0Y3AiLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAic25pIjogInZtZXNzLmV4YW1wbGUudGVzdCIsCiAgImZwIjogImNocm9tZSIsCiAgImFscG4iOiAiaDIsaHR0cC8xLjEiCn0="`;
+
+exports[`genInboundLinks orchestrator > wireguard-server: byte-stable 1`] = `
+"[Interface]
+PrivateKey = QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=
+Address = 10.0.0.2/32
+DNS = 1.1.1.1, 1.0.0.1
+MTU = 1420
+
+# parity-test-1
+[Peer]
+PublicKey = Piehk2n8UewhMHMyJiBS+Sxn/OK0FalyFW1GAGzokHM=
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = override.test:51820
+PersistentKeepalive = 25
+"
+`;
+
+exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@example.test:8388?type=tcp#parity-test"`;
+
+exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
+
+exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
+
+exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
+
+exports[`genVmessLink > vmess-tcp-tls: byte-stable 1`] = `"vmess://ewogICJ2IjogIjIiLAogICJwcyI6ICJwYXJpdHktdGVzdCIsCiAgImFkZCI6ICJleGFtcGxlLnRlc3QiLAogICJwb3J0IjogODQ0MywKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAic2N5IjogImF1dG8iLAogICJuZXQiOiAidGNwIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInNuaSI6ICJ2bWVzcy5leGFtcGxlLnRlc3QiLAogICJmcCI6ICJjaHJvbWUiLAogICJhbHBuIjogImgyLGh0dHAvMS4xIgp9"`;
+
+exports[`genWireguardLink + genWireguardConfig > wireguard-server: byte-stable 1`] = `
+{
+  "config": "[Interface]
+PrivateKey = QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=
+Address = 10.0.0.2/32
+DNS = 1.1.1.1, 1.0.0.1
+MTU = 1420
+
+# wg-peer-1
+[Peer]
+PublicKey = Piehk2n8UewhMHMyJiBS+Sxn/OK0FalyFW1GAGzokHM=
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = wg.example.test:51820
+PersistentKeepalive = 25
+",
+  "link": "wireguard://QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0%[email protected]:51820?publickey=Piehk2n8UewhMHMyJiBS%2BSxn%2FOK0FalyFW1GAGzokHM%3D&address=10.0.0.2%2F32&mtu=1420#wg-peer-1",
+}
+`;

+ 1681 - 0
frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap

@@ -0,0 +1,1681 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`protocol capability predicates > http-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > http-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > hysteria2-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mixed-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > shadowsocks-2022 :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": true,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > trojan-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > tunnel-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: grpc/none 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: grpc/reality 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: grpc/tls 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: tcp/none 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: tcp/reality 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": true,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: tcp/tls 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": true,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: xhttp/none 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: xhttp/reality 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vless-tcp-none :: xhttp/tls 1`] = `
+{
+  "canEnableReality": true,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > vmess-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": true,
+  "canEnableTls": true,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > wireguard-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;

+ 219 - 0
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -0,0 +1,219 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`InboundSettingsSchema fixtures > parses http-basic byte-stably 1`] = `
+{
+  "protocol": "http",
+  "settings": {
+    "accounts": [
+      {
+        "pass": "proxypass",
+        "user": "proxyuser",
+      },
+      {
+        "pass": "guest123",
+        "user": "guest",
+      },
+    ],
+    "allowTransparent": false,
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`] = `
+{
+  "protocol": "hysteria",
+  "settings": {
+    "clients": [
+      {
+        "auth": "hyst3ria-v1-token-XYZ",
+        "comment": "legacy v1",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "hy1-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "version": 1,
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses hysteria2-basic byte-stably 1`] = `
+{
+  "protocol": "hysteria2",
+  "settings": {
+    "clients": [
+      {
+        "auth": "hyst3ria2-auth-token-XYZ",
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "hy2-001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "version": 2,
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
+{
+  "protocol": "mixed",
+  "settings": {
+    "accounts": [
+      {
+        "pass": "sockspass",
+        "user": "socksuser",
+      },
+    ],
+    "auth": "password",
+    "ip": "127.0.0.1",
+    "udp": true,
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
+{
+  "protocol": "shadowsocks",
+  "settings": {
+    "clients": [
+      {
+        "comment": "multi-user shadowsocks 2022",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "method": "",
+        "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==",
+        "reset": 0,
+        "subId": "ssm001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "ivCheck": false,
+    "method": "2022-blake3-aes-256-gcm",
+    "network": "tcp,udp",
+    "password": "9oCBhTZxJ5wQa3fLs2vK7nM6pR4tY1uX",
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses trojan-basic byte-stably 1`] = `
+{
+  "protocol": "trojan",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "limitIp": 0,
+        "password": "tr0jan-passw0rd-XyZ-123!",
+        "reset": 0,
+        "subId": "trj001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "fallbacks": [],
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses tunnel-basic byte-stably 1`] = `
+{
+  "protocol": "tunnel",
+  "settings": {
+    "allowedNetwork": "tcp,udp",
+    "followRedirect": false,
+    "portMap": {
+      "8080": "10.0.0.5:80",
+      "8443": "10.0.0.5:443",
+    },
+    "rewriteAddress": "1.1.1.1",
+    "rewritePort": 53,
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses vless-tcp-none byte-stably 1`] = `
+{
+  "protocol": "vless",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "flow": "",
+        "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02",
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "abc123def",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": [],
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses vmess-basic byte-stably 1`] = `
+{
+  "protocol": "vmess",
+  "settings": {
+    "clients": [
+      {
+        "comment": "primary tester",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "id": "c0aa1b9e-4d56-4e8b-9a01-bf2e5d7c4f31",
+        "limitIp": 2,
+        "reset": 0,
+        "security": "auto",
+        "subId": "vmess001",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+  },
+}
+`;
+
+exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`] = `
+{
+  "protocol": "wireguard",
+  "settings": {
+    "mtu": 1420,
+    "noKernelTun": false,
+    "peers": [
+      {
+        "allowedIPs": [
+          "10.0.0.2/32",
+        ],
+        "keepAlive": 25,
+        "privateKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+        "publicKey": "DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=",
+      },
+    ],
+    "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+  },
+}
+`;

+ 72 - 0
frontend/src/test/__snapshots__/security.test.ts.snap

@@ -0,0 +1,72 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SecuritySettingsSchema fixtures > parses none byte-stably 1`] = `
+{
+  "security": "none",
+}
+`;
+
+exports[`SecuritySettingsSchema fixtures > parses reality-basic byte-stably 1`] = `
+{
+  "realitySettings": {
+    "maxClientVer": "",
+    "maxTimediff": 0,
+    "minClientVer": "",
+    "mldsa65Seed": "",
+    "privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
+    "serverNames": [
+      "yahoo.com",
+      "www.yahoo.com",
+    ],
+    "settings": {
+      "fingerprint": "chrome",
+      "mldsa65Verify": "",
+      "publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
+      "serverName": "",
+      "spiderX": "/",
+    },
+    "shortIds": [
+      "a3f1",
+      "b8c2",
+      "d9e4",
+    ],
+    "show": false,
+    "target": "yahoo.com:443",
+    "xver": 0,
+  },
+  "security": "reality",
+}
+`;
+
+exports[`SecuritySettingsSchema fixtures > parses tls-cert-file byte-stably 1`] = `
+{
+  "security": "tls",
+  "tlsSettings": {
+    "alpn": [
+      "h2",
+      "http/1.1",
+    ],
+    "certificates": [
+      {
+        "buildChain": false,
+        "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
+        "keyFile": "/etc/ssl/private/cdn.example.test.key",
+        "oneTimeLoading": false,
+        "usage": "encipherment",
+      },
+    ],
+    "cipherSuites": "",
+    "disableSystemRoot": false,
+    "echServerKeys": "",
+    "enableSessionResumption": false,
+    "maxVersion": "1.3",
+    "minVersion": "1.2",
+    "rejectUnknownSni": false,
+    "serverName": "cdn.example.test",
+    "settings": {
+      "echConfigList": "",
+      "fingerprint": "chrome",
+    },
+  },
+}
+`;

+ 34 - 0
frontend/src/test/__snapshots__/stream.test.ts.snap

@@ -0,0 +1,34 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`NetworkSettingsSchema fixtures > parses grpc-basic byte-stably 1`] = `
+{
+  "grpcSettings": {
+    "authority": "grpc.example.test",
+    "multiMode": false,
+    "serviceName": "GunService",
+  },
+  "network": "grpc",
+}
+`;
+
+exports[`NetworkSettingsSchema fixtures > parses tcp-none byte-stably 1`] = `
+{
+  "network": "tcp",
+  "tcpSettings": {},
+}
+`;
+
+exports[`NetworkSettingsSchema fixtures > parses ws-default byte-stably 1`] = `
+{
+  "network": "ws",
+  "wsSettings": {
+    "acceptProxyProtocol": false,
+    "headers": {
+      "X-Forwarded-Proto": "https",
+    },
+    "heartbeatPeriod": 30,
+    "host": "cdn.example.test",
+    "path": "/api/v2",
+  },
+}
+`;

+ 67 - 0
frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json

@@ -0,0 +1,67 @@
+{
+  "id": 21,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "gina-hysteria-v1",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 36715,
+  "tag": "inbound-hysteria-v1",
+  "sniffing": {
+    "enabled": false,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "hysteria",
+  "settings": {
+    "version": 1,
+    "clients": [
+      {
+        "auth": "hyst-v1-auth-XYZ",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "hy1-001",
+        "comment": "",
+        "reset": 0
+      }
+    ]
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {},
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "hysteria.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/hysteria.crt",
+          "keyFile": "/etc/ssl/private/hysteria.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h3"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

+ 49 - 0
frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json

@@ -0,0 +1,49 @@
+{
+  "id": 17,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "frank-ss-tcp-2022",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 8388,
+  "tag": "inbound-ss-2022",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "shadowsocks",
+  "settings": {
+    "method": "2022-blake3-aes-256-gcm",
+    "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
+    "network": "tcp,udp",
+    "clients": [
+      {
+        "method": "",
+        "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "ss-001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "ivCheck": false
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {
+      "header": { "type": "none" }
+    },
+    "security": "none"
+  }
+}

+ 73 - 0
frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json

@@ -0,0 +1,73 @@
+{
+  "id": 13,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "eve-trojan-ws-tls",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 443,
+  "tag": "inbound-trojan-ws",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "trojan",
+  "settings": {
+    "clients": [
+      {
+        "password": "trojan-test-pw-XYZ",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "trj-001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "fallbacks": []
+  },
+  "streamSettings": {
+    "network": "ws",
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "path": "/trojan",
+      "host": "trojan.example.test",
+      "headers": {},
+      "heartbeatPeriod": 0
+    },
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "trojan.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/trojan.crt",
+          "keyFile": "/etc/ssl/private/trojan.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h2", "http/1.1"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

+ 67 - 0
frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json

@@ -0,0 +1,67 @@
+{
+  "id": 9,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "dave-vless-tcp-reality",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 443,
+  "tag": "inbound-vless-reality",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "vless",
+  "settings": {
+    "clients": [
+      {
+        "id": "22222222-3333-4444-9555-666666666666",
+        "email": "[email protected]",
+        "flow": "xtls-rprx-vision",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "vless-reality-001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": []
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {
+      "header": { "type": "none" }
+    },
+    "security": "reality",
+    "realitySettings": {
+      "show": false,
+      "xver": 0,
+      "target": "yahoo.com:443",
+      "serverNames": ["yahoo.com", "www.yahoo.com"],
+      "privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
+      "minClientVer": "",
+      "maxClientVer": "",
+      "maxTimediff": 0,
+      "shortIds": ["a3f1", "b8c2"],
+      "mldsa65Seed": "",
+      "settings": {
+        "publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
+        "fingerprint": "chrome",
+        "serverName": "",
+        "spiderX": "/",
+        "mldsa65Verify": ""
+      }
+    }
+  }
+}

+ 76 - 0
frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json

@@ -0,0 +1,76 @@
+{
+  "id": 42,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "alice-vless-ws-tls",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 443,
+  "tag": "inbound-vless-1",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "vless",
+  "settings": {
+    "clients": [
+      {
+        "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02",
+        "email": "[email protected]",
+        "flow": "",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "abc123def",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": []
+  },
+  "streamSettings": {
+    "network": "ws",
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "path": "/ws",
+      "host": "cdn.example.test",
+      "headers": {},
+      "heartbeatPeriod": 0
+    },
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "cdn.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
+          "keyFile": "/etc/ssl/private/cdn.example.test.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h2", "http/1.1"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

+ 69 - 0
frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json

@@ -0,0 +1,69 @@
+{
+  "id": 7,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "carol-vmess-tcp-tls",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 8443,
+  "tag": "inbound-vmess-1",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "vmess",
+  "settings": {
+    "clients": [
+      {
+        "id": "11111111-2222-4333-8444-555555555555",
+        "security": "auto",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "vmess-001",
+        "comment": "",
+        "reset": 0
+      }
+    ]
+  },
+  "streamSettings": {
+    "network": "tcp",
+    "tcpSettings": {
+      "header": { "type": "none" }
+    },
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "vmess.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/vmess.crt",
+          "keyFile": "/etc/ssl/private/vmess.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h2", "http/1.1"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": ""
+      }
+    }
+  }
+}

+ 34 - 0
frontend/src/test/golden/fixtures/inbound-full/wireguard-server.json

@@ -0,0 +1,34 @@
+{
+  "id": 25,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "wg-server",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 51820,
+  "tag": "inbound-wg-1",
+  "sniffing": {
+    "enabled": false,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "wireguard",
+  "settings": {
+    "mtu": 1420,
+    "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+    "peers": [
+      {
+        "privateKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+        "publicKey": "DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=",
+        "allowedIPs": ["10.0.0.2/32"],
+        "keepAlive": 25
+      }
+    ],
+    "noKernelTun": false
+  }
+}

+ 10 - 0
frontend/src/test/golden/fixtures/inbound/http-basic.json

@@ -0,0 +1,10 @@
+{
+  "protocol": "http",
+  "settings": {
+    "accounts": [
+      { "user": "proxyuser", "pass": "proxypass" },
+      { "user": "guest", "pass": "guest123" }
+    ],
+    "allowTransparent": false
+  }
+}

+ 20 - 0
frontend/src/test/golden/fixtures/inbound/hysteria-basic.json

@@ -0,0 +1,20 @@
+{
+  "protocol": "hysteria",
+  "settings": {
+    "version": 1,
+    "clients": [
+      {
+        "auth": "hyst3ria-v1-token-XYZ",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "hy1-001",
+        "comment": "legacy v1",
+        "reset": 0
+      }
+    ]
+  }
+}

+ 20 - 0
frontend/src/test/golden/fixtures/inbound/hysteria2-basic.json

@@ -0,0 +1,20 @@
+{
+  "protocol": "hysteria2",
+  "settings": {
+    "version": 2,
+    "clients": [
+      {
+        "auth": "hyst3ria2-auth-token-XYZ",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "hy2-001",
+        "comment": "",
+        "reset": 0
+      }
+    ]
+  }
+}

+ 11 - 0
frontend/src/test/golden/fixtures/inbound/mixed-basic.json

@@ -0,0 +1,11 @@
+{
+  "protocol": "mixed",
+  "settings": {
+    "auth": "password",
+    "accounts": [
+      { "user": "socksuser", "pass": "sockspass" }
+    ],
+    "udp": true,
+    "ip": "127.0.0.1"
+  }
+}

+ 24 - 0
frontend/src/test/golden/fixtures/inbound/shadowsocks-2022.json

@@ -0,0 +1,24 @@
+{
+  "protocol": "shadowsocks",
+  "settings": {
+    "method": "2022-blake3-aes-256-gcm",
+    "password": "9oCBhTZxJ5wQa3fLs2vK7nM6pR4tY1uX",
+    "network": "tcp,udp",
+    "clients": [
+      {
+        "method": "",
+        "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "ssm001",
+        "comment": "multi-user shadowsocks 2022",
+        "reset": 0
+      }
+    ],
+    "ivCheck": false
+  }
+}

+ 20 - 0
frontend/src/test/golden/fixtures/inbound/trojan-basic.json

@@ -0,0 +1,20 @@
+{
+  "protocol": "trojan",
+  "settings": {
+    "clients": [
+      {
+        "password": "tr0jan-passw0rd-XyZ-123!",
+        "email": "[email protected]",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "trj001",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "fallbacks": []
+  }
+}

+ 13 - 0
frontend/src/test/golden/fixtures/inbound/tunnel-basic.json

@@ -0,0 +1,13 @@
+{
+  "protocol": "tunnel",
+  "settings": {
+    "rewriteAddress": "1.1.1.1",
+    "rewritePort": 53,
+    "portMap": {
+      "8080": "10.0.0.5:80",
+      "8443": "10.0.0.5:443"
+    },
+    "allowedNetwork": "tcp,udp",
+    "followRedirect": false
+  }
+}

+ 23 - 0
frontend/src/test/golden/fixtures/inbound/vless-tcp-none.json

@@ -0,0 +1,23 @@
+{
+  "protocol": "vless",
+  "settings": {
+    "clients": [
+      {
+        "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02",
+        "email": "[email protected]",
+        "flow": "",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "abc123def",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": []
+  }
+}

Някои файлове не бяха показани, защото твърде много файлове са промени