34 Incheckningar bfdaf7a8f8 ... 6c279d48fd

Upphovsman SHA1 Meddelande Datum
  MHSanaei 6c279d48fd feat(sub): clash row + reorganise SubPage around Subscription info 15 timmar sedan
  MHSanaei 87eaa79e5d fix(schemas): widen VLESS decryption/encryption to accept PQ values 15 timmar sedan
  MHSanaei 1752702f74 feat(clients): hide QR for post-quantum links in client info modal 15 timmar sedan
  MHSanaei e7ac1fadaa feat(sub): compact subscription rows with per-link email + PQ QR hide 15 timmar sedan
  MHSanaei ad8d58c2b6 fix(xray): heal shadowsocks per-client method across all start paths 15 timmar sedan
  MHSanaei 66f5026356 feat(clients): compact link + inbound rows in the info modal and table 16 timmar sedan
  MHSanaei 069c57adff chore(frontend): bump deps + refresh lockfile 17 timmar sedan
  MHSanaei 8be84e6e2c docs(frontend): refresh README + simplify deprecated-scan config 17 timmar sedan
  MHSanaei 2346782e44 feat(clients): show comment under email in the Client column 17 timmar sedan
  MHSanaei 7bd54a300c refactor(frontend): retire all AntD + Zod deprecations 17 timmar sedan
  MHSanaei d843014461 refactor(backend): retire hysteria2 as a top-level protocol 17 timmar sedan
  MHSanaei 15787dbdfe perf(clients): batch BulkAdjust per inbound, skip no-op xray calls on local 18 timmar sedan
  MHSanaei e0e6200e2f feat(clients): server-side bulk create/delete with per-inbound batching 18 timmar sedan
  MHSanaei 989333b0b1 fix(frontend): serialize bulk client delete + drop deprecated Alert.message 19 timmar sedan
  MHSanaei a6a3ef8e64 test(frontend): golden fixtures for DNS, Balancer, Rule schemas 19 timmar sedan
  MHSanaei 0208396802 feat(frontend): migrate DNS + Routing to Zod, align with xray docs 19 timmar sedan
  MHSanaei 0442be5078 feat(frontend): align finalmask + sockopt with xray docs, add golden fixtures 20 timmar sedan
  MHSanaei 3fdd9765a7 fix(frontend): xhttp form binding + drop empty strings from JSON (B23) 21 timmar sedan
  MHSanaei 6e90b24af1 fix(frontend): derive QUIC/UDP-hop switch state from data presence (B22) 21 timmar sedan
  MHSanaei 66deec95ae refactor(frontend): extract fillStreamDefaults to shared helper 21 timmar sedan
  MHSanaei bb20cf506b fix(frontend): blur active element on every tab switch path (B21 follow-up) 22 timmar sedan
  MHSanaei d2f5f530e0 fix(frontend): Outbound submit crash on non-mux protocols + tab a11y (B21) 22 timmar sedan
  MHSanaei f910bfbcda fix(frontend): outbound link parser handles extra/fm/x_padding_bytes (B20) 22 timmar sedan
  MHSanaei ce2fd2f0dd fix(frontend): QUIC udpHop.interval is a range string, not a number (B19) 22 timmar sedan
  MHSanaei 2b4686de99 fix(frontend): inboundFromDb fills Zod defaults for stream + settings 22 timmar sedan
  MHSanaei f92f07e8f2 refactor(frontend): retire class-based xray models (Step 5) 23 timmar sedan
  MHSanaei 5a90f7e348 refactor(frontend): align hysteria with new docs + drop hysteria2 protocol 1 dag sedan
  MHSanaei 90e11dc0f6 fix(frontend): forceRender all tabs so fields register at modal open (B18) 1 dag sedan
  MHSanaei a3dfafadb1 fix(frontend): seed full Zod-schema defaults for stream slices + QUIC params (B17) 1 dag sedan
  MHSanaei ece20d16f7 fix(frontend): inbound TCP HTTP camouflage drops request fields + KCP UI field rename (B15/B16) 1 dag sedan
  MHSanaei fbdc6cdf91 fix(frontend): FinalMaskForm relative paths + network-switch defaults (B13/B14) 1 dag sedan
  MHSanaei f3c0a94d80 fix(frontend): import InboundFormModal.css so layout classes apply (B12) 1 dag sedan
  MHSanaei 36afdf53af fix(frontend): FinalMaskForm TCP Mask sub-forms + Advanced JSON wrap (B10/B11) 1 dag sedan
  MHSanaei 60350f93e7 fix(frontend): Phase 2 Inbound form reactivity bugs (B1-B9, consolidated) 1 dag sedan
100 ändrade filer med 5254 tillägg och 8272 borttagningar
  1. 72 11
      database/model/model.go
  2. 0 18
      database/model/model_test.go
  3. 167 49
      frontend/README.md
  4. 0 132
      frontend/ZOD_MIGRATION_STATUS.md
  5. 26 0
      frontend/eslint.deprecated.config.js
  6. 160 160
      frontend/package-lock.json
  7. 4 4
      frontend/package.json
  8. 137 1
      frontend/public/openapi.json
  9. 301 173
      frontend/src/components/FinalMaskForm.tsx
  10. 22 3
      frontend/src/components/HeaderMapEditor.tsx
  11. 1 0
      frontend/src/env.d.ts
  12. 49 16
      frontend/src/hooks/useClients.ts
  13. 25 8
      frontend/src/lib/xray/inbound-defaults.ts
  14. 139 3
      frontend/src/lib/xray/inbound-form-adapter.ts
  15. 55 0
      frontend/src/lib/xray/inbound-from-db.ts
  16. 1 3
      frontend/src/lib/xray/inbound-link.ts
  17. 0 7
      frontend/src/lib/xray/outbound-defaults.ts
  18. 14 22
      frontend/src/lib/xray/outbound-form-adapter.ts
  19. 43 4
      frontend/src/lib/xray/outbound-link-parser.ts
  20. 69 0
      frontend/src/lib/xray/stream-defaults.ts
  21. 1 58
      frontend/src/models/dbinbound.ts
  22. 0 3359
      frontend/src/models/inbound.ts
  23. 0 2405
      frontend/src/models/outbound.ts
  24. 15 1
      frontend/src/pages/api-docs/endpoints.ts
  25. 130 136
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  26. 1 1
      frontend/src/pages/clients/ClientBulkAdjustModal.tsx
  27. 0 1
      frontend/src/pages/clients/ClientFormModal.css
  28. 176 176
      frontend/src/pages/clients/ClientFormModal.tsx
  29. 61 0
      frontend/src/pages/clients/ClientInfoModal.css
  30. 382 166
      frontend/src/pages/clients/ClientInfoModal.tsx
  31. 60 19
      frontend/src/pages/clients/ClientsPage.tsx
  32. 467 239
      frontend/src/pages/inbounds/InboundFormModal.tsx
  33. 199 35
      frontend/src/pages/inbounds/InboundInfoModal.tsx
  34. 55 24
      frontend/src/pages/inbounds/InboundList.tsx
  35. 45 25
      frontend/src/pages/inbounds/InboundsPage.tsx
  36. 37 17
      frontend/src/pages/inbounds/QrCodeModal.tsx
  37. 20 10
      frontend/src/pages/inbounds/useInbounds.ts
  38. 5 3
      frontend/src/pages/index/IndexPage.tsx
  39. 26 14
      frontend/src/pages/index/LogModal.tsx
  40. 13 7
      frontend/src/pages/index/XrayLogModal.tsx
  41. 1 2
      frontend/src/pages/settings/SettingsPage.tsx
  42. 48 53
      frontend/src/pages/sub/SubPage.css
  43. 230 76
      frontend/src/pages/sub/SubPage.tsx
  44. 190 64
      frontend/src/pages/xray/BalancerFormModal.tsx
  45. 86 84
      frontend/src/pages/xray/BalancersTab.tsx
  46. 175 157
      frontend/src/pages/xray/DnsServerModal.tsx
  47. 3 15
      frontend/src/pages/xray/DnsTab.tsx
  48. 3 6
      frontend/src/pages/xray/NordModal.tsx
  49. 168 157
      frontend/src/pages/xray/OutboundFormModal.tsx
  50. 4 2
      frontend/src/pages/xray/RoutingTab.tsx
  51. 2 2
      frontend/src/schemas/_envelope.ts
  52. 16 0
      frontend/src/schemas/client.ts
  53. 2 0
      frontend/src/schemas/defaults.ts
  54. 64 0
      frontend/src/schemas/dns.ts
  55. 1 1
      frontend/src/schemas/forms/outbound-form.ts
  56. 6 6
      frontend/src/schemas/primitives/options.ts
  57. 1 2
      frontend/src/schemas/primitives/protocol.ts
  58. 0 13
      frontend/src/schemas/protocols/inbound/hysteria2.ts
  59. 3 3
      frontend/src/schemas/protocols/inbound/index.ts
  60. 12 0
      frontend/src/schemas/protocols/inbound/tun.ts
  61. 2 2
      frontend/src/schemas/protocols/inbound/vless.ts
  62. 11 2
      frontend/src/schemas/protocols/inbound/vmess.ts
  63. 0 12
      frontend/src/schemas/protocols/outbound/hysteria2.ts
  64. 0 3
      frontend/src/schemas/protocols/outbound/index.ts
  65. 1 1
      frontend/src/schemas/protocols/outbound/vless.ts
  66. 20 9
      frontend/src/schemas/protocols/stream/finalmask.ts
  67. 11 44
      frontend/src/schemas/protocols/stream/hysteria.ts
  68. 36 15
      frontend/src/schemas/protocols/stream/sockopt.ts
  69. 77 0
      frontend/src/schemas/routing.ts
  70. 13 12
      frontend/src/schemas/xray.ts
  71. 73 0
      frontend/src/test/__snapshots__/balancer.test.ts.snap
  72. 116 0
      frontend/src/test/__snapshots__/dns.test.ts.snap
  73. 174 0
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  74. 10 8
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  75. 0 168
      frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap
  76. 0 23
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  77. 91 0
      frontend/src/test/__snapshots__/rule.test.ts.snap
  78. 100 0
      frontend/src/test/__snapshots__/sockopt.test.ts.snap
  79. 147 0
      frontend/src/test/__snapshots__/stream.test.ts.snap
  80. 26 0
      frontend/src/test/balancer.test.ts
  81. 43 0
      frontend/src/test/dns.test.ts
  82. 26 0
      frontend/src/test/finalmask.test.ts
  83. 18 0
      frontend/src/test/golden/fixtures/balancer/leastload-full.json
  84. 8 0
      frontend/src/test/golden/fixtures/balancer/leastping.json
  85. 4 0
      frontend/src/test/golden/fixtures/balancer/random-minimal.json
  86. 8 0
      frontend/src/test/golden/fixtures/balancer/roundrobin.json
  87. 25 0
      frontend/src/test/golden/fixtures/dns-server/full.json
  88. 6 0
      frontend/src/test/golden/fixtures/dns-server/legacy-expectips.json
  89. 42 0
      frontend/src/test/golden/fixtures/dns/full.json
  90. 6 0
      frontend/src/test/golden/fixtures/dns/minimal.json
  91. 15 0
      frontend/src/test/golden/fixtures/finalmask/combined.json
  92. 16 0
      frontend/src/test/golden/fixtures/finalmask/quic-params.json
  93. 30 0
      frontend/src/test/golden/fixtures/finalmask/tcp-mask.json
  94. 29 0
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  95. 0 20
      frontend/src/test/golden/fixtures/inbound/hysteria2-basic.json
  96. 6 0
      frontend/src/test/golden/fixtures/rule/balancer-routed.json
  97. 60 0
      frontend/src/test/golden/fixtures/rule/full.json
  98. 4 0
      frontend/src/test/golden/fixtures/rule/minimal.json
  99. 6 0
      frontend/src/test/golden/fixtures/rule/port-number.json
  100. 1 0
      frontend/src/test/golden/fixtures/sockopt/defaults.json

+ 72 - 11
database/model/model.go

@@ -14,7 +14,11 @@ import (
 // Protocol represents the protocol type for Xray inbounds.
 // Protocol represents the protocol type for Xray inbounds.
 type Protocol string
 type Protocol string
 
 
-// Protocol constants for different Xray inbound protocols
+// Protocol constants for different Xray inbound protocols.
+// Hysteria v2 is not a distinct protocol — it is plain "hysteria"
+// with streamSettings.version = 2. The share-link URI scheme
+// "hysteria2://" is independent of this and is still emitted by the
+// link generator when the stream version is 2.
 const (
 const (
 	VMESS       Protocol = "vmess"
 	VMESS       Protocol = "vmess"
 	VLESS       Protocol = "vless"
 	VLESS       Protocol = "vless"
@@ -25,16 +29,8 @@ const (
 	Mixed       Protocol = "mixed"
 	Mixed       Protocol = "mixed"
 	WireGuard   Protocol = "wireguard"
 	WireGuard   Protocol = "wireguard"
 	Hysteria    Protocol = "hysteria"
 	Hysteria    Protocol = "hysteria"
-	Hysteria2   Protocol = "hysteria2"
 )
 )
 
 
-// IsHysteria returns true for both "hysteria" and "hysteria2".
-// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
-// with the literal v2 string would otherwise fall through (#4081).
-func IsHysteria(p Protocol) bool {
-	return p == Hysteria || p == Hysteria2
-}
-
 // User represents a user account in the 3x-ui panel.
 // User represents a user account in the 3x-ui panel.
 type User struct {
 type User struct {
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -60,7 +56,7 @@ type Inbound struct {
 	// Xray configuration fields
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
 	Listen         string   `json:"listen" form:"listen"`
 	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
 	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
-	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria hysteria2 http mixed tunnel"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel"`
 	Settings       string   `json:"settings" form:"settings"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
@@ -223,17 +219,82 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	}
 	}
 	listen = fmt.Sprintf("\"%v\"", listen)
 	listen = fmt.Sprintf("\"%v\"", listen)
 	protocol := string(i.Protocol)
 	protocol := string(i.Protocol)
+	settings := i.Settings
+	if i.Protocol == Shadowsocks {
+		if healed, ok := HealShadowsocksClientMethods(settings); ok {
+			settings = healed
+		}
+	}
 	return &xray.InboundConfig{
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
 		Listen:         json_util.RawMessage(listen),
 		Port:           i.Port,
 		Port:           i.Port,
 		Protocol:       protocol,
 		Protocol:       protocol,
-		Settings:       json_util.RawMessage(i.Settings),
+		Settings:       json_util.RawMessage(settings),
 		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		Tag:            i.Tag,
 		Tag:            i.Tag,
 		Sniffing:       json_util.RawMessage(i.Sniffing),
 		Sniffing:       json_util.RawMessage(i.Sniffing),
 	}
 	}
 }
 }
 
 
