3 Комити 20edaee8ed ... 3c5e9fa774

Аутор SHA1 Порука Датум
  MHSanaei 3c5e9fa774 fix(sub): preserve userinfo encoding in trojan/shadowsocks/hysteria links пре 6 часа
  MHSanaei 31d7ed5103 refactor(outbound): probe via xray burstObservatory instead of SOCKS round-trip пре 12 часа
  Sanaei 3f787ae169 feat: complete Zod migration of frontend + bulk client batching (#4599) пре 12 часа
100 измењених фајлова са 11834 додато и 10442 уклоњено
  1. 6 0
      .github/workflows/ci.yml
  2. 79 18
      database/model/model.go
  3. 0 18
      database/model/model_test.go
  4. 167 49
      frontend/README.md
  5. 6 0
      frontend/eslint.config.js
  6. 26 0
      frontend/eslint.deprecated.config.js
  7. 349 210
      frontend/package-lock.json
  8. 18 6
      frontend/package.json
  9. 137 1
      frontend/public/openapi.json
  10. 14 12
      frontend/src/api/queries/useAllSettings.ts
  11. 16 21
      frontend/src/api/queries/useNodeMutations.ts
  12. 7 34
      frontend/src/api/queries/useNodesQuery.ts
  13. 4 1
      frontend/src/api/queries/useStatusQuery.ts
  14. 505 439
      frontend/src/components/FinalMaskForm.tsx
  15. 141 0
      frontend/src/components/HeaderMapEditor.tsx
  16. 1 0
      frontend/src/env.d.ts
  17. 359 0
      frontend/src/generated/types.ts
  18. 380 0
      frontend/src/generated/zod.ts
  19. 106 109
      frontend/src/hooks/useClients.ts
  20. 5 5
      frontend/src/hooks/useDatepicker.ts
  21. 40 60
      frontend/src/hooks/useXraySetting.ts
  22. 78 0
      frontend/src/lib/xray/headers.ts
  23. 277 0
      frontend/src/lib/xray/inbound-defaults.ts
  24. 271 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  25. 55 0
      frontend/src/lib/xray/inbound-from-db.ts
  26. 922 0
      frontend/src/lib/xray/inbound-link.ts
  27. 167 0
      frontend/src/lib/xray/outbound-defaults.ts
  28. 619 0
      frontend/src/lib/xray/outbound-form-adapter.ts
  29. 439 0
      frontend/src/lib/xray/outbound-link-parser.ts
  30. 74 0
      frontend/src/lib/xray/protocol-capabilities.ts
  31. 69 0
      frontend/src/lib/xray/stream-defaults.ts
  32. 1 58
      frontend/src/models/dbinbound.ts
  33. 0 3359
      frontend/src/models/inbound.ts
  34. 0 2405
      frontend/src/models/outbound.ts
  35. 1 1
      frontend/src/models/setting.ts
  36. 15 1
      frontend/src/pages/api-docs/endpoints.ts
  37. 136 159
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  38. 10 5
      frontend/src/pages/clients/ClientBulkAdjustModal.tsx
  39. 0 1
      frontend/src/pages/clients/ClientFormModal.css
  40. 199 183
      frontend/src/pages/clients/ClientFormModal.tsx
  41. 61 0
      frontend/src/pages/clients/ClientInfoModal.css
  42. 395 166
      frontend/src/pages/clients/ClientInfoModal.tsx
  43. 60 19
      frontend/src/pages/clients/ClientsPage.tsx
  44. 667 810
      frontend/src/pages/inbounds/InboundFormModal.tsx
  45. 201 37
      frontend/src/pages/inbounds/InboundInfoModal.tsx
  46. 55 24
      frontend/src/pages/inbounds/InboundList.tsx
  47. 47 26
      frontend/src/pages/inbounds/InboundsPage.tsx
  48. 39 19
      frontend/src/pages/inbounds/QrCodeModal.tsx
  49. 42 44
      frontend/src/pages/inbounds/useInbounds.ts
  50. 7 25
      frontend/src/pages/index/CustomGeoFormModal.tsx
  51. 1 1
      frontend/src/pages/index/CustomGeoSection.tsx
  52. 7 5
      frontend/src/pages/index/IndexPage.tsx
  53. 27 15
      frontend/src/pages/index/LogModal.tsx
  54. 1 1
      frontend/src/pages/index/VersionModal.tsx
  55. 14 8
      frontend/src/pages/index/XrayLogModal.tsx
  56. 10 9
      frontend/src/pages/index/XrayMetricsModal.tsx
  57. 6 8
      frontend/src/pages/login/LoginPage.tsx
  58. 152 180
      frontend/src/pages/nodes/NodeFormModal.tsx
  59. 15 3
      frontend/src/pages/settings/SettingsPage.tsx
  60. 9 3
      frontend/src/pages/settings/TwoFactorModal.tsx
  61. 48 53
      frontend/src/pages/sub/SubPage.css
  62. 256 77
      frontend/src/pages/sub/SubPage.tsx
  63. 87 0
      frontend/src/pages/sub/SubUsageSummary.css
  64. 96 0
      frontend/src/pages/sub/SubUsageSummary.tsx
  65. 202 67
      frontend/src/pages/xray/BalancerFormModal.tsx
  66. 86 84
      frontend/src/pages/xray/BalancersTab.tsx
  67. 2 2
      frontend/src/pages/xray/BasicsTab.tsx
  68. 175 157
      frontend/src/pages/xray/DnsServerModal.tsx
  69. 3 15
      frontend/src/pages/xray/DnsTab.tsx
  70. 12 15
      frontend/src/pages/xray/NordModal.tsx
  71. 2141 1368
      frontend/src/pages/xray/OutboundFormModal.tsx
  72. 4 9
      frontend/src/pages/xray/OutboundsTab.tsx
  73. 4 2
      frontend/src/pages/xray/RoutingTab.tsx
  74. 18 28
      frontend/src/pages/xray/RuleFormModal.tsx
  75. 7 7
      frontend/src/pages/xray/WarpModal.tsx
  76. 10 0
      frontend/src/schemas/_envelope.ts
  77. 64 0
      frontend/src/schemas/api/inbound.ts
  78. 158 0
      frontend/src/schemas/client.ts
  79. 20 0
      frontend/src/schemas/defaults.ts
  80. 64 0
      frontend/src/schemas/dns.ts
  81. 83 0
      frontend/src/schemas/forms/inbound-form.ts
  82. 265 0
      frontend/src/schemas/forms/outbound-form.ts
  83. 32 0
      frontend/src/schemas/inbound.ts
  84. 2 0
      frontend/src/schemas/index.ts
  85. 15 0
      frontend/src/schemas/login.ts
  86. 53 0
      frontend/src/schemas/node.ts
  87. 16 0
      frontend/src/schemas/primitives/flow.ts
  88. 6 0
      frontend/src/schemas/primitives/index.ts
  89. 111 0
      frontend/src/schemas/primitives/options.ts
  90. 30 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  91. 4 0
      frontend/src/schemas/primitives/port.ts
  92. 34 0
      frontend/src/schemas/primitives/protocol.ts
  93. 16 0
      frontend/src/schemas/primitives/sniffing.ts
  94. 17 0
      frontend/src/schemas/protocols/inbound/http.ts
  95. 26 0
      frontend/src/schemas/protocols/inbound/hysteria.ts
  96. 42 0
      frontend/src/schemas/protocols/inbound/index.ts
  97. 21 0
      frontend/src/schemas/protocols/inbound/mixed.ts
  98. 45 0
      frontend/src/schemas/protocols/inbound/shadowsocks.ts
  99. 32 0
      frontend/src/schemas/protocols/inbound/trojan.ts
  100. 12 0
      frontend/src/schemas/protocols/inbound/tun.ts

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

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

+ 79 - 18
database/model/model.go

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

+ 0 - 18
database/model/model_test.go

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

+ 167 - 49
frontend/README.md

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

+ 6 - 0
frontend/eslint.config.js

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

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

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

Разлика између датотеке није приказан због своје велике величине
+ 349 - 210
frontend/package-lock.json


+ 18 - 6
frontend/package.json

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

+ 137 - 1
frontend/public/openapi.json

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

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

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

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

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

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

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

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

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

Разлика између датотеке није приказан због своје велике величине
+ 505 - 439
frontend/src/components/FinalMaskForm.tsx


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Разлика између датотеке није приказан због своје велике величине
+ 667 - 810
frontend/src/pages/inbounds/InboundFormModal.tsx


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

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

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

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

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

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

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

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

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

@@ -2,10 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 
 import { HttpUtil } from '@/utils';
-import { DBInbound } from '@/models/dbinbound';
-import { Protocols } from '@/models/inbound';
+import { parseMsg } from '@/utils/zodValidate';
+import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
+import { Protocols } from '@/schemas/primitives';
+import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
 import { setDatepicker } from '@/hooks/useDatepicker';
 import { keys } from '@/api/queryKeys';
+import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
+import { OnlinesSchema } from '@/schemas/client';
+import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
 
 export interface SubSettings {
   enable: boolean;
@@ -27,28 +32,7 @@ interface ClientRollup {
   comments: Map<string, string>;
 }
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  obj?: T;
-  msg?: string;
-}
-
-interface DefaultsPayload {
-  expireDiff?: number;
-  trafficDiff?: number;
-  tgBotEnable?: boolean;
-  subEnable?: boolean;
-  subTitle?: string;
-  subURI?: string;
-  subJsonURI?: string;
-  subJsonEnable?: boolean;
-  pageSize?: number;
-  remarkModel?: string;
-  datepicker?: string;
-  ipLimitEnable?: boolean;
-}
-
-const TRACKED_PROTOCOLS = [
+const TRACKED_PROTOCOLS: readonly string[] = [
   Protocols.VMESS,
   Protocols.VLESS,
   Protocols.TROJAN,
@@ -57,27 +41,31 @@ const TRACKED_PROTOCOLS = [
 ];
 
 async function fetchSlimInbounds(): Promise<unknown[]> {
-  const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
+  const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchOnlineClients(): Promise<string[]> {
-  const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
+  const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchLastOnlineMap(): Promise<Record<string, number>> {
-  const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
+  const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
-  return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
+  const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
+  return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
 }
 
 async function fetchDefaultSettings(): Promise<DefaultsPayload> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
+  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
-  return (msg.obj as DefaultsPayload) || {};
+  const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+  return validated.obj ?? {};
 }
 
 export function useInbounds() {
@@ -113,7 +101,7 @@ export function useInbounds() {
   const tgBotEnable = !!defaults.tgBotEnable;
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const pageSize = defaults.pageSize ?? 0;
-  const remarkModel = defaults.remarkModel || '-ieo';
+  const remarkModel = defaults.remarkModel || '-io';
   const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
 
   const subSettings: SubSettings = useMemo(() => ({
@@ -214,12 +202,14 @@ export function useInbounds() {
   const rebuildClientCount = useCallback(() => {
     const counts: Record<number, ClientRollup> = {};
     for (const dbInbound of dbInboundsRef.current) {
-      const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean }; isSS: boolean; protocol: string }).toInbound();
-      const protocol = (dbInbound as unknown as { protocol: string }).protocol;
+      const protocol = dbInbound.protocol;
       if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
-      const isSS = (dbInbound as unknown as { isSS: boolean }).isSS;
-      if (isSS && !parsed.isSSMultiUser) continue;
-      counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
+      const settings = coerceInboundJsonField(dbInbound.settings) as {
+        method?: string;
+        clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
+      };
+      if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
+      counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
     }
     setClientCount(counts);
   }, [rollupClients]);
@@ -232,11 +222,14 @@ export function useInbounds() {
     const counts: Record<number, ClientRollup> = {};
     for (const row of slimQuery.data as { protocol: string; id: number }[]) {
       const dbInbound = new DBInbound(row) as DBInboundInstance;
-      const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
       next.push(dbInbound);
       if (TRACKED_PROTOCOLS.includes(row.protocol)) {
-        if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
-        counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
+        const settings = coerceInboundJsonField(dbInbound.settings) as {
+          method?: string;
+          clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
+        };
+        if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue;
+        counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
       }
     }
     dbInboundsRef.current = next;
@@ -258,8 +251,12 @@ export function useInbounds() {
   const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
 
   const refresh = useCallback(async () => {
+    // Invalidate at the inbounds root so both `slim` (this page's list)
+    // and `options` (the Clients page's inbound picker) refetch. Without
+    // the options bucket, a freshly-created inbound stays invisible in
+    // the client add/edit modal until a full page reload.
     await Promise.all([
-      queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }),
+      queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
     ]);
@@ -272,8 +269,9 @@ export function useInbounds() {
   const hydrateInbound = useCallback(async (id: number) => {
     const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
     if (!msg?.success || !msg.obj) return null;
-    const full = msg.obj as { id: number; protocol: string };
-    const dbInbound = new DBInbound(full) as DBInboundInstance;
+    const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
+    if (!validated.obj) return null;
+    const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
     setDbInbounds((prev) => {
       const next = prev.map((row) => (
         (row as unknown as { id: number }).id === id ? dbInbound : row

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

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

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

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

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

@@ -86,10 +86,10 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
+    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
       if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
     });
-    HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
+    HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
       if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
     });
   }, []);
@@ -480,7 +480,9 @@ export default function IndexPage() {
             open={configTextOpen}
             title={t('pages.index.config')}
             width={isMobile ? '100%' : 900}
-            style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
+            style={isMobile
+              ? { top: 20, maxWidth: 'calc(100vw - 16px)' }
+              : { top: 20 }}
             onCancel={() => setConfigTextOpen(false)}
             footer={[
               <Button
@@ -505,8 +507,8 @@ export default function IndexPage() {
             <JsonEditor
               value={configText}
               onChange={setConfigText}
-              minHeight={isMobile ? '300px' : '420px'}
-              maxHeight={isMobile ? '500px' : '720px'}
+              minHeight={isMobile ? '300px' : 'calc(100vh - 220px)'}
+              maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
               readOnly
             />
           </Modal>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 9
frontend/src/pages/xray/OutboundsTab.tsx

@@ -34,7 +34,7 @@ import {
 import type { ColumnsType } from 'antd/es/table';
 
 import { SizeFormatter } from '@/utils';
-import { Protocols } from '@/models/outbound';
+import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import OutboundFormModal from './OutboundFormModal';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
@@ -101,10 +101,10 @@ function showSecurity(security?: string): boolean {
   return security === 'tls' || security === 'reality';
 }
 
-function hasBreakdown(r: { endpoints?: unknown[]; ttfbMs?: number; tlsMs?: number; connectMs?: number; dnsMs?: number; statusCode?: number; error?: string } | null | undefined): boolean {
+function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
   if (!r) return false;
   if (r.endpoints?.length) return true;
-  return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
+  return !!r.error;
 }
 
 export default function OutboundsTab({
@@ -130,7 +130,7 @@ export default function OutboundsTab({
   const [existingTags, setExistingTags] = useState<string[]>([]);
 
   const outbounds = useMemo(
-    () => (templateSettings?.outbounds || []) as OutboundRow[],
+    () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
     [templateSettings?.outbounds],
   );
 
@@ -335,11 +335,6 @@ export default function OutboundsTab({
                   </div>
                   {hasBreakdown(r) && (
                     <>
-                      {r.ttfbMs ? <div>TTFB: {r.ttfbMs} ms</div> : null}
-                      {r.tlsMs ? <div>TLS: {r.tlsMs} ms</div> : null}
-                      {r.connectMs ? <div>Connect: {r.connectMs} ms</div> : null}
-                      {r.dnsMs ? <div>DNS: {r.dnsMs} ms</div> : null}
-                      {r.statusCode ? <div>HTTP {r.statusCode}</div> : null}
                       {(r.endpoints || []).map((ep) => (
                         <div key={ep.address} className="endpoint-row">
                           <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Неке датотеке нису приказане због велике количине промена