diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a4f9110..dd2537a6 100644 --- a/.github/workflows/ci.yml +++ b/.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 diff --git a/database/model/model.go b/database/model/model.go index 99566d9d..6c4230ad 100644 --- a/database/model/model.go +++ b/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"` diff --git a/database/model/model_test.go b/database/model/model_test.go index 4938ca57..aa64605d 100644 --- a/database/model/model_test.go +++ b/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) - } - } -} diff --git a/frontend/README.md b/frontend/README.md index d94f6ee3..c6462da8 100644 --- a/frontend/README.md +++ b/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,15 +18,44 @@ 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. +## 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 +npx eslint --config eslint.deprecated.config.js src +``` + +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. + ## Production build ```sh @@ -27,57 +63,139 @@ npm run build ``` 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. - -## Type check and lint - -```sh -npm run typecheck -npm run lint -``` - -`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. +`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` — 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` 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 `` — + 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(); + +const onFinish = async () => { + const values = await form.validateFields(); + await createInbound.mutateAsync(values); +}; + +
+ + + +
+``` + +No `safeParse`-on-submit handlers, no `useRef` for form +references, no inline `z.string().min(1)` in rules. Conditional +fields use `` 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/.html` referencing `/src/entries/.tsx`. -2. Add `src/entries/.tsx` that imports the page component and - mounts it with `createRoot(...).render(...)`. -3. Add the page component under `src/pages//`. -4. Register the entry in `rollupOptions.input` in `vite.config.js`. -5. If the page is reachable from the sidebar at `/panel/`, add - it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML. -6. Wire the Go controller to `serveDistPage(c, ".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//`. +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/.html`, an entry at + `src/entries/.tsx`, and register it in `rollupOptions.input` + in `vite.config.js`. Then add the Go controller call to + `serveDistPage(c, ".html")`. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 27a23fbc..8ab8c79c 100644 --- a/frontend/eslint.config.js +++ b/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', diff --git a/frontend/eslint.deprecated.config.js b/frontend/eslint.deprecated.config.js new file mode 100644 index 00000000..e1784055 --- /dev/null +++ b/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', + }, + }, +]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2042a04f..1daece88 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,8 +16,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", @@ -26,7 +26,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", @@ -38,8 +39,9 @@ "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" }, "engines": { "node": ">=22.0.0", @@ -140,13 +142,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -155,9 +157,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -165,21 +167,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -196,14 +198,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -213,14 +215,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -230,9 +232,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -240,29 +242,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -272,9 +274,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -282,9 +284,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -292,9 +294,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -302,27 +304,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -332,18 +334,18 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", - "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz", + "integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==", "license": "MIT", "dependencies": { "core-js-pure": "^3.48.0" @@ -353,33 +355,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -387,14 +389,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -866,9 +868,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1043,13 +1045,13 @@ } }, "node_modules/@rc-component/input": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.0.tgz", - "integrity": "sha512-IUUNOdAuWuEvDEFFgfmwQl818tiDbvXwLgon4HL1q2hJeYkqrRrYwYhJN0zfPHGTDxs3gvyVC/C02D4hWFoIcA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", "license": "MIT", "dependencies": { "@rc-component/resize-observer": "^1.1.1", - "@rc-component/util": "^1.4.0", + "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { @@ -1396,9 +1398,9 @@ } }, "node_modules/@rc-component/table": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.0.tgz", - "integrity": "sha512-SjtpcCf+rL7dDc62GKT3rXTdERjVuJvRiqjpU7g0Jc/ewCifXynHc7Nm3Em1XsD+WhGrgQtxNDScI/0+Lpfr0w==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.1.tgz", + "integrity": "sha512-XEjyZePbePSdfJjBV3p+I5x/HZ2+UevdiaUJ/ghRm3UtQ9AC+V9hIFM2H349nM/C5ndOa433e/RRQF+RbJQB5g==", "license": "MIT", "dependencies": { "@rc-component/context": "^2.0.1", @@ -1471,14 +1473,14 @@ } }, "node_modules/@rc-component/tree": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.1.tgz", - "integrity": "sha512-zlL0PW0bTFlveTtLcA01VD/yMWKK73EywItFMgIZUY5sb6tMOAw7zV6qGzqldufqrV93ZWQB4H3NBNoTMCueJA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", + "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", "license": "MIT", "dependencies": { "@rc-component/motion": "^1.0.0", - "@rc-component/util": "^1.8.1", - "@rc-component/virtual-list": "^1.0.1", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "engines": { @@ -1526,12 +1528,12 @@ } }, "node_modules/@rc-component/upload": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz", - "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", "license": "MIT", "dependencies": { - "@rc-component/util": "^1.3.0", + "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { @@ -1553,6 +1555,12 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/@rc-component/virtual-list": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz", @@ -1609,9 +1617,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1626,9 +1634,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1643,9 +1651,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1660,9 +1668,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1677,9 +1685,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1694,9 +1702,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1714,9 +1722,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1734,9 +1742,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1754,9 +1762,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1774,9 +1782,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1794,9 +1802,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1814,9 +1822,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1831,9 +1839,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1850,9 +1858,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1867,9 +1875,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -2632,6 +2640,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2695,6 +2714,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2790,17 +2816,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2813,7 +2839,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2829,16 +2855,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2854,14 +2880,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2876,14 +2902,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2894,9 +2920,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -2911,15 +2937,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2936,9 +2962,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -2950,16 +2976,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2991,16 +3017,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3015,13 +3041,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3058,6 +3084,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3185,6 +3324,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3257,9 +3406,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", - "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3407,6 +3556,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3690,9 +3849,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { @@ -3793,9 +3952,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", - "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.6.tgz", + "integrity": "sha512-+7gzEI8trIIQkVCvQ3ucGtNfH3nOmDgVTzc62rAAOlMxLth78pwpPoZCPc7CyRzAQF89MqcfPdEWkDwnjgqktg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3825,9 +3984,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.362", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", + "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==", "dev": true, "license": "ISC" }, @@ -3849,10 +4008,17 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3877,9 +4043,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "license": "MIT", "workspaces": [ "docs", @@ -4071,6 +4237,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4087,6 +4263,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4500,9 +4686,9 @@ } }, "node_modules/i18next": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz", - "integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==", + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", + "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", "funding": [ { "type": "individual", @@ -5164,6 +5350,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5321,6 +5517,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/openapi-path-templating": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz", @@ -5452,6 +5659,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/persian-calendar-suite": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/persian-calendar-suite/-/persian-calendar-suite-1.5.5.tgz", @@ -5662,6 +5876,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-copy-to-clipboard": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz", + "integrity": "sha512-s+HrzLyJBxrpGTYXF15dTgMjAJpEPZT/Yp6NytAtZMRngejxt6Pt5WrfFxLAcsqUDU6sY1Jz6tyHwIicE1U2Xg==", + "license": "MIT", + "dependencies": { + "copy-to-clipboard": "^3.3.3", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=15.3.0" + } + }, + "node_modules/react-debounce-input": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz", + "integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, "node_modules/react-dom": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", @@ -5724,11 +5964,21 @@ "react-dom": ">= 16.6" } }, + "node_modules/react-inspector": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz", + "integrity": "sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.3.0", @@ -5841,12 +6091,6 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/recharts/node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5928,9 +6172,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, "node_modules/ret": { @@ -5943,13 +6187,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5959,21 +6203,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/safe-buffer": { @@ -6184,6 +6428,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6210,6 +6461,20 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -6303,41 +6568,6 @@ "react-dom": ">=16.8.0 <20" } }, - "node_modules/swagger-ui-react/node_modules/react-copy-to-clipboard": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", - "integrity": "sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==", - "license": "MIT", - "dependencies": { - "copy-to-clipboard": "^3.3.1", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": "^15.3.0 || 16 || 17 || 18" - } - }, - "node_modules/swagger-ui-react/node_modules/react-debounce-input": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz", - "integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==", - "license": "MIT", - "dependencies": { - "lodash.debounce": "^4", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": "^15.3.0 || 16 || 17 || 18" - } - }, - "node_modules/swagger-ui-react/node_modules/react-inspector": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz", - "integrity": "sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.4 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -6353,6 +6583,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6370,6 +6617,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -6516,16 +6773,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6628,16 +6885,16 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -6705,6 +6962,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -6744,13 +7091,13 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -6764,6 +7111,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6819,7 +7183,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index 524f4a9d..2110ba63 100644 --- a/frontend/package.json +++ b/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" + } } } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index fedcded9..c4919ac9 100644 --- a/frontend/public/openapi.json +++ b/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": "alice@example.com", + "totalGB": 53687091200, + "expiryTime": 0, + "enable": true + }, + "inboundIds": [ + 7 + ] + }, + { + "client": { + "email": "bob@example.com", + "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": "alice@example.com", + "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": [ { diff --git a/frontend/src/api/queries/useAllSettings.ts b/frontend/src/api/queries/useAllSettings.ts index cd2566d9..593853c9 100644 --- a/frontend/src/api/queries/useAllSettings.ts +++ b/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 { - success?: boolean; - obj?: T; - msg?: string; -} - -async function fetchAllSetting(): Promise { - const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }) as ApiMsg; +async function fetchAllSetting(): Promise { + 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, + mutationFn: async (next: AllSetting): Promise> => { + 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() }); }, diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index 2b9f707e..5863cb14 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/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 { - 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) => - HttpUtil.post('/panel/api/nodes/add', payload) as Promise, + HttpUtil.post('/panel/api/nodes/add', payload), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const updateMut = useMutation({ mutationFn: ({ id, payload }: { id: number; payload: Partial }) => - HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise, + 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, + 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, + 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>, + mutationFn: async (id: number): Promise> => { + 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) => - HttpUtil.post('/panel/api/nodes/test', payload) as Promise>, + testConnection: async (payload: Partial): Promise> => { + const raw = await HttpUtil.post('/panel/api/nodes/test', payload); + return parseMsg(raw, ProbeResultSchema, 'nodes/test'); + }, }; } diff --git a/frontend/src/api/queries/useNodesQuery.ts b/frontend/src/api/queries/useNodesQuery.ts index 5c7c6b07..a916fd61 100644 --- a/frontend/src/api/queries/useNodesQuery.ts +++ b/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 { - success?: boolean; - msg?: string; - obj?: T; -} - async function fetchNodes(): Promise { - const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg; + 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() { diff --git a/frontend/src/api/queries/useStatusQuery.ts b/frontend/src/api/queries/useStatusQuery.ts index bb33eb50..755b73aa 100644 --- a/frontend/src/api/queries/useStatusQuery.ts +++ b/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 { 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() { diff --git a/frontend/src/components/FinalMaskForm.tsx b/frontend/src/components/FinalMaskForm.tsx index b66709da..682eee75 100644 --- a/frontend/src/components/FinalMaskForm.tsx +++ b/frontend/src/components/FinalMaskForm.tsx @@ -1,532 +1,598 @@ -import { useMemo } from 'react'; -import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd'; +import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { FormInstance } from 'antd/es/form'; +import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; -import { Protocols } from '@/models/outbound'; +import { OutboundProtocols } from '@/schemas/primitives'; -interface StreamShape { - network?: string; - kcp?: { mtu?: number }; - finalmask: { - tcp?: MaskRow[]; - udp?: MaskRow[]; - enableQuicParams?: boolean; - quicParams?: QuicParams; - }; - addTcpMask: (type?: string) => void; - delTcpMask: (index: number) => void; - addUdpMask: (type?: string) => void; - delUdpMask: (index: number) => void; -} +// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute +// paths under `name`; the parent modal owns the Form instance. +// +// Naming convention inside Form.List: AntD prefixes Form.Item `name` +// with the Form.List's own `name`. So Form.Items inside the render +// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested +// Form.Lists also use relative names. Using absolute paths here would +// double up the prefix and silently route reads/writes to the wrong +// storage path. -interface MaskRow { - type: string; - settings: Record; - _getDefaultSettings: (type: string, settings: Record) => Record; -} - -interface ItemRow { - type: string; - packet: string | unknown[]; - delay?: number | string; - rand?: number | string; - randRange?: string; -} - -interface QuicParams { - congestion: string; - debug?: boolean; - brutalUp?: number | string; - brutalDown?: number | string; - hasUdpHop?: boolean; - udpHop?: { ports: string; interval: string | number }; - maxIdleTimeout?: number; - keepAlivePeriod?: number; - disablePathMTUDiscovery?: boolean; - maxIncomingStreams?: number; - initStreamReceiveWindow?: number; - maxStreamReceiveWindow?: number; - initConnectionReceiveWindow?: number; - maxConnectionReceiveWindow?: number; -} - -interface FinalMaskFormProps { - stream: StreamShape; +export interface FinalMaskFormProps { + name: NamePath; + network: string; protocol: string; - onChange: () => void; + form: FormInstance; } -function changeMaskType(mask: MaskRow, type: string) { - mask.type = type; - mask.settings = mask._getDefaultSettings(type, {}); +const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; + +function asPath(name: NamePath): (string | number)[] { + return Array.isArray(name) ? [...name] : [name]; } -function changeItemType(item: ItemRow, type: string) { - item.type = type; - if (type === 'base64') item.packet = RandomUtil.randomBase64(); - else if (type === 'array') { - item.rand = 0; - item.packet = []; - } else item.packet = ''; +function defaultTcpMaskSettings(type: string): Record { + switch (type) { + case 'fragment': + return { packets: '1-3', length: '', delay: '', maxSplit: '' }; + case 'sudoku': + return { + password: '', ascii: '', customTable: '', customTables: '', + paddingMin: 0, paddingMax: 0, + }; + case 'header-custom': + return { clients: [], servers: [] }; + default: + return {}; + } } -function newClientServerItem(): ItemRow { +function defaultUdpMaskSettings(type: string): Record { + switch (type) { + case 'salamander': + case 'mkcp-aes128gcm': + return { password: '' }; + case 'header-dns': + return { domain: '' }; + case 'xdns': + return { domains: [] }; + case 'xicmp': + return { ip: '0.0.0.0', id: 0 }; + case 'header-custom': + return { client: [], server: [] }; + case 'noise': + return { reset: 0, noise: [] }; + default: + return {}; + } +} + +function defaultClientServerItem(): Record { return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] }; } -function newUdpClientServerItem(): ItemRow { +function defaultUdpClientServerItem(): Record { return { rand: 0, randRange: '0-255', type: 'array', packet: [] }; } -function newNoiseItem(): ItemRow { - return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' }; +function defaultNoiseItem(): Record { + return { + rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20', + }; } -export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) { - const isHysteria = protocol === Protocols.Hysteria || protocol === 'hysteria'; - const network = stream?.network || ''; +function defaultQuicParams(): Record { + return { + congestion: 'bbr', + debug: false, + maxIdleTimeout: 30, + keepAlivePeriod: 10, + disablePathMTUDiscovery: false, + maxIncomingStreams: 1024, + initStreamReceiveWindow: 8388608, + maxStreamReceiveWindow: 8388608, + initConnectionReceiveWindow: 20971520, + maxConnectionReceiveWindow: 20971520, + }; +} - const showTcp = useMemo( - () => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network), - [network], - ); +function defaultUdpHop(): Record { + return { ports: '20000-50000', interval: '5-10' }; +} + +export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) { + const base = asPath(name); + const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; + const showTcp = TCP_NETWORKS.includes(network); const showUdp = isHysteria || network === 'kcp'; const showQuic = isHysteria || network === 'xhttp'; - - function notify() { - onChange(); - } - - function changeUdpMaskType(mask: MaskRow, type: string) { - changeMaskType(mask, type); - if (network === 'kcp' && stream.kcp) { - stream.kcp.mtu = type === 'xdns' ? 900 : 1350; - } - notify(); - } - - function addUdpMaskWithDefault() { - const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm'; - stream.addUdpMask(def); - notify(); - } - - const tcpMasks = stream.finalmask.tcp || []; - const udpMasks = stream.finalmask.udp || []; + const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); + const hasQuicParams = quicParams != null; if (!showTcp && !showUdp && !showQuic) return null; return ( -
- {showTcp && ( + <> + {showTcp && } + {showUdp && } + {showQuic && ( + <> + + { + form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined); + }} + /> + + {hasQuicParams && } + + )} + + ); +} + +function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) { + return ( + + {(fields, { add, remove }) => ( <> + + ); +} diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 6a2d2216..b73a457a 100644 --- a/frontend/src/env.d.ts +++ b/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; diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts new file mode 100644 index 00000000..304bb6ac --- /dev/null +++ b/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; +} + diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts new file mode 100644 index 00000000..18b5a90f --- /dev/null +++ b/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; + +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; + +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; + +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; + +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; + +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; + +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; + +export const ClientReverseSchema = z.object({ + tag: z.string(), +}); +export type ClientReverse = z.infer; + +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; + +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; + +export const FallbackParentInfoSchema = z.object({ + masterId: z.number().int(), + path: z.string().optional(), +}); +export type FallbackParentInfo = z.infer; + +export const HistoryOfSeedersSchema = z.object({ + id: z.number().int(), + seederName: z.string(), +}); +export type HistoryOfSeeders = z.infer; + +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; + +export const InboundClientIpsSchema = z.object({ + clientEmail: z.string(), + id: z.number().int(), + ips: z.unknown(), +}); +export type InboundClientIps = z.infer; + +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; + +export const MsgSchema = z.object({ + msg: z.string(), + obj: z.unknown(), + success: z.boolean(), +}); +export type Msg = z.infer; + +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; + +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; + +export const SettingSchema = z.object({ + id: z.number().int(), + key: z.string(), + value: z.string(), +}); +export type Setting = z.infer; + +export const UserSchema = z.object({ + id: z.number().int(), + password: z.string(), + username: z.string(), +}); +export type User = z.infer; + diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 4d9b1dfb..dc6f8989 100644 --- a/frontend/src/hooks/useClients.ts +++ b/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 { - 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 { const qs = buildQS(params); - const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg; + 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 { - const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg; + 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> { - const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg>; + 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; + 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 => { 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, + 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, + 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; + 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; - })); - return results; + const bulkDeleteMut = useMutation({ + mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise> => { + 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> => { + 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>, + mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise> => { + 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, + 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, + 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, + 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, + 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>, + 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); 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); 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); + 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); + 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); 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); 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); 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, diff --git a/frontend/src/hooks/useDatepicker.ts b/frontend/src/hooks/useDatepicker.ts index 381e29bf..60e78363 100644 --- a/frontend/src/hooks/useDatepicker.ts +++ b/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 { } 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 { diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index 6ae8bd5e..787c0d34 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/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; 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; - policy?: { system?: Record }; - 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; } -interface ApiMsg { - success?: boolean; - obj?: T; - msg?: string; -} - -interface XrayConfigPayload { - xraySetting: XraySettingsValue; - inboundTags?: string[]; - clientReverseTags?: string[]; - outboundTestUrl?: string; -} +type XrayConfigPayload = z.infer; async function fetchXrayConfig(): Promise { - const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg; + 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 { - const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg; + 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, + }), 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, + 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; - 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>, + mutationFn: async (): Promise> => { + 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; + }); + 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; } diff --git a/frontend/src/lib/xray/headers.ts b/frontend/src/lib/xray/headers.ts new file mode 100644 index 00000000..8368f92c --- /dev/null +++ b/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; + +// 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; + 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> | 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; +} diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts new file mode 100644 index 00000000..4c78fcc8 --- /dev/null +++ b/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..()` 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; + } +} diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts new file mode 100644 index 00000000..322744f1 --- /dev/null +++ b/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 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 { + if (value == null) return {}; + if (typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + 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) + : {}; + } 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 = { + tcp: 'tcpSettings', + kcp: 'kcpSettings', + ws: 'wsSettings', + grpc: 'grpcSettings', + httpupgrade: 'httpupgradeSettings', + xhttp: 'xhttpSettings', + hysteria: 'hysteriaSettings', +}; + +function healStreamNetworkKey(stream: Record): 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); + } + 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 = {}; + for (const [k, v] of Object.entries(value as Record)) { + 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/.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 { + if (!s || !s.enabled) return { enabled: false }; + const out: Record = { + 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, + stream: Record | 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; + if (Array.isArray(settingsPruned.clients)) { + settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients); + } + const streamPruned = values.streamSettings + ? ((pruneEmpty(values.streamSettings) ?? {}) as Record) + : 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; +} diff --git a/frontend/src/lib/xray/inbound-from-db.ts b/frontend/src/lib/xray/inbound-from-db.ts new file mode 100644 index 00000000..c29abc38 --- /dev/null +++ b/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): Record { + const parsed = InboundSettingsSchema.safeParse({ protocol, settings }); + if (parsed.success) { + const tagged = parsed.data as { settings: Record }; + 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; +} diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts new file mode 100644 index 00000000..07089dd1 --- /dev/null +++ b/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 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 | null { + if (!xhttp) return null; + const extra: Record = {}; + + 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 = {}; + 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): 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).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, +): 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, + 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 = { + 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://@:?#. 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, 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, 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, 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://@:?#. +// 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://@:?#. +// 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://@: +// ?publickey=&address=&mtu=# +// 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 = { 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'); +} diff --git a/frontend/src/lib/xray/outbound-defaults.ts b/frontend/src/lib/xray/outbound-defaults.ts new file mode 100644 index 00000000..9b4a35b0 --- /dev/null +++ b/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.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; + } +} diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts new file mode 100644 index 00000000..0b2c5bf6 --- /dev/null +++ b/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; + +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; +} diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts new file mode 100644 index 00000000..bd1ed0ed --- /dev/null +++ b/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; + +// 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=`. + // 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; + 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): 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; + 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; + 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) + ); +} diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts new file mode 100644 index 00000000..417071df --- /dev/null +++ b/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; +} diff --git a/frontend/src/lib/xray/stream-defaults.ts b/frontend/src/lib/xray/stream-defaults.ts new file mode 100644 index 00000000..f962796e --- /dev/null +++ b/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): Record { + const network = (stream.network as string | undefined) ?? 'tcp'; + const security = (stream.security as string | undefined) ?? 'none'; + const out: Record = { ...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; +} diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index 391838c2..16e769e8 100644 --- a/frontend/src/models/dbinbound.ts +++ b/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 | unknown[]; @@ -85,7 +85,6 @@ export class DBInbound { nodeId: number | null; fallbackParent: FallbackParentRef | null; - private _cachedInbound: Inbound | null = null; private _clientStatsMap: Map | 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); - } } diff --git a/frontend/src/models/inbound.ts b/frontend/src/models/inbound.ts deleted file mode 100644 index e1342faa..00000000 --- a/frontend/src/models/inbound.ts +++ /dev/null @@ -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; -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(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; - 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 { - const v2Headers: Record = {}; - 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=. - 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, - }; - } -}; diff --git a/frontend/src/models/outbound.ts b/frontend/src/models/outbound.ts deleted file mode 100644 index 5a8ab442..00000000 --- a/frontend/src/models/outbound.ts +++ /dev/null @@ -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¶m2=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 - }; - } -}; diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index d4d09d91..7e09c315 100644 --- a/frontend/src/models/setting.ts +++ b/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 = ''; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 6fc4e5a5..b0827a48 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\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": "alice@example.com", "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).' }, ], diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 264b3229..3039d3bc 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/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(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(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; + }, + 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)} - > -
- - 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.emailMethod > 1 && ( - <> - - update('firstNum', Number(v) || 1)} /> - - - update('lastNum', Number(v) || 1)} /> - - - )} - {form.emailMethod > 0 && ( - - update('emailPrefix', e.target.value)} /> - - )} - {form.emailMethod > 2 && ( - - update('emailPostfix', e.target.value)} /> - - )} - {form.emailMethod < 2 && ( - - update('quantity', Number(v) || 1)} /> - - )} - - - {t('subscription.title')} - update('subId', RandomUtil.randomLowerAndNum(16))} - /> - - }> - update('subId', e.target.value)} /> - - - - update('comment', e.target.value)} /> - - - {showFlow && ( - + cancelText={t('close')} + confirmLoading={saving} + mask={{ closable: false }} + width={640} + onOk={submit} + onCancel={() => onOpenChange(false)} + > + + 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' }, ]} /> - )} - {ipLimitEnable && ( - - update('limitIp', Number(v) || 0)} /> + {form.emailMethod > 1 && ( + <> + + update('firstNum', Number(v) || 1)} /> + + + update('lastNum', Number(v) || 1)} /> + + + )} + {form.emailMethod > 0 && ( + + update('emailPrefix', e.target.value)} /> + + )} + {form.emailMethod > 2 && ( + + update('emailPostfix', e.target.value)} /> + + )} + {form.emailMethod < 2 && ( + + update('quantity', Number(v) || 1)} /> + + )} + + + {t('subscription.title')} + update('subId', RandomUtil.randomLowerAndNum(16))} + /> + + }> + update('subId', e.target.value)} /> - )} - - update('totalGB', Number(v) || 0)} /> - + + update('comment', e.target.value)} /> + - - { setDelayedStart(!delayedStart); update('expiryTime', 0); }} - /> - + {showFlow && ( + + update('email', e.target.value)} - /> - - - - - - - - update('subId', e.target.value)} /> - - - - - - - - - - - update('auth', e.target.value)} /> - - - - - - - - update('password', e.target.value)} /> - - - - - - - - - - - update('uuid', e.target.value)} /> - - - - - - - update('totalGB', Number(v) || 0)} /> - - - {ipLimitEnable && ( - - - update('limitIp', Number(v) || 0)} /> + okText={isEdit ? t('save') : t('create')} + cancelText={t('cancel')} + okButtonProps={{ loading: submitting }} + width={720} + onOk={onSubmit} + onCancel={close} + > + + + + + + update('email', e.target.value)} + /> + + - )} - - - - - {form.delayedStart ? ( - - update('delayedDays', Number(v) || 0)} /> + + + + update('subId', e.target.value)} /> + + - ) : ( - - update('expiryDate', d || null)} - /> - - )} - - - - { - update('delayedStart', v); - if (v) update('expiryDate', null); - else update('delayedDays', 0); - }} - /> - - - + + - {(showFlow || showReverseTag) && ( - {showFlow && ( - - - update('reverseTag', e.target.value)} /> + + + + update('auth', e.target.value)} /> + + + + + + + + update('password', e.target.value)} /> + + + + + + + + + + + update('uuid', e.target.value)} /> + + + + + + + update('totalGB', Number(v) || 0)} /> + + + {ipLimitEnable && ( + + + update('limitIp', Number(v) || 0)} /> )} - )} - - {tgBotEnable && ( + - - update('tgId', Number(v) || 0)} /> + {form.delayedStart ? ( + + update('delayedDays', Number(v) || 0)} /> + + ) : ( + + update('expiryDate', d || null)} + /> + + )} + + + + { + update('delayedStart', v); + if (v) update('expiryDate', null); + else update('delayedDays', 0); + }} + /> + + + {(showFlow || showReverseTag) && ( + + {showFlow && ( + + + update('reverseTag', e.target.value)} /> + + + )} + )} - - - update('comment', e.target.value)} /> - - - - - update('comment', e.target.value)} /> + + + + + + + + + + + + - const ib = inboundRef.current; - const form = dbFormRef.current; - if (!ib || !form) { - return ; - } - - const totalGB = form.total ? Math.round((form.total / SizeFormatter.ONE_GB) * 100) / 100 : 0; - const expiryDate: Dayjs | null = form.expiryTime > 0 ? dayjs(form.expiryTime) : null; - - const renderBasicsTab = () => ( - - - { form.enable = v; refresh(); }} /> + + - - { form.remark = e.target.value; refresh(); }} /> + + + + {selectableNodes.length > 0 && isNodeEligible && ( - + + options={[ + { value: null, label: t('pages.inbounds.localPanel') }, + ...selectableNodes.map((n) => ({ + value: n.id, + label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`, + disabled: n.status === 'offline', + })), + ]} + /> )} - - + + + + + + + + + + + + {t('pages.inbounds.totalFlow')} + + } + > + prev.total !== curr.total} > - {PROTOCOLS.map((p) => {p})} - - - - { ib.listen = e.target.value; refresh(); }} - /> - - - { ib.port = Number(v) || 0; refresh(); }} - /> - - {t('pages.inbounds.totalFlow')}}> - { - form.total = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0); - refresh(); + {({ getFieldValue, setFieldValue }) => { + const totalBytes = (getFieldValue('total') as number) ?? 0; + const totalGB = totalBytes + ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100 + : 0; + return ( + { + const bytes = NumberFormatter.toFixed( + (Number(v) || 0) * SizeFormatter.ONE_GB, + 0, + ); + setFieldValue('total', bytes); + }} + /> + ); }} + + + + + { form.trafficReset = v; refresh(); }}> - {TRAFFIC_RESETS.map((r) => ( - {t(`pages.inbounds.periodicTrafficReset.${r}`)} - ))} - + + + {t('pages.inbounds.expireDate')} + + } + > + prev.expiryTime !== curr.expiryTime} + > + {({ getFieldValue, setFieldValue }) => { + const expiry = (getFieldValue('expiryTime') as number) ?? 0; + return ( + 0 ? dayjs(expiry) : null} + onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)} + /> + ); + }} + - {t('pages.inbounds.expireDate')}}> - { form.expiryTime = d ? d.valueOf() : 0; refresh(); }} - /> - - + ); - const renderFallbacksCard = () => ( + const fallbacksCard = ( - - {t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.'} - {fallbacks.length === 0 && ( - + )} - {fallbacks.map((record, index) => ( -
- - - - - - - - - updateFallback(record.rowKey, { name: e.target.value })} /> - - - - - ALPN - updateFallback(record.rowKey, { alpn: e.target.value })} /> - - - - - Path - updateFallback(record.rowKey, { path: e.target.value })} /> - - - - - xver - updateFallback(record.rowKey, { xver: Number(v) || 0 })} /> - - - - )} + {fallbacks.map((record, idx) => ( +
+ + updateFallback(record.rowKey, { name: e.target.value })} + /> + ALPN + updateFallback(record.rowKey, { alpn: e.target.value })} + /> + Path + updateFallback(record.rowKey, { path: e.target.value })} + /> + xver + updateFallback(record.rowKey, { xver: Number(v) || 0 })} + /> +
))} - - - ); - const renderProtocolTab = () => ( + const protocolTab = ( <> - {isVlessLike && ( -
- - { ib.settings.decryption = e.target.value; refresh(); }} /> + {protocol === Protocols.WIREGUARD && ( + <> + + Secret key{' '} + + + } + > + - - { ib.settings.encryption = e.target.value; refresh(); }} /> + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + + + + {fields.map((field, idx) => ( +
+ + + Peer {idx + 1} + {fields.length > 1 && ( + + {ipFields.map((ipField) => ( + + + + + {ipFields.length > 1 && ( + + )} + + ))} + + )} + + + + +
+ ))} + + )} +
+ + )} + + {protocol === Protocols.TUN && ( + <> + + + + + + + + {(fields, { add, remove }) => ( + + + {fields.map((field, j) => ( + + + + + + + ))} + + )} + + + {(fields, { add, remove }) => ( + + + {fields.map((field, j) => ( + + + + + + + ))} + + )} + + + + + + {(fields, { add, remove }) => ( + + Auto system routes + + } + > + + {fields.map((field, j) => ( + + + + + + + ))} + + )} + + + Auto outbounds interface + + } + > + + + + )} + + {protocol === Protocols.TUNNEL && ( + <> + + + + + + + + + + + + + + + ))} +
+ )} + + )} + + {protocol === Protocols.HTTP && ( + + + + )} + {protocol === Protocols.MIXED && ( + <> + + + + )} + + )} + + )} + + {protocol === Protocols.SHADOWSOCKS && ( + <> + + + + )} + + + + + @@ -1193,1039 +1425,1575 @@ export default function InboundFormModal({ {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })} - + )} - {isFallbackHost && renderFallbacksCard()} - - {ib.protocol === Protocols.SHADOWSOCKS && ( -
- - - - {ib.isSS2022 && ( - Password randomSSPassword(ib.settings)} />}> - { ib.settings.password = e.target.value; refresh(); }} /> - - )} - - - - - { ib.settings.ivCheck = v; refresh(); }} /> - -
- )} - - {(ib.protocol === Protocols.HTTP || ib.protocol === Protocols.MIXED) && ( -
- - - - - {(ib.settings.accounts || []).map((account: HttpAccount, idx: number) => ( - - {String(idx + 1)} - { account.user = e.target.value; refresh(); }} /> - { account.pass = e.target.value; refresh(); }} /> - - - ))} - - {ib.protocol === Protocols.HTTP && ( - - { ib.settings.allowTransparent = v; refresh(); }} /> - - )} - {ib.protocol === Protocols.MIXED && ( - <> - - - - - { ib.settings.udp = v; refresh(); }} /> - - {ib.settings.udp && ( - - { ib.settings.ip = e.target.value; refresh(); }} /> - - )} - - )} -
- )} - - {ib.protocol === Protocols.TUNNEL && ( -
- - { ib.settings.rewriteAddress = e.target.value; refresh(); }} /> - - - { ib.settings.rewritePort = Number(v) || 0; refresh(); }} /> - - - - - - - - {(ib.settings.portMap || []).length > 0 && ( - - {(ib.settings.portMap as { name: string; value: string }[]).map((pm, idx) => ( - - {String(idx + 1)} - { pm.name = e.target.value; refresh(); }} /> - { pm.value = e.target.value; refresh(); }} /> - - - ))} - - )} - - { ib.settings.followRedirect = v; refresh(); }} /> - -
- )} - - {ib.protocol === Protocols.TUN && ( -
- - { ib.settings.name = e.target.value; refresh(); }} /> - - - { ib.settings.mtu = Number(v) || 0; refresh(); }} /> - - - - {(ib.settings.gateway || []).map((_ip: string, j: number) => ( - - { ib.settings.gateway[j] = e.target.value; refresh(); }} /> - - - ))} - - - - {(ib.settings.dns || []).map((_ip: string, j: number) => ( - - { ib.settings.dns[j] = e.target.value; refresh(); }} /> - - - ))} - - - { ib.settings.userLevel = Number(v) || 0; refresh(); }} /> - - Auto system routes}> - - {(ib.settings.autoSystemRoutingTable || []).map((_ip: string, j: number) => ( - - { ib.settings.autoSystemRoutingTable[j] = e.target.value; refresh(); }} /> - - - ))} - - Auto outbounds interface}> - { ib.settings.autoOutboundsInterface = e.target.value; refresh(); }} /> - -
- )} - - {ib.protocol === Protocols.WIREGUARD && ( -
- Secret key }> - { ib.settings.secretKey = e.target.value; refresh(); }} /> - - - - - - { ib.settings.mtu = Number(v) || 0; refresh(); }} /> - - - { ib.settings.noKernelTun = v; refresh(); }} /> - - - - - {(ib.settings.peers || []).map((peer: WireguardPeer, idx: number) => ( -
- - Peer {idx + 1} - {ib.settings.peers.length > 1 && ( - { ib.settings.delPeer(idx); refresh(); }} /> - )} - - Secret key regenWgKeypair(peer)} />}> - { peer.privateKey = e.target.value; refresh(); }} /> - - - { peer.publicKey = e.target.value; refresh(); }} /> - - - { peer.psk = e.target.value; refresh(); }} /> - - - - {(peer.allowedIPs || []).map((_ip: string, j: number) => ( - - { peer.allowedIPs[j] = e.target.value; refresh(); }} /> - {peer.allowedIPs.length > 1 && ( - - )} - - ))} - - - { peer.keepAlive = Number(v) || 0; refresh(); }} /> - -
- ))} -
- )} + {isFallbackHost && fallbacksCard} ); - const renderStreamTab = () => { - const network = ib.stream?.network; - return ( - <> -
- {ib.protocol !== Protocols.HYSTERIA && ( - - - - )} + // Switching `network` swaps which per-network key (tcpSettings, + // wsSettings, grpcSettings, ...) appears on the wire. Clear the old + // network's blob and seed the new one with the schema defaults so the + // Form.Items inside it have valid initial values (KCP needs MTU=1350 + // etc., not empty strings). + // Seed each network's settings blob with its Zod schema defaults so + // every Form.Item inside the network sub-form has a defined starting + // value. XHTTP in particular has ~20 fields (sessionPlacement, + // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value + // is the literal "" sentinel meaning "let xray-core pick its + // default". Without seeding "", the Form.Item reads `undefined` and + // the Select shows blank instead of the "Default (path)" option. + const newStreamSlice = (n: string): Record => { + switch (n) { + case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } }); + case 'kcp': return KcpStreamSettingsSchema.parse({}); + case 'ws': return WsStreamSettingsSchema.parse({}); + case 'grpc': return GrpcStreamSettingsSchema.parse({}); + case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({}); + case 'xhttp': return XHttpStreamSettingsSchema.parse({}); + default: return {}; + } + }; + const onNetworkChange = (next: string) => { + const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; + const current = (form.getFieldValue('streamSettings') as Record) ?? {}; + const cleaned: Record = { ...current, network: next }; + for (const k of ALL) { + if (k !== `${next}Settings`) delete cleaned[k]; + } + cleaned[`${next}Settings`] = newStreamSlice(next); + form.setFieldValue('streamSettings', cleaned); + }; - {network === 'tcp' && ( - <> - {canEnableTls && ( - - { ib.stream.tcp.acceptProxyProtocol = v; refresh(); }} /> - - )} - - { ib.stream.tcp.type = v ? 'http' : 'none'; refresh(); }} /> - - {ib.stream.tcp.type === 'http' && ( - <> - {t('pages.inbounds.stream.general.request')} - - { ib.stream.tcp.request.version = e.target.value; refresh(); }} /> - - - { ib.stream.tcp.request.method = e.target.value; refresh(); }} /> - - {t('pages.inbounds.stream.tcp.path')} }> - {(ib.stream.tcp.request.path || []).map((_p: string, idx: number) => ( - - { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }} /> - {ib.stream.tcp.request.path.length > 1 && ( - - )} - - ))} - - - - - {(ib.stream.tcp.request.headers || []).length > 0 && ( - - {(ib.stream.tcp.request.headers as { name: string; value: string }[]).map((h, idx) => ( - - {String(idx + 1)} - { h.name = e.target.value; refresh(); }} /> - { h.value = e.target.value; refresh(); }} /> - - - ))} - - )} - {t('pages.inbounds.stream.general.response')} - - { ib.stream.tcp.response.version = e.target.value; refresh(); }} /> - - - { ib.stream.tcp.response.status = e.target.value; refresh(); }} /> - - - { ib.stream.tcp.response.reason = e.target.value; refresh(); }} /> - - - - - {(ib.stream.tcp.response.headers || []).length > 0 && ( - - {(ib.stream.tcp.response.headers as { name: string; value: string }[]).map((h, idx) => ( - - {String(idx + 1)} - { h.name = e.target.value; refresh(); }} /> - { h.value = e.target.value; refresh(); }} /> - - - ))} - - )} - - )} - - )} + const streamTab = ( + <> + {protocol !== Protocols.HYSTERIA && ( + + { ib.stream.ws.host = e.target.value; refresh(); }} /> - { ib.stream.ws.path = e.target.value; refresh(); }} /> - { ib.stream.ws.heartbeatPeriod = Number(v) || 0; refresh(); }} /> - - - - {(ib.stream.ws.headers || []).length > 0 && ( - - {(ib.stream.ws.headers as { name: string; value: string }[]).map((h, idx) => ( - - {String(idx + 1)} - { h.name = e.target.value; refresh(); }} /> - { h.value = e.target.value; refresh(); }} /> - - - ))} - - )} - - )} - - {network === 'grpc' && ( - <> - { ib.stream.grpc.serviceName = e.target.value; refresh(); }} /> - { ib.stream.grpc.authority = e.target.value; refresh(); }} /> - { ib.stream.grpc.multiMode = v; refresh(); }} /> - - )} - - {network === 'httpupgrade' && ( - <> - { ib.stream.httpupgrade.acceptProxyProtocol = v; refresh(); }} /> - { ib.stream.httpupgrade.host = e.target.value; refresh(); }} /> - { ib.stream.httpupgrade.path = e.target.value; refresh(); }} /> - - - - {(ib.stream.httpupgrade.headers || []).length > 0 && ( - - {(ib.stream.httpupgrade.headers as { name: string; value: string }[]).map((h, idx) => ( - - {String(idx + 1)} - { h.name = e.target.value; refresh(); }} /> - { h.value = e.target.value; refresh(); }} /> - - - ))} - - )} - - )} - - {network === 'xhttp' && ( - <> - { ib.stream.xhttp.host = e.target.value; refresh(); }} /> - { ib.stream.xhttp.path = e.target.value; refresh(); }} /> - - - - {(ib.stream.xhttp.headers || []).length > 0 && ( - - {(ib.stream.xhttp.headers as { name: string; value: string }[]).map((h, idx) => ( - - {String(idx + 1)} - { h.name = e.target.value; refresh(); }} /> - { h.value = e.target.value; refresh(); }} /> - - - ))} - - )} - - - - {ib.stream.xhttp.mode === 'packet-up' && ( - <> - { ib.stream.xhttp.scMaxBufferedPosts = Number(v) || 0; refresh(); }} /> - { ib.stream.xhttp.scMaxEachPostBytes = e.target.value; refresh(); }} /> - - )} - {ib.stream.xhttp.mode === 'stream-up' && ( - { ib.stream.xhttp.scStreamUpServerSecs = e.target.value; refresh(); }} /> - )} - { ib.stream.xhttp.serverMaxHeaderBytes = Number(v) || 0; refresh(); }} /> - { ib.stream.xhttp.xPaddingBytes = e.target.value; refresh(); }} /> - - - - { ib.stream.xhttp.xPaddingObfsMode = v; refresh(); }} /> - {ib.stream.xhttp.xPaddingObfsMode && ( - <> - { ib.stream.xhttp.xPaddingKey = e.target.value; refresh(); }} /> - { ib.stream.xhttp.xPaddingHeader = e.target.value; refresh(); }} /> - - - - - - - - )} - - - - {ib.stream.xhttp.sessionPlacement && ib.stream.xhttp.sessionPlacement !== 'path' && ( - { ib.stream.xhttp.sessionKey = e.target.value; refresh(); }} /> - )} - - - - {ib.stream.xhttp.seqPlacement && ib.stream.xhttp.seqPlacement !== 'path' && ( - { ib.stream.xhttp.seqKey = e.target.value; refresh(); }} /> - )} - {ib.stream.xhttp.mode === 'packet-up' && ( - - - - )} - {ib.stream.xhttp.mode === 'packet-up' && ib.stream.xhttp.uplinkDataPlacement && ib.stream.xhttp.uplinkDataPlacement !== 'body' && ( - { ib.stream.xhttp.uplinkDataKey = e.target.value; refresh(); }} /> - )} - { ib.stream.xhttp.noSSEHeader = v; refresh(); }} /> - - )} - - - - {externalProxyOn && ( - - )} + {/* Inbound Hysteria stream sub-form. The transport for hysteria + isn't user-selectable (always 'hysteria'), so the network + dropdown is hidden above. Fields here mirror the legacy + HysteriaStreamSettings inbound class: version is locked to 2, + auth + udpIdleTimeout are required, masquerade is an optional + sub-object that lets xray-core disguise the listener as an + HTTP server when probed. */} + {protocol === Protocols.HYSTERIA && ( + <> + + - {externalProxyOn && ( - - {(ib.stream.externalProxy as { forceTls: string; dest: string; port: number; remark: string; sni?: string; fingerprint?: string; alpn?: string[] }[]).map((row, idx) => ( -
- - - - - { row.dest = e.target.value; refresh(); }} /> - - { row.port = Number(v) || 0; refresh(); }} /> - - { row.remark = e.target.value; refresh(); }} /> - { ib.stream.externalProxy.splice(idx, 1); refresh(); }}> - - - - {row.forceTls === 'tls' && ( - - { row.sni = e.target.value; refresh(); }} /> - - - - )} -
- ))} + + + + + + + {() => { + const m = form.getFieldValue([ + 'streamSettings', 'hysteriaSettings', 'masquerade', + ]); + return ( + + form.setFieldValue( + ['streamSettings', 'hysteriaSettings', 'masquerade'], + checked + ? { + type: '', dir: '', url: '', + rewriteHost: false, insecure: false, + content: '', headers: {}, statusCode: 0, + } + : undefined, + ) + } + /> + ); + }} - )} - - { ib.stream.sockoptSwitch = v; refresh(); }} /> - {ib.stream.sockoptSwitch && ib.stream.sockopt && ( - <> - { ib.stream.sockopt.mark = Number(v) || 0; refresh(); }} /> - { ib.stream.sockopt.tcpKeepAliveInterval = Number(v) || 0; refresh(); }} /> - { ib.stream.sockopt.tcpKeepAliveIdle = Number(v) || 0; refresh(); }} /> - { ib.stream.sockopt.tcpMaxSeg = Number(v) || 0; refresh(); }} /> - { ib.stream.sockopt.tcpUserTimeout = Number(v) || 0; refresh(); }} /> - { ib.stream.sockopt.tcpWindowClamp = Number(v) || 0; refresh(); }} /> - { ib.stream.sockopt.acceptProxyProtocol = v; refresh(); }} /> - { ib.stream.sockopt.tcpFastOpen = v; refresh(); }} /> - { ib.stream.sockopt.tcpMptcp = v; refresh(); }} /> - { ib.stream.sockopt.penetrate = v; refresh(); }} /> - { ib.stream.sockopt.V6Only = v; refresh(); }} /> - - - - - - - - - - { ib.stream.sockopt.dialerProxy = e.target.value; refresh(); }} /> - { ib.stream.sockopt.interfaceName = e.target.value; refresh(); }} /> - - - - - )} - - {ib.protocol === Protocols.HYSTERIA && ( - <> - Version}> - { ib.stream.hysteria.version = Number(v) || 2; refresh(); }} /> - - UDP idle timeout}> - { ib.stream.hysteria.udpIdleTimeout = Number(v) || 0; refresh(); }} /> - - - { ib.stream.hysteria.masqueradeSwitch = v; refresh(); }} /> - - {ib.stream.hysteria.masqueradeSwitch && ( + + + {() => { + const m = form.getFieldValue([ + 'streamSettings', 'hysteriaSettings', 'masquerade', + ]) as { type?: string } | undefined; + if (!m) return null; + return ( <> - - + + { ib.stream.hysteria.masquerade.url = e.target.value; refresh(); }} /> - { ib.stream.hysteria.masquerade.rewriteHost = v; refresh(); }} /> - { ib.stream.hysteria.masquerade.insecure = v; refresh(); }} /> + + + + + + + + + )} - {ib.stream.hysteria.masquerade.type === 'file' && ( - { ib.stream.hysteria.masquerade.dir = e.target.value; refresh(); }} /> + {m.type === 'file' && ( + + + )} - {ib.stream.hysteria.masquerade.type === 'string' && ( + {m.type === 'string' && ( <> -