+// HealShadowsocksClientMethods normalises the per-client `method` field
+// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
+//   - Legacy ciphers (aes-*, chacha20-*): every client must carry a
+//     per-user `method` matching the inbound's top-level method, otherwise
+//     xray fails with "unsupported cipher method:".
+//   - Shadowsocks 2022 (2022-blake3-*): xray's multi-user code rejects the
+//     inbound with "users must have empty method" when a client carries
+//     one — strip stale entries left over from a switch off a legacy
+//     cipher.
+// Returns the rewritten settings string and true when anything changed.
+func HealShadowsocksClientMethods(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	method, _ := parsed["method"].(string)
+	clients, ok := parsed["clients"].([]any)
+	if !ok {
+		return settings, false
+	}
+	is2022 := strings.HasPrefix(method, "2022-blake3-")
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if is2022 {
+			if _, hasKey := cm["method"]; hasKey {
+				delete(cm, "method")
+				clients[i] = cm
+				changed = true
+			}
+			continue
+		}
+		if method == "" {
+			continue
+		}
+		existing, _ := cm["method"].(string)
+		if existing == method {
+			continue
+		}
+		cm["method"] = method
+		clients[i] = cm
+		changed = true
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // Setting stores key-value configuration settings for the 3x-ui panel.
 // Setting stores key-value configuration settings for the 3x-ui panel.
 type Setting struct {
 type Setting struct {
 	Id    int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
 	Id    int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`

+ 0 - 18
database/model/model_test.go

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

+ 167 - 49
frontend/README.md

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

+ 0 - 132
frontend/ZOD_MIGRATION_STATUS.md

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

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

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

+ 160 - 160
frontend/package-lock.json

@@ -16,8 +16,8 @@
         "antd": "^6.4.3",
         "antd": "^6.4.3",
         "axios": "^1.16.1",
         "axios": "^1.16.1",
         "codemirror": "^6.0.2",
         "codemirror": "^6.0.2",
-        "dayjs": "^1.11.20",
-        "i18next": "^26.2.0",
+        "dayjs": "^1.11.21",
+        "i18next": "^26.3.0",
         "otpauth": "^9.5.1",
         "otpauth": "^9.5.1",
         "persian-calendar-suite": "^1.5.5",
         "persian-calendar-suite": "^1.5.5",
         "qs": "^6.15.2",
         "qs": "^6.15.2",
@@ -39,8 +39,8 @@
         "eslint-plugin-react-hooks": "^7.1.1",
         "eslint-plugin-react-hooks": "^7.1.1",
         "globals": "^17.6.0",
         "globals": "^17.6.0",
         "typescript": "^6.0.3",
         "typescript": "^6.0.3",
-        "typescript-eslint": "^8.59.4",
-        "vite": "8.0.13",
+        "typescript-eslint": "^8.60.0",
+        "vite": "8.0.14",
         "vitest": "^4.1.7"
         "vitest": "^4.1.7"
       },
       },
       "engines": {
       "engines": {
@@ -868,9 +868,9 @@
       }
       }
     },
     },
     "node_modules/@oxc-project/types": {
     "node_modules/@oxc-project/types": {
-      "version": "0.130.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
-      "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
+      "version": "0.132.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+      "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "funding": {
       "funding": {
@@ -1398,9 +1398,9 @@
       }
       }
     },
     },
     "node_modules/@rc-component/table": {
     "node_modules/@rc-component/table": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.0.tgz",
-      "integrity": "sha512-SjtpcCf+rL7dDc62GKT3rXTdERjVuJvRiqjpU7g0Jc/ewCifXynHc7Nm3Em1XsD+WhGrgQtxNDScI/0+Lpfr0w==",
+      "version": "1.10.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.1.tgz",
+      "integrity": "sha512-XEjyZePbePSdfJjBV3p+I5x/HZ2+UevdiaUJ/ghRm3UtQ9AC+V9hIFM2H349nM/C5ndOa433e/RRQF+RbJQB5g==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@rc-component/context": "^2.0.1",
         "@rc-component/context": "^2.0.1",
@@ -1528,12 +1528,12 @@
       }
       }
     },
     },
     "node_modules/@rc-component/upload": {
     "node_modules/@rc-component/upload": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz",
-      "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz",
+      "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@rc-component/util": "^1.3.0",
+        "@rc-component/util": "^1.11.1",
         "clsx": "^2.1.1"
         "clsx": "^2.1.1"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
@@ -1617,9 +1617,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-android-arm64": {
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
-      "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
+      "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -1634,9 +1634,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
-      "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
+      "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -1651,9 +1651,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-darwin-x64": {
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
-      "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
+      "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -1668,9 +1668,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
-      "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
+      "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -1685,9 +1685,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
-      "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
+      "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -1702,9 +1702,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
-      "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
+      "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -1722,9 +1722,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
-      "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
+      "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -1742,9 +1742,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
-      "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
+      "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
       "cpu": [
       "cpu": [
         "ppc64"
         "ppc64"
       ],
       ],
@@ -1762,9 +1762,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
-      "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
+      "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
       "cpu": [
       "cpu": [
         "s390x"
         "s390x"
       ],
       ],
@@ -1782,9 +1782,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
-      "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
+      "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -1802,9 +1802,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
-      "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
+      "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -1822,9 +1822,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
-      "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
+      "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -1839,9 +1839,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
-      "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
+      "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
       "cpu": [
       "cpu": [
         "wasm32"
         "wasm32"
       ],
       ],
@@ -1858,9 +1858,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
-      "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
+      "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -1875,9 +1875,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
-      "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
+      "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2816,17 +2816,17 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
-      "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
+      "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@eslint-community/regexpp": "^4.12.2",
         "@eslint-community/regexpp": "^4.12.2",
-        "@typescript-eslint/scope-manager": "8.59.4",
-        "@typescript-eslint/type-utils": "8.59.4",
-        "@typescript-eslint/utils": "8.59.4",
-        "@typescript-eslint/visitor-keys": "8.59.4",
+        "@typescript-eslint/scope-manager": "8.60.0",
+        "@typescript-eslint/type-utils": "8.60.0",
+        "@typescript-eslint/utils": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0",
         "ignore": "^7.0.5",
         "ignore": "^7.0.5",
         "natural-compare": "^1.4.0",
         "natural-compare": "^1.4.0",
         "ts-api-utils": "^2.5.0"
         "ts-api-utils": "^2.5.0"
@@ -2839,7 +2839,7 @@
         "url": "https://opencollective.com/typescript-eslint"
         "url": "https://opencollective.com/typescript-eslint"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
-        "@typescript-eslint/parser": "^8.59.4",
+        "@typescript-eslint/parser": "^8.60.0",
         "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
         "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
         "typescript": ">=4.8.4 <6.1.0"
         "typescript": ">=4.8.4 <6.1.0"
       }
       }
@@ -2855,16 +2855,16 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/parser": {
     "node_modules/@typescript-eslint/parser": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
-      "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
+      "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/scope-manager": "8.59.4",
-        "@typescript-eslint/types": "8.59.4",
-        "@typescript-eslint/typescript-estree": "8.59.4",
-        "@typescript-eslint/visitor-keys": "8.59.4",
+        "@typescript-eslint/scope-manager": "8.60.0",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0",
         "debug": "^4.4.3"
         "debug": "^4.4.3"
       },
       },
       "engines": {
       "engines": {
@@ -2880,14 +2880,14 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/project-service": {
     "node_modules/@typescript-eslint/project-service": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
-      "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
+      "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/tsconfig-utils": "^8.59.4",
-        "@typescript-eslint/types": "^8.59.4",
+        "@typescript-eslint/tsconfig-utils": "^8.60.0",
+        "@typescript-eslint/types": "^8.60.0",
         "debug": "^4.4.3"
         "debug": "^4.4.3"
       },
       },
       "engines": {
       "engines": {
@@ -2902,14 +2902,14 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/scope-manager": {
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
-      "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
+      "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/types": "8.59.4",
-        "@typescript-eslint/visitor-keys": "8.59.4"
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0"
       },
       },
       "engines": {
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2920,9 +2920,9 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/tsconfig-utils": {
     "node_modules/@typescript-eslint/tsconfig-utils": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
-      "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
+      "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
@@ -2937,15 +2937,15 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/type-utils": {
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
-      "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
+      "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/types": "8.59.4",
-        "@typescript-eslint/typescript-estree": "8.59.4",
-        "@typescript-eslint/utils": "8.59.4",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0",
+        "@typescript-eslint/utils": "8.60.0",
         "debug": "^4.4.3",
         "debug": "^4.4.3",
         "ts-api-utils": "^2.5.0"
         "ts-api-utils": "^2.5.0"
       },
       },
@@ -2962,9 +2962,9 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/types": {
     "node_modules/@typescript-eslint/types": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
-      "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
+      "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
@@ -2976,16 +2976,16 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/typescript-estree": {
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
-      "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
+      "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/project-service": "8.59.4",
-        "@typescript-eslint/tsconfig-utils": "8.59.4",
-        "@typescript-eslint/types": "8.59.4",
-        "@typescript-eslint/visitor-keys": "8.59.4",
+        "@typescript-eslint/project-service": "8.60.0",
+        "@typescript-eslint/tsconfig-utils": "8.60.0",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/visitor-keys": "8.60.0",
         "debug": "^4.4.3",
         "debug": "^4.4.3",
         "minimatch": "^10.2.2",
         "minimatch": "^10.2.2",
         "semver": "^7.7.3",
         "semver": "^7.7.3",
@@ -3017,16 +3017,16 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/utils": {
     "node_modules/@typescript-eslint/utils": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
-      "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
+      "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.9.1",
         "@eslint-community/eslint-utils": "^4.9.1",
-        "@typescript-eslint/scope-manager": "8.59.4",
-        "@typescript-eslint/types": "8.59.4",
-        "@typescript-eslint/typescript-estree": "8.59.4"
+        "@typescript-eslint/scope-manager": "8.60.0",
+        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0"
       },
       },
       "engines": {
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3041,13 +3041,13 @@
       }
       }
     },
     },
     "node_modules/@typescript-eslint/visitor-keys": {
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
-      "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
+      "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/types": "8.59.4",
+        "@typescript-eslint/types": "8.60.0",
         "eslint-visitor-keys": "^5.0.0"
         "eslint-visitor-keys": "^5.0.0"
       },
       },
       "engines": {
       "engines": {
@@ -3849,9 +3849,9 @@
       }
       }
     },
     },
     "node_modules/dayjs": {
     "node_modules/dayjs": {
-      "version": "1.11.20",
-      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
-      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+      "version": "1.11.21",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+      "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/debug": {
     "node_modules/debug": {
@@ -3952,9 +3952,9 @@
       }
       }
     },
     },
     "node_modules/dompurify": {
     "node_modules/dompurify": {
-      "version": "3.4.5",
-      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
-      "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
+      "version": "3.4.6",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.6.tgz",
+      "integrity": "sha512-+7gzEI8trIIQkVCvQ3ucGtNfH3nOmDgVTzc62rAAOlMxLth78pwpPoZCPc7CyRzAQF89MqcfPdEWkDwnjgqktg==",
       "license": "(MPL-2.0 OR Apache-2.0)",
       "license": "(MPL-2.0 OR Apache-2.0)",
       "optionalDependencies": {
       "optionalDependencies": {
         "@types/trusted-types": "^2.0.7"
         "@types/trusted-types": "^2.0.7"
@@ -3984,9 +3984,9 @@
       }
       }
     },
     },
     "node_modules/electron-to-chromium": {
     "node_modules/electron-to-chromium": {
-      "version": "1.5.361",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
-      "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
+      "version": "1.5.362",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz",
+      "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==",
       "dev": true,
       "dev": true,
       "license": "ISC"
       "license": "ISC"
     },
     },
@@ -4686,9 +4686,9 @@
       }
       }
     },
     },
     "node_modules/i18next": {
     "node_modules/i18next": {
-      "version": "26.2.0",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
-      "integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
+      "version": "26.3.0",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
+      "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
       "funding": [
       "funding": [
         {
         {
           "type": "individual",
           "type": "individual",
@@ -6187,13 +6187,13 @@
       }
       }
     },
     },
     "node_modules/rolldown": {
     "node_modules/rolldown": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
-      "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
+      "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@oxc-project/types": "=0.130.0",
+        "@oxc-project/types": "=0.132.0",
         "@rolldown/pluginutils": "^1.0.0"
         "@rolldown/pluginutils": "^1.0.0"
       },
       },
       "bin": {
       "bin": {
@@ -6203,21 +6203,21 @@
         "node": "^20.19.0 || >=22.12.0"
         "node": "^20.19.0 || >=22.12.0"
       },
       },
       "optionalDependencies": {
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.0.1",
-        "@rolldown/binding-darwin-arm64": "1.0.1",
-        "@rolldown/binding-darwin-x64": "1.0.1",
-        "@rolldown/binding-freebsd-x64": "1.0.1",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
-        "@rolldown/binding-linux-arm64-gnu": "1.0.1",
-        "@rolldown/binding-linux-arm64-musl": "1.0.1",
-        "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
-        "@rolldown/binding-linux-s390x-gnu": "1.0.1",
-        "@rolldown/binding-linux-x64-gnu": "1.0.1",
-        "@rolldown/binding-linux-x64-musl": "1.0.1",
-        "@rolldown/binding-openharmony-arm64": "1.0.1",
-        "@rolldown/binding-wasm32-wasi": "1.0.1",
-        "@rolldown/binding-win32-arm64-msvc": "1.0.1",
-        "@rolldown/binding-win32-x64-msvc": "1.0.1"
+        "@rolldown/binding-android-arm64": "1.0.2",
+        "@rolldown/binding-darwin-arm64": "1.0.2",
+        "@rolldown/binding-darwin-x64": "1.0.2",
+        "@rolldown/binding-freebsd-x64": "1.0.2",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.2",
+        "@rolldown/binding-linux-arm64-musl": "1.0.2",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.2",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.2",
+        "@rolldown/binding-linux-x64-gnu": "1.0.2",
+        "@rolldown/binding-linux-x64-musl": "1.0.2",
+        "@rolldown/binding-openharmony-arm64": "1.0.2",
+        "@rolldown/binding-wasm32-wasi": "1.0.2",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.2",
+        "@rolldown/binding-win32-x64-msvc": "1.0.2"
       }
       }
     },
     },
     "node_modules/safe-buffer": {
     "node_modules/safe-buffer": {
@@ -6773,16 +6773,16 @@
       }
       }
     },
     },
     "node_modules/typescript-eslint": {
     "node_modules/typescript-eslint": {
-      "version": "8.59.4",
-      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
-      "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
+      "version": "8.60.0",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
+      "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@typescript-eslint/eslint-plugin": "8.59.4",
-        "@typescript-eslint/parser": "8.59.4",
-        "@typescript-eslint/typescript-estree": "8.59.4",
-        "@typescript-eslint/utils": "8.59.4"
+        "@typescript-eslint/eslint-plugin": "8.60.0",
+        "@typescript-eslint/parser": "8.60.0",
+        "@typescript-eslint/typescript-estree": "8.60.0",
+        "@typescript-eslint/utils": "8.60.0"
       },
       },
       "engines": {
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6885,16 +6885,16 @@
       }
       }
     },
     },
     "node_modules/vite": {
     "node_modules/vite": {
-      "version": "8.0.13",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
-      "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
+      "version": "8.0.14",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+      "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "lightningcss": "^1.32.0",
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
         "picomatch": "^4.0.4",
-        "postcss": "^8.5.14",
-        "rolldown": "1.0.1",
+        "postcss": "^8.5.15",
+        "rolldown": "1.0.2",
         "tinyglobby": "^0.2.16"
         "tinyglobby": "^0.2.16"
       },
       },
       "bin": {
       "bin": {
@@ -7091,13 +7091,13 @@
       }
       }
     },
     },
     "node_modules/which-typed-array": {
     "node_modules/which-typed-array": {
-      "version": "1.1.20",
-      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
-      "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+      "version": "1.1.21",
+      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
+      "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "available-typed-arrays": "^1.0.7",
         "available-typed-arrays": "^1.0.7",
-        "call-bind": "^1.0.8",
+        "call-bind": "^1.0.9",
         "call-bound": "^1.0.4",
         "call-bound": "^1.0.4",
         "for-each": "^0.3.5",
         "for-each": "^0.3.5",
         "get-proto": "^1.0.1",
         "get-proto": "^1.0.1",

+ 4 - 4
frontend/package.json

@@ -28,8 +28,8 @@
     "antd": "^6.4.3",
     "antd": "^6.4.3",
     "axios": "^1.16.1",
     "axios": "^1.16.1",
     "codemirror": "^6.0.2",
     "codemirror": "^6.0.2",
-    "dayjs": "^1.11.20",
-    "i18next": "^26.2.0",
+    "dayjs": "^1.11.21",
+    "i18next": "^26.3.0",
     "otpauth": "^9.5.1",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "persian-calendar-suite": "^1.5.5",
     "qs": "^6.15.2",
     "qs": "^6.15.2",
@@ -51,8 +51,8 @@
     "eslint-plugin-react-hooks": "^7.1.1",
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.6.0",
     "globals": "^17.6.0",
     "typescript": "^6.0.3",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.59.4",
-    "vite": "8.0.13",
+    "typescript-eslint": "^8.60.0",
+    "vite": "8.0.14",
     "vitest": "^4.1.7"
     "vitest": "^4.1.7"
   },
   },
   "overrides": {
   "overrides": {

+ 137 - 1
frontend/public/openapi.json

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

+ 301 - 173
frontend/src/components/FinalMaskForm.tsx

@@ -1,4 +1,4 @@
-import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import type { FormInstance } from 'antd/es/form';
 import type { FormInstance } from 'antd/es/form';
 import type { NamePath } from 'antd/es/form/interface';
 import type { NamePath } from 'antd/es/form/interface';
@@ -7,11 +7,14 @@ import { RandomUtil } from '@/utils';
 import { OutboundProtocols } from '@/schemas/primitives';
 import { OutboundProtocols } from '@/schemas/primitives';
 
 
 // Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
 // Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
-// paths under `name`; the parent modal owns the Form instance and the
-// surrounding layout. The legacy class-coupled component (which mutated
-// `stream.finalmask.*` via .addTcpMask/.delTcpMask methods) is gone — all
-// state lives in the parent form values, accessed via the `form` and
-// `name` props.
+// paths under `name`; the parent modal owns the Form instance.
+//
+// Naming convention inside Form.List: AntD prefixes Form.Item `name`
+// with the Form.List's own `name`. So Form.Items inside the render
+// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
+// Form.Lists also use relative names. Using absolute paths here would
+// double up the prefix and silently route reads/writes to the wrong
+// storage path.
 
 
 export interface FinalMaskFormProps {
 export interface FinalMaskFormProps {
   name: NamePath;
   name: NamePath;
@@ -80,37 +83,47 @@ function defaultQuicParams(): Record<string, unknown> {
   return {
   return {
     congestion: 'bbr',
     congestion: 'bbr',
     debug: false,
     debug: false,
-    udpHop: { ports: '20000-50000', interval: 5 },
+    maxIdleTimeout: 30,
+    keepAlivePeriod: 10,
+    disablePathMTUDiscovery: false,
+    maxIncomingStreams: 1024,
+    initStreamReceiveWindow: 8388608,
+    maxStreamReceiveWindow: 8388608,
+    initConnectionReceiveWindow: 20971520,
+    maxConnectionReceiveWindow: 20971520,
   };
   };
 }
 }
 
 
+function defaultUdpHop(): Record<string, unknown> {
+  return { ports: '20000-50000', interval: '5-10' };
+}
+
 export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
 export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
   const base = asPath(name);
   const base = asPath(name);
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
   const showTcp = TCP_NETWORKS.includes(network);
   const showTcp = TCP_NETWORKS.includes(network);
   const showUdp = isHysteria || network === 'kcp';
   const showUdp = isHysteria || network === 'kcp';
   const showQuic = isHysteria || network === 'xhttp';
   const showQuic = isHysteria || network === 'xhttp';
-  const enableQuic = Form.useWatch([...base, 'enableQuicParams'], form);
+  const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
+  const hasQuicParams = quicParams != null;
 
 
   if (!showTcp && !showUdp && !showQuic) return null;
   if (!showTcp && !showUdp && !showQuic) return null;
 
 
   return (
   return (
     <>
     <>
       {showTcp && <TcpMasksList base={base} form={form} />}
       {showTcp && <TcpMasksList base={base} form={form} />}
-      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} />}
+      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
       {showQuic && (
       {showQuic && (
         <>
         <>
-          <Form.Item label="QUIC Params" name={[...base, 'enableQuicParams']} valuePropName="checked">
+          <Form.Item label="QUIC Params">
             <Switch
             <Switch
+              checked={hasQuicParams}
               onChange={(v) => {
               onChange={(v) => {
-                if (v) {
-                  const current = form.getFieldValue([...base, 'quicParams']);
-                  if (!current) form.setFieldValue([...base, 'quicParams'], defaultQuicParams());
-                }
+                form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined);
               }}
               }}
             />
             />
           </Form.Item>
           </Form.Item>
-          {enableQuic && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
+          {hasQuicParams && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
         </>
         </>
       )}
       )}
     </>
     </>
@@ -133,10 +146,10 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
           {fields.map((field, mIdx) => (
           {fields.map((field, mIdx) => (
             <TcpMaskItem
             <TcpMaskItem
               key={field.key}
               key={field.key}
-              base={base}
-              index={field.name}
+              fieldName={field.name}
               displayIndex={mIdx + 1}
               displayIndex={mIdx + 1}
               form={form}
               form={form}
+              listPath={[...base, 'tcp']}
               onRemove={() => remove(field.name)}
               onRemove={() => remove(field.name)}
             />
             />
           ))}
           ))}
@@ -147,16 +160,18 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
 }
 }
 
 
 function TcpMaskItem({
 function TcpMaskItem({
-  base, index, displayIndex, form, onRemove,
+  fieldName, displayIndex, form, listPath, onRemove,
 }: {
 }: {
-  base: (string | number)[];
-  index: number;
+  fieldName: number;
   displayIndex: number;
   displayIndex: number;
   form: FormInstance;
   form: FormInstance;
+  listPath: (string | number)[];
   onRemove: () => void;
   onRemove: () => void;
 }) {
 }) {
-  const path = [...base, 'tcp', index];
-  const type = Form.useWatch([...path, 'type'], form) as string | undefined;
+  // Absolute path for setFieldValue side effects (resetting settings on
+  // type change). All Form.Item `name=` use RELATIVE paths within the
+  // outer Form.List context.
+  const absolutePath = [...listPath, fieldName];
 
 
   return (
   return (
     <div>
     <div>
@@ -165,9 +180,11 @@ function TcpMaskItem({
         <DeleteOutlined className="danger-icon" onClick={onRemove} />
         <DeleteOutlined className="danger-icon" onClick={onRemove} />
       </Divider>
       </Divider>
 
 
-      <Form.Item label="Type" name={[...path, 'type']}>
+      <Form.Item label="Type" name={[fieldName, 'type']}>
         <Select
         <Select
-          onChange={(v) => form.setFieldValue([...path, 'settings'], defaultTcpMaskSettings(v))}
+          onChange={(v) =>
+            form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
+          }
           options={[
           options={[
             { value: 'fragment', label: 'Fragment' },
             { value: 'fragment', label: 'Fragment' },
             { value: 'header-custom', label: 'Header Custom' },
             { value: 'header-custom', label: 'Header Custom' },
@@ -176,56 +193,95 @@ function TcpMaskItem({
         />
         />
       </Form.Item>
       </Form.Item>
 
 
-      {type === 'fragment' && (
-        <>
-          <Form.Item label="Packets" name={[...path, 'settings', 'packets']}>
-            <Select
-              options={[
-                { value: 'tlshello', label: 'tlshello' },
-                { value: '1-3', label: '1-3' },
-                { value: '1-5', label: '1-5' },
-              ]}
-            />
-          </Form.Item>
-          <Form.Item label="Length" name={[...path, 'settings', 'length']}>
-            <Input />
-          </Form.Item>
-          <Form.Item label="Delay" name={[...path, 'settings', 'delay']}>
-            <Input />
-          </Form.Item>
-          <Form.Item label="Max Split" name={[...path, 'settings', 'maxSplit']}>
-            <Input />
-          </Form.Item>
-        </>
-      )}
-
-      {type === 'sudoku' && (
-        <>
-          <Form.Item label="Password" name={[...path, 'settings', 'password']}><Input /></Form.Item>
-          <Form.Item label="ASCII" name={[...path, 'settings', 'ascii']}><Input /></Form.Item>
-          <Form.Item label="Custom Table" name={[...path, 'settings', 'customTable']}><Input /></Form.Item>
-          <Form.Item label="Custom Tables" name={[...path, 'settings', 'customTables']}><Input /></Form.Item>
-          <Form.Item label="Padding Min" name={[...path, 'settings', 'paddingMin']}>
-            <InputNumber min={0} />
-          </Form.Item>
-          <Form.Item label="Padding Max" name={[...path, 'settings', 'paddingMax']}>
-            <InputNumber min={0} />
-          </Form.Item>
-        </>
-      )}
-
-      {type === 'header-custom' && (
-        <HeaderCustomGroups base={[...path, 'settings']} form={form} />
-      )}
+      <Form.Item
+        noStyle
+        shouldUpdate={(prev, curr) => {
+          const a = getDeep(prev, [...absolutePath, 'type']);
+          const b = getDeep(curr, [...absolutePath, 'type']);
+          return a !== b;
+        }}
+      >
+        {({ getFieldValue }) => {
+          const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
+          if (type === 'fragment') {
+            return (
+              <>
+                <Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
+                  <Select
+                    options={[
+                      { value: 'tlshello', label: 'tlshello' },
+                      { value: '1-3', label: '1-3' },
+                      { value: '1-5', label: '1-5' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
+                  <Input />
+                </Form.Item>
+                <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
+                  <Input />
+                </Form.Item>
+                <Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
+                  <Input />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'sudoku') {
+            return (
+              <>
+                <Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
+                <Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
+                <Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
+                <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
+                <Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
+                  <InputNumber min={0} />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'header-custom') {
+            return (
+              <HeaderCustomGroups
+                tcpFieldName={fieldName}
+                form={form}
+                absoluteSettingsPath={[...absolutePath, 'settings']}
+              />
+            );
+          }
+          return null;
+        }}
+      </Form.Item>
     </div>
     </div>
   );
   );
 }
 }
 
 
-function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: FormInstance }) {
+// Walks a deep object path safely. Used inside shouldUpdate which gets
+// the whole form values blob; we need to compare a deep field across
+// prev/curr without crashing on missing intermediates.
+function getDeep(obj: unknown, path: (string | number)[]): unknown {
+  let cur: unknown = obj;
+  for (const key of path) {
+    if (cur == null || typeof cur !== 'object') return undefined;
+    cur = (cur as Record<string | number, unknown>)[key];
+  }
+  return cur;
+}
+
+function HeaderCustomGroups({
+  tcpFieldName, form, absoluteSettingsPath,
+}: {
+  tcpFieldName: number;
+  form: FormInstance;
+  absoluteSettingsPath: (string | number)[];
+}) {
   return (
   return (
     <>
     <>
       {(['clients', 'servers'] as const).map((groupKey) => (
       {(['clients', 'servers'] as const).map((groupKey) => (
-        <Form.List key={groupKey} name={[...base, groupKey]}>
+        <Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
           {(groups, { add: addGroup, remove: removeGroup }) => (
           {(groups, { add: addGroup, remove: removeGroup }) => (
             <>
             <>
               <Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
               <Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
@@ -242,7 +298,7 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
                     {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
                     {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
                     <DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
                     <DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
                   </Divider>
                   </Divider>
-                  <Form.List name={[...base, groupKey, group.name]}>
+                  <Form.List name={[group.name]}>
                     {(items, { add: addItem, remove: removeItem }) => (
                     {(items, { add: addItem, remove: removeItem }) => (
                       <>
                       <>
                         <Form.Item label="Items">
                         <Form.Item label="Items">
@@ -255,8 +311,9 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
                         {items.map((item) => (
                         {items.map((item) => (
                           <ItemEditor
                           <ItemEditor
                             key={item.key}
                             key={item.key}
-                            base={[...base, groupKey, group.name, item.name]}
+                            fieldName={item.name}
                             form={form}
                             form={form}
+                            absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
                             delayMode="number"
                             delayMode="number"
                             onRemove={() => removeItem(item.name)}
                             onRemove={() => removeItem(item.name)}
                           />
                           />
@@ -275,8 +332,8 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
 }
 }
 
 
 function UdpMasksList({
 function UdpMasksList({
-  base, form, isHysteria,
-}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean }) {
+  base, form, isHysteria, network,
+}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) {
   return (
   return (
     <Form.List name={[...base, 'udp']}>
     <Form.List name={[...base, 'udp']}>
       {(fields, { add, remove }) => (
       {(fields, { add, remove }) => (
@@ -295,11 +352,12 @@ function UdpMasksList({
           {fields.map((field, mIdx) => (
           {fields.map((field, mIdx) => (
             <UdpMaskItem
             <UdpMaskItem
               key={field.key}
               key={field.key}
-              base={base}
-              index={field.name}
+              fieldName={field.name}
               displayIndex={mIdx + 1}
               displayIndex={mIdx + 1}
               form={form}
               form={form}
+              listPath={[...base, 'udp']}
               isHysteria={isHysteria}
               isHysteria={isHysteria}
+              network={network}
               onRemove={() => remove(field.name)}
               onRemove={() => remove(field.name)}
             />
             />
           ))}
           ))}
@@ -310,24 +368,23 @@ function UdpMasksList({
 }
 }
 
 
 function UdpMaskItem({
 function UdpMaskItem({
-  base, index, displayIndex, form, isHysteria, onRemove,
+  fieldName, displayIndex, form, listPath, isHysteria, network, onRemove,
 }: {
 }: {
-  base: (string | number)[];
-  index: number;
+  fieldName: number;
   displayIndex: number;
   displayIndex: number;
   form: FormInstance;
   form: FormInstance;
+  listPath: (string | number)[];
   isHysteria: boolean;
   isHysteria: boolean;
+  network: string;
   onRemove: () => void;
   onRemove: () => void;
 }) {
 }) {
-  const path = [...base, 'udp', index];
-  const type = Form.useWatch([...path, 'type'], form) as string | undefined;
-  const network = Form.useWatch([...base.slice(0, -1), 'network'], form) as string | undefined;
+  const absolutePath = [...listPath, fieldName];
 
 
   const onTypeChange = (v: string) => {
   const onTypeChange = (v: string) => {
-    form.setFieldValue([...path, 'settings'], defaultUdpMaskSettings(v));
+    form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
     if (network === 'kcp') {
     if (network === 'kcp') {
-      const kcpPath = [...base.slice(0, -1), 'kcpSettings', 'mtu'];
-      form.setFieldValue(kcpPath, v === 'xdns' ? 900 : 1350);
+      const kcpMtuPath = [...listPath.slice(0, -1), 'kcpSettings', 'mtu'];
+      form.setFieldValue(kcpMtuPath, v === 'xdns' ? 900 : 1350);
     }
     }
   };
   };
 
 
@@ -355,55 +412,85 @@ function UdpMaskItem({
         <DeleteOutlined className="danger-icon" onClick={onRemove} />
         <DeleteOutlined className="danger-icon" onClick={onRemove} />
       </Divider>
       </Divider>
 
 
-      <Form.Item label="Type" name={[...path, 'type']}>
+      <Form.Item label="Type" name={[fieldName, 'type']}>
         <Select onChange={onTypeChange} options={options} />
         <Select onChange={onTypeChange} options={options} />
       </Form.Item>
       </Form.Item>
 
 
-      {(type === 'mkcp-aes128gcm' || type === 'salamander') && (
-        <Form.Item label="Password" name={[...path, 'settings', 'password']}>
-          <Input placeholder="Obfuscation password" />
-        </Form.Item>
-      )}
-
-      {type === 'header-dns' && (
-        <Form.Item label="Domain" name={[...path, 'settings', 'domain']}>
-          <Input placeholder="e.g., www.example.com" />
-        </Form.Item>
-      )}
-
-      {type === 'xdns' && (
-        <Form.Item label="Domains" name={[...path, 'settings', 'domains']}>
-          <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
-        </Form.Item>
-      )}
-
-      {type === 'xicmp' && (
-        <>
-          <Form.Item label="IP" name={[...path, 'settings', 'ip']}>
-            <Input placeholder="0.0.0.0" />
-          </Form.Item>
-          <Form.Item label="ID" name={[...path, 'settings', 'id']}>
-            <InputNumber min={0} />
-          </Form.Item>
-        </>
-      )}
-
-      {type === 'header-custom' && (
-        <UdpHeaderCustom base={[...path, 'settings']} form={form} />
-      )}
-
-      {type === 'noise' && (
-        <NoiseItems base={[...path, 'settings']} form={form} />
-      )}
+      <Form.Item
+        noStyle
+        shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
+      >
+        {({ getFieldValue }) => {
+          const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
+          if (type === 'mkcp-aes128gcm' || type === 'salamander') {
+            return (
+              <Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
+                <Input placeholder="Obfuscation password" />
+              </Form.Item>
+            );
+          }
+          if (type === 'header-dns') {
+            return (
+              <Form.Item label="Domain" name={[fieldName, 'settings', 'domain']}>
+                <Input placeholder="e.g., www.example.com" />
+              </Form.Item>
+            );
+          }
+          if (type === 'xdns') {
+            return (
+              <Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
+                <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
+              </Form.Item>
+            );
+          }
+          if (type === 'xicmp') {
+            return (
+              <>
+                <Form.Item label="IP" name={[fieldName, 'settings', 'ip']}>
+                  <Input placeholder="0.0.0.0" />
+                </Form.Item>
+                <Form.Item label="ID" name={[fieldName, 'settings', 'id']}>
+                  <InputNumber min={0} />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'header-custom') {
+            return (
+              <UdpHeaderCustom
+                udpFieldName={fieldName}
+                form={form}
+                absoluteSettingsPath={[...absolutePath, 'settings']}
+              />
+            );
+          }
+          if (type === 'noise') {
+            return (
+              <NoiseItems
+                udpFieldName={fieldName}
+                form={form}
+                absoluteSettingsPath={[...absolutePath, 'settings']}
+              />
+            );
+          }
+          return null;
+        }}
+      </Form.Item>
     </div>
     </div>
   );
   );
 }
 }
 
 
-function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: FormInstance }) {
+function UdpHeaderCustom({
+  udpFieldName, form, absoluteSettingsPath,
+}: {
+  udpFieldName: number;
+  form: FormInstance;
+  absoluteSettingsPath: (string | number)[];
+}) {
   return (
   return (
     <>
     <>
       {(['client', 'server'] as const).map((groupKey) => (
       {(['client', 'server'] as const).map((groupKey) => (
-        <Form.List key={groupKey} name={[...base, groupKey]}>
+        <Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
           {(items, { add, remove }) => (
           {(items, { add, remove }) => (
             <>
             <>
               <Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
               <Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
@@ -421,8 +508,9 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form
                     <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
                     <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
                   </Divider>
                   </Divider>
                   <ItemEditor
                   <ItemEditor
-                    base={[...base, groupKey, item.name]}
+                    fieldName={item.name}
                     form={form}
                     form={form}
+                    absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
                     onRemove={() => remove(item.name)}
                     onRemove={() => remove(item.name)}
                   />
                   />
                 </div>
                 </div>
@@ -435,13 +523,19 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form
   );
   );
 }
 }
 
 
-function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInstance }) {
+function NoiseItems({
+  udpFieldName, form, absoluteSettingsPath,
+}: {
+  udpFieldName: number;
+  form: FormInstance;
+  absoluteSettingsPath: (string | number)[];
+}) {
   return (
   return (
     <>
     <>
-      <Form.Item label="Reset" name={[...base, 'reset']}>
+      <Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
         <InputNumber min={0} />
         <InputNumber min={0} />
       </Form.Item>
       </Form.Item>
-      <Form.List name={[...base, 'noise']}>
+      <Form.List name={[udpFieldName, 'settings', 'noise']}>
         {(items, { add, remove }) => (
         {(items, { add, remove }) => (
           <>
           <>
             <Form.Item label="Noise">
             <Form.Item label="Noise">
@@ -459,8 +553,9 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta
                   <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
                   <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
                 </Divider>
                 </Divider>
                 <ItemEditor
                 <ItemEditor
-                  base={[...base, 'noise', item.name]}
+                  fieldName={item.name}
                   form={form}
                   form={form}
+                  absoluteItemPath={[...absoluteSettingsPath, 'noise', item.name]}
                   delayMode="string"
                   delayMode="string"
                   onRemove={() => remove(item.name)}
                   onRemove={() => remove(item.name)}
                 />
                 />
@@ -474,28 +569,28 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta
 }
 }
 
 
 function ItemEditor({
 function ItemEditor({
-  base, form, delayMode, onRemove: _onRemove,
+  fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove,
 }: {
 }: {
-  base: (string | number)[];
+  fieldName: number;
   form: FormInstance;
   form: FormInstance;
+  absoluteItemPath: (string | number)[];
   delayMode?: 'number' | 'string';
   delayMode?: 'number' | 'string';
   onRemove?: () => void;
   onRemove?: () => void;
 }) {
 }) {
-  const type = Form.useWatch([...base, 'type'], form) as string | undefined;
-
   const onTypeChange = (v: string) => {
   const onTypeChange = (v: string) => {
-    if (v === 'base64') form.setFieldValue([...base, 'packet'], RandomUtil.randomBase64());
-    else if (v === 'array') {
-      form.setFieldValue([...base, 'rand'], delayMode === 'string' ? '1-8192' : 0);
-      form.setFieldValue([...base, 'packet'], []);
+    if (v === 'base64') {
+      form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
+    } else if (v === 'array') {
+      form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
+      form.setFieldValue([...absoluteItemPath, 'packet'], []);
     } else {
     } else {
-      form.setFieldValue([...base, 'packet'], '');
+      form.setFieldValue([...absoluteItemPath, 'packet'], '');
     }
     }
   };
   };
 
 
   return (
   return (
     <>
     <>
-      <Form.Item label="Type" name={[...base, 'type']}>
+      <Form.Item label="Type" name={[fieldName, 'type']}>
         <Select
         <Select
           onChange={onTypeChange}
           onChange={onTypeChange}
           options={[
           options={[
@@ -508,53 +603,68 @@ function ItemEditor({
       </Form.Item>
       </Form.Item>
 
 
       {delayMode === 'number' && (
       {delayMode === 'number' && (
-        <Form.Item label="Delay (ms)" name={[...base, 'delay']}>
+        <Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
           <InputNumber min={0} />
           <InputNumber min={0} />
         </Form.Item>
         </Form.Item>
       )}
       )}
       {delayMode === 'string' && (
       {delayMode === 'string' && (
-        <Form.Item label="Delay" name={[...base, 'delay']}>
+        <Form.Item label="Delay" name={[fieldName, 'delay']}>
           <Input placeholder="10-20" />
           <Input placeholder="10-20" />
         </Form.Item>
         </Form.Item>
       )}
       )}
 
 
-      {type === 'array' ? (
-        <>
-          <Form.Item label="Rand" name={[...base, 'rand']}>
-            {delayMode === 'string' ? (
-              <Input placeholder="0 or 1-8192" />
-            ) : (
-              <InputNumber min={0} />
-            )}
-          </Form.Item>
-          <Form.Item label="Rand Range" name={[...base, 'randRange']}>
-            <Input placeholder="0-255" />
-          </Form.Item>
-        </>
-      ) : type === 'base64' ? (
-        <Form.Item label="Packet">
-          <Input.Group compact>
-            <Form.Item name={[...base, 'packet']} noStyle>
-              <Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
+      <Form.Item
+        noStyle
+        shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
+      >
+        {({ getFieldValue }) => {
+          const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
+          if (type === 'array') {
+            return (
+              <>
+                <Form.Item label="Rand" name={[fieldName, 'rand']}>
+                  {delayMode === 'string' ? (
+                    <Input placeholder="0 or 1-8192" />
+                  ) : (
+                    <InputNumber min={0} />
+                  )}
+                </Form.Item>
+                <Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
+                  <Input placeholder="0-255" />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'base64') {
+            return (
+              <Form.Item label="Packet">
+                <Space.Compact block>
+                  <Form.Item name={[fieldName, 'packet']} noStyle>
+                    <Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
+                  </Form.Item>
+                  <Button
+                    icon={<ReloadOutlined />}
+                    onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
+                  />
+                </Space.Compact>
+              </Form.Item>
+            );
+          }
+          return (
+            <Form.Item label="Packet" name={[fieldName, 'packet']}>
+              <Input placeholder="binary data" />
             </Form.Item>
             </Form.Item>
-            <Button
-              icon={<ReloadOutlined />}
-              onClick={() => form.setFieldValue([...base, 'packet'], RandomUtil.randomBase64())}
-            />
-          </Input.Group>
-        </Form.Item>
-      ) : (
-        <Form.Item label="Packet" name={[...base, 'packet']}>
-          <Input placeholder="binary data" />
-        </Form.Item>
-      )}
+          );
+        }}
+      </Form.Item>
     </>
     </>
   );
   );
 }
 }
 
 
 function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
 function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
   const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
   const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
-  const hasUdpHop = Form.useWatch([...base, 'hasUdpHop'], form) as boolean | undefined;
+  const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as Record<string, unknown> | undefined;
+  const hasUdpHop = udpHop != null;
 
 
   return (
   return (
     <>
     <>
@@ -568,6 +678,19 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
           ]}
           ]}
         />
         />
       </Form.Item>
       </Form.Item>
+      {congestion === 'bbr' && (
+        <Form.Item label="BBR Profile" name={[...base, 'bbrProfile']}>
+          <Select
+            allowClear
+            placeholder="standard"
+            options={[
+              { value: 'conservative', label: 'Conservative' },
+              { value: 'standard', label: 'Standard' },
+              { value: 'aggressive', label: 'Aggressive' },
+            ]}
+          />
+        </Form.Item>
+      )}
       <Form.Item label="Debug" name={[...base, 'debug']} valuePropName="checked">
       <Form.Item label="Debug" name={[...base, 'debug']} valuePropName="checked">
         <Switch />
         <Switch />
       </Form.Item>
       </Form.Item>
@@ -575,16 +698,21 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
       {(congestion === 'brutal' || congestion === 'force-brutal') && (
       {(congestion === 'brutal' || congestion === 'force-brutal') && (
         <>
         <>
           <Form.Item label="Brutal Up" name={[...base, 'brutalUp']}>
           <Form.Item label="Brutal Up" name={[...base, 'brutalUp']}>
-            <Input placeholder="65537" />
+            <Input placeholder="e.g. 60 mbps" />
           </Form.Item>
           </Form.Item>
           <Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
           <Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
-            <Input placeholder="65537" />
+            <Input placeholder="e.g. 100 mbps" />
           </Form.Item>
           </Form.Item>
         </>
         </>
       )}
       )}
 
 
-      <Form.Item label="UDP Hop" name={[...base, 'hasUdpHop']} valuePropName="checked">
-        <Switch />
+      <Form.Item label="UDP Hop">
+        <Switch
+          checked={hasUdpHop}
+          onChange={(v) => {
+            form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
+          }}
+        />
       </Form.Item>
       </Form.Item>
       {hasUdpHop && (
       {hasUdpHop && (
         <>
         <>
@@ -592,7 +720,7 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
             <Input placeholder="e.g. 20000-50000" />
             <Input placeholder="e.g. 20000-50000" />
           </Form.Item>
           </Form.Item>
           <Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
           <Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
-            <InputNumber min={5} />
+            <Input placeholder="e.g. 5-10" />
           </Form.Item>
           </Form.Item>
         </>
         </>
       )}
       )}

+ 22 - 3
frontend/src/components/HeaderMapEditor.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { Button, Input, Space } from 'antd';
 import { Button, Input, Space } from 'antd';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
 
 
@@ -74,10 +74,29 @@ function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, strin
 }
 }
 
 
 export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
 export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
-  const rows = useMemo(() => mapToRows(value), [value]);
+  // Local state holds rows including blanks. Without it, addRow() would
+  // append a {name:'', value:''} that rowsToMap immediately filters out
+  // before reaching the form, so the new row would never reach UI. The
+  // form-bound map only sees rows with non-empty names; blank rows live
+  // here until the user fills them in.
+  const [rows, setRows] = useState<HeaderRow[]>(() => mapToRows(value));
+  const lastEmittedRef = useRef<string>(JSON.stringify(rowsToMap(rows, mode)));
+
+  // Re-sync local rows when the form value changes from outside (modal
+  // re-open with edit data, JSON tab edits, etc.) but not when it's our
+  // own emission echoing back.
+  useEffect(() => {
+    const incoming = JSON.stringify(value ?? {});
+    if (incoming === lastEmittedRef.current) return;
+    setRows(mapToRows(value));
+    lastEmittedRef.current = incoming;
+  }, [value]);
 
 
   function commit(next: HeaderRow[]) {
   function commit(next: HeaderRow[]) {
-    onChange?.(rowsToMap(next, mode));
+    setRows(next);
+    const map = rowsToMap(next, mode);
+    lastEmittedRef.current = JSON.stringify(map);
+    onChange?.(map);
   }
   }
 
 
   function setRow(index: number, patch: Partial<HeaderRow>) {
   function setRow(index: number, patch: Partial<HeaderRow>) {

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

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

+ 49 - 16
frontend/src/hooks/useClients.ts

@@ -10,6 +10,8 @@ import {
   InboundOptionsSchema,
   InboundOptionsSchema,
   OnlinesSchema,
   OnlinesSchema,
   BulkAdjustResultSchema,
   BulkAdjustResultSchema,
+  BulkCreateResultSchema,
+  BulkDeleteResultSchema,
   DelDepletedResultSchema,
   DelDepletedResultSchema,
   type ClientHydrate,
   type ClientHydrate,
   type ClientRecord,
   type ClientRecord,
@@ -18,6 +20,8 @@ import {
   type ClientPageResponse,
   type ClientPageResponse,
   type InboundOption,
   type InboundOption,
   type BulkAdjustResult,
   type BulkAdjustResult,
+  type BulkCreateResult,
+  type BulkDeleteResult,
 } from '@/schemas/client';
 } from '@/schemas/client';
 import { DefaultsPayloadSchema } from '@/schemas/defaults';
 import { DefaultsPayloadSchema } from '@/schemas/defaults';
 
 
@@ -30,6 +34,8 @@ interface SubSettings {
   subURI: string;
   subURI: string;
   subJsonURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
   subJsonEnable: boolean;
+  subClashURI: string;
+  subClashEnable: boolean;
 }
 }
 
 
 export interface ClientQueryParams {
 export interface ClientQueryParams {
@@ -153,7 +159,16 @@ export function useClients() {
     subURI: (defaults.subURI as string) || '',
     subURI: (defaults.subURI as string) || '',
     subJsonURI: (defaults.subJsonURI as string) || '',
     subJsonURI: (defaults.subJsonURI as string) || '',
     subJsonEnable: !!defaults.subJsonEnable,
     subJsonEnable: !!defaults.subJsonEnable,
-  }), [defaults.subEnable, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
+    subClashURI: (defaults.subClashURI as string) || '',
+    subClashEnable: !!defaults.subClashEnable,
+  }), [
+    defaults.subEnable,
+    defaults.subURI,
+    defaults.subJsonURI,
+    defaults.subJsonEnable,
+    defaults.subClashURI,
+    defaults.subClashEnable,
+  ]);
 
 
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const tgBotEnable = !!defaults.tgBotEnable;
   const tgBotEnable = !!defaults.tgBotEnable;
@@ -161,8 +176,17 @@ export function useClients() {
   const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
   const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
   const pageSize = (defaults.pageSize as number) ?? 0;
   const pageSize = (defaults.pageSize as number) ?? 0;
 
 
+  // Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
+  // mutate inbound rows server-side too — adding a client appends to
+  // settings.clients on each attached inbound, the slim list's per-inbound
+  // client count is derived from that. Invalidate both buckets so the
+  // Inbounds page and any open edit modal pick up the new shape without
+  // a manual reload.
   const invalidateAll = useCallback(
   const invalidateAll = useCallback(
-    () => queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
+    () => Promise.all([
+      queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
+      queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
+    ]),
     [queryClient],
     [queryClient],
   );
   );
 
 
@@ -200,16 +224,20 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
   });
 
 
-  const removeManyMut = useMutation({
-    mutationFn: async ({ emails, keepTraffic }: { emails: string[]; keepTraffic?: boolean }) => {
-      const suffix = keepTraffic ? '?keepTraffic=1' : '';
-      const results = await Promise.all(emails.map((email) => {
-        const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
-        return HttpUtil.post(url, undefined, { silent: true });
-      }));
-      return results;
+  const bulkDeleteMut = useMutation({
+    mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
+      return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
     },
     },
-    onSuccess: () => invalidateAll(),
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
+  const bulkCreateMut = useMutation({
+    mutationFn: async (payloads: unknown[]): Promise<Msg<BulkCreateResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
+      return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
   });
 
 
   const bulkAdjustMut = useMutation({
   const bulkAdjustMut = useMutation({
@@ -260,10 +288,14 @@ export function useClients() {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return removeMut.mutateAsync({ email, keepTraffic });
     return removeMut.mutateAsync({ email, keepTraffic });
   }, [removeMut]);
   }, [removeMut]);
-  const removeMany = useCallback((emails: string[], keepTraffic = false) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as Msg<unknown>[]);
-    return removeManyMut.mutateAsync({ emails, keepTraffic });
-  }, [removeManyMut]);
+  const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
+    return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
+  }, [bulkDeleteMut]);
+  const bulkCreate = useCallback((payloads: unknown[]) => {
+    if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
+    return bulkCreateMut.mutateAsync(payloads);
+  }, [bulkCreateMut]);
   const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
   const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
@@ -368,9 +400,10 @@ export function useClients() {
     pageSize,
     pageSize,
     refresh,
     refresh,
     create,
     create,
+    bulkCreate,
     update,
     update,
     remove,
     remove,
-    removeMany,
+    bulkDelete,
     bulkAdjust,
     bulkAdjust,
     attach,
     attach,
     detach,
     detach,

+ 25 - 8
frontend/src/lib/xray/inbound-defaults.ts

@@ -1,11 +1,11 @@
 import { RandomUtil, Wireguard } from '@/utils';
 import { RandomUtil, Wireguard } from '@/utils';
 
 
 import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
 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 { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
 import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
 import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
 import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
 import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
 import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
 import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
+import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
 import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
 import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
 import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
 import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
 import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
 import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
@@ -184,10 +184,6 @@ export function createDefaultHysteriaInboundSettings(
   };
   };
 }
 }
 
 
-export function createDefaultHysteria2InboundSettings(): Hysteria2InboundSettings {
-  return { version: 2, clients: [] };
-}
-
 export function createDefaultHttpInboundSettings(): HttpInboundSettings {
 export function createDefaultHttpInboundSettings(): HttpInboundSettings {
   return { accounts: [], allowTransparent: false };
   return { accounts: [], allowTransparent: false };
 }
 }
@@ -209,19 +205,40 @@ export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
   };
   };
 }
 }
 
 
+export function createDefaultTunInboundSettings(): TunInboundSettings {
+  return {
+    name: 'xray0',
+    mtu: 1500,
+    gateway: [],
+    dns: [],
+    userLevel: 0,
+    autoSystemRoutingTable: [],
+    autoOutboundsInterface: 'auto',
+  };
+}
+
 export interface WireguardInboundSeed {
 export interface WireguardInboundSeed {
   mtu?: number;
   mtu?: number;
   secretKey?: string;
   secretKey?: string;
   noKernelTun?: boolean;
   noKernelTun?: boolean;
+  peerPrivateKey?: string;
 }
 }
 
 
 export function createDefaultWireguardInboundSettings(
 export function createDefaultWireguardInboundSettings(
   seed: WireguardInboundSeed = {},
   seed: WireguardInboundSeed = {},
 ): WireguardInboundSettings {
 ): WireguardInboundSettings {
+  const peerKp = seed.peerPrivateKey
+    ? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
+    : Wireguard.generateKeypair();
   return {
   return {
     mtu: seed.mtu ?? 1420,
     mtu: seed.mtu ?? 1420,
     secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
     secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
-    peers: [],
+    peers: [{
+      privateKey: peerKp.privateKey,
+      publicKey: peerKp.publicKey,
+      allowedIPs: ['10.0.0.2/32'],
+      keepAlive: 0,
+    }],
     noKernelTun: seed.noKernelTun ?? false,
     noKernelTun: seed.noKernelTun ?? false,
   };
   };
 }
 }
@@ -237,9 +254,9 @@ export type AnyInboundSettings =
   | TrojanInboundSettings
   | TrojanInboundSettings
   | ShadowsocksInboundSettings
   | ShadowsocksInboundSettings
   | HysteriaInboundSettings
   | HysteriaInboundSettings
-  | Hysteria2InboundSettings
   | HttpInboundSettings
   | HttpInboundSettings
   | MixedInboundSettings
   | MixedInboundSettings
+  | TunInboundSettings
   | TunnelInboundSettings
   | TunnelInboundSettings
   | WireguardInboundSettings;
   | WireguardInboundSettings;
 
 
@@ -250,10 +267,10 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
     case 'trojan':      return createDefaultTrojanInboundSettings();
     case 'trojan':      return createDefaultTrojanInboundSettings();
     case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
     case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
     case 'hysteria':    return createDefaultHysteriaInboundSettings();
     case 'hysteria':    return createDefaultHysteriaInboundSettings();
-    case 'hysteria2':   return createDefaultHysteria2InboundSettings();
     case 'http':        return createDefaultHttpInboundSettings();
     case 'http':        return createDefaultHttpInboundSettings();
     case 'mixed':       return createDefaultMixedInboundSettings();
     case 'mixed':       return createDefaultMixedInboundSettings();
     case 'tunnel':      return createDefaultTunnelInboundSettings();
     case 'tunnel':      return createDefaultTunnelInboundSettings();
+    case 'tun':         return createDefaultTunInboundSettings();
     case 'wireguard':   return createDefaultWireguardInboundSettings();
     case 'wireguard':   return createDefaultWireguardInboundSettings();
     default:            return null;
     default:            return null;
   }
   }

+ 139 - 3
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -1,7 +1,15 @@
 import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
 import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
 import type { InboundSettings } from '@/schemas/protocols/inbound';
 import type { InboundSettings } from '@/schemas/protocols/inbound';
+import {
+  HysteriaClientSchema,
+  ShadowsocksClientSchema,
+  TrojanClientSchema,
+  VlessClientSchema,
+  VmessClientSchema,
+} from '@/schemas/protocols/inbound';
 import type { StreamSettings } from '@/schemas/api/inbound';
 import type { StreamSettings } from '@/schemas/api/inbound';
 import type { Sniffing } from '@/schemas/primitives';
 import type { Sniffing } from '@/schemas/primitives';
+import type { z } from 'zod';
 
 
 // Plain-data adapter between the panel's stored inbound row shape and
 // Plain-data adapter between the panel's stored inbound row shape and
 // the typed InboundFormValues that Form.useForm<T> carries inside
 // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -79,6 +87,31 @@ function coerceTrafficReset(v: unknown): TrafficReset {
     : 'never';
     : 'never';
 }
 }
 
 
+// Network values that map to a required `${network}Settings` key in
+// NetworkSettingsSchema. Older saved inbounds may be missing the per-
+// network sub-object (the legacy panel sometimes emitted streamSettings
+// without it, and an earlier panel-side prune wrongly stripped empty
+// `tcpSettings: {}` out of the wire payload). Reseat an empty object
+// here so InboundFormSchema.safeParse doesn't blow up at edit time.
+const NETWORK_SETTINGS_KEY: Record<string, string> = {
+  tcp: 'tcpSettings',
+  kcp: 'kcpSettings',
+  ws: 'wsSettings',
+  grpc: 'grpcSettings',
+  httpupgrade: 'httpupgradeSettings',
+  xhttp: 'xhttpSettings',
+  hysteria: 'hysteriaSettings',
+};
+
+function healStreamNetworkKey(stream: Record<string, unknown>): void {
+  const network = typeof stream.network === 'string' ? stream.network : '';
+  const key = NETWORK_SETTINGS_KEY[network];
+  if (!key) return;
+  if (stream[key] == null || typeof stream[key] !== 'object') {
+    stream[key] = {};
+  }
+}
+
 // Map a raw DB row (settings/streamSettings/sniffing as string OR object)
 // Map a raw DB row (settings/streamSettings/sniffing as string OR object)
 // into the typed InboundFormValues. Does NOT validate against the schema —
 // into the typed InboundFormValues. Does NOT validate against the schema —
 // callers that want a hard guarantee should follow up with
 // callers that want a hard guarantee should follow up with
@@ -90,6 +123,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
   const streamSettings = Object.keys(rawStream).length > 0
   const streamSettings = Object.keys(rawStream).length > 0
     ? (rawStream as StreamSettings)
     ? (rawStream as StreamSettings)
     : undefined;
     : undefined;
+  if (streamSettings) {
+    healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
+  }
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
 
 
   return {
   return {
@@ -112,7 +148,107 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
   } as InboundFormValues;
   } as InboundFormValues;
 }
 }
 
 
+// Recursively strip undefined leaves from the wire payload. Empty arrays
+// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
+// shells like `tcpSettings: {}` so xray-core picks up its built-in
+// defaults, and stripping them led the FE to lose required-but-empty
+// arrays (vless clients, wireguard peers, etc.) which the Go side then
+// serialized back as `null`. Primitive values (including 0, false, '')
+// are kept verbatim.
+export function pruneEmpty(value: unknown): unknown {
+  if (Array.isArray(value)) {
+    return value.map(pruneEmpty);
+  }
+  if (value !== null && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      const p = pruneEmpty(v);
+      if (p === undefined) continue;
+      out[k] = p;
+    }
+    return out;
+  }
+  return value;
+}
+
+// Per-protocol client field whitelist — the Zod schemas in
+// schemas/protocols/inbound/<proto>.ts define which keys a given
+// protocol's clients accept on the wire. When a global client is created
+// the panel may persist cross-protocol fields on the same row (`auth` for
+// hysteria, `password` for trojan, `security` for vmess, etc.); rendering
+// those inside a vless inbound's settings.clients is confusing and rides
+// dead weight in the wire payload. Parsing through the protocol's schema
+// gives us the canonical projection.
+function clientSchemaForProtocol(protocol: string): z.ZodType | null {
+  switch (protocol) {
+    case 'vless':       return VlessClientSchema;
+    case 'vmess':       return VmessClientSchema;
+    case 'trojan':      return TrojanClientSchema;
+    case 'shadowsocks': return ShadowsocksClientSchema;
+    case 'hysteria':    return HysteriaClientSchema;
+    default:            return null;
+  }
+}
+
+export function normalizeClients(protocol: string, clients: unknown): unknown {
+  const schema = clientSchemaForProtocol(protocol);
+  if (!schema || !Array.isArray(clients)) return clients;
+  return clients.map((c) => {
+    const parsed = schema.safeParse(c);
+    return parsed.success ? parsed.data : c;
+  });
+}
+
+// Sniffing normalizer matching the legacy Sniffing.toJson(): when
+// disabled the payload is the bare `{ enabled: false }` regardless of
+// what the form holds; when enabled, only non-default fields ride.
+export function normalizeSniffing(s: Sniffing | undefined): Record<string, unknown> {
+  if (!s || !s.enabled) return { enabled: false };
+  const out: Record<string, unknown> = {
+    enabled: true,
+    destOverride: s.destOverride,
+  };
+  if (s.metadataOnly) out.metadataOnly = true;
+  if (s.routeOnly) out.routeOnly = true;
+  if (s.ipsExcluded?.length) out.ipsExcluded = s.ipsExcluded;
+  if (s.domainsExcluded?.length) out.domainsExcluded = s.domainsExcluded;
+  return out;
+}
+
+// Drops cosmetic empty-array keys that legacy XrayCommonClass.toJson()
+// explicitly skipped (fallbacks/finalmask). Mutates the pruned settings
+// objects in place; called AFTER pruneEmpty so we can lean on the
+// already-shallow shape.
+export function dropLegacyOptionalEmpties(
+  settings: Record<string, unknown>,
+  stream: Record<string, unknown> | undefined,
+): void {
+  // VLESS/Trojan emit `fallbacks` only when non-empty.
+  const fb = settings.fallbacks;
+  if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
+
+  // StreamSettings emits `finalmask` only when at least one transport
+  // mask exists (legacy `hasFinalMask`). Otherwise drop the whole block.
+  if (stream) {
+    const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
+    if (fm && typeof fm === 'object') {
+      const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
+      const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
+      const hasQuic = fm.quicParams != null;
+      if (!hasTcp && !hasUdp && !hasQuic) delete stream.finalmask;
+    }
+  }
+}
+
 export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
 export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
+  const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
+  if (Array.isArray(settingsPruned.clients)) {
+    settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
+  }
+  const streamPruned = values.streamSettings
+    ? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
+    : undefined;
+  dropLegacyOptionalEmpties(settingsPruned, streamPruned);
   const payload: WireInboundPayload = {
   const payload: WireInboundPayload = {
     up: values.up,
     up: values.up,
     down: values.down,
     down: values.down,
@@ -125,9 +261,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     listen: values.listen,
     listen: values.listen,
     port: values.port,
     port: values.port,
     protocol: values.protocol,
     protocol: values.protocol,
-    settings: JSON.stringify(values.settings ?? {}),
-    streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '',
-    sniffing: JSON.stringify(values.sniffing ?? {}),
+    settings: JSON.stringify(settingsPruned),
+    streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
+    sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
     tag: values.tag,
     tag: values.tag,
   };
   };
   if (values.nodeId != null) payload.nodeId = values.nodeId;
   if (values.nodeId != null) payload.nodeId = values.nodeId;

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

@@ -0,0 +1,55 @@
+import type { Inbound } from '@/schemas/api/inbound';
+import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
+import { coerceInboundJsonField } from '@/models/dbinbound';
+
+import { fillStreamDefaults } from './stream-defaults';
+
+export interface DbInboundLike {
+  port: number;
+  listen: string;
+  protocol: string;
+  settings: unknown;
+  streamSettings: unknown;
+  sniffing: unknown;
+  tag?: string;
+  remark?: string;
+  enable?: boolean;
+  expiryTime?: number;
+  up?: number;
+  down?: number;
+  total?: number;
+}
+
+function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
+  const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
+  if (parsed.success) {
+    const tagged = parsed.data as { settings: Record<string, unknown> };
+    return { ...tagged.settings };
+  }
+  return settings;
+}
+
+export function inboundFromDb(raw: DbInboundLike): Inbound {
+  const rawSettings = coerceInboundJsonField(raw.settings);
+  const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings);
+  const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings);
+  const sniffing = coerceInboundJsonField(raw.sniffing);
+  const streamSettings = Object.keys(streamSettingsRaw).length === 0
+    ? streamSettingsRaw
+    : fillStreamDefaults(streamSettingsRaw);
+  return {
+    protocol: raw.protocol,
+    port: raw.port,
+    listen: raw.listen ?? '',
+    tag: raw.tag ?? '',
+    remark: raw.remark ?? '',
+    enable: raw.enable ?? true,
+    expiryTime: raw.expiryTime ?? 0,
+    up: raw.up ?? 0,
+    down: raw.down ?? 0,
+    total: raw.total ?? 0,
+    settings,
+    streamSettings,
+    sniffing,
+  } as unknown as Inbound;
+}

+ 1 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -572,7 +572,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     clientAuth,
     clientAuth,
   } = input;
   } = input;
 
 
-  if (inbound.protocol !== 'hysteria' && inbound.protocol !== 'hysteria2') return '';
+  if (inbound.protocol !== 'hysteria') return '';
   const stream = inbound.streamSettings;
   const stream = inbound.streamSettings;
   if (!stream || stream.security !== 'tls') return '';
   if (!stream || stream.security !== 'tls') return '';
 
 
@@ -707,7 +707,6 @@ export function getInboundClients(inbound: Inbound): ClientShape[] | null {
     case 'trojan':
     case 'trojan':
       return (inbound.settings.clients ?? []) as ClientShape[];
       return (inbound.settings.clients ?? []) as ClientShape[];
     case 'hysteria':
     case 'hysteria':
-    case 'hysteria2':
       return (inbound.settings.clients ?? []) as ClientShape[];
       return (inbound.settings.clients ?? []) as ClientShape[];
     case 'shadowsocks': {
     case 'shadowsocks': {
       const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
       const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
@@ -764,7 +763,6 @@ export function genLink(input: GenLinkInput): string {
         externalProxy,
         externalProxy,
       });
       });
     case 'hysteria':
     case 'hysteria':
-    case 'hysteria2':
       return genHysteriaLink({
       return genHysteriaLink({
         inbound, address, port, remark,
         inbound, address, port, remark,
         clientAuth: client.auth ?? '',
         clientAuth: client.auth ?? '',

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

@@ -4,7 +4,6 @@ import type { BlackholeOutboundSettings } from '@/schemas/protocols/outbound/bla
 import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
 import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
 import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
 import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
 import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
 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 { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
 import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
 import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
 import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
 import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
@@ -126,17 +125,12 @@ export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSetting
   return { address: '', port: 443, version: 2 };
   return { address: '', port: 443, version: 2 };
 }
 }
 
 
-export function createDefaultHysteria2OutboundSettings(): Hysteria2OutboundSettings {
-  return { address: '', port: 443, version: 2 };
-}
-
 export type AnyOutboundSettings =
 export type AnyOutboundSettings =
   | BlackholeOutboundSettings
   | BlackholeOutboundSettings
   | DNSOutboundSettings
   | DNSOutboundSettings
   | FreedomOutboundSettings
   | FreedomOutboundSettings
   | HttpOutboundSettings
   | HttpOutboundSettings
   | HysteriaOutboundSettings
   | HysteriaOutboundSettings
-  | Hysteria2OutboundSettings
   | LoopbackOutboundSettings
   | LoopbackOutboundSettings
   | ShadowsocksOutboundSettings
   | ShadowsocksOutboundSettings
   | SocksOutboundSettings
   | SocksOutboundSettings
@@ -167,7 +161,6 @@ export function createDefaultOutboundSettings(protocol: string): AnyOutboundSett
     case 'http':        return createDefaultHttpOutboundSettings();
     case 'http':        return createDefaultHttpOutboundSettings();
     case 'wireguard':   return createDefaultWireguardOutboundSettings();
     case 'wireguard':   return createDefaultWireguardOutboundSettings();
     case 'hysteria':    return createDefaultHysteriaOutboundSettings();
     case 'hysteria':    return createDefaultHysteriaOutboundSettings();
-    case 'hysteria2':   return createDefaultHysteria2OutboundSettings();
     case 'loopback':    return createDefaultLoopbackOutboundSettings();
     case 'loopback':    return createDefaultLoopbackOutboundSettings();
     default:            return null;
     default:            return null;
   }
   }

+ 14 - 22
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -20,13 +20,6 @@ import type {
   WireguardOutboundFormSettings,
   WireguardOutboundFormSettings,
 } from '@/schemas/forms/outbound-form';
 } 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>;
 type Raw = Record<string, unknown>;
 
 
 function asObject(value: unknown): Raw {
 function asObject(value: unknown): Raw {
@@ -348,20 +341,12 @@ export interface RawOutboundRow {
   mux?: 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 {
 export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
   const protocol = asString(raw.protocol, 'vless');
   const protocol = asString(raw.protocol, 'vless');
   const settings = asObject(raw.settings);
   const settings = asObject(raw.settings);
   const tag = asString(raw.tag);
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);
   const sendThrough = asString(raw.sendThrough);
   const mux = muxFromWire(raw.mux);
   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
   const hasStream = raw.streamSettings
     && typeof raw.streamSettings === 'object'
     && typeof raw.streamSettings === 'object'
     && Object.keys(raw.streamSettings as Raw).length > 0;
     && Object.keys(raw.streamSettings as Raw).length > 0;
@@ -554,18 +539,22 @@ function loopbackToWire(s: LoopbackOutboundFormSettings) {
 const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
 const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
 const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
 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 dropEmptyStrings(obj: Raw): Raw {
+  const out: Raw = {};
+  for (const [k, v] of Object.entries(obj)) {
+    if (v === '') continue;
+    out[k] = v;
+  }
+  return out;
+}
+
 function stripUiOnlyStreamFields(stream: unknown): Raw {
 function stripUiOnlyStreamFields(stream: unknown): Raw {
   const next = { ...(stream as Raw) };
   const next = { ...(stream as Raw) };
   const xh = next.xhttpSettings;
   const xh = next.xhttpSettings;
   if (xh && typeof xh === 'object') {
   if (xh && typeof xh === 'object') {
     const cleaned = { ...(xh as Raw) };
     const cleaned = { ...(xh as Raw) };
     delete cleaned.enableXmux;
     delete cleaned.enableXmux;
-    next.xhttpSettings = cleaned;
+    next.xhttpSettings = dropEmptyStrings(cleaned);
   }
   }
   return next;
   return next;
 }
 }
@@ -620,7 +609,10 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
   }
   }
 
 
   if (values.sendThrough) result.sendThrough = values.sendThrough;
   if (values.sendThrough) result.sendThrough = values.sendThrough;
-  if (values.mux.enabled && muxAllowed(values)) {
+  // mux may be absent when the modal didn't render the Mux switch (non-
+  // stream protocols or when isMuxAllowed gated it out). validateFields()
+  // only returns registered fields, so values.mux can be undefined.
+  if (values.mux?.enabled && muxAllowed(values)) {
     result.mux = values.mux;
     result.mux = values.mux;
   }
   }
   return result;
   return result;

+ 43 - 4
frontend/src/lib/xray/outbound-link-parser.ts

@@ -40,6 +40,30 @@ function asBool(s: string | null): boolean | undefined {
 }
 }
 
 
 function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
 function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
+  // Precedence from lowest to highest: stream-init default →
+  // x_padding_bytes snake_case alias → extra JSON payload →
+  // explicit camelCase URL param. Apply in that order so each tier
+  // overwrites the previous when present.
+  const padBytesAlt = params.get('x_padding_bytes');
+  if (padBytesAlt !== null && padBytesAlt !== '') {
+    xhttp.xPaddingBytes = padBytesAlt;
+  }
+  // The inbound link bundles advanced xhttp knobs into `extra=<json>`.
+  // Decode and merge so re-importing a share link round-trips the full
+  // xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
+  const extra = params.get('extra');
+  if (extra) {
+    try {
+      const parsed = JSON.parse(extra) as Record<string, unknown>;
+      applyXhttpStringFromJson(xhttp, parsed);
+      if (parsed.headers && typeof parsed.headers === 'object') {
+        xhttp.headers = parsed.headers;
+      }
+    } catch {
+      // malformed extra — silently ignore, the panel can still operate
+      // on the rest of the link
+    }
+  }
   for (const k of XHTTP_STRING_KEYS) {
   for (const k of XHTTP_STRING_KEYS) {
     const v = params.get(k);
     const v = params.get(k);
     if (v !== null && v !== '') xhttp[k] = v;
     if (v !== null && v !== '') xhttp[k] = v;
@@ -156,6 +180,22 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void {
   }
   }
 }
 }
 
 
+// The inbound link emits the entire finalmask object as a JSON-encoded
+// `fm` query param. Decode and attach to streamSettings so udpHop /
+// quicParams / tcp+udp masks round-trip on outbound import.
+function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
+  const fm = params.get('fm');
+  if (!fm) return;
+  try {
+    const parsed = JSON.parse(fm) as Record<string, unknown>;
+    if (parsed && typeof parsed === 'object') {
+      stream.finalmask = parsed;
+    }
+  } catch {
+    // malformed fm — leave streamSettings.finalmask absent
+  }
+}
+
 function applySecurityParams(stream: Raw, params: URLSearchParams): void {
 function applySecurityParams(stream: Raw, params: URLSearchParams): void {
   if (stream.security === 'tls') {
   if (stream.security === 'tls') {
     const tls = stream.tlsSettings as Raw;
     const tls = stream.tlsSettings as Raw;
@@ -263,6 +303,7 @@ export function parseVlessLink(link: string): Raw | null {
   const stream = buildStream(network, security);
   const stream = buildStream(network, security);
   applyTransportParams(stream, params);
   applyTransportParams(stream, params);
   applySecurityParams(stream, params);
   applySecurityParams(stream, params);
+  applyFinalMaskParam(stream, params);
   return {
   return {
     protocol: 'vless',
     protocol: 'vless',
     tag: decodeRemark(url),
     tag: decodeRemark(url),
@@ -289,6 +330,7 @@ export function parseTrojanLink(link: string): Raw | null {
   const stream = buildStream(network, security);
   const stream = buildStream(network, security);
   applyTransportParams(stream, params);
   applyTransportParams(stream, params);
   applySecurityParams(stream, params);
   applySecurityParams(stream, params);
+  applyFinalMaskParam(stream, params);
   return {
   return {
     protocol: 'trojan',
     protocol: 'trojan',
     tag: decodeRemark(url),
     tag: decodeRemark(url),
@@ -363,10 +405,7 @@ export function parseHysteria2Link(link: string): Raw | null {
     network: 'hysteria',
     network: 'hysteria',
     security: 'tls',
     security: 'tls',
     hysteriaSettings: {
     hysteriaSettings: {
-      version: 2, auth, congestion: '', up: '0', down: '0',
-      initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608,
-      initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520,
-      maxIdleTimeout: 30, keepAlivePeriod: 2, disablePathMTUDiscovery: false,
+      version: 2, auth, udpIdleTimeout: 60,
     },
     },
     tlsSettings: {
     tlsSettings: {
       serverName: params.get('sni') ?? '',
       serverName: params.get('sni') ?? '',

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -75,7 +75,7 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
           type="info"
           type="info"
           showIcon
           showIcon
           style={{ marginBottom: 16 }}
           style={{ marginBottom: 16 }}
-          message={t('pages.clients.bulkAdjustHint')}
+          title={t('pages.clients.bulkAdjustHint')}
         />
         />
         <Form layout="vertical">
         <Form layout="vertical">
           <Form.Item label={t('pages.clients.addDays')}>
           <Form.Item label={t('pages.clients.addDays')}>

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

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

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

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

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

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

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

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

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

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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 467 - 239
frontend/src/pages/inbounds/InboundFormModal.tsx


+ 199 - 35
frontend/src/pages/inbounds/InboundInfoModal.tsx

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

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

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

+ 45 - 25
frontend/src/pages/inbounds/InboundsPage.tsx

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

+ 37 - 17
frontend/src/pages/inbounds/QrCodeModal.tsx

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

+ 20 - 10
frontend/src/pages/inbounds/useInbounds.ts

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

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

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

+ 26 - 14
frontend/src/pages/index/LogModal.tsx

@@ -117,20 +117,32 @@ export default function LogModal({ open, onClose }: LogModalProps) {
       <Form layout="inline" className="log-toolbar">
       <Form layout="inline" className="log-toolbar">
         <Form.Item>
         <Form.Item>
           <Space.Compact>
           <Space.Compact>
-            <Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
-              <Select.Option value="10">10</Select.Option>
-              <Select.Option value="20">20</Select.Option>
-              <Select.Option value="50">50</Select.Option>
-              <Select.Option value="100">100</Select.Option>
-              <Select.Option value="500">500</Select.Option>
-            </Select>
-            <Select value={level} size="small" style={{ width: 95 }} onChange={setLevel}>
-              <Select.Option value="debug">Debug</Select.Option>
-              <Select.Option value="info">Info</Select.Option>
-              <Select.Option value="notice">Notice</Select.Option>
-              <Select.Option value="warning">Warning</Select.Option>
-              <Select.Option value="err">Error</Select.Option>
-            </Select>
+            <Select
+              value={rows}
+              size="small"
+              style={{ width: 70 }}
+              onChange={setRows}
+              options={[
+                { value: '10', label: '10' },
+                { value: '20', label: '20' },
+                { value: '50', label: '50' },
+                { value: '100', label: '100' },
+                { value: '500', label: '500' },
+              ]}
+            />
+            <Select
+              value={level}
+              size="small"
+              style={{ width: 95 }}
+              onChange={setLevel}
+              options={[
+                { value: 'debug', label: 'Debug' },
+                { value: 'info', label: 'Info' },
+                { value: 'notice', label: 'Notice' },
+                { value: 'warning', label: 'Warning' },
+                { value: 'err', label: 'Error' },
+              ]}
+            />
           </Space.Compact>
           </Space.Compact>
         </Form.Item>
         </Form.Item>
         <Form.Item>
         <Form.Item>

+ 13 - 7
frontend/src/pages/index/XrayLogModal.tsx

@@ -124,13 +124,19 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
     >
     >
       <Form layout="inline" className="log-toolbar">
       <Form layout="inline" className="log-toolbar">
         <Form.Item>
         <Form.Item>
-          <Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
-            <Select.Option value="10">10</Select.Option>
-            <Select.Option value="20">20</Select.Option>
-            <Select.Option value="50">50</Select.Option>
-            <Select.Option value="100">100</Select.Option>
-            <Select.Option value="500">500</Select.Option>
-          </Select>
+          <Select
+            value={rows}
+            size="small"
+            style={{ width: 70 }}
+            onChange={setRows}
+            options={[
+              { value: '10', label: '10' },
+              { value: '20', label: '20' },
+              { value: '50', label: '50' },
+              { value: '100', label: '100' },
+              { value: '500', label: '500' },
+            ]}
+          />
         </Form.Item>
         </Form.Item>
         <Form.Item label={t('filter')} className="filter-item">
         <Form.Item label={t('filter')} className="filter-item">
           <Input
           <Input

+ 1 - 2
frontend/src/pages/settings/SettingsPage.tsx

@@ -293,9 +293,8 @@ export default function SettingsPage() {
                     <Alert
                     <Alert
                       type="error"
                       type="error"
                       showIcon
                       showIcon
-                      closable
+                      closable={{ onClose: () => setAlertVisible(false) }}
                       className="conf-alert"
                       className="conf-alert"
-                      onClose={() => setAlertVisible(false)}
                       title={t('pages.settings.securityWarnings')}
                       title={t('pages.settings.securityWarnings')}
                       description={(
                       description={(
                         <>
                         <>

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

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

+ 230 - 76
frontend/src/pages/sub/SubPage.tsx

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

+ 190 - 64
frontend/src/pages/xray/BalancerFormModal.tsx

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

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

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

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

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

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

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

+ 3 - 6
frontend/src/pages/xray/NordModal.tsx

@@ -318,8 +318,7 @@ export default function NordModal({
             <Form.Item label="Country">
             <Form.Item label="Country">
               <Select
               <Select
                 value={countryId ?? undefined}
                 value={countryId ?? undefined}
-                showSearch
-                optionFilterProp="label"
+                showSearch={{ optionFilterProp: 'label' }}
                 onChange={(v) => fetchServers(v)}
                 onChange={(v) => fetchServers(v)}
                 options={countries.map((c) => ({
                 options={countries.map((c) => ({
                   value: c.id,
                   value: c.id,
@@ -332,8 +331,7 @@ export default function NordModal({
               <Form.Item label="City">
               <Form.Item label="City">
                 <Select
                 <Select
                   value={cityId}
                   value={cityId}
-                  showSearch
-                  optionFilterProp="label"
+                  showSearch={{ optionFilterProp: 'label' }}
                   onChange={setCityId}
                   onChange={setCityId}
                   options={[{ value: null, label: 'All cities' }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
                   options={[{ value: null, label: 'All cities' }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
                 />
                 />
@@ -344,8 +342,7 @@ export default function NordModal({
               <Form.Item label="Server">
               <Form.Item label="Server">
                 <Select
                 <Select
                   value={serverId}
                   value={serverId}
-                  showSearch
-                  optionFilterProp="label"
+                  showSearch={{ optionFilterProp: 'label' }}
                   onChange={setServerId}
                   onChange={setServerId}
                   options={filteredServers.map((s) => ({
                   options={filteredServers.map((s) => ({
                     value: s.id,
                     value: s.id,

+ 168 - 157
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -37,6 +37,7 @@ import {
   ALPN_OPTION,
   ALPN_OPTION,
   Address_Port_Strategy,
   Address_Port_Strategy,
   DNSRuleActions,
   DNSRuleActions,
+  DOMAIN_STRATEGY_OPTION,
   MODE_OPTION,
   MODE_OPTION,
   OutboundDomainStrategies,
   OutboundDomainStrategies,
   OutboundProtocols as Protocols,
   OutboundProtocols as Protocols,
@@ -47,6 +48,10 @@ import {
   UTLS_FINGERPRINT,
   UTLS_FINGERPRINT,
   WireguardDomainStrategy,
   WireguardDomainStrategy,
 } from '@/schemas/primitives';
 } from '@/schemas/primitives';
+import {
+  HappyEyeballsSchema,
+  SockoptStreamSettingsSchema,
+} from '@/schemas/protocols/stream/sockopt';
 import {
 import {
   canEnableReality,
   canEnableReality,
   canEnableStream,
   canEnableStream,
@@ -149,16 +154,7 @@ function newStreamSlice(network: string): Record<string, unknown> {
         hysteriaSettings: {
         hysteriaSettings: {
           version: 2,
           version: 2,
           auth: '',
           auth: '',
-          congestion: '',
-          up: '0',
-          down: '0',
-          initStreamReceiveWindow: 8388608,
-          maxStreamReceiveWindow: 8388608,
-          initConnectionReceiveWindow: 20971520,
-          maxConnectionReceiveWindow: 20971520,
-          maxIdleTimeout: 30,
-          keepAlivePeriod: 2,
-          disablePathMTUDiscovery: false,
+          udpIdleTimeout: 60,
         },
         },
       };
       };
     default:
     default:
@@ -213,7 +209,7 @@ export default function OutboundFormModal({
     setJsonDirty(false);
     setJsonDirty(false);
     setLinkInput('');
     setLinkInput('');
     messageApi.success('Link imported successfully');
     messageApi.success('Link imported successfully');
-    setActiveKey('1');
+    switchTab('1');
   }
   }
 
 
   const isEdit = outboundProp != null;
   const isEdit = outboundProp != null;
@@ -236,8 +232,14 @@ export default function OutboundFormModal({
 
 
   const tag = Form.useWatch('tag', form) ?? '';
   const tag = Form.useWatch('tag', form) ?? '';
   const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
   const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
-  const network = (Form.useWatch(['streamSettings', 'network'], form) ?? '') as string;
-  const security = (Form.useWatch(['streamSettings', 'security'], form) ?? 'none') as string;
+  // preserve: true — without it useWatch only reflects values whose
+  // Form.Item is currently mounted. The streamSettings selectors live
+  // INSIDE `{streamAllowed && network && (...)}`, so the moment that
+  // conditional gates them out, useWatch returns undefined, the gate
+  // keeps returning false, and the stream block never renders even
+  // though streamSettings is in the form store.
+  const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
+  const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
 
 
   const streamAllowed = canEnableStream({ protocol });
   const streamAllowed = canEnableStream({ protocol });
   const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
   const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
@@ -361,21 +363,32 @@ export default function OutboundFormModal({
     return true;
     return true;
   }
   }
 
 
-  function onTabChange(key: string) {
-    if (document.activeElement instanceof HTMLElement) {
-      document.activeElement.blur();
+  // Wrap every tab switch with a blur of the active element. AntD marks
+  // the outgoing panel `aria-hidden="true"` synchronously when the
+  // controlled activeKey flips; if a focused input is still inside that
+  // panel (e.g. Input.Search on the JSON tab after user hits Enter to
+  // import), Chrome logs a WAI-ARIA warning. Doing the blur right
+  // before setActiveKey ensures the panel is unfocused by the time
+  // AntD applies the attribute.
+  function switchTab(key: string) {
+    if (typeof document !== 'undefined') {
+      (document.activeElement as HTMLElement | null)?.blur?.();
     }
     }
+    setActiveKey(key);
+  }
+
+  function onTabChange(key: string) {
     if (key === '2') {
     if (key === '2') {
       const values = form.getFieldsValue(true) as OutboundFormValues;
       const values = form.getFieldsValue(true) as OutboundFormValues;
       setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
       setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
       setJsonDirty(false);
       setJsonDirty(false);
-      setActiveKey(key);
+      switchTab(key);
       return;
       return;
     }
     }
     if (key === '1' && activeKey === '2') {
     if (key === '1' && activeKey === '2') {
       if (!applyJsonToForm()) return;
       if (!applyJsonToForm()) return;
     }
     }
-    setActiveKey(key);
+    switchTab(key);
   }
   }
 
 
   async function onOk() {
   async function onOk() {
@@ -1465,16 +1478,23 @@ export default function OutboundFormModal({
                             </Form.Item>
                             </Form.Item>
 
 
                             <Form.Item
                             <Form.Item
-                              label="Uplink HTTP method"
-                              name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
+                              noStyle
+                              shouldUpdate={(prev, curr) =>
+                                prev?.streamSettings?.xhttpSettings?.mode !==
+                                curr?.streamSettings?.xhttpSettings?.mode
+                              }
                             >
                             >
-                              <Form.Item shouldUpdate noStyle>
-                                {() => {
-                                  const mode = form.getFieldValue([
-                                    'streamSettings', 'xhttpSettings', 'mode',
-                                  ]);
-                                  return (
+                              {() => {
+                                const mode = form.getFieldValue([
+                                  'streamSettings', 'xhttpSettings', 'mode',
+                                ]);
+                                return (
+                                  <Form.Item
+                                    label="Uplink HTTP method"
+                                    name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
+                                  >
                                     <Select
                                     <Select
+                                      placeholder="Default (POST)"
                                       options={[
                                       options={[
                                         { value: '', label: 'Default (POST)' },
                                         { value: '', label: 'Default (POST)' },
                                         { value: 'POST', label: 'POST' },
                                         { value: 'POST', label: 'POST' },
@@ -1482,9 +1502,9 @@ export default function OutboundFormModal({
                                         { value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
                                         { value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
                                       ]}
                                       ]}
                                     />
                                     />
-                                  );
-                                }}
-                              </Form.Item>
+                                  </Form.Item>
+                                );
+                              }}
                             </Form.Item>
                             </Form.Item>
 
 
                             {/* Session + sequence + uplinkData placements:
                             {/* Session + sequence + uplinkData placements:
@@ -1497,6 +1517,7 @@ export default function OutboundFormModal({
                               name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
                               name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
                             >
                             >
                               <Select
                               <Select
+                                placeholder="Default (path)"
                                 options={[
                                 options={[
                                   { value: '', label: 'Default (path)' },
                                   { value: '', label: 'Default (path)' },
                                   { value: 'path', label: 'path' },
                                   { value: 'path', label: 'path' },
@@ -1527,6 +1548,7 @@ export default function OutboundFormModal({
                               name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
                               name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
                             >
                             >
                               <Select
                               <Select
+                                placeholder="Default (path)"
                                 options={[
                                 options={[
                                   { value: '', label: 'Default (path)' },
                                   { value: '', label: 'Default (path)' },
                                   { value: 'path', label: 'path' },
                                   { value: 'path', label: 'path' },
@@ -1709,113 +1731,11 @@ export default function OutboundFormModal({
                               <Input />
                               <Input />
                             </Form.Item>
                             </Form.Item>
                             <Form.Item
                             <Form.Item
-                              label="Congestion"
-                              name={['streamSettings', 'hysteriaSettings', 'congestion']}
+                              label="UDP idle timeout (s)"
+                              name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
                             >
                             >
-                              <Select
-                                options={[
-                                  { value: '', label: 'BBR (auto)' },
-                                  { value: 'brutal', label: 'Brutal' },
-                                ]}
-                              />
+                              <InputNumber min={1} style={{ width: '100%' }} />
                             </Form.Item>
                             </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>
                           </>
                           </>
                         )}
                         )}
                       </>
                       </>
@@ -1967,7 +1887,7 @@ export default function OutboundFormModal({
                       </>
                       </>
                     )}
                     )}
 
 
-                    {streamAllowed && network && (
+                    {((streamAllowed && network) || !streamAllowed) && (
                       <Form.Item shouldUpdate noStyle>
                       <Form.Item shouldUpdate noStyle>
                         {() => {
                         {() => {
                           const hasSockopt = !!form.getFieldValue([
                           const hasSockopt = !!form.getFieldValue([
@@ -1982,27 +1902,7 @@ export default function OutboundFormModal({
                                   onChange={(checked) => {
                                   onChange={(checked) => {
                                     form.setFieldValue(
                                     form.setFieldValue(
                                       ['streamSettings', 'sockopt'],
                                       ['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,
+                                      checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
                                     );
                                     );
                                   }}
                                   }}
                                 />
                                 />
@@ -2020,9 +1920,18 @@ export default function OutboundFormModal({
                                     name={['streamSettings', 'sockopt', 'domainStrategy']}
                                     name={['streamSettings', 'sockopt', 'domainStrategy']}
                                   >
                                   >
                                     <Select
                                     <Select
-                                      options={ADDRESS_PORT_STRATEGY_OPTIONS}
+                                      options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
+                                        value: v,
+                                        label: v,
+                                      }))}
                                     />
                                     />
                                   </Form.Item>
                                   </Form.Item>
+                                  <Form.Item
+                                    label="Address+port strategy"
+                                    name={['streamSettings', 'sockopt', 'addressPortStrategy']}
+                                  >
+                                    <Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
+                                  </Form.Item>
                                   <Form.Item
                                   <Form.Item
                                     label="Keep alive interval"
                                     label="Keep alive interval"
                                     name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
                                     name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
@@ -2133,6 +2042,108 @@ export default function OutboundFormModal({
                                       placeholder="trusted-proxy.example,10.0.0.0/8"
                                       placeholder="trusted-proxy.example,10.0.0.0/8"
                                     />
                                     />
                                   </Form.Item>
                                   </Form.Item>
+                                  <Form.Item shouldUpdate noStyle>
+                                    {() => {
+                                      const he = form.getFieldValue([
+                                        'streamSettings', 'sockopt', 'happyEyeballs',
+                                      ]);
+                                      const hasHe = he != null;
+                                      return (
+                                        <>
+                                          <Form.Item label="Happy Eyeballs">
+                                            <Switch
+                                              checked={hasHe}
+                                              onChange={(v) => {
+                                                form.setFieldValue(
+                                                  ['streamSettings', 'sockopt', 'happyEyeballs'],
+                                                  v ? HappyEyeballsSchema.parse({}) : undefined,
+                                                );
+                                              }}
+                                            />
+                                          </Form.Item>
+                                          {hasHe && (
+                                            <>
+                                              <Form.Item
+                                                label="Try delay (ms)"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
+                                              >
+                                                <InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
+                                              </Form.Item>
+                                              <Form.Item
+                                                label="Prioritize IPv6"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
+                                                valuePropName="checked"
+                                              >
+                                                <Switch />
+                                              </Form.Item>
+                                              <Form.Item
+                                                label="Interleave"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
+                                              >
+                                                <InputNumber min={1} style={{ width: '100%' }} />
+                                              </Form.Item>
+                                              <Form.Item
+                                                label="Max concurrent try"
+                                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
+                                              >
+                                                <InputNumber min={0} style={{ width: '100%' }} />
+                                              </Form.Item>
+                                            </>
+                                          )}
+                                        </>
+                                      );
+                                    }}
+                                  </Form.Item>
+                                  <Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
+                                    {(fields, { add, remove }) => (
+                                      <>
+                                        <Form.Item label="Custom sockopt">
+                                          <Button
+                                            type="dashed"
+                                            size="small"
+                                            onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
+                                          >
+                                            + Add custom option
+                                          </Button>
+                                        </Form.Item>
+                                        {fields.map((field) => (
+                                          <Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
+                                            <Form.Item name={[field.name, 'system']} noStyle>
+                                              <Select
+                                                placeholder="all"
+                                                allowClear
+                                                style={{ width: 100 }}
+                                                options={[
+                                                  { value: 'linux', label: 'linux' },
+                                                  { value: 'windows', label: 'windows' },
+                                                  { value: 'darwin', label: 'darwin' },
+                                                ]}
+                                              />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'type']} noStyle>
+                                              <Select
+                                                style={{ width: 80 }}
+                                                options={[
+                                                  { value: 'int', label: 'int' },
+                                                  { value: 'str', label: 'str' },
+                                                ]}
+                                              />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'level']} noStyle>
+                                              <Input placeholder="level (6=TCP)" style={{ width: 100 }} />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'opt']} noStyle>
+                                              <Input placeholder="opt (decimal)" style={{ width: 120 }} />
+                                            </Form.Item>
+                                            <Form.Item name={[field.name, 'value']} noStyle>
+                                              <Input placeholder="value" style={{ flex: 1 }} />
+                                            </Form.Item>
+                                            <Button danger onClick={() => remove(field.name)}>−</Button>
+                                          </Space.Compact>
+                                        ))}
+                                      </>
+                                    )}
+                                  </Form.List>
                                 </>
                                 </>
                               )}
                               )}
                             </>
                             </>

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

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

+ 2 - 2
frontend/src/schemas/_envelope.ts

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

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

@@ -77,6 +77,20 @@ export const BulkAdjustResultSchema = z.object({
     .optional(),
     .optional(),
 });
 });
 
 
+export const BulkDeleteResultSchema = z.object({
+  deleted: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
+export const BulkCreateResultSchema = z.object({
+  created: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
 export const DelDepletedResultSchema = z.object({
 export const DelDepletedResultSchema = z.object({
   deleted: z.number().optional(),
   deleted: z.number().optional(),
 });
 });
@@ -137,6 +151,8 @@ export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
 export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
 export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
 export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
+export type BulkDeleteResult = z.infer<typeof BulkDeleteResultSchema>;
+export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
 export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
 export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
 export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
 export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
 export type ClientFormValues = z.infer<typeof ClientFormSchema>;
 export type ClientFormValues = z.infer<typeof ClientFormSchema>;

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

@@ -9,6 +9,8 @@ export const DefaultsPayloadSchema = z.object({
   subURI: z.string().optional(),
   subURI: z.string().optional(),
   subJsonURI: z.string().optional(),
   subJsonURI: z.string().optional(),
   subJsonEnable: z.boolean().optional(),
   subJsonEnable: z.boolean().optional(),
+  subClashURI: z.string().optional(),
+  subClashEnable: z.boolean().optional(),
   pageSize: z.number().optional(),
   pageSize: z.number().optional(),
   remarkModel: z.string().optional(),
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),

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

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

+ 1 - 1
frontend/src/schemas/forms/outbound-form.ts

@@ -64,7 +64,7 @@ export const VlessOutboundFormSettingsSchema = z.object({
   port: PortSchema.default(443),
   port: PortSchema.default(443),
   id: z.string().default(''),
   id: z.string().default(''),
   flow: z.string().default(''),
   flow: z.string().default(''),
-  encryption: z.literal('none').default('none'),
+  encryption: z.string().min(1).default('none'),
   reverseTag: z.string().default(''),
   reverseTag: z.string().default(''),
   reverseSniffing: ReverseSniffingFormSchema.default({
   reverseSniffing: ReverseSniffingFormSchema.default({
     enabled: false,
     enabled: false,

+ 6 - 6
frontend/src/schemas/primitives/options.ts

@@ -51,12 +51,12 @@ export const WireguardDomainStrategy = Object.freeze([
 
 
 export const Address_Port_Strategy = Object.freeze({
 export const Address_Port_Strategy = Object.freeze({
   NONE: 'none',
   NONE: 'none',
-  SrvPortOnly: 'srvportonly',
-  SrvAddressOnly: 'srvaddressonly',
-  SrvPortAndAddress: 'srvportandaddress',
-  TxtPortOnly: 'txtportonly',
-  TxtAddressOnly: 'txtaddressonly',
-  TxtPortAndAddress: 'txtportandaddress',
+  SRV_PORT_ONLY: 'SrvPortOnly',
+  SRV_ADDRESS_ONLY: 'SrvAddressOnly',
+  SRV_PORT_AND_ADDRESS: 'SrvPortAndAddress',
+  TXT_PORT_ONLY: 'TxtPortOnly',
+  TXT_ADDRESS_ONLY: 'TxtAddressOnly',
+  TXT_PORT_AND_ADDRESS: 'TxtPortAndAddress',
 });
 });
 
 
 export const DNSRuleActions = Object.freeze(['direct', 'drop', 'reject', 'hijack'] as const);
 export const DNSRuleActions = Object.freeze(['direct', 'drop', 'reject', 'hijack'] as const);

+ 1 - 2
frontend/src/schemas/primitives/protocol.ts

@@ -7,10 +7,10 @@ export const ProtocolSchema = z.enum([
   'shadowsocks',
   'shadowsocks',
   'wireguard',
   'wireguard',
   'hysteria',
   'hysteria',
-  'hysteria2',
   'http',
   'http',
   'mixed',
   'mixed',
   'tunnel',
   'tunnel',
+  'tun',
 ]);
 ]);
 export type Protocol = z.infer<typeof ProtocolSchema>;
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
 
@@ -27,7 +27,6 @@ export const Protocols = Object.freeze({
   SHADOWSOCKS: 'shadowsocks',
   SHADOWSOCKS: 'shadowsocks',
   WIREGUARD: 'wireguard',
   WIREGUARD: 'wireguard',
   HYSTERIA: 'hysteria',
   HYSTERIA: 'hysteria',
-  HYSTERIA2: 'hysteria2',
   HTTP: 'http',
   HTTP: 'http',
   MIXED: 'mixed',
   MIXED: 'mixed',
   TUNNEL: 'tunnel',
   TUNNEL: 'tunnel',

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

@@ -1,13 +0,0 @@
-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>;

+ 3 - 3
frontend/src/schemas/protocols/inbound/index.ts

@@ -1,11 +1,11 @@
 import { z } from 'zod';
 import { z } from 'zod';
 
 
 import { HttpInboundSettingsSchema } from './http';
 import { HttpInboundSettingsSchema } from './http';
-import { Hysteria2InboundSettingsSchema } from './hysteria2';
 import { HysteriaInboundSettingsSchema } from './hysteria';
 import { HysteriaInboundSettingsSchema } from './hysteria';
 import { MixedInboundSettingsSchema } from './mixed';
 import { MixedInboundSettingsSchema } from './mixed';
 import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
 import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
 import { TrojanInboundSettingsSchema } from './trojan';
 import { TrojanInboundSettingsSchema } from './trojan';
+import { TunInboundSettingsSchema } from './tun';
 import { TunnelInboundSettingsSchema } from './tunnel';
 import { TunnelInboundSettingsSchema } from './tunnel';
 import { VlessInboundSettingsSchema } from './vless';
 import { VlessInboundSettingsSchema } from './vless';
 import { VmessInboundSettingsSchema } from './vmess';
 import { VmessInboundSettingsSchema } from './vmess';
@@ -13,10 +13,10 @@ import { WireguardInboundSettingsSchema } from './wireguard';
 
 
 export * from './http';
 export * from './http';
 export * from './hysteria';
 export * from './hysteria';
-export * from './hysteria2';
 export * from './mixed';
 export * from './mixed';
 export * from './shadowsocks';
 export * from './shadowsocks';
 export * from './trojan';
 export * from './trojan';
+export * from './tun';
 export * from './tunnel';
 export * from './tunnel';
 export * from './vless';
 export * from './vless';
 export * from './vmess';
 export * from './vmess';
@@ -34,9 +34,9 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksInboundSettingsSchema }),
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksInboundSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'),   settings: WireguardInboundSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'),   settings: WireguardInboundSettingsSchema }),
   z.object({ protocol: z.literal('hysteria'),    settings: HysteriaInboundSettingsSchema }),
   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('http'),        settings: HttpInboundSettingsSchema }),
   z.object({ protocol: z.literal('mixed'),       settings: MixedInboundSettingsSchema }),
   z.object({ protocol: z.literal('mixed'),       settings: MixedInboundSettingsSchema }),
   z.object({ protocol: z.literal('tunnel'),      settings: TunnelInboundSettingsSchema }),
   z.object({ protocol: z.literal('tunnel'),      settings: TunnelInboundSettingsSchema }),
+  z.object({ protocol: z.literal('tun'),         settings: TunInboundSettingsSchema }),
 ]);
 ]);
 export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
 export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

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

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

+ 2 - 2
frontend/src/schemas/protocols/inbound/vless.ts

@@ -39,8 +39,8 @@ export type VlessClient = z.infer<typeof VlessClientSchema>;
 
 
 export const VlessInboundSettingsSchema = z.object({
 export const VlessInboundSettingsSchema = z.object({
   clients: z.array(VlessClientSchema).default([]),
   clients: z.array(VlessClientSchema).default([]),
-  decryption: z.literal('none').default('none'),
-  encryption: z.literal('none').default('none'),
+  decryption: z.string().min(1).default('none'),
+  encryption: z.string().min(1).default('none'),
   fallbacks: z.array(VlessFallbackSchema).default([]),
   fallbacks: z.array(VlessFallbackSchema).default([]),
   // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
   // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
   // exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses
   // exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses

+ 11 - 2
frontend/src/schemas/protocols/inbound/vmess.ts

@@ -1,13 +1,22 @@
 import { z } from 'zod';
 import { z } from 'zod';
 
 
-export const VmessSecuritySchema = z.enum([
+const VmessSecurityEnum = z.enum([
   'aes-128-gcm',
   'aes-128-gcm',
   'chacha20-poly1305',
   'chacha20-poly1305',
   'auto',
   'auto',
   'none',
   'none',
   'zero',
   'zero',
 ]);
 ]);
-export type VmessSecurity = z.infer<typeof VmessSecuritySchema>;
+
+// Legacy rows persisted `security: ""` (especially on VMess inbounds
+// created before the enum was nailed down). Preprocess maps the empty
+// string back to the documented default so existing data parses cleanly
+// — subsequent writes serialize the normalized value.
+export const VmessSecuritySchema = z.preprocess(
+  (val) => (val === '' ? 'auto' : val),
+  VmessSecurityEnum,
+);
+export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;
 
 
 export const VmessClientSchema = z.object({
 export const VmessClientSchema = z.object({
   id: z.uuid(),
   id: z.uuid(),

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

@@ -1,12 +0,0 @@
-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>;

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

@@ -4,7 +4,6 @@ import { BlackholeOutboundSettingsSchema } from './blackhole';
 import { DNSOutboundSettingsSchema } from './dns';
 import { DNSOutboundSettingsSchema } from './dns';
 import { FreedomOutboundSettingsSchema } from './freedom';
 import { FreedomOutboundSettingsSchema } from './freedom';
 import { HttpOutboundSettingsSchema } from './http';
 import { HttpOutboundSettingsSchema } from './http';
-import { Hysteria2OutboundSettingsSchema } from './hysteria2';
 import { HysteriaOutboundSettingsSchema } from './hysteria';
 import { HysteriaOutboundSettingsSchema } from './hysteria';
 import { LoopbackOutboundSettingsSchema } from './loopback';
 import { LoopbackOutboundSettingsSchema } from './loopback';
 import { ShadowsocksOutboundSettingsSchema } from './shadowsocks';
 import { ShadowsocksOutboundSettingsSchema } from './shadowsocks';
@@ -19,7 +18,6 @@ export * from './dns';
 export * from './freedom';
 export * from './freedom';
 export * from './http';
 export * from './http';
 export * from './hysteria';
 export * from './hysteria';
-export * from './hysteria2';
 export * from './loopback';
 export * from './loopback';
 export * from './shadowsocks';
 export * from './shadowsocks';
 export * from './socks';
 export * from './socks';
@@ -39,7 +37,6 @@ export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'),   settings: WireguardOutboundSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'),   settings: WireguardOutboundSettingsSchema }),
   z.object({ protocol: z.literal('hysteria'),    settings: HysteriaOutboundSettingsSchema }),
   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('http'),        settings: HttpOutboundSettingsSchema }),
   z.object({ protocol: z.literal('socks'),       settings: SocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('socks'),       settings: SocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('freedom'),     settings: FreedomOutboundSettingsSchema }),
   z.object({ protocol: z.literal('freedom'),     settings: FreedomOutboundSettingsSchema }),

+ 1 - 1
frontend/src/schemas/protocols/outbound/vless.ts

@@ -7,7 +7,7 @@ export const VlessOutboundSettingsSchema = z.object({
   port: z.number().int().min(1).max(65535),
   port: z.number().int().min(1).max(65535),
   id: z.uuid(),
   id: z.uuid(),
   flow: FlowSchema.default(''),
   flow: FlowSchema.default(''),
-  encryption: z.literal('none').default('none'),
+  encryption: z.string().min(1).default('none'),
   reverse: z
   reverse: z
     .object({
     .object({
       tag: z.string(),
       tag: z.string(),

+ 20 - 9
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -33,6 +33,7 @@ export const UdpMaskTypeSchema = z.enum([
   'xdns',
   'xdns',
   'xicmp',
   'xicmp',
   'noise',
   'noise',
+  'sudoku',
 ]);
 ]);
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 
 
@@ -42,32 +43,42 @@ export const UdpMaskSchema = z.object({
 });
 });
 export type UdpMask = z.infer<typeof UdpMaskSchema>;
 export type UdpMask = z.infer<typeof UdpMaskSchema>;
 
 
-export const QuicCongestionSchema = z.enum(['bbr', 'cubic', 'reno', 'brutal', 'force-brutal']);
+export const QuicCongestionSchema = z.enum(['reno', 'bbr', 'brutal', 'force-brutal']);
 export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
 export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
 
 
+export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
+export type BbrProfile = z.infer<typeof BbrProfileSchema>;
+
 // udpHop randomizes the QUIC port between a range every `interval` seconds
 // udpHop randomizes the QUIC port between a range every `interval` seconds
 // to dodge port-based blocking. Both fields are dash-range strings on the
 // to dodge port-based blocking. Both fields are dash-range strings on the
-// wire (e.g. '20000-50000', '5-10').
+// wire (e.g. '20000-50000', '5-10'). preprocess coerces legacy DB rows
+// where interval was stored as a number (UI bug — see B19 in commit history).
+const StringRangeSchema = z.preprocess(
+  (v) => (typeof v === 'number' ? String(v) : v),
+  z.string(),
+);
+
 export const QuicUdpHopSchema = z.object({
 export const QuicUdpHopSchema = z.object({
-  ports: z.string().default('20000-50000'),
-  interval: z.string().default('5-10'),
+  ports: StringRangeSchema.default('20000-50000'),
+  interval: StringRangeSchema.default('5-10'),
 });
 });
 export type QuicUdpHop = z.infer<typeof QuicUdpHopSchema>;
 export type QuicUdpHop = z.infer<typeof QuicUdpHopSchema>;
 
 
 export const QuicParamsSchema = z.object({
 export const QuicParamsSchema = z.object({
   congestion: QuicCongestionSchema.default('bbr'),
   congestion: QuicCongestionSchema.default('bbr'),
+  bbrProfile: BbrProfileSchema.optional(),
   debug: z.boolean().optional(),
   debug: z.boolean().optional(),
-  brutalUp: z.number().int().min(0).optional(),
-  brutalDown: z.number().int().min(0).optional(),
+  brutalUp: z.string().optional(),
+  brutalDown: z.string().optional(),
   udpHop: QuicUdpHopSchema.optional(),
   udpHop: QuicUdpHopSchema.optional(),
   initStreamReceiveWindow: z.number().int().min(0).optional(),
   initStreamReceiveWindow: z.number().int().min(0).optional(),
   maxStreamReceiveWindow: z.number().int().min(0).optional(),
   maxStreamReceiveWindow: z.number().int().min(0).optional(),
   initConnectionReceiveWindow: z.number().int().min(0).optional(),
   initConnectionReceiveWindow: z.number().int().min(0).optional(),
   maxConnectionReceiveWindow: 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(),
+  maxIdleTimeout: z.number().int().min(4).max(120).optional(),
+  keepAlivePeriod: z.number().int().min(2).max(60).optional(),
   disablePathMTUDiscovery: z.boolean().optional(),
   disablePathMTUDiscovery: z.boolean().optional(),
-  maxIncomingStreams: z.number().int().min(0).optional(),
+  maxIncomingStreams: z.number().int().min(8).optional(),
 });
 });
 export type QuicParams = z.infer<typeof QuicParamsSchema>;
 export type QuicParams = z.infer<typeof QuicParamsSchema>;
 
 

+ 11 - 44
frontend/src/schemas/protocols/stream/hysteria.ts

@@ -1,29 +1,17 @@
 import { z } from 'zod';
 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.
+// Hysteria stream transport. Per Xray docs (transports/hysteria.html), the
+// Xray implementation of Hysteria2's underlying QUIC transport keeps only
+// the essentials — version, auth, udpIdleTimeout, and masquerade. The
+// extended bandwidth/window/udphop knobs that earlier hysteria builds
+// exposed are not part of this transport's wire shape.
 
 
-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.
+// Inbound masquerade — Xray's hysteria inbound can disguise itself as an
+// HTTP/3 server. `type` is the empty string by default (serves the default
+// 404 page), and per-type config keys are only honored when their type is
+// active.
 export const HysteriaMasqueradeSchema = z.object({
 export const HysteriaMasqueradeSchema = z.object({
-  type: z.enum(['proxy', 'file', 'string']).default('proxy'),
+  type: z.enum(['', 'proxy', 'file', 'string']).default(''),
   dir: z.string().default(''),
   dir: z.string().default(''),
   url: z.string().default(''),
   url: z.string().default(''),
   rewriteHost: z.boolean().default(false),
   rewriteHost: z.boolean().default(false),
@@ -35,30 +23,9 @@ export const HysteriaMasqueradeSchema = z.object({
 export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
 export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
 
 
 export const HysteriaStreamSettingsSchema = z.object({
 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),
   version: z.literal(2).default(2),
   auth: z.string().default(''),
   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(),
+  udpIdleTimeout: z.number().int().min(1).default(60),
   masquerade: HysteriaMasqueradeSchema.optional(),
   masquerade: HysteriaMasqueradeSchema.optional(),
 });
 });
 export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;
 export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;

+ 36 - 15
frontend/src/schemas/protocols/stream/sockopt.ts

@@ -21,33 +21,54 @@ export type TcpCongestion = z.infer<typeof TcpCongestionSchema>;
 export const TproxyModeSchema = z.enum(['off', 'redirect', 'tproxy']);
 export const TproxyModeSchema = z.enum(['off', 'redirect', 'tproxy']);
 export type TproxyMode = z.infer<typeof TproxyModeSchema>;
 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 AddressPortStrategySchema = z.enum([
+  'none',
+  'SrvPortOnly',
+  'SrvAddressOnly',
+  'SrvPortAndAddress',
+  'TxtPortOnly',
+  'TxtAddressOnly',
+  'TxtPortAndAddress',
+]);
+export type AddressPortStrategy = z.infer<typeof AddressPortStrategySchema>;
+
+export const HappyEyeballsSchema = z.object({
+  tryDelayMs: z.number().int().min(0).default(0),
+  prioritizeIPv6: z.boolean().default(false),
+  interleave: z.number().int().min(1).default(1),
+  maxConcurrentTry: z.number().int().min(0).default(4),
+});
+export type HappyEyeballs = z.infer<typeof HappyEyeballsSchema>;
+
+export const CustomSockoptSchema = z.object({
+  system: z.enum(['linux', 'windows', 'darwin']).optional(),
+  type: z.enum(['int', 'str']),
+  level: z.string().default('6'),
+  opt: z.string(),
+  value: z.union([z.string(), z.number()]),
+});
+export type CustomSockopt = z.infer<typeof CustomSockoptSchema>;
+
 export const SockoptStreamSettingsSchema = z.object({
 export const SockoptStreamSettingsSchema = z.object({
   acceptProxyProtocol: z.boolean().default(false),
   acceptProxyProtocol: z.boolean().default(false),
-  tcpFastOpen: z.boolean().default(false),
-  mark: z.number().int().min(0).default(0),
+  tcpFastOpen: z.union([z.boolean(), z.number().int()]).default(false),
+  mark: z.number().int().default(0),
   tproxy: TproxyModeSchema.default('off'),
   tproxy: TproxyModeSchema.default('off'),
   tcpMptcp: z.boolean().default(false),
   tcpMptcp: z.boolean().default(false),
   penetrate: z.boolean().default(false),
   penetrate: z.boolean().default(false),
-  domainStrategy: SockoptDomainStrategySchema.default('UseIP'),
+  domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
   tcpMaxSeg: z.number().int().min(0).default(1440),
   tcpMaxSeg: z.number().int().min(0).default(1440),
   dialerProxy: z.string().default(''),
   dialerProxy: z.string().default(''),
-  tcpKeepAliveInterval: z.number().int().min(0).default(0),
-  tcpKeepAliveIdle: z.number().int().min(0).default(300),
+  tcpKeepAliveInterval: z.number().int().min(0).default(45),
+  tcpKeepAliveIdle: z.number().int().min(0).default(45),
   tcpUserTimeout: z.number().int().min(0).default(10000),
   tcpUserTimeout: z.number().int().min(0).default(10000),
   tcpcongestion: TcpCongestionSchema.default('bbr'),
   tcpcongestion: TcpCongestionSchema.default('bbr'),
   V6Only: z.boolean().default(false),
   V6Only: z.boolean().default(false),
   tcpWindowClamp: z.number().int().min(0).default(600),
   tcpWindowClamp: z.number().int().min(0).default(600),
   interfaceName: z.string().default(''),
   interfaceName: z.string().default(''),
   trustedXForwardedFor: z.array(z.string()).default([]),
   trustedXForwardedFor: z.array(z.string()).default([]),
+  addressPortStrategy: AddressPortStrategySchema.default('none'),
+  happyEyeballs: HappyEyeballsSchema.optional(),
+  customSockopt: z.array(CustomSockoptSchema).default([]),
 });
 });
 export type SockoptStreamSettings = z.infer<typeof SockoptStreamSettingsSchema>;
 export type SockoptStreamSettings = z.infer<typeof SockoptStreamSettingsSchema>;

+ 77 - 0
frontend/src/schemas/routing.ts

@@ -0,0 +1,77 @@
+import { z } from 'zod';
+
+export const RuleProtocolSchema = z.enum(['http', 'tls', 'quic', 'bittorrent']);
+export type RuleProtocol = z.infer<typeof RuleProtocolSchema>;
+
+const PortValueSchema = z.union([
+  z.number().int().min(0).max(65535),
+  z.string(),
+]);
+
+export const RuleWebhookSchema = z.object({
+  url: z.string(),
+  deduplication: z.number().int().min(0).optional(),
+  headers: z.record(z.string(), z.string()).optional(),
+});
+export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
+
+export const RuleObjectSchema = z.object({
+  type: z.literal('field').default('field'),
+  domain: z.array(z.string()).optional(),
+  ip: z.array(z.string()).optional(),
+  port: PortValueSchema.optional(),
+  sourcePort: PortValueSchema.optional(),
+  localPort: PortValueSchema.optional(),
+  network: z.string().optional(),
+  sourceIP: z.array(z.string()).optional(),
+  localIP: z.array(z.string()).optional(),
+  user: z.array(z.string()).optional(),
+  vlessRoute: PortValueSchema.optional(),
+  inboundTag: z.array(z.string()).optional(),
+  protocol: z.array(z.string()).optional(),
+  attrs: z.record(z.string(), z.string()).optional(),
+  process: z.array(z.string()).optional(),
+  outboundTag: z.string().optional(),
+  balancerTag: z.string().optional(),
+  ruleTag: z.string().optional(),
+  webhook: RuleWebhookSchema.optional(),
+});
+export type RuleObject = z.infer<typeof RuleObjectSchema>;
+
+export const BalancerStrategyTypeSchema = z.enum([
+  'random',
+  'roundRobin',
+  'leastPing',
+  'leastLoad',
+]);
+export type BalancerStrategyType = z.infer<typeof BalancerStrategyTypeSchema>;
+
+export const BalancerCostObjectSchema = z.object({
+  regexp: z.boolean().default(false),
+  match: z.string(),
+  value: z.number(),
+});
+export type BalancerCostObject = z.infer<typeof BalancerCostObjectSchema>;
+
+export const BalancerStrategySettingsSchema = z.object({
+  expected: z.number().int().min(0).optional(),
+  maxRTT: z.string().optional(),
+  tolerance: z.number().min(0).max(1).optional(),
+  baselines: z.array(z.string()).optional(),
+  costs: z.array(BalancerCostObjectSchema).optional(),
+});
+export type BalancerStrategySettings = z.infer<typeof BalancerStrategySettingsSchema>;
+
+export const BalancerStrategySchema = z.object({
+  type: BalancerStrategyTypeSchema.default('random'),
+  settings: BalancerStrategySettingsSchema.optional(),
+});
+export type BalancerStrategy = z.infer<typeof BalancerStrategySchema>;
+
+export const BalancerObjectSchema = z.object({
+  tag: z.string().trim().min(1),
+  selector: z.array(z.string()).min(1),
+  fallbackTag: z.string().optional(),
+  strategy: BalancerStrategySchema.optional(),
+});
+export type BalancerObject = z.infer<typeof BalancerObjectSchema>;

+ 13 - 12
frontend/src/schemas/xray.ts

@@ -1,4 +1,11 @@
 import { z } from 'zod';
 import { z } from 'zod';
+import { DnsObjectSchema } from './dns';
+import {
+  BalancerObjectSchema,
+  BalancerStrategySettingsSchema,
+  BalancerStrategyTypeSchema,
+  RuleObjectSchema,
+} from './routing';
 
 
 export const XraySettingsValueSchema = z.object({
 export const XraySettingsValueSchema = z.object({
   inbounds: z.array(z.unknown()).optional(),
   inbounds: z.array(z.unknown()).optional(),
@@ -13,18 +20,11 @@ export const XraySettingsValueSchema = z.object({
     )
     )
     .optional(),
     .optional(),
   routing: z.object({
   routing: z.object({
-    rules: z.array(z.object({
-      type: z.string().optional(),
-      outboundTag: z.string().optional(),
-      balancerTag: z.string().optional(),
-    }).loose()).optional(),
-    balancers: z.array(z.unknown()).optional(),
+    rules: z.array(RuleObjectSchema).optional(),
+    balancers: z.array(BalancerObjectSchema).optional(),
     domainStrategy: z.string().optional(),
     domainStrategy: z.string().optional(),
   }).loose().optional(),
   }).loose().optional(),
-  dns: z.object({
-    tag: z.string().optional(),
-    servers: z.array(z.unknown()).optional(),
-  }).loose().optional(),
+  dns: DnsObjectSchema.optional(),
   log: z.record(z.string(), z.unknown()).optional(),
   log: z.record(z.string(), z.unknown()).optional(),
   policy: z.object({
   policy: z.object({
     system: z.record(z.string(), z.boolean()).optional(),
     system: z.record(z.string(), z.boolean()).optional(),
@@ -109,9 +109,10 @@ export const RuleFormSchema = z.object({
 
 
 export const BalancerFormSchema = z.object({
 export const BalancerFormSchema = z.object({
   tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'),
   tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'),
-  strategy: z.string(),
+  strategy: BalancerStrategyTypeSchema.default('random'),
   selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
   selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
-  fallbackTag: z.string(),
+  fallbackTag: z.string().default(''),
+  settings: BalancerStrategySettingsSchema.optional(),
 });
 });
 
 
 export const OutboundTagSchema = z
 export const OutboundTagSchema = z

+ 73 - 0
frontend/src/test/__snapshots__/balancer.test.ts.snap

@@ -0,0 +1,73 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`BalancerObjectSchema fixtures > parses leastload-full byte-stably 1`] = `
+{
+  "fallbackTag": "fallback-out",
+  "selector": [
+    "proxy-",
+  ],
+  "strategy": {
+    "settings": {
+      "baselines": [
+        "500ms",
+        "1s",
+        "2s",
+      ],
+      "costs": [
+        {
+          "match": "proxy-premium",
+          "regexp": false,
+          "value": 0.1,
+        },
+        {
+          "match": "^proxy-cheap-.+$",
+          "regexp": true,
+          "value": 5,
+        },
+      ],
+      "expected": 3,
+      "maxRTT": "1s",
+      "tolerance": 0.05,
+    },
+    "type": "leastLoad",
+  },
+  "tag": "balancer-load",
+}
+`;
+
+exports[`BalancerObjectSchema fixtures > parses leastping byte-stably 1`] = `
+{
+  "fallbackTag": "fallback-out",
+  "selector": [
+    "proxy-",
+  ],
+  "strategy": {
+    "type": "leastPing",
+  },
+  "tag": "balancer-ping",
+}
+`;
+
+exports[`BalancerObjectSchema fixtures > parses random-minimal byte-stably 1`] = `
+{
+  "selector": [
+    "proxy-",
+  ],
+  "tag": "balancer-random",
+}
+`;
+
+exports[`BalancerObjectSchema fixtures > parses roundrobin byte-stably 1`] = `
+{
+  "fallbackTag": "direct",
+  "selector": [
+    "proxy-a",
+    "proxy-b",
+    "proxy-c",
+  ],
+  "strategy": {
+    "type": "roundRobin",
+  },
+  "tag": "balancer-rr",
+}
+`;

+ 116 - 0
frontend/src/test/__snapshots__/dns.test.ts.snap

@@ -0,0 +1,116 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`DnsObjectSchema fixtures > parses full byte-stably 1`] = `
+{
+  "clientIp": "1.2.3.4",
+  "disableCache": false,
+  "disableFallback": false,
+  "disableFallbackIfMatch": true,
+  "enableParallelQuery": true,
+  "hosts": {
+    "domain:example.com": [
+      "10.0.0.1",
+      "10.0.0.2",
+    ],
+    "full:dns.google": "8.8.8.8",
+    "geosite:category-ads-all": "127.0.0.1",
+  },
+  "queryStrategy": "UseIP",
+  "serveExpiredTTL": 300,
+  "serveStale": true,
+  "servers": [
+    "fakedns",
+    "localhost",
+    "https://dns.google/dns-query",
+    {
+      "address": "tcp://1.1.1.1",
+      "clientIP": "8.8.4.4",
+      "domains": [
+        "geosite:cn",
+      ],
+      "expectedIPs": [
+        "geoip:cn",
+      ],
+      "port": 53,
+      "queryStrategy": "UseIPv4",
+      "skipFallback": true,
+      "tag": "cn-dns",
+      "timeoutMs": 3000,
+    },
+    {
+      "address": "quic+local://dns.adguard.com",
+      "disableCache": true,
+      "finalQuery": true,
+      "port": 53,
+      "serveExpiredTTL": 60,
+      "serveStale": false,
+      "timeoutMs": 5000,
+      "unexpectedIPs": [
+        "geoip:private",
+      ],
+    },
+  ],
+  "tag": "dns_inbound",
+  "useSystemHosts": true,
+}
+`;
+
+exports[`DnsObjectSchema fixtures > parses minimal byte-stably 1`] = `
+{
+  "disableCache": false,
+  "disableFallback": false,
+  "disableFallbackIfMatch": false,
+  "enableParallelQuery": false,
+  "queryStrategy": "UseIP",
+  "serveExpiredTTL": 0,
+  "serveStale": false,
+  "servers": [
+    "8.8.8.8",
+    "1.1.1.1",
+  ],
+  "useSystemHosts": false,
+}
+`;
+
+exports[`DnsServerObjectSchema fixtures > parses full byte-stably 1`] = `
+{
+  "address": "https://dns.google/dns-query",
+  "clientIP": "9.9.9.9",
+  "disableCache": false,
+  "domains": [
+    "domain:google.com",
+    "domain:youtube.com",
+    "geosite:google",
+  ],
+  "expectedIPs": [
+    "geoip:us",
+    "1.2.3.0/24",
+  ],
+  "finalQuery": false,
+  "port": 443,
+  "queryStrategy": "UseIPv6",
+  "serveExpiredTTL": 600,
+  "serveStale": true,
+  "skipFallback": false,
+  "tag": "google-doh",
+  "timeoutMs": 4000,
+  "unexpectedIPs": [
+    "geoip:private",
+  ],
+}
+`;
+
+exports[`DnsServerObjectSchema fixtures > parses legacy-expectips byte-stably 1`] = `
+{
+  "address": "8.8.8.8",
+  "domains": [
+    "geosite:cn",
+  ],
+  "expectedIPs": [
+    "geoip:cn",
+    "10.0.0.0/8",
+  ],
+  "port": 53,
+  "timeoutMs": 4000,
+}
+`;

+ 174 - 0
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -0,0 +1,174 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`] = `
+{
+  "quicParams": {
+    "brutalDown": "200 mbps",
+    "brutalUp": "100 mbps",
+    "congestion": "brutal",
+    "udpHop": {
+      "interval": "5-10",
+      "ports": "10000-20000",
+    },
+  },
+  "tcp": [
+    {
+      "settings": {
+        "packets": "1-3",
+      },
+      "type": "fragment",
+    },
+  ],
+  "udp": [
+    {
+      "settings": {
+        "password": "swordfish",
+      },
+      "type": "salamander",
+    },
+    {
+      "type": "header-wireguard",
+    },
+  ],
+}
+`;
+
+exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably 1`] = `
+{
+  "quicParams": {
+    "bbrProfile": "standard",
+    "congestion": "bbr",
+    "debug": false,
+    "disablePathMTUDiscovery": false,
+    "initConnectionReceiveWindow": 20971520,
+    "initStreamReceiveWindow": 8388608,
+    "keepAlivePeriod": 10,
+    "maxConnectionReceiveWindow": 20971520,
+    "maxIdleTimeout": 30,
+    "maxIncomingStreams": 1024,
+    "maxStreamReceiveWindow": 8388608,
+    "udpHop": {
+      "interval": "5-10",
+      "ports": "20000-50000",
+    },
+  },
+  "tcp": [],
+  "udp": [],
+}
+`;
+
+exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
+{
+  "tcp": [
+    {
+      "settings": {
+        "delay": "5-10",
+        "length": "10-20",
+        "maxSplit": "0",
+        "packets": "1-3",
+      },
+      "type": "fragment",
+    },
+    {
+      "type": "sudoku",
+    },
+    {
+      "settings": {
+        "clients": [
+          [
+            {
+              "delay": 0,
+              "packet": [
+                "GET / HTTP/1.1",
+              ],
+              "type": "str",
+            },
+          ],
+        ],
+        "errors": [],
+        "servers": [
+          [
+            {
+              "delay": 0,
+              "packet": [
+                "HTTP/1.1 200 OK",
+              ],
+              "type": "str",
+            },
+          ],
+        ],
+      },
+      "type": "header-custom",
+    },
+  ],
+  "udp": [],
+}
+`;
+
+exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "password": "swordfish",
+      },
+      "type": "salamander",
+    },
+    {
+      "settings": {
+        "password": "abcdef0123456789",
+      },
+      "type": "mkcp-aes128gcm",
+    },
+    {
+      "settings": {
+        "domain": "cloudflare.com",
+      },
+      "type": "header-dns",
+    },
+    {
+      "type": "header-wireguard",
+    },
+    {
+      "settings": {
+        "noise": [
+          {
+            "delay": "10-16",
+            "rand": "10-20",
+            "type": "rand",
+          },
+          {
+            "delay": "5",
+            "packet": [
+              "ping",
+            ],
+            "type": "str",
+          },
+        ],
+        "reset": "60",
+      },
+      "type": "noise",
+    },
+    {
+      "settings": {
+        "domains": [
+          "example.com:txt",
+          "example.org:a",
+        ],
+        "resolvers": [
+          "example.com:txt+udp://1.1.1.1:53",
+        ],
+      },
+      "type": "xdns",
+    },
+    {
+      "settings": {
+        "id": 0,
+        "listenIp": "0.0.0.0",
+      },
+      "type": "xicmp",
+    },
+  ],
+}
+`;

+ 10 - 8
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -14,13 +14,6 @@ exports[`createDefault*InboundSettings factories > hysteria (v1, defaults to v2
 }
 }
 `;
 `;
 
 
-exports[`createDefault*InboundSettings factories > hysteria2 1`] = `
-{
-  "clients": [],
-  "version": 2,
-}
-`;
-
 exports[`createDefault*InboundSettings factories > mixed 1`] = `
 exports[`createDefault*InboundSettings factories > mixed 1`] = `
 {
 {
   "accounts": [],
   "accounts": [],
@@ -74,7 +67,16 @@ exports[`createDefault*InboundSettings factories > wireguard 1`] = `
 {
 {
   "mtu": 1420,
   "mtu": 1420,
   "noKernelTun": false,
   "noKernelTun": false,
-  "peers": [],
+  "peers": [
+    {
+      "allowedIPs": [
+        "10.0.0.2/32",
+      ],
+      "keepAlive": 0,
+      "privateKey": "cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==",
+      "publicKey": "RNa/H++60PStnhoiiU/vIuwFimZUBuIkLkbrmEoDz34=",
+    },
+  ],
   "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
   "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
 }
 }
 `;
 `;

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

@@ -336,174 +336,6 @@ exports[`protocol capability predicates > hysteria-basic :: xhttp/tls 1`] = `
 }
 }
 `;
 `;
 
 
-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`] = `
 exports[`protocol capability predicates > mixed-basic :: grpc/none 1`] = `
 {
 {
   "canEnableReality": false,
   "canEnableReality": false,

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

@@ -42,29 +42,6 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 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`] = `
 exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
 {
 {
   "protocol": "mixed",
   "protocol": "mixed",

+ 91 - 0
frontend/src/test/__snapshots__/rule.test.ts.snap

@@ -0,0 +1,91 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`RuleObjectSchema fixtures > parses balancer-routed byte-stably 1`] = `
+{
+  "balancerTag": "balancer-load",
+  "domain": [
+    "geosite:geolocation-!cn",
+  ],
+  "ruleTag": "outbound-load-balance",
+  "type": "field",
+}
+`;
+
+exports[`RuleObjectSchema fixtures > parses full byte-stably 1`] = `
+{
+  "attrs": {
+    "Host": "example.com",
+    "User-Agent": "regexp:^Mozilla.*",
+  },
+  "domain": [
+    "domain:google.com",
+    "full:example.com",
+    "keyword:cdn",
+    "regexp:^api\\.example\\.com$",
+    "geosite:cn",
+  ],
+  "inboundTag": [
+    "inbound-1",
+    "inbound-2",
+  ],
+  "ip": [
+    "10.0.0.0/8",
+    "geoip:cn",
+    "geoip:private",
+    "!geoip:cn",
+  ],
+  "localIP": [
+    "10.10.10.0/24",
+  ],
+  "localPort": "5353",
+  "network": "tcp,udp",
+  "outboundTag": "proxy-out",
+  "port": "80,443,1000-2000",
+  "process": [
+    "chrome.exe",
+    "curl",
+    "self/",
+  ],
+  "protocol": [
+    "http",
+    "tls",
+    "quic",
+    "bittorrent",
+  ],
+  "ruleTag": "main-policy-rule",
+  "sourceIP": [
+    "192.168.0.0/16",
+    "geoip:private",
+  ],
+  "sourcePort": "53",
+  "type": "field",
+  "user": [
+    "[email protected]",
+    "regexp:^.+@admin\\..+$",
+  ],
+  "vlessRoute": "443,8443",
+  "webhook": {
+    "deduplication": 30,
+    "headers": {
+      "X-Auth-Token": "secret",
+    },
+    "url": "https://hook.example.com/events",
+  },
+}
+`;
+
+exports[`RuleObjectSchema fixtures > parses minimal byte-stably 1`] = `
+{
+  "outboundTag": "direct",
+  "type": "field",
+}
+`;
+
+exports[`RuleObjectSchema fixtures > parses port-number byte-stably 1`] = `
+{
+  "network": "tcp",
+  "outboundTag": "tls-out",
+  "port": 443,
+  "type": "field",
+}
+`;

+ 100 - 0
frontend/src/test/__snapshots__/sockopt.test.ts.snap

@@ -0,0 +1,100 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`] = `
+{
+  "V6Only": false,
+  "acceptProxyProtocol": false,
+  "addressPortStrategy": "none",
+  "customSockopt": [],
+  "dialerProxy": "",
+  "domainStrategy": "AsIs",
+  "interfaceName": "",
+  "mark": 0,
+  "penetrate": false,
+  "tcpFastOpen": false,
+  "tcpKeepAliveIdle": 45,
+  "tcpKeepAliveInterval": 45,
+  "tcpMaxSeg": 1440,
+  "tcpMptcp": false,
+  "tcpUserTimeout": 10000,
+  "tcpWindowClamp": 600,
+  "tcpcongestion": "bbr",
+  "tproxy": "off",
+  "trustedXForwardedFor": [],
+}
+`;
+
+exports[`SockoptStreamSettingsSchema fixtures > parses full byte-stably 1`] = `
+{
+  "V6Only": false,
+  "acceptProxyProtocol": true,
+  "addressPortStrategy": "none",
+  "customSockopt": [],
+  "dialerProxy": "out-proxy-tag",
+  "domainStrategy": "UseIP",
+  "interfaceName": "eth0",
+  "mark": 100,
+  "penetrate": false,
+  "tcpFastOpen": true,
+  "tcpKeepAliveIdle": 300,
+  "tcpKeepAliveInterval": 15,
+  "tcpMaxSeg": 1440,
+  "tcpMptcp": true,
+  "tcpUserTimeout": 10000,
+  "tcpWindowClamp": 600,
+  "tcpcongestion": "cubic",
+  "tproxy": "redirect",
+  "trustedXForwardedFor": [
+    "10.0.0.0/8",
+    "192.168.0.0/16",
+  ],
+}
+`;
+
+exports[`SockoptStreamSettingsSchema fixtures > parses tcp-tuning byte-stably 1`] = `
+{
+  "V6Only": false,
+  "acceptProxyProtocol": false,
+  "addressPortStrategy": "none",
+  "customSockopt": [],
+  "dialerProxy": "",
+  "domainStrategy": "AsIs",
+  "interfaceName": "",
+  "mark": 0,
+  "penetrate": false,
+  "tcpFastOpen": true,
+  "tcpKeepAliveIdle": 120,
+  "tcpKeepAliveInterval": 30,
+  "tcpMaxSeg": 1440,
+  "tcpMptcp": true,
+  "tcpUserTimeout": 5000,
+  "tcpWindowClamp": 600,
+  "tcpcongestion": "bbr",
+  "tproxy": "off",
+  "trustedXForwardedFor": [],
+}
+`;
+
+exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] = `
+{
+  "V6Only": false,
+  "acceptProxyProtocol": false,
+  "addressPortStrategy": "none",
+  "customSockopt": [],
+  "dialerProxy": "",
+  "domainStrategy": "ForceIPv4",
+  "interfaceName": "",
+  "mark": 255,
+  "penetrate": true,
+  "tcpFastOpen": false,
+  "tcpKeepAliveIdle": 45,
+  "tcpKeepAliveInterval": 45,
+  "tcpMaxSeg": 1440,
+  "tcpMptcp": false,
+  "tcpUserTimeout": 10000,
+  "tcpWindowClamp": 600,
+  "tcpcongestion": "bbr",
+  "tproxy": "tproxy",
+  "trustedXForwardedFor": [],
+}
+`;

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

@@ -32,3 +32,150 @@ exports[`NetworkSettingsSchema fixtures > parses ws-default byte-stably 1`] = `
   },
   },
 }
 }
 `;
 `;
+
+exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = `
+{
+  "network": "xhttp",
+  "xhttpSettings": {
+    "enableXmux": false,
+    "headers": {},
+    "host": "edge.example.test",
+    "mode": "auto",
+    "noGRPCHeader": false,
+    "noSSEHeader": false,
+    "path": "/sp",
+    "scMaxBufferedPosts": 30,
+    "scMaxEachPostBytes": "1000000",
+    "scMinPostsIntervalMs": "30",
+    "scStreamUpServerSecs": "20-80",
+    "seqKey": "",
+    "seqPlacement": "",
+    "serverMaxHeaderBytes": 0,
+    "sessionKey": "",
+    "sessionPlacement": "",
+    "uplinkChunkSize": 0,
+    "uplinkDataKey": "",
+    "uplinkDataPlacement": "",
+    "uplinkHTTPMethod": "",
+    "xPaddingBytes": "100-1000",
+    "xPaddingHeader": "",
+    "xPaddingKey": "",
+    "xPaddingMethod": "",
+    "xPaddingObfsMode": false,
+    "xPaddingPlacement": "",
+  },
+}
+`;
+
+exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably 1`] = `
+{
+  "network": "xhttp",
+  "xhttpSettings": {
+    "enableXmux": false,
+    "headers": {},
+    "host": "edge.example.test",
+    "mode": "stream-up",
+    "noGRPCHeader": false,
+    "noSSEHeader": false,
+    "path": "/sp",
+    "scMaxBufferedPosts": 30,
+    "scMaxEachPostBytes": "1000000",
+    "scMinPostsIntervalMs": "30",
+    "scStreamUpServerSecs": "20-80",
+    "seqKey": "",
+    "seqPlacement": "",
+    "serverMaxHeaderBytes": 0,
+    "sessionKey": "",
+    "sessionPlacement": "",
+    "uplinkChunkSize": 0,
+    "uplinkDataKey": "",
+    "uplinkDataPlacement": "",
+    "uplinkHTTPMethod": "",
+    "xPaddingBytes": "500-1500",
+    "xPaddingHeader": "X-Pad",
+    "xPaddingKey": "secret-key",
+    "xPaddingMethod": "random",
+    "xPaddingObfsMode": true,
+    "xPaddingPlacement": "header",
+  },
+}
+`;
+
+exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stably 1`] = `
+{
+  "network": "xhttp",
+  "xhttpSettings": {
+    "enableXmux": false,
+    "headers": {},
+    "host": "edge.example.test",
+    "mode": "auto",
+    "noGRPCHeader": false,
+    "noSSEHeader": false,
+    "path": "/sp",
+    "scMaxBufferedPosts": 30,
+    "scMaxEachPostBytes": "1000000",
+    "scMinPostsIntervalMs": "30",
+    "scStreamUpServerSecs": "20-80",
+    "seqKey": "X-Seq",
+    "seqPlacement": "cookie",
+    "serverMaxHeaderBytes": 0,
+    "sessionKey": "X-Session",
+    "sessionPlacement": "header",
+    "uplinkChunkSize": 0,
+    "uplinkDataKey": "u",
+    "uplinkDataPlacement": "query",
+    "uplinkHTTPMethod": "",
+    "xPaddingBytes": "100-1000",
+    "xPaddingHeader": "",
+    "xPaddingKey": "",
+    "xPaddingMethod": "",
+    "xPaddingObfsMode": false,
+    "xPaddingPlacement": "",
+  },
+}
+`;
+
+exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably 1`] = `
+{
+  "network": "xhttp",
+  "xhttpSettings": {
+    "enableXmux": false,
+    "headers": {
+      "X-Forwarded-For": "10.0.0.1",
+      "X-Real-IP": "1.2.3.4",
+    },
+    "host": "edge.example.test",
+    "mode": "packet-up",
+    "noGRPCHeader": true,
+    "noSSEHeader": true,
+    "path": "/sp",
+    "scMaxBufferedPosts": 50,
+    "scMaxEachPostBytes": "2000000",
+    "scMinPostsIntervalMs": "60",
+    "scStreamUpServerSecs": "30-90",
+    "seqKey": "",
+    "seqPlacement": "",
+    "serverMaxHeaderBytes": 16384,
+    "sessionKey": "",
+    "sessionPlacement": "",
+    "uplinkChunkSize": 8192,
+    "uplinkDataKey": "",
+    "uplinkDataPlacement": "",
+    "uplinkHTTPMethod": "PUT",
+    "xPaddingBytes": "100-1000",
+    "xPaddingHeader": "",
+    "xPaddingKey": "",
+    "xPaddingMethod": "",
+    "xPaddingObfsMode": false,
+    "xPaddingPlacement": "",
+    "xmux": {
+      "cMaxReuseTimes": 0,
+      "hKeepAlivePeriod": 30,
+      "hMaxRequestTimes": "600-900",
+      "hMaxReusableSecs": "1800-3000",
+      "maxConcurrency": "16-32",
+      "maxConnections": 4,
+    },
+  },
+}
+`;

+ 26 - 0
frontend/src/test/balancer.test.ts

@@ -0,0 +1,26 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { BalancerObjectSchema } from '@/schemas/routing';
+
+const fixtures = import.meta.glob<unknown>(
+  './golden/fixtures/balancer/*.json',
+  { eager: true, import: 'default' },
+);
+
+function fixtureName(path: string): string {
+  const file = path.split('/').pop() ?? path;
+  return file.replace(/\.json$/, '');
+}
+
+describe('BalancerObjectSchema fixtures', () => {
+  const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
+  expect(entries.length, 'expected at least one fixture under golden/fixtures/balancer').toBeGreaterThan(0);
+
+  for (const [path, raw] of entries) {
+    it(`parses ${fixtureName(path)} byte-stably`, () => {
+      const parsed = BalancerObjectSchema.parse(raw);
+      expect(parsed).toMatchSnapshot();
+    });
+  }
+});

+ 43 - 0
frontend/src/test/dns.test.ts

@@ -0,0 +1,43 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { DnsObjectSchema, DnsServerObjectSchema } from '@/schemas/dns';
+
+function fixtureName(path: string): string {
+  const file = path.split('/').pop() ?? path;
+  return file.replace(/\.json$/, '');
+}
+
+const dnsFixtures = import.meta.glob<unknown>(
+  './golden/fixtures/dns/*.json',
+  { eager: true, import: 'default' },
+);
+
+const serverFixtures = import.meta.glob<unknown>(
+  './golden/fixtures/dns-server/*.json',
+  { eager: true, import: 'default' },
+);
+
+describe('DnsObjectSchema fixtures', () => {
+  const entries = Object.entries(dnsFixtures).sort(([a], [b]) => a.localeCompare(b));
+  expect(entries.length, 'expected at least one fixture under golden/fixtures/dns').toBeGreaterThan(0);
+
+  for (const [path, raw] of entries) {
+    it(`parses ${fixtureName(path)} byte-stably`, () => {
+      const parsed = DnsObjectSchema.parse(raw);
+      expect(parsed).toMatchSnapshot();
+    });
+  }
+});
+
+describe('DnsServerObjectSchema fixtures', () => {
+  const entries = Object.entries(serverFixtures).sort(([a], [b]) => a.localeCompare(b));
+  expect(entries.length, 'expected at least one fixture under golden/fixtures/dns-server').toBeGreaterThan(0);
+
+  for (const [path, raw] of entries) {
+    it(`parses ${fixtureName(path)} byte-stably`, () => {
+      const parsed = DnsServerObjectSchema.parse(raw);
+      expect(parsed).toMatchSnapshot();
+    });
+  }
+});

+ 26 - 0
frontend/src/test/finalmask.test.ts

@@ -0,0 +1,26 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
+
+const fixtures = import.meta.glob<unknown>(
+  './golden/fixtures/finalmask/*.json',
+  { eager: true, import: 'default' },
+);
+
+function fixtureName(path: string): string {
+  const file = path.split('/').pop() ?? path;
+  return file.replace(/\.json$/, '');
+}
+
+describe('FinalMaskStreamSettingsSchema fixtures', () => {
+  const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
+  expect(entries.length, 'expected at least one fixture under golden/fixtures/finalmask').toBeGreaterThan(0);
+
+  for (const [path, raw] of entries) {
+    it(`parses ${fixtureName(path)} byte-stably`, () => {
+      const parsed = FinalMaskStreamSettingsSchema.parse(raw);
+      expect(parsed).toMatchSnapshot();
+    });
+  }
+});

+ 18 - 0
frontend/src/test/golden/fixtures/balancer/leastload-full.json

@@ -0,0 +1,18 @@
+{
+  "tag": "balancer-load",
+  "selector": ["proxy-"],
+  "fallbackTag": "fallback-out",
+  "strategy": {
+    "type": "leastLoad",
+    "settings": {
+      "expected": 3,
+      "maxRTT": "1s",
+      "tolerance": 0.05,
+      "baselines": ["500ms", "1s", "2s"],
+      "costs": [
+        { "regexp": false, "match": "proxy-premium", "value": 0.1 },
+        { "regexp": true, "match": "^proxy-cheap-.+$", "value": 5 }
+      ]
+    }
+  }
+}

+ 8 - 0
frontend/src/test/golden/fixtures/balancer/leastping.json

@@ -0,0 +1,8 @@
+{
+  "tag": "balancer-ping",
+  "selector": ["proxy-"],
+  "fallbackTag": "fallback-out",
+  "strategy": {
+    "type": "leastPing"
+  }
+}

+ 4 - 0
frontend/src/test/golden/fixtures/balancer/random-minimal.json

@@ -0,0 +1,4 @@
+{
+  "tag": "balancer-random",
+  "selector": ["proxy-"]
+}

+ 8 - 0
frontend/src/test/golden/fixtures/balancer/roundrobin.json

@@ -0,0 +1,8 @@
+{
+  "tag": "balancer-rr",
+  "selector": ["proxy-a", "proxy-b", "proxy-c"],
+  "fallbackTag": "direct",
+  "strategy": {
+    "type": "roundRobin"
+  }
+}

+ 25 - 0
frontend/src/test/golden/fixtures/dns-server/full.json

@@ -0,0 +1,25 @@
+{
+  "address": "https://dns.google/dns-query",
+  "port": 443,
+  "domains": [
+    "domain:google.com",
+    "domain:youtube.com",
+    "geosite:google"
+  ],
+  "expectedIPs": [
+    "geoip:us",
+    "1.2.3.0/24"
+  ],
+  "unexpectedIPs": [
+    "geoip:private"
+  ],
+  "skipFallback": false,
+  "finalQuery": false,
+  "tag": "google-doh",
+  "clientIP": "9.9.9.9",
+  "queryStrategy": "UseIPv6",
+  "disableCache": false,
+  "timeoutMs": 4000,
+  "serveStale": true,
+  "serveExpiredTTL": 600
+}

+ 6 - 0
frontend/src/test/golden/fixtures/dns-server/legacy-expectips.json

@@ -0,0 +1,6 @@
+{
+  "address": "8.8.8.8",
+  "port": 53,
+  "domains": ["geosite:cn"],
+  "expectIPs": ["geoip:cn", "10.0.0.0/8"]
+}

+ 42 - 0
frontend/src/test/golden/fixtures/dns/full.json

@@ -0,0 +1,42 @@
+{
+  "tag": "dns_inbound",
+  "clientIp": "1.2.3.4",
+  "queryStrategy": "UseIP",
+  "disableCache": false,
+  "disableFallback": false,
+  "disableFallbackIfMatch": true,
+  "enableParallelQuery": true,
+  "useSystemHosts": true,
+  "serveStale": true,
+  "serveExpiredTTL": 300,
+  "hosts": {
+    "geosite:category-ads-all": "127.0.0.1",
+    "domain:example.com": ["10.0.0.1", "10.0.0.2"],
+    "full:dns.google": "8.8.8.8"
+  },
+  "servers": [
+    "fakedns",
+    "localhost",
+    "https://dns.google/dns-query",
+    {
+      "address": "tcp://1.1.1.1",
+      "port": 53,
+      "domains": ["geosite:cn"],
+      "expectedIPs": ["geoip:cn"],
+      "queryStrategy": "UseIPv4",
+      "skipFallback": true,
+      "tag": "cn-dns",
+      "clientIP": "8.8.4.4",
+      "timeoutMs": 3000
+    },
+    {
+      "address": "quic+local://dns.adguard.com",
+      "unexpectedIPs": ["geoip:private"],
+      "finalQuery": true,
+      "disableCache": true,
+      "serveStale": false,
+      "serveExpiredTTL": 60,
+      "timeoutMs": 5000
+    }
+  ]
+}

+ 6 - 0
frontend/src/test/golden/fixtures/dns/minimal.json

@@ -0,0 +1,6 @@
+{
+  "servers": [
+    "8.8.8.8",
+    "1.1.1.1"
+  ]
+}

+ 15 - 0
frontend/src/test/golden/fixtures/finalmask/combined.json

@@ -0,0 +1,15 @@
+{
+  "tcp": [
+    { "type": "fragment", "settings": { "packets": "1-3" } }
+  ],
+  "udp": [
+    { "type": "salamander", "settings": { "password": "swordfish" } },
+    { "type": "header-wireguard" }
+  ],
+  "quicParams": {
+    "congestion": "brutal",
+    "brutalUp": "100 mbps",
+    "brutalDown": "200 mbps",
+    "udpHop": { "ports": "10000-20000", "interval": "5-10" }
+  }
+}

+ 16 - 0
frontend/src/test/golden/fixtures/finalmask/quic-params.json

@@ -0,0 +1,16 @@
+{
+  "quicParams": {
+    "congestion": "bbr",
+    "bbrProfile": "standard",
+    "debug": false,
+    "udpHop": { "ports": "20000-50000", "interval": "5-10" },
+    "initStreamReceiveWindow": 8388608,
+    "maxStreamReceiveWindow": 8388608,
+    "initConnectionReceiveWindow": 20971520,
+    "maxConnectionReceiveWindow": 20971520,
+    "maxIdleTimeout": 30,
+    "keepAlivePeriod": 10,
+    "disablePathMTUDiscovery": false,
+    "maxIncomingStreams": 1024
+  }
+}

+ 30 - 0
frontend/src/test/golden/fixtures/finalmask/tcp-mask.json

@@ -0,0 +1,30 @@
+{
+  "tcp": [
+    {
+      "type": "fragment",
+      "settings": {
+        "packets": "1-3",
+        "length": "10-20",
+        "delay": "5-10",
+        "maxSplit": "0"
+      }
+    },
+    { "type": "sudoku" },
+    {
+      "type": "header-custom",
+      "settings": {
+        "clients": [
+          [
+            { "type": "str", "packet": ["GET / HTTP/1.1"], "delay": 0 }
+          ]
+        ],
+        "servers": [
+          [
+            { "type": "str", "packet": ["HTTP/1.1 200 OK"], "delay": 0 }
+          ]
+        ],
+        "errors": []
+      }
+    }
+  ]
+}

+ 29 - 0
frontend/src/test/golden/fixtures/finalmask/udp-mask.json

@@ -0,0 +1,29 @@
+{
+  "udp": [
+    { "type": "salamander", "settings": { "password": "swordfish" } },
+    { "type": "mkcp-aes128gcm", "settings": { "password": "abcdef0123456789" } },
+    { "type": "header-dns", "settings": { "domain": "cloudflare.com" } },
+    { "type": "header-wireguard" },
+    {
+      "type": "noise",
+      "settings": {
+        "reset": "60",
+        "noise": [
+          { "type": "rand", "rand": "10-20", "delay": "10-16" },
+          { "type": "str", "packet": ["ping"], "delay": "5" }
+        ]
+      }
+    },
+    {
+      "type": "xdns",
+      "settings": {
+        "domains": ["example.com:txt", "example.org:a"],
+        "resolvers": ["example.com:txt+udp://1.1.1.1:53"]
+      }
+    },
+    {
+      "type": "xicmp",
+      "settings": { "listenIp": "0.0.0.0", "id": 0 }
+    }
+  ]
+}

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

@@ -1,20 +0,0 @@
-{
-  "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
-      }
-    ]
-  }
-}

+ 6 - 0
frontend/src/test/golden/fixtures/rule/balancer-routed.json

@@ -0,0 +1,6 @@
+{
+  "type": "field",
+  "domain": ["geosite:geolocation-!cn"],
+  "balancerTag": "balancer-load",
+  "ruleTag": "outbound-load-balance"
+}

+ 60 - 0
frontend/src/test/golden/fixtures/rule/full.json

@@ -0,0 +1,60 @@
+{
+  "type": "field",
+  "domain": [
+    "domain:google.com",
+    "full:example.com",
+    "keyword:cdn",
+    "regexp:^api\\.example\\.com$",
+    "geosite:cn"
+  ],
+  "ip": [
+    "10.0.0.0/8",
+    "geoip:cn",
+    "geoip:private",
+    "!geoip:cn"
+  ],
+  "port": "80,443,1000-2000",
+  "sourcePort": "53",
+  "localPort": "5353",
+  "network": "tcp,udp",
+  "sourceIP": [
+    "192.168.0.0/16",
+    "geoip:private"
+  ],
+  "localIP": [
+    "10.10.10.0/24"
+  ],
+  "user": [
+    "[email protected]",
+    "regexp:^.+@admin\\..+$"
+  ],
+  "vlessRoute": "443,8443",
+  "inboundTag": [
+    "inbound-1",
+    "inbound-2"
+  ],
+  "protocol": [
+    "http",
+    "tls",
+    "quic",
+    "bittorrent"
+  ],
+  "attrs": {
+    "User-Agent": "regexp:^Mozilla.*",
+    "Host": "example.com"
+  },
+  "process": [
+    "chrome.exe",
+    "curl",
+    "self/"
+  ],
+  "outboundTag": "proxy-out",
+  "ruleTag": "main-policy-rule",
+  "webhook": {
+    "url": "https://hook.example.com/events",
+    "deduplication": 30,
+    "headers": {
+      "X-Auth-Token": "secret"
+    }
+  }
+}

+ 4 - 0
frontend/src/test/golden/fixtures/rule/minimal.json

@@ -0,0 +1,4 @@
+{
+  "type": "field",
+  "outboundTag": "direct"
+}

+ 6 - 0
frontend/src/test/golden/fixtures/rule/port-number.json

@@ -0,0 +1,6 @@
+{
+  "type": "field",
+  "port": 443,
+  "network": "tcp",
+  "outboundTag": "tls-out"
+}

+ 1 - 0
frontend/src/test/golden/fixtures/sockopt/defaults.json

@@ -0,0 +1 @@
+{}

Vissa filer visades inte eftersom för många filer har ändrats