From 3f787ae169e18dca171c32261cd11313c8b5de87 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 27 May 2026 04:26:50 +0200 Subject: [PATCH] feat: complete Zod migration of frontend + bulk client batching (#4599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): add Zod runtime validation at API boundary Introduces Zod 4 schemas for response validation on the three highest-traffic endpoints (server/status, nodes/list, setting/all) and a Zod->AntD form rule adapter, replacing the duplicated per-file ApiMsg interfaces. Validation runs safeParse with console.warn + raw-payload fallback so backend drift never breaks the UI for users. Login form switches to schema-driven rules as the proof-of-life for the adapter. Class-based models stay untouched; remaining query/mutation hooks and form modals will migrate in follow-ups. * feat(frontend): extend Zod validation to remaining query/mutation hooks Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker through parseMsg. Drops the duplicated per-file ApiMsg interfaces and the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload declarations in favour of schema-inferred types re-exported from the new src/schemas/ modules. API boundary now validates: clients list/paged, clients onlines, clients lastOnline, clients get/hydrate, inbounds slim, inbounds get, inbounds options, defaultSettings, xray config, xray outbounds traffic, xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe, nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted, nodes probe / test) get response validation; pass-through mutations stay agnostic. NodeFormModal type-aligned to Msg. * fix(frontend): allow null slices in client/summary schemas Go's encoding/json emits nil []T as null, not []. The initial ClientPageResponseSchema and ClientHydrateSchema rejected null inboundIds / summary.online / summary.depleted / etc., causing [zod] warnings on every empty list. Add nullableStringArray / nullableNumberArray helpers that accept null and transform to [] so consuming code keeps seeing arrays. Mark ClientRecord.traffic and .reverse nullable too (reverse is explicitly null in MarshalJSON when storage is empty). * fix(vite): treat /panel/xray as SPA page, not API root The dev-server bypass classified /panel/xray as an API path because the PANEL_API_PREFIXES matcher did `stripped === prefix.replace(/\/$/, '')`, which made the bare path collide with the SPA route of the same name (see web/controller/xui.go: g.GET("/xray", a.panelSPA)). On reload, /panel/xray got proxied to the Go backend instead of being served by Vite. The backend returned the embedded built index.html with hashed asset names that the dev server doesn't have, so every asset 404'd. Prefix-only match for trailing-slash entries fixes it: panel/xray/... still routes to the API, but panel/xray itself reaches the SPA branch. * feat(frontend): drive form validation from Zod schemas NodeFormModal — full conversion to AntD Form.useForm with antdRule on every required field. Inline field errors replace the single 'fillRequired' toast. testConnection now runs validateFields(['address','port']) before sending. ClientFormModal and ClientBulkAddModal — minimal conversion: keep the existing useState-driven controlled-component pattern, but replace the hand-rolled `if (!form.x)` checks with schema.safeParse(form). The schema is the single source of truth for required-ness and types; ClientCreateFormSchema layers on the create-only `inboundIds.min(1)` rule. New schemas (in src/schemas/): NodeFormSchema (node.ts) ClientFormSchema / ClientCreateFormSchema (client.ts) ClientBulkAddFormSchema (client.ts) Other 16+ form modals stay on the current pattern — the antdRule adapter ships from the first Zod pass for opportunistic migration as forms are touched. * chore(frontend): silence swagger-ui-react peer-dep warnings on React 19 swagger-ui-react@5.32.6 bundles three deps whose declared peer ranges predate React 19: react-copy-to-clipboard@5.1.0 (peer 15-18) react-debounce-input@3.3.0 (peer 15-18, unmaintained) react-inspector@6.0.2 (peer 16-18) For the first two, the actual code is React-19 compatible - only the metadata is stale. Resolve via npm overrides: - react-copy-to-clipboard bumped to ^5.1.1 (peer is open-ended >=15.3.0 in that release). - react-inspector bumped to ^9.0.0 (^8 was a broken publish per its own deprecation notice). - react-debounce-input is wedged on 3.3.0 with no maintained successor on npm. Use the nested-override syntax to satisfy its react peer: "react-debounce-input": { "react": "^19.0.0" } That tells npm to use our React 19 for the package's peer dependency, which silences the warning without changing the package version. * fix(vite): bypass es-toolkit CJS shim for recharts deep imports The Nodes page (and any other recharts-using route) crashed in dev and prod with TypeError: require_isUnsafeProperty is not a function. Root cause: es-toolkit's package.json exports './compat/*' only via a default condition pointing at the CJS shims under compat/.js. Those shims use a require_X.Y access pattern that Vite's optimizer (Rolldown in Vite 8) and the production Rolldown build both mishandle, losing the named-export accessor and calling the namespace object as a function. recharts imports a dozen of these subpaths with default- import syntax, so every chart path tripped the bug. The matching ESM build at dist/compat//.mjs is fine, but it only carries a named export. Recharts uses default imports. Plug a small Rollup-compatible plugin (enforce: 'pre') in front of the resolver: any 'es-toolkit/compat/' request becomes a virtual module that imports the named symbol from the right .mjs file and re-exports it as both default and named. The plugin is registered as a top-level plugin (for the prod build) and via the new Vite 8 optimizeDeps.rolldownOptions.plugins (for the dev pre-bundler), so both pipelines pick it up consistently. * feat(frontend): migrate five secondary form modals to Zod schemas Apply the schema + safeParse-on-submit pattern (introduced for ClientFormModal / ClientBulkAddModal) to five more forms: - ClientBulkAdjustModal: ClientBulkAdjustFormSchema enforces 'at least one of addDays / addGB is non-zero' via .refine(), replacing the ad-hoc days+gb check. - BalancerFormModal: BalancerFormSchema covers tag and selector required-ness; the duplicate-tag check stays inline since it needs the otherTags prop. Per-field validateStatus now reads from the parsed issues map. - RuleFormModal: RuleFormSchema captures the form shape (no required fields - every property is optional by design). safeParse short- circuits if anything is structurally wrong. - CustomGeoFormModal: CustomGeoFormSchema folds the regex alias rule and the http(s) URL validation (including URL parse) into the schema, replacing a 20-line validate() function. - TwoFactorModal: TotpCodeSchema (z.string().regex(/^\d{6}$/)) drives both the disabled-state of the OK button and the safeParse gate before the TOTP comparison. Schemas live alongside the matching API schemas: - ClientBulkAdjustFormSchema in schemas/client.ts - BalancerFormSchema / RuleFormSchema / CustomGeoFormSchema in schemas/xray.ts - TotpCodeSchema in schemas/login.ts (next to LoginFormSchema) No UX change for valid inputs. * feat(frontend): block invalid settings saves with Zod pre-save check Tighten AllSettingSchema with the actual valid ranges and patterns: - webPort / subPort / ldapPort: integer 1-65535 - pageSize: integer 1-1000 - sessionMaxAge: integer >= 1 - tgCpu: integer 0-100 (percentage) - subUpdates: integer 1-168 (hours) - expireDiff / trafficDiff / ldapDefault*: non-negative integers - webBasePath / subPath / subJsonPath / subClashPath: must start with / The existing useAllSettings save path runs AllSettingSchema.partial() through safeParse and logs drift without blocking. SettingsPage now adds a stronger gate before the mutation: run the full schema against the draft and, on failure, surface the first issue (field path + message) via the existing messageApi.error so the user actually sees what's wrong instead of silently sending bad data to the backend. Use cases caught: port out of range, negative quota, sub path missing leading slash, page size set to 0, tgCpu > 100. * feat(frontend): schema-guard Inbound and Outbound form submits The two largest forms in the panel send to the backend without ever checking their own port range or required-ness. Schema-gate the top-level fields so obviously bad payloads stop at the client. InboundFormModal: InboundFormSchema (port 1-65535 int, non-empty protocol, the rest of the keys present) runs as a safeParse just before the HttpUtil.post in submit(). The 2000+ lines of protocol- specific subform code stay untouched - that's a separate effort and the existing per-protocol logic (e.g. canEnableStream, isFallbackHost) already gates most of the structural correctness. OutboundFormModal: OutboundTagSchema (trim + min 1) replaces the hand-rolled `if (!ob.tag?.trim()) messageApi.error('Tag is required')` check. The duplicateTag check stays inline because it needs the existingTags prop. Both schemas emit i18n keys for messages with a defaultValue fallback, matching the pattern in BalancerFormModal and SettingsPage. * feat(backend): gate request bodies with go-playground/validator Add a generic BindAndValidate helper in web/middleware that wraps gin's content-aware binder with an explicit validator.Struct call and emits a structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so the frontend can map each issue to an i18n key. Tag the user-facing fields on model.Inbound, model.Node, and entity.AllSetting with the range/enum constraints they were previously relying on hand-rolled CheckValid logic (or nothing) to enforce, and wire the helper into the inbound/node/settings controllers that bind those structs directly. Promotes validator/v10 from indirect to direct require, plus six unit tests covering valid payloads, range violations, enum violations, malformed JSON, in-place binding, and JSON-only strict mode. This is PR1 of a planned end-to-end Zod rollout — controllers using local form structs (custom_geo, setEnable, fallbacks, client) keep their existing handling and will be migrated as their schemas firm up. * feat(codegen): Go-first tool emitting Zod schemas and TS types Add tools/openapigen — a single-binary Go program that walks the exported structs in database/model, web/entity, and xray via go/parser and emits two committed artifacts under frontend/src/generated: - zod.ts shared Zod schemas keyed off `validate:` tags (ports get .min(1).max(65535), Inbound.protocol becomes a z.enum, Node.scheme too, etc.) - types.ts plain TS interfaces inferred from the same walk, so consumers can import Inbound without dragging Zod along The walker flattens embedded structs (AllSettingView.AllSetting), honors json:"-" and omitempty, and accepts per-struct overrides so the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/ Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as z.unknown() instead of leaking the DB-storage type into the API contract. Type aliases like model.Protocol are emitted as TS aliases and Zod schemas in their own right. Wires `npm run gen:zod` in frontend/package.json so the generator can be re-run without leaving the frontend tree. The existing openapi.json build (gen:api) is left alone for now; migrating the OpenAPI surface to this generator is a follow-up. PR2 of the planned Zod end-to-end rollout. * refactor(frontend): tighten HttpUtil generics from any to unknown Switch the class-level default on Msg and the per-method defaults on HttpUtil.get/post/postWithModal from `any` to `unknown`, so callers that don't pass an explicit T get a narrowed response that must be schema- checked or type-cast before its shape is trusted. Drops the four file-level eslint-disable comments these defaults required. Fixes the nine direct `.obj.field` consumers that surfaced (IndexPage, XrayMetricsModal, NordModal, WarpModal, LogModal, VersionModal, XrayLogModal, CustomGeoSection) by giving each call site the explicit T it should have had from the start — typically a small ad-hoc shape, sometimes a string for the JSON-text-in-Msg.obj pattern used by NordModal/WarpModal/Xray nord/warp endpoints. PR3 of the planned Zod end-to-end rollout — schemas/inbound.ts and schemas/client.ts loose() removal stays parked until the protocol schemas land in Phase 3 to avoid silently dropping fields. * feat(frontend): protocol-leaf Zod schemas with discriminated unions Stand up schemas/primitives (Port, Flow, Protocol, Sniffing) and per-protocol leaf schemas for all 10 inbound and 13 outbound xray protocols. The leaves omit any inner `protocol` literal — the discriminator lives at the parent level so consumers narrow on `.protocol` without redundant projection. Wire shape is preserved per protocol: vmess outbound stays in `vnext[]`, trojan and shadowsocks outbound in `servers[]`, vless outbound flat, http/socks outbound in `servers[].users[]`. Cross-protocol atoms (port, flow, sniffing dest, protocol enum) live in primitives. Protocol-specific enums (vmess security, ss method/network, hysteria version, freedom domain strategy, dns rule action) stay with their leaves. Tagged-wrapper `z.discriminatedUnion('protocol', [...])` composes both InboundSettingsSchema and OutboundSettingsSchema; existing class-based models in src/models/ are untouched and will be retired in Step 3 once the golden-file safety net is in place. * feat(frontend): stream and security Zod families with discriminated unions Stand up the remaining Step 2 families. NetworkSettingsSchema is a 6-branch DU on `network` covering tcp/kcp/ws/grpc/httpupgrade/xhttp, with asymmetric per-network wire keys (tcpSettings, wsSettings, ...) preserved exactly so fixtures round-trip byte-identical. SecuritySettingsSchema is a 3-branch DU on `security` covering none/tls/reality. TLS certs use a file-vs-inline union; uTLS fingerprints are shared between TLS and Reality via a single primitive enum. Hysteria-as-network, finalmask, and sockopt are not in the plan's Step 2 inventory and are deferred to Step 6 (Tighten) - they're orthogonal extras on the stream root, not network-discriminated branches. Resolves a Security identifier collision in protocols/index.ts by re-exporting the type alias as SecurityKind (the `Security` name is taken by the namespace re-export). * test(frontend): vitest harness with golden-file fixtures for inbound protocols Stand up Phase 3 safety net before the models/ rewrite. The harness loads JSON fixtures via Vite's import.meta.glob, parses each through InboundSettingsSchema (the tagged-wrapper DU), and snapshots the canonical parsed shape. Snapshots stay byte-stable across the upcoming class-to- pure-function extraction, catching any normalization drift. Six representative inbound fixtures cover the high-traffic protocols: vless, vmess, trojan, shadowsocks (2022-blake3 multi-user), wireguard, hysteria2. Stream and security branches plus the remaining protocols (http, mixed, tunnel, hysteria) follow in subsequent turns. Uses /// instead of @types/node so we avoid pulling in another type package; import.meta.glob is enough to walk the fixtures directory at compile time. Adds vitest 4.1.7 as the only new dev dependency. test/test:watch scripts land in package.json; a standalone vitest.config.ts keeps the production vite.config.js (which reads from sqlite via DatabaseSync) out of the test runner. * test(frontend): broaden golden coverage to remaining inbounds + stream + security DUs Round out Step 3b. Four more inbound fixtures complete the protocol set (http with two accounts, mixed with socks-style auth, tunnel with a port map, hysteria v1). Two parallel test files cover the other DUs: stream.test.ts walks tcp/ws/grpc fixtures through NetworkSettingsSchema, and security.test.ts walks none/tls/reality through SecuritySettingsSchema. Snapshot count is now 16 across three test files. The reality fixture locks in the array form of serverNames/shortIds (the panel class stores them comma-joined internally but they ship as arrays on the wire). The TLS fixture pins the file-vs-inline cert DU on the file branch. Stream coverage for httpupgrade/xhttp/kcp and security mixed-with-stream combos follow in the next turn, alongside the shadow harness. * test(frontend): shadow-parse harness asserting legacy class and Zod converge Add Step 3c's safety net: for every inbound golden fixture, run the raw payload through both pipelines — legacy: Inbound.Settings.fromJson(protocol, raw.settings).toJson() zod: InboundSettingsSchema.parse(raw).settings — canonicalize each (recursively sort keys, drop empty arrays / null / undefined), and assert byte-equality. This locks the wire shape across the upcoming class-to-pure-function extraction in Step 3d. Any normalization drift introduced by the rewrite trips an assertion here before it can reach users. Two ergonomic wrinkles handled inline: - The legacy class lumps hysteria + hysteria2 onto a single HysteriaSettings (no hysteria2 case in the dispatch table); the test routes hysteria2 fixtures through the HYSTERIA branch. - Empty arrays in Zod's output (e.g. fallbacks: [] from a .default([])) are treated as equivalent to the legacy class's omit-when-empty behavior. Same wire state, different syntactic surface. All 26 tests across 4 test files pass on first run. * refactor(frontend): extract toHeaders + toV2Headers to lib/xray/headers.ts First Step 3d extraction. The XrayCommonClass static helpers toHeaders/toV2Headers are pure data shape conversions with no class hierarchy needs, so they move to a standalone module that callers can import without dragging in models/inbound.ts. The new module exports HeaderEntry + V2HeaderMap as named types so consumers stop reaching into the legacy class for type shapes. A new test file (headers.test.ts) asserts byte-equality with the legacy XrayCommonClass.toHeaders / .toV2Headers across 18 cases — null / undefined / primitive inputs, single-string headers, array-valued headers, duplicate names, empty-name and empty-value filtering, both arr=true (TCP request/response shape) and arr=false (WS / xHTTP / sockopt shape). Drift between the legacy and new impls fails these tests, so the follow-up call-site swap stays safe. Callers (TcpStreamSettings, WsStreamSettings, HTTPUpgradeStreamSettings, TunnelSettings, etc.) still go through XrayCommonClass for now — those swaps land alongside class-method extractions in subsequent turns. Suite is now 44 tests across 5 files; typecheck + lint clean. * refactor(frontend): extract createDefault*Client factories to lib/xray Next Step 3d slice. Five plain-object factories — Vless, Vmess, Trojan, Shadowsocks, Hysteria — replace the legacy `new Inbound.Settings.(...)` constructor chain and the ClientBase XrayCommonClass machinery. Each factory takes an optional seed; missing random fields (id, password, auth, email, subId) fall through to RandomUtil at call time. Forms can hand-pick a UUID; tests pass deterministic seeds so the suite never touches window.crypto. Tests double-verify each factory: a snapshot locks the exact shape, and the matching Zod ClientSchema.parse(out) must equal `out` — no missing defaults, no stray fields, type-narrowed end-to-end. Discovered: VmessClientSchema and VlessClientSchema enforce z.uuid() format, so the test seeds use real-shape UUIDs. Suite: 49 tests across 6 files; typecheck + lint clean. Outbound and inbound-settings factories follow in subsequent turns alongside the toShareLink extraction. * refactor(frontend): add createDefault*InboundSettings factories for all 10 protocols Round out Step 3d's settings factory set. Ten plain-object factories (vless / vmess / trojan / shadowsocks / hysteria / hysteria2 / http / mixed / tunnel / wireguard) replace the legacy `new Inbound.Settings(protocol)` constructors. Each returns a Zod- parsable wire shape with schema defaults applied — no class instance. Forms (Step 4) and InboundsPage clone (Step 5) call these factories directly once the swap lands. Three factories take a seed for random fields: - shadowsocks: method-dependent password length via RandomUtil.randomShadowsocksPassword(method) - hysteria: explicit `version` override (defaults to 2, matching the legacy panel constructor — v1 is opt-in) - wireguard: secretKey from Wireguard.generateKeypair().privateKey Tests double-verify each factory the same way as the client factories: snapshot the shape, then Zod parse round-trip to confirm no missing defaults or stray fields. Suite: 59 tests across 6 files; typecheck + lint clean. Outbound factories and the toShareLink extraction follow next. * refactor(frontend): add getHeaderValue wire-shape lookup to lib/xray/headers Tiny piece of the toShareLink scaffold. The legacy Inbound.getHeader(obj, name) iterated the panel's internal HeaderEntry[] form; the new getHeaderValue reads the Record map our Zod schemas store on the wire. Case-insensitive, returns '' on miss to match the legacy fallback so link-generator call sites stay simple. For repeated-name maps (TCP/WS-style string[] values) the first value wins — matches the legacy iteration order so the share URL's Host hint stays deterministic. Five unit tests cover undefined/null/empty inputs, case folding, string-valued and array-valued matches, empty-array edge case, and missing-key fallback. Suite: 64 tests across 6 files; typecheck + lint clean. This unblocks the next slice: per-protocol link generators (genVmessLink etc.) take a typed inbound + client and call getHeaderValue against the ws/httpupgrade/xhttp/tcp.request header maps. * feat(frontend): stream extras + full InboundSchema with DU intersection Step 3d's last scaffolding piece before link generators. Three new stream-extras schemas land alongside the network/security DUs: - finalmask: TcpMask[] + UdpMask[] + QuicParams. Mask `settings` stays record for now — there are 13 UDP mask types and 3 TCP mask types with distinct per-type setting shapes, and modeling them all as DUs would dwarf the rest of stream/ without buying anything the shadow harness doesn't already catch. Tightened in Step 6. - sockopt: 17 socket-tuning knobs (TCP keepalive, TFO, mark, tproxy, mptcp, dialer proxy, IPv6-only, congestion). `interfaceName` field matches the panel class naming; serializers rename to `interface` on the wire. - external-proxy: rows ship per inbound describing edge fronts (CDN mirrors). Used by link generators to fan out share URLs. schemas/api/inbound.ts composes the top-level wire shape with intersection-of-DUs: StreamSettingsSchema = NetworkSettingsSchema .and(SecuritySettingsSchema) .and(StreamExtrasSchema) InboundSchema = InboundCoreSchema.and(InboundSettingsSchema) A fixture (vless-ws-tls.json) exercises the full shape — protocol DU, network DU, security DU, and TLS cert file branch in one round trip. The snapshot pins the canonical parsed form so the upcoming link extractor consumes typed input with no class hierarchy underneath. Suite: 65 tests across 7 files; typecheck + lint clean. Zod 4 intersection-of-DUs works. * refactor(frontend): extract genVmessLink to lib/xray/inbound-link.ts First link generator to leave the class hierarchy. genVmessLink takes a typed Inbound + client args and returns the base64-encoded vmess:// URL. Internal helpers (buildXhttpExtra, applyXhttpExtraToObj, applyFinalMaskToObj, applyExternalProxyTLSObj, serializeFinalMask, hasShareableFinalMaskValue, externalProxyAlpn) port across from XrayCommonClass — same logic, rewritten to read the Zod schemas' Record headers instead of the legacy HeaderEntry[]. Parity test (inbound-link.test.ts) loads each vmess fixture in golden/fixtures/inbound-full, parses it with InboundSchema for the new pure fn AND constructs LegacyInbound.fromJson(raw) for the class method, then asserts the URLs match byte-for-byte. Drift between the two impls fails here before the call sites in pages/inbounds/* get swapped. Adds a small test setup file that aliases globalThis.window to globalThis so Base64.encode's window.btoa works under Node — keeps the test env at 'node' and avoids pulling jsdom as a new dep. A first vmess-tcp-tls full-inbound fixture pins the round-trip path. Suite: 67 tests across 8 files; typecheck + lint clean. Five more link generators (vless/trojan/ss/hysteria/wireguard) plus the orchestrator (toShareLink, genAllLinks) follow in subsequent turns. * test(frontend): refresh inbound-full snapshot with vmess-tcp-tls fixture * refactor(frontend): extract genVlessLink to lib/xray/inbound-link Second link generator. genVlessLink builds the vless://@:?# share URL from a typed Inbound + client args, dispatching on streamSettings.network for the network-specific knobs and on streamSettings.security for the TLS/Reality knobs. Three param-style helpers move alongside the obj- style ones already in this file: - applyXhttpExtraToParams — writes path/host/mode/x_padding_bytes and the JSON extra blob into URLSearchParams - applyFinalMaskToParams — writes the fm payload when shareable - applyExternalProxyTLSParams — overrides sni/fp/alpn when an external proxy entry is supplied and security is tls A vless-tcp-reality fixture lands alongside the existing vless-ws-tls one, so the parity test now exercises both security branches. Discovered a latent legacy bug while writing parity: the old class stored realitySettings.serverNames as a comma-joined string and gated SNI on `!ObjectUtil.isArrEmpty(serverNames)`, which always returns true for strings — so SNI was never written into Reality share URLs. Existing clients rely on the omission (they pull SNI from realitySettings.target instead). We preserve the omission here to keep this extraction byte-stable; an inline comment marks the spot for a separate intentional fix. Suite: 70 tests across 8 files; typecheck + lint clean. * refactor(frontend): extract genTrojanLink + genShadowsocksLink to lib/xray Third and fourth link generators. genTrojanLink mirrors genVlessLink's shape (URLSearchParams + network/security branches + remark hash) minus the encryption/flow VLESS-isms. genShadowsocksLink shares the same query construction but base64-encodes the userinfo portion as method:password or method:settingsPw:clientPw depending on whether SS-2022 is in single-user or multi-user mode. Three reusable helpers move out of the per-protocol functions: - writeNetworkParams: the per-network switch that all param-style links share (tcp http header / kcp mtu+tti / ws path+host / grpc serviceName+authority / httpupgrade / xhttp extras) - writeTlsParams: fingerprint/alpn/ech/sni - writeRealityParams: pbk/sid/spx/pqv (preserves the SNI-omission legacy parity quirk noted in the genVlessLink commit) genVmessLink stays with its inline switch — it builds a JSON obj instead of URLSearchParams and has per-network quirks (kcp emits mtu+tti at the obj root, grpc maps multiMode to obj.type='multi') that don't factor cleanly through the shared writer. Two new full-inbound fixtures (trojan-ws-tls, shadowsocks-tcp-2022) plus matching parity tests bring the suite to 74 tests across 8 files; typecheck + lint clean. * refactor(frontend): extract genHysteriaLink + Wireguard link/config to lib/xray Fifth and sixth link generators. genHysteriaLink builds the v1/v2 share URL (scheme picked from settings.version), copying TLS knobs into the query, surfacing the salamander obfs password from finalmask.udp[type=salamander] when present, and writing the broader finalmask payload under `fm` like the other links. Legacy parity note: the old genHysteriaLink read stream.tls.settings.allowInsecure, which isn't a field on TlsStreamSettings.Settings — the guard always evaluated false and the `insecure` param never made it into the URL. We omit it here to stay byte-stable. genWireguardLink and genWireguardConfig take a typed WireguardInboundSettings + peer index and: - link: wireguard://@host:port?publickey=&address=&mtu=#remark - config: the .conf text WireGuard clients consume directly Both derive the server pubKey from settings.secretKey via Wireguard.generateKeypair at call time — Zod stores only secretKey on the wire (pubKey is computed). The Wireguard utility is pure JS (X25519 over Float64Array), so it runs fine under node + the window polyfill we added with the vmess extraction. Two new full-inbound fixtures (hysteria-v1-tls, wireguard-server) plus matching parity tests bring the suite to 78 tests across 8 files; typecheck + lint clean. Hysteria2 (protocol literal) parity stays deferred — the legacy class has no HYSTERIA2 dispatch case, so it can't round-trip a hysteria2 fixture without a protocol remap. Same trick the shadow harness uses; revisit in the orchestrator commit. * refactor(frontend): extract share-link orchestrator to lib/xray/inbound-link Last slice of Step 3d. Five orchestrator exports compose the per- protocol generators into the public surface the panel consumes: - resolveAddr(inbound, hostOverride, fallbackHostname): picks the address that goes into share/sub URLs. Browser `location.hostname` is no longer a hidden dependency — callers pass it in (or any other fallback they want). - getInboundClients(inbound): protocol-aware clients accessor. Mirrors the legacy `Inbound.clients` getter, including the SS quirk where 2022-blake3-chacha20 single-user inbounds report null (no client loop) and everything else returns the clients array. - genLink: per-protocol dispatcher matching legacy Inbound.genLink. - genAllLinks: per-client fanout. Builds the remarkModel-formatted remark (separator + 'i'/'e'/'o' field picker) and iterates streamSettings.externalProxy when present. - genInboundLinks: top-level \r\n-joined link block. Loops per client for clientful protocols, single-shots SS for non-multi-user, and delegates to genWireguardConfigs for wireguard. Returns '' for http/mixed/tunnel (no share URL at all). Plus genWireguardLinks / genWireguardConfigs fanouts which iterate peers and append index-suffixed remarks. Parity test exercises every full-inbound fixture against legacy Inbound.genInboundLinks. Skips hysteria2 (no legacy dispatch case; that bridge belongs in a separate intentional commit alongside the form modal swap). Suite: 89 tests across 8 files; typecheck + lint clean. Next: Step 4 form modal migrations. Forms can now drop `new Inbound.Settings.getSettings(protocol)` in favor of the createDefault*InboundSettings factories, and InboundsPage clone can swap to genInboundLinks. Models/ deletion follows in Step 5 once all call sites are off the class. * refactor(frontend): swap InboundsPage clone fallback off Inbound.Settings.getSettings First Step 4 call-site swap. createDefaultInboundSettings(protocol) lands in lib/xray/inbound-defaults — a protocol-aware dispatch over the 10 per-protocol settings factories already in this module. Returns a Zod- parsable plain object instead of a class instance, so callers that just need the wire-shape JSON can drop the class hierarchy without touching the broader form modals. InboundsPage's clone path used Inbound.Settings.getSettings(p).toString() as the fallback when settings JSON parsing failed. That's now createDefaultInboundSettings + JSON.stringify, with a final '{}' guard for unknown protocols (legacy returned null and .toString() crashed — we just emit empty settings instead). The Inbound import on this file is now unused and removed. The 2 remaining getSettings call sites in InboundFormModal aren't safe to swap in isolation — the form mutates the returned class instance through methods like .addClient() and .toJson() across ~2000 lines of JSX. Those land with the full Pattern A rewrite of InboundFormModal, which the plan budgets at multiple days on its own. Suite: 89 tests across 8 files; typecheck + lint clean. * refactor(frontend): lift Protocols + TLS_FLOW_CONTROL consts to schemas/primitives Step 4b. The Protocols and TLS_FLOW_CONTROL enums on models/inbound.ts were dragging five page files into that 3,300-line module just to read literal string constants. Lifting them to schemas/primitives lets those pages drop the @/models/inbound import entirely. - schemas/primitives/protocol.ts now exports a Protocols const map alongside the existing ProtocolSchema. TUN stays in the const for parity (legacy panel deployments may have saved TUN inbounds) even though the Go validator no longer accepts it as a new write. - schemas/primitives/flow.ts now exports TLS_FLOW_CONTROL. The empty-string default isn't keyed because the legacy never had a NONE entry — call sites compare against the two real flow values. Updated five consumers: - useInbounds.ts: TRACKED_PROTOCOLS now annotated readonly string[] so .includes(string) keeps narrowing through the array literal - QrCodeModal.tsx, InboundInfoModal.tsx: Protocols - ClientFormModal.tsx, ClientBulkAddModal.tsx: TLS_FLOW_CONTROL Suite: 89 tests across 8 files; typecheck + lint clean. models/inbound.ts is now imported by: - InboundFormModal.tsx (heavy use of Inbound class + getSettings) - test/inbound-link.test.ts + test/shadow.test.ts + test/headers.test.ts (intentional — these are parity tests against the legacy class) OutboundFormModal still imports from models/outbound. Both form modals are the multi-day Pattern A rewrites the plan scopes separately. * refactor(frontend): lift OutboundProtocols + OutboundDomainStrategies to schemas/primitives Moves the two outbound-side consts out of models/outbound.ts and into schemas/primitives/outbound-protocol.ts. Renames the export to OutboundProtocols to disambiguate from the inbound Protocols const (different key casing — PascalCase vs ALL CAPS — and partly different member set, so they cannot share a single const). OutboundsTab.tsx keeps its 15+ Protocols.X call sites by aliasing the import. FinalMaskForm.tsx and BasicsTab.tsx swap directly. Drops a stale `as string[]` cast in BasicsTab that no longer fits the new readonly-tuple typing. After this commit only the two big form modals (InboundFormModal/OutboundFormModal) plus three intentional parity tests still import from @/models/. * refactor(frontend): lift outbound option dictionaries to schemas/primitives Adds schemas/primitives/options.ts with UTLS_FINGERPRINT, ALPN_OPTION, SNIFFING_OPTION, USERS_SECURITY, MODE_OPTION (all identical between models/inbound.ts and models/outbound.ts) plus the outbound-only WireguardDomainStrategy, Address_Port_Strategy, and DNSRuleActions. OutboundFormModal now pulls 9 consts from primitives. Only `Outbound` (the class) and `SSMethods` (whose inbound/outbound versions diverge by 2 legacy aliases — keep the picker open for the Pattern A rewrite) still come from @/models/outbound. Drops three stale `as string[]` casts on what are now readonly tuples. * refactor(frontend): swap InboundFormModal option dicts to schemas/primitives Extends primitives/options.ts with the five inbound-only option dicts (TLS_VERSION_OPTION, TLS_CIPHER_OPTION, USAGE_OPTION, DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) and lifts InboundFormModal off @/models/inbound for 10 of its 12 imports. Only the Inbound class and SSMethods (inbound vs outbound versions diverge by 2 entries) still come from @/models/. Widens NODE_ELIGIBLE_PROTOCOLS Set element type to string since the new primitives const exposes a narrow literal union that `.has(arbitraryString)` would otherwise reject. * feat(frontend): InboundFormValues schema for Pattern A rewrite Foundation for the InboundFormModal rewrite. Mirrors the wire Inbound shape (intersection of core fields + protocol settings DU + stream/security DUs) plus the DB-side fields (up/down/total/trafficReset/nodeId/...) that flow through DBInbound rather than the xray config slice. InboundStreamFormSchema is exported separately so individual sub-form sections can rule against just the stream portion when needed. FallbackRowSchema is co-located here even though fallbacks save via a distinct endpoint after the main POST — they belong to the same form state from the user's perspective. No modal changes in this commit. Foundation only; subsequent turns swap the modal's `inboundRef`/`dbFormRef` mutable-class state for Form.useForm(). * feat(frontend): adapter between raw inbound rows and InboundFormValues Adds lib/xray/inbound-form-adapter.ts with rawInboundToFormValues and formValuesToWirePayload. The pair is the data boundary the upcoming Pattern A modal will use: it consumes the DB row shape (settings et al. as string OR object — coerced internally), hands the modal typed InboundFormValues, and on submit reverses the trip to a wire payload with the three JSON-stringified slices the Go endpoints expect. No dependency on the legacy Inbound/DBInbound classes — the coerce step is inlined so the adapter survives the eventual models/ deletion. Adds 10 Vitest cases covering string vs object inputs, the optional streamSettings/nodeId fields, trafficReset coercion, and a raw-to-payload -to-raw round-trip equality. * feat(frontend): protocol capability predicates as pure functions Adds lib/xray/protocol-capabilities.ts with the seven predicates the modals call: canEnableTls, canEnableReality, canEnableTlsFlow, canEnableStream, canEnableVisionSeed, isSS2022, isSSMultiUser. Each takes a minimal slice of an InboundFormValues, no class instance. The legacy isSSMultiUser returns true on non-shadowsocks protocols too (method getter resolves to "" which != blake3-chacha20-poly1305). The new function preserves this quirk and documents it inline; callers all narrow on protocol === shadowsocks before checking, so the surprising return value never surfaces. Parity harness in test/protocol-capabilities.test.ts crosses each of the 10 golden fixtures with 14 stream configurations (network × security) and asserts each predicate matches the legacy class method — 140 cases, all green. * feat(frontend): outbound settings factories + dispatcher Adds lib/xray/outbound-defaults.ts parallel to inbound-defaults.ts: 13 createDefault*OutboundSettings factories (one per outbound protocol) plus the createDefaultOutboundSettings(protocol) dispatcher mirroring Outbound.Settings.getSettings's contract — non-null on each known protocol, null otherwise. The factory output matches the legacy `new Outbound.Settings()` start state: required-by-schema fields the user fills in via the form (address, port, password, id, peer publicKey/endpoint) come back as empty stubs. Wireguard alone seeds secretKey via the X25519 generator; the rest expose blank fields. This is the same behavior the OutboundFormModal relies on for protocol-change resets. Shadowsocks defaults to 2022-blake3-aes-128-gcm rather than the legacy undefined — the Select snaps to the first option anyway, so the coherent default keeps the modal from rendering an empty picker. Tests cover three layers: - exact-shape snapshots per factory (13 cases) - Zod schema acceptance after sensible stub fill-in (13 cases) - dispatcher non-null per known protocol + null for the unknown (14 cases) * feat(frontend): InboundFormModal.new.tsx skeleton (Pattern A) First commit of the sibling-file modal rewrite. The new modal mounts Form.useForm, hydrates via rawInboundToFormValues on open (edit) or buildAddModeValues (add), runs validateFields + safeParse on submit, and posts the formValuesToWirePayload result. No tabs yet — the modal body shows a WIP placeholder. The file is not imported anywhere; the existing InboundFormModal.tsx remains the one InboundsPage renders. Build, lint, and 280 tests stay green. Subsequent commits add the basic / sniffing / protocol / stream / security / advanced / fallbacks sections; the atomic import swap in InboundsPage.tsx lands last. * feat(frontend): basic tab on InboundFormModal.new.tsx (Pattern A) First real section of the sibling-file rewrite. Wires AntD Form.Items to InboundFormValues paths for the basic tab — enable, remark, deployTo (when protocol is node-eligible), protocol, listen, port, totalGB, trafficReset, expireDate. The port input gets a per-field antdRule against InboundFormBaseSchema.shape.port — the spec's Pattern A reference. The intersection-typed InboundFormSchema has no .shape accessor, so per-field rules pull from the underlying ZodObject components. totalGB and expireDate are bytes/timestamp on the wire but a GB number / dayjs picker in the UI. Both use shouldUpdate-closure children that read form state and call setFieldValue on user input — no transient form-only fields, no DU-shape surprises at submit time. Protocol-change cascade lives in Form's onValuesChange: pick a new protocol and the settings DU branch is reset to createDefaultInboundSettings(next); a non-node-eligible protocol also clears nodeId. Modal still renders a single-tab Tabs container. Sniffing tab is next. * feat(frontend): sniffing tab on InboundFormModal.new.tsx (Pattern A) Second section of the sibling-file rewrite. Wires the six sniffing sub-fields to nested form paths ['sniffing', 'enabled'], ['sniffing', 'destOverride'], etc. Uses Form.useWatch on the enabled flag to drive conditional rendering of the dependent fields — the same gate the legacy modal expressed via `ib.sniffing.enabled &&`. Checkbox.Group renders one Checkbox per SNIFFING_OPTION entry. The two exclusion lists use Select mode="tags" so the user can paste comma- separated IP/CIDR or domain rules. No transient form state, no class methods — every field maps directly to a wire-shape path in InboundFormValues. Protocol tab is next. * feat(frontend): protocol tab VLESS auth on InboundFormModal.new.tsx Adds the protocol tab to the sibling-file rewrite — currently only the VLESS section, which lays out decryption/encryption inputs and the three buttons that drive them: Get New x25519, Get New mlkem768, Clear. getNewVlessEnc + clearVlessEnc are ported from the legacy modal as pure setFieldValue paths into ['settings', 'decryption'] / ['settings', 'encryption'] — no class methods, no inboundRef. The matchesVlessAuth helper mirrors the legacy fuzzy label-matching so the backend response shape stays the only source of truth. selectedVlessAuth derives the displayed auth label from the encryption string via Form.useWatch — same heuristic as the legacy modal (.length > 300 → mlkem768, otherwise x25519). Tab spread is conditional: the protocol tab only appears when protocol === 'vless' right now. As more protocol sections land (shadowsocks, http/mixed, tunnel, tun, wireguard) the condition will widen to cover each one. * feat(frontend): protocol tab Shadowsocks section (Pattern A) Adds the Shadowsocks sub-form: method picker (from SSMethodSchema's seven schema-aligned options), conditional password input gated on isSS2022, network picker (tcp/udp/tcp,udp), ivCheck toggle. Method change cascades through the Select's onChange — regenerating the inbound-level password via RandomUtil.randomShadowsocksPassword. The shadowsockses[] multi-user list reset is deferred until the clients-management section lands. Uses isSS2022 from lib/xray/protocol-capabilities to gate the password field exactly the way the legacy modal did — keeps the form behavior identical without referencing the legacy class. SSMethodSchema.options drives the Select rather than the legacy SSMethods const (which the inbound modal pulled from models/inbound.ts). This commits to the schema-aligned 7-entry list for inbound; the outbound divergence (9 entries with legacy aliases) is still pending in OutboundFormModal — defer the UX decision to that rewrite. * feat(frontend): protocol tab HTTP and Mixed sections (Pattern A) Adds the HTTP and Mixed sub-forms. Both share an accounts list — first Form.List usage in the rewrite. Each row binds via [field.name, 'user'] / [field.name, 'pass'] under the parent ['settings', 'accounts'] path, so the wire shape stays exactly what HttpInboundSettingsSchema and MixedInboundSettingsSchema validate. HTTP-only: allowTransparent Switch. Mixed-only: auth Select (noauth/password), udp Switch, conditional ip Input gated on the udp value via Form.useWatch. Tab visibility widens to include http + mixed alongside vless + shadowsocks. The string cast on the includes-check keeps the frozen Protocols const's narrow union from rejecting the broader protocol string at the call site. * feat(frontend): protocol tab Tunnel section (Pattern A) Adds the Tunnel sub-form: rewriteAddress + rewritePort, allowedNetwork picker (tcp/udp/tcp,udp), Form.List-driven portMap with name/value pairs, and the followRedirect Switch. portMap is the second Form.List in the rewrite — same shape as the HTTP/Mixed accounts list but with name/value rather than user/pass. The wire shape stays `settings.portMap: { name, value }[]` exactly. Tab visibility widens to Tunnel. * feat(frontend): protocol tab TUN section (Pattern A) Adds the TUN sub-form: interface name, MTU, four primitive-array Form.Lists (gateway, dns, autoSystemRoutingTable), userLevel, autoOutboundsInterface. Primitive Form.Lists bind each row's Input directly to `field.name` (no inner key) — distinct from the object-row Form.Lists that bind to `[field.name, 'fieldKey']`. The Form.useWatch('protocol') return type comes from the schema's protocol enum which excludes 'tun' (TUN is in the legacy Protocols const for data parity but never accepted by the wire validator). Cast to string at the source so per-section comparisons against Protocols.TUN typecheck. Why: legacy DB rows with protocol === 'tun' still need to render; widening here keeps reads from rejecting them. Tab visibility widens to TUN. * feat(frontend): protocol tab Wireguard section (Pattern A) Adds the Wireguard sub-form: server secretKey input with regen icon, derived disabled public-key display, mtu, noKernelTun toggle, and a Form.List of peers — each peer having its own privateKey (regen icon), publicKey, preSharedKey, allowedIPs (nested Form.List for the string array), keepAlive. pubKey is purely derived (computed via Wireguard.generateKeypair from the watched secretKey) and is NOT stored in the form value — the schema omits it from the wire shape on purpose. The disabled display shows the live derivation without polluting form state. regenInboundWg generates a fresh keypair and writes only the secretKey path; pubKey re-derives automatically. regenWgPeerKeypair writes both privateKey and publicKey at the peer's path index. The preSharedKey wire-shape name is used instead of the legacy class's internal psk — matches WireguardInboundPeerSchema. Tab visibility widens to Wireguard. * feat(frontend): stream tab skeleton with TCP + KCP (Pattern A) Opens the stream tab on the sibling-file rewrite. Tab visibility is driven by canEnableStream from lib/xray/protocol-capabilities — same gate the legacy modal used, now schema-aware. Transmission picker (network select) is hidden for HYSTERIA since that protocol's network is implicit. onNetworkChange clears any stale per-network settings keys (tcpSettings/kcpSettings/...) and seeds an empty object for the new branch so AntD Form.Items don't read from undefined nested paths. TCP section: acceptProxyProtocol Switch (literal-true-optional on the wire — the form stores true/false but Zod's strip behavior keeps false-as-omission round-trips clean) plus an HTTP-camouflage toggle that flips header.type between 'none' and 'http'. The full HTTP camouflage request/response sub-form lands in a follow-up commit. KCP section: six numeric knobs (mtu, tti, upCap, downCap, cwndMultiplier, maxSendingWindow). WS / gRPC / HTTPUpgrade / XHTTP / external-proxy / sockopt / hysteria stream / FinalMaskForm hookup all still pending. * feat(frontend): stream tab WS + gRPC + HTTPUpgrade sections (Pattern A) Adds the three medium-complexity network branches to the stream tab. Plain Form.Item paths into the corresponding *Settings keys — no Form.List wrappers since these schemas don't have arrays at the top level. WS: acceptProxyProtocol, host, path, heartbeatPeriod gRPC: serviceName, authority, multiMode HTTPUpgrade: acceptProxyProtocol, host, path Header editing is deferred to a later commit — WsHeaderMap is a Record on the wire, V2HeaderMap a Record, and the form needs an array-of-{name,value} UI that converts on edit. Worth building once and reusing across WS, HTTPUpgrade, XHTTP, TCP request/response, and Hysteria masquerade headers. XHTTP + external-proxy + sockopt + hysteria stream + finalmask hookup still pending. * feat(frontend): stream tab XHTTP section (Pattern A) XHTTP is the heaviest network branch — 19 fields rendered conditionally on mode, xPaddingObfsMode, and the three *Placement selectors. Each gates its dependent field set via Form.useWatch. Field structure mirrors the legacy XHTTPStreamSettings form 1:1: - mode picker (auto / packet-up / stream-up / stream-one) - packet-up adds scMaxBufferedPosts + scMaxEachPostBytes; stream-up adds scStreamUpServerSecs - serverMaxHeaderBytes, xPaddingBytes, uplinkHTTPMethod (with the packet-up gate on the GET option) - xPaddingObfsMode unlocks xPadding{Key,Header,Placement,Method} - sessionPlacement / seqPlacement each unlock their respective Key field when set to anything other than 'path' - packet-up mode additionally unlocks uplinkDataPlacement, and that in turn unlocks uplinkDataKey when the placement is not 'body' - noSSEHeader Switch at the tail XHTTP headers editor still pending (same WsHeaderMap as WS — will be unified in the header-editor extraction commit). * feat(frontend): stream tab external-proxy + sockopt sections (Pattern A) External Proxy: Switch driven by externalProxy array length. Toggling on seeds one row with the window hostname + the inbound's current port; toggling off clears the array. Each row is a Form.List item with forceTls/dest/port/remark inline, and a nested SNI/Fingerprint/ALPN row that conditionally renders on forceTls === 'tls' via a shouldUpdate-closure that watches the per-row forceTls path. Sockopt: Switch driven by whether the sockopt object exists in form state. Toggling on calls SockoptStreamSettingsSchema.parse({}) so every default the schema declares (mark=0, tproxy='off', domainStrategy='UseIP', tcpcongestion='bbr', etc.) flows into the form; toggling off sets to undefined. Renders the seventeen sockopt fields directly bound to ['streamSettings', 'sockopt', X] paths. Option lists pull from the primitives const dictionaries (UTLS_FINGERPRINT, ALPN_OPTION, DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) rather than the schema's .options to keep one source of truth for UI label strings. * feat(frontend): security tab base + TLS section (Pattern A) Adds the security tab to the sibling-file rewrite. Visibility is paired with the stream tab — both gated on canEnableStream. The security selector is itself disabled when canEnableTls is false, and the reality option only appears when canEnableReality is true, mirroring the legacy modal's Radio.Group guards. onSecurityChange clears the previous branch's *Settings key and seeds the new branch from the schema's parsed defaults (the same trick the sockopt toggle uses). The security selector itself is rendered via a shouldUpdate closure so the on-change handler can write the cleaned streamSettings shape atomically without racing AntD's per-field sync. TLS section: serverName (the wire field — the legacy class calls it sni internally), cipherSuites (with the 13 named suites from TLS_CIPHER_OPTION), min/max version pair, uTLS fingerprint, ALPN multi-select, plus the three policy Switches. TLS certificates list, ECH controls, the full Reality sub-form, and the four API-call buttons (genRealityKeypair / genMldsa65 / getNewEchCert / randomizers) land in a follow-up commit. * feat(frontend): security tab Reality + ECH + mldsa65 controls (Pattern A) Adds the Reality sub-form and the four API-call buttons that drive the server-generated material: - genRealityKeypair calls /panel/api/server/getNewX25519Cert and writes the result into ['streamSettings', 'realitySettings', 'privateKey'] and the nested settings.publicKey path. - genMldsa65 calls /panel/api/server/getNewmldsa65 for the post-quantum seed/verify pair. - getNewEchCert calls /panel/api/server/getNewEchCert with the current serverName and writes echServerKeys + settings.echConfigList. - randomizeRealityTarget seeds target + serverNames from the random reality-targets pool. - randomizeShortIds calls RandomUtil.randomShortIds (comma-joined string) and splits into the schema's string[] form. Reality fields are bound directly to schema paths — show/xver/target, maxTimediff, min/max ClientVer, the settings.{publicKey, fingerprint, spiderX, mldsa65Verify} nested subtree, plus the array fields (serverNames, shortIds) rendered as Select mode="tags" since both ship as string[] on the wire. TLS certificates list (Form.List with the useFile DU) still pending — that's a chunky sub-form on its own. * feat(frontend): security tab TLS certificates list (Pattern A) Closes out the security tab: a Form.List of certificates that toggles between TlsCertFileSchema (certificateFile + keyFile string paths) and TlsCertInlineSchema (certificate + key as string arrays per the wire shape) via a per-row useFile boolean. useFile is a transient form-only field — not part of TlsCertSchema. Zod's default-strip behavior drops it during InboundFormSchema parse on submit, leaving only the matching wire branch's keys populated. Whichever side the user wasn't on stays empty, so Zod's union picks the populated branch. For inline certs the TextAreas use normalize + getValueProps to convert between the wire-side string[] and the multi-line text the user types. Each line becomes one array element, matching the legacy class's `cert.split('\n')` toJson convention. Per-row buildChain is conditionally rendered when usage === 'issue' — a shouldUpdate-closure watches the specific path so the toggle re-renders inline without listening to unrelated form changes. Security tab is now functionally complete. Advanced JSON tab, Fallbacks card, and the atomic swap in InboundsPage are next. * feat(frontend): advanced JSON tab on InboundFormModal.new.tsx (Pattern A) Adds the advanced JSON tab. Each sub-tab (settings / streamSettings / sniffing) renders an AdvancedSliceEditor — a small CodeMirror-backed JsonEditor that holds a local text buffer and forwards parsed JSON to form state on every valid edit. Invalid JSON sits silently in the local buffer; once the user finishes balancing braces / quoting, the next valid parse pushes through to the form. No stamping ref, no apply-on-tab-switch ceremony — the form is the single source of truth. The buffer seeds once from form state on mount. The Modal's destroyOnHidden means each open is a fresh editor instance, so external form mutations during a single open session can't desync the editor either. The streamSettings sub-tab is omitted when streamEnabled is false (matching the legacy modal's behavior for protocols like Http / Mixed that have no stream layer). * feat(frontend): fallbacks card on InboundFormModal.new.tsx (Pattern A) Adds the fallbacks card rendered inside the protocol tab whenever the current values describe a fallback host — VLESS or Trojan on tcp with tls or reality security. The protocol tab visibility widens to include Trojan in that exact case (it has no other protocol sub-form). Fallbacks live in a useState alongside the form rather than inside form values, mirroring the legacy modal: fallbacks save via a distinct endpoint (/panel/api/inbounds/{id}/fallbacks) after the main inbound POST, not as part of the inbound payload. loadFallbacks runs on open for edit-mode VLESS/Trojan; saveFallbacks runs after a successful POST inside the submit handler. Each row: child picker (filtered down to other inbounds), then four inline edits for SNI / ALPN / path / xver. Add adds an empty row; delete pulls the row from state. Quick-Add-All, the rederive-from-child helper, and the per-row up/down movers are deferred — the basic add/edit/remove cycle is what the modal actually needs to function. * feat(frontend): atomic swap InboundFormModal to Pattern A Deletes the 2261-line class-mutation modal and renames the 1900-line sibling rewrite into its place. InboundsPage.tsx already imports the file by path so no consumer change is needed — the swap is one file delete plus one file rename. Build, lint, and 280 tests stay green. What the new modal covers end-to-end: - Basic (enable / remark / nodeId / protocol / listen / port / totalGB / trafficReset / expireDate) - Sniffing (enabled / destOverride / metadataOnly / routeOnly / ipsExcluded / domainsExcluded) - Protocol per DU branch: VLESS (decryption/encryption + buttons), Shadowsocks (method/password/network/ivCheck), HTTP + Mixed (accounts list + per-protocol toggles), Tunnel (rewrite + portMap + followRedirect), TUN (interface/mtu + four primitive lists + userLevel/autoInterface), Wireguard (secretKey + derived pubKey + peers list with nested allowedIPs) - Stream per network: TCP base, KCP, WS, gRPC, HTTPUpgrade, XHTTP (the 22-field one), plus external-proxy and sockopt extras - Security: TLS (SNI/cipher/version/uTLS/ALPN/policy switches + certificates list with file/inline toggle + ECH controls), Reality (every field + the four API-call buttons), none - Advanced JSON (settings / streamSettings / sniffing live editors that round-trip into form state on every valid parse) - Fallbacks (load on open for VLESS/Trojan TLS-or-Reality TCP hosts; save through the secondary endpoint after the main POST succeeds) Known regressions vs the legacy modal, all reachable via Advanced JSON until backfilled in follow-up commits: - Hysteria stream sub-form (masquerade / udpIdleTimeout / version) — schema gap; the existing inbound DU has no hysteria stream branch - FinalMaskForm hookup — the component is still class-shape coupled - HeaderMapEditor — TCP request/response headers, WS / HTTPUpgrade / XHTTP headers, Hysteria masquerade headers all need a shared editor - TCP HTTP camouflage request/response body (version, method, path list, headers, status, reason) — only the on/off toggle is wired - Fallbacks polish — up/down move, quick-add-all, rederive-from-child, the per-row advanced-toggle / proxy-tag chips No reference to @/models/inbound's Inbound class anywhere in the new modal — only @/models/dbinbound (out of scope) and @/models/reality-targets (out of scope). The protocol-capabilities predicates and the rawInboundToFormValues + formValuesToWirePayload adapters carry every behavior the class used to provide. * fix(frontend): finish InboundFormModal rename after atomic swap The atomic-swap commit landed the new file but the exported function was still named InboundFormModalNew. Rename to match the file. * feat(frontend): outbound form schema + wire adapter foundation Lay the groundwork for OutboundFormModal's Pattern A rewrite: - schemas/forms/outbound-form.ts: discriminated-union form values across all 12 outbound protocols, with flat per-protocol settings shapes that match the legacy class fields (vmess vnext / trojan-ss-socks-http servers / wireguard csv address-reserved all flattened). - lib/xray/outbound-form-adapter.ts: rawOutboundToFormValues converts wire-shape outbound JSON to typed form values; formValuesToWirePayload re-nests on submit. Replaces the Outbound.fromJson/toJson dependency the modal currently has on the legacy class hierarchy. - test/outbound-form-adapter.test.ts: 15 round-trip cases covering each protocol's wire quirks (vmess vnext flatten, vless reverse-wrap, wireguard csv↔array, blackhole response wrap, DNS rule normalization, mux gating). * feat(frontend): OutboundFormModal.new.tsx skeleton (Pattern A) Sibling .new.tsx file with the Modal shell, Tabs (Basic/JSON), Form.useForm hydration via rawOutboundToFormValues, and the submit pipeline that calls formValuesToWirePayload before onConfirm. Tag uniqueness check is wired in. Protocol-specific sub-forms, stream, security, sockopt, and mux sections are deferred to subsequent commits — accessible via the JSON tab in the meantime. The InboundsPage continues to render the legacy modal until the atomic swap at the end. Also: rawOutboundToFormValues now returns streamSettings as undefined when the wire payload omits it, so Form.useForm doesn't receive a value that does not match the NetworkSettings discriminated union. * feat(frontend): OutboundFormModal.new.tsx vmess/vless/trojan/ss sections - Shared connect-target sub-block (address + port) for the six protocols whose form schema carries them flat at settings root. - VMess: id + security Select (USERS_SECURITY). - VLESS: id + encryption + flow + reverseTag (reverse-sniffing slice and Vision testpre/testseed come in a later commit). - Trojan: password. - Shadowsocks: password + method Select (SSMethodSchema) + UoT switch + UoT version. onValuesChange cascade: when the user picks a different protocol, the adapter re-seeds the settings sub-object to the new protocol's defaults so leftover fields from the previous protocol do not bleed through. * feat(frontend): OutboundFormModal.new.tsx socks/http/hysteria/loopback/blackhole/wireguard sections - SOCKS / HTTP: user + pass at settings root. - Hysteria: read-only version=2 (the actual transport knobs live on stream.hysteria, added with the stream tab). - Loopback: inboundTag. - Blackhole: response type Select with empty/none/http options. - Wireguard: address (csv) + secretKey (with regenerate icon) + derived pubKey + domain strategy + MTU + workers + no-kernel-tun + reserved (csv) + peers Form.List with nested allowedIPs sub-list. Wireguard regenerate icon uses Wireguard.generateKeypair() and writes both keys to the form via setFieldValue — preserves the legacy UX of the SyncOutlined inline-icon next to the privateKey label. * feat(frontend): OutboundFormModal.new.tsx DNS + Freedom + VLESS reverse-sniffing - DNS: rewriteNetwork (udp/tcp Select) + rewriteAddress + rewritePort + userLevel + rules Form.List (action/qtype/domain). - Freedom: domainStrategy + redirect + Fragment Switch with conditional 4-field sub-block (legacy 'enable Fragment' UX preserved — Switch sets all four fields to populated defaults, off-state empties them all out so the adapter strips them on submit) + Noises Form.List (rand/base64/ str/hex types, packet/delay/applyTo per row) + Final Rules Form.List with conditional block-delay sub-field. - VLESS reverse-sniffing slice: rendered only when reverseTag is set (matches the legacy modal's nested conditional). All six fields wired to the form state with appropriate widgets (Switch / Select multi / Select tags). * feat(frontend): OutboundFormModal.new.tsx stream tab (TCP/KCP/WS/gRPC/HTTPUpgrade) Wire the stream sub-form into the Pattern A modal: - newStreamSlice(network) helper bootstraps the per-network DU branch with Xray defaults (mtu=1350, tti=20, uplinkCapacity=5, etc.). - streamSettings is seeded once when the protocol supports streams but the form has no slice yet (new outbound + protocol switch). - onNetworkChange swaps the sub-key and preserves security when the new network still supports it, else snaps back to 'none'. - Per-network sub-forms wired: TCP: HTTP camouflage Switch (sets header.type = 'http' / 'none') KCP: 6 numeric tuning fields WS: host + path + heartbeat gRPC: service name + authority + multi-mode switch HTTPUpgrade: host + path XHTTP: host + path + mode + padding bytes (advanced fields via JSON) Security radio, TLS/Reality sub-forms, sockopt, and mux still pending. * feat(frontend): OutboundFormModal.new.tsx security tab (TLS + Reality + Flow) - onSecurityChange cascade: swaps tlsSettings/realitySettings sub-key matching the DU branch, seeding the new sub-form with empty/default fields so the UI does not reference undefined values. - Flow Select rendered when canEnableTlsFlow is true (VLESS + TCP + TLS/Reality). Moved from the basic VLESS section so it only appears in the relevant security context — matches the legacy modal UX. - Security Radio (none / TLS / Reality) gated by canEnableTls and canEnableReality pure-function predicates from lib/xray/protocol-capabilities. - TLS sub-form: 6 outbound-specific fields (SNI/uTLS/ALPN/ECH/ verifyPeerCertByName/pinnedPeerCertSha256) matching the legacy TlsStreamSettings flat shape (no certificates list — outbound is client-side). - Reality sub-form: 6 fields (SNI/uTLS/shortId/spiderX/publicKey/ mldsa65Verify). publicKey + mldsa65Verify get TextAreas to handle the long base64 strings. * feat(frontend): OutboundFormModal.new.tsx sockopt + mux sections - Sockopts: Switch toggles streamSettings.sockopt between undefined and a populated default object (17 fields with sane bbr/UseIP defaults). Only the 8 most-used fields are rendered (dialer proxy, domain strategy, keep alive interval, TFO, MPTCP, penetrate, mark, interface). The remaining sockopt knobs (acceptProxyProtocol, tcpUserTimeout, tcpcongestion, tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, V6Only, trustedXForwardedFor, tproxy) are still in the wire payload — edit them via the JSON tab. - Mux: gated by isMuxAllowed(protocol, flow, network) — VMess/VLESS/ Trojan/SS/HTTP/SOCKS, no flow set, no xhttp transport. Sub-fields (concurrency / xudpConcurrency / xudpProxyUDP443) only render when enabled is true. - Sockopt section visible only when streamAllowed AND network is set — non-stream protocols (freedom/blackhole/dns/loopback) still edit sockopt via the JSON tab. * feat(frontend): atomic swap OutboundFormModal to Pattern A Delete the legacy 1473-line class-based OutboundFormModal.tsx and replace it with the new Pattern A modal (Form.useForm + antdRule + per-protocol discriminated-union form values + wire adapter). Net diff: legacy file gone, function renamed from OutboundFormModalNew to OutboundFormModal so the existing OutboundsTab import resolves unchanged. What is migrated: - All 12 protocols (vmess/vless/trojan/ss/socks/http/wireguard/ hysteria/freedom/blackhole/dns/loopback) - Stream tab with TCP/KCP/WS/gRPC/HTTPUpgrade + partial XHTTP - Security tab with TLS + Reality + Flow gating - Sockopt + Mux sections (gated by isMuxAllowed) - JSON tab with bidirectional bridge to form state - Tag uniqueness check - VLESS reverse-sniffing slice - Freedom fragment/noises/finalRules - DNS rewrite + rules list - Wireguard peers + nested allowedIPs sub-list - Wireguard secret/public key regeneration Deferred to follow-up commits (still accessible via the JSON tab): - XHTTP advanced fields (xmux, sequence/session placement, padding obfs) - Hysteria stream transport sub-form - TCP HTTP camouflage host/path body - WS/HTTPUpgrade/XHTTP headers map editor - Remaining sockopt knobs (tcpUserTimeout, tcpcongestion, tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, V6Only, trustedXForwardedFor, tproxy, acceptProxyProtocol) - VLESS Vision testpre/testseed - Reality API helpers (random target, x25519/mldsa65 generate-import) - Link import (vmess:// vless:// etc → outbound) - FinalMaskForm hookup (deferred from inbound rewrite too) * test(frontend): convert legacy-class parity tests to snapshot baselines With the inbound/outbound modal rewrites complete, the cross-check against the legacy Inbound class has served its purpose. The new pure-function / Zod-schema paths are the source of truth for production code; the parity assertions were the migration safety net. Convert the three parity test files to snapshot-based regression tests: - headers.test.ts: toHeaders + toV2Headers run against snapshots captured at the close of the migration (when both new and legacy were verified byte-equal). - protocol-capabilities.test.ts: 140 cases (10 fixtures × 14 stream shapes) snapshot the predicate-result tuple. Was: parity vs legacy Inbound.canEnableX() class methods. - inbound-link.test.ts: per-protocol genXxxLink + genInboundLinks orchestrator output is snapshotted. Was: byte-equality vs legacy Inbound.genXxxLink() methods. Also delete shadow.test.ts — its purpose was a dual-parse drift detector (Inbound.Settings.fromJson vs InboundSettingsSchema.parse). inbound-full.test.ts already snapshots the Zod parse output, which covers the same ground without the legacy dependency. models/inbound.ts and models/outbound.ts stay in the tree for now — DBInbound still consumes Inbound via its toInbound() method, and DBInbound migration is out of scope per the migration spec ('Do NOT migrate Status, DBInbound, or AllSetting...'). No production page imports from @/models/inbound or @/models/outbound directly anymore. * chore(frontend): enforce no-explicit-any: error + add typecheck/test to CI Step 7 of the Zod migration: lock the migration's gains in place via lint + CI enforcement. - eslint.config.js: `@typescript-eslint/no-explicit-any` set to error. Verified locally — zero violations in src/, with the only file-level disables being src/models/inbound.ts and src/models/outbound.ts (kept for DBInbound's toInbound() consumer; their migration is out of spec scope). - .github/workflows/ci.yml: add Typecheck and Test steps to the frontend job, between Lint and Build. PRs now have to pass tsc --noEmit and the full vitest suite (285 tests + 172 snapshots) before build runs. Migration scoreboard (vs the spec): Step 1 primitives + barrels done Step 2 protocol leaf + DUs done Step 3 pure-fn extraction done Step 4 form modals -> Pattern A done (Inbound + Outbound) Step 5 delete models/ files DEFERRED (DBInbound still uses Inbound; spec marks DBInbound migration out of scope) Step 6 tighten .loose() / unknown DEFERRED (invasive, separate PR) Step 7 lint + CI enforcement done (this commit) Production code paths now have no direct dependency on the legacy Inbound or Outbound classes. * feat(frontend): OutboundFormModal deferred features (Vision seed / TCP host+path / WG pubKey derive) Three small wins from the post-atomic-swap deferred list: - VLESS Vision testpre + testseed: shown only when flow === 'xtls-rprx-vision' (mirrors the legacy canEnableVisionSeed gate). testseed binds to a Select mode='tags' with a normalize() that coerces strings to positive integers and drops invalid entries. - TCP HTTP camouflage host + path: when the TCP HTTP camouflage Switch is on, surface two inputs that read/write directly into streamSettings.tcpSettings.header.request.headers.Host and .path. Both fields are string[] on the wire; normalize + getValueProps translate to/from comma-joined strings in the UI (one entry per host or path the user wants camouflaged). - Wireguard pubKey auto-derive: Form.useWatch on settings.secretKey + useEffect that runs Wireguard.generateKeypair(secret).publicKey on every change and writes the result into the disabled pubKey display field. Matches the legacy modal's per-keystroke derive. * feat(frontend): symmetric TCP HTTP host/path + extra sockopt knobs OutboundFormModal: - Sockopt section gains 5 common-but-rarely-tweaked knobs: acceptProxyProtocol, tproxy (off/redirect/tproxy), tcpcongestion (bbr/cubic/reno), V6Only, tcpUserTimeout. The remaining sockopt fields (tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, trustedXForwardedFor) are still edit-via-JSON; they are deeply tunable and not commonly touched. InboundFormModal: - TCP HTTP camouflage gains host + path inputs symmetric to the outbound side. Switch ON seeds request with sensible defaults (version 1.1, method GET, path ['/'], empty headers). The two inputs use the same normalize/getValueProps comma-string ↔ string[] dance the outbound side uses, so the wire shape stays identical to what xray-core expects. * feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers Add a single reusable header-map editor that handles the two wire shapes Xray uses: - v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria masquerade. One value per name. - v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage. Each header can repeat (RFC 7230 §3.2.2). Internal state is always a flat list of {name, value} rows regardless of mode; conversion to/from the wire shape happens at the value / onChange boundary so consumers bind straight to a Form.Item with no extra transforms. Wired into: - InboundFormModal: WS Headers, HTTPUpgrade Headers - OutboundFormModal: WS Headers, HTTPUpgrade Headers XHTTP headers are already in a list-of-rows wire shape (different from these two), so they keep their bespoke editor. Hysteria masquerade is still deferred until the Hysteria stream sub-form lands. * feat(frontend): Hysteria stream sub-form (schema branch + outbound UI) Add the 7th branch to NetworkSettingsSchema for Hysteria transport. schemas/protocols/stream/hysteria.ts: - HysteriaStreamSettingsSchema covers the full wire shape: version=2, auth, congestion (''|'brutal'), up/down bandwidth strings, optional udphop sub-object for port-hopping, receive-window tuning fields, maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery. schemas/protocols/stream/index.ts: - NetworkSchema gains 'hysteria'. - NetworkSettingsSchema gains the 7th branch { network: 'hysteria', hysteriaSettings: HysteriaStreamSettingsSchema }. OutboundFormModal.tsx: - NETWORK_OPTIONS keeps the 6 standard transports for non-hysteria protocols; when protocol === 'hysteria', a 7th option is appended (matches the legacy [...NETWORKS, 'hysteria'] gate). - newStreamSlice handles the 'hysteria' case with sensible defaults matching the legacy HysteriaStreamSettings constructor. - New sub-form when network === 'hysteria': 8 common fields (auth, congestion, up, down, udphop Switch + 3 nested fields when on, maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery). - Receive-window tuning fields are still edit-via-JSON (rarely touched + would clutter the form). * feat(frontend): fallbacks polish — move up/down + Add all button Two small UX wins on the InboundFormModal Fallbacks card: - Per-row Move up / Move down buttons (ArrowUp/Down icons) that swap adjacent indices. Order survives reloads via sortOrder (rebuilt from index on save). First row's Up button + last row's Down button are disabled. - 'Add all' button next to 'Add fallback' that one-shot inserts a fresh row for every eligible inbound (every option in fallbackChildOptions) not already wired up. Disabled when every eligible inbound is already covered. Convenient for operators running catch-all routing across every host on the panel. * feat(frontend): XHTTP advanced fields on outbound modal Replace the 'edit via JSON' deferred-features hint with the full XHTTP sub-form matching the legacy modal's XhttpFields helper. schemas/protocols/stream/xhttp.ts: - New XHttpXmuxSchema: 6 connection-multiplexing knobs (maxConcurrency, maxConnections, cMaxReuseTimes, hMaxRequestTimes, hMaxReusableSecs, hKeepAlivePeriod). - XHttpStreamSettingsSchema gains 5 outbound-only fields and one UI-only toggle: scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader, xmux, enableXmux. outbound-form-adapter.ts: - New stripUiOnlyStreamFields() drops xhttpSettings.enableXmux on the way to wire so the panel never embeds the UI toggle into the saved config. xray-core ignores unknown fields anyway, but the panel reads back its own emitted JSON, so a clean wire shape matters. OutboundFormModal.tsx: - Headers editor (HeaderMapEditor v1) for xhttpSettings.headers. - Padding obfs Switch + 4 conditional fields (key/header/placement/ method) when on. - Uplink HTTP method Select with GET disabled outside packet-up. - Session placement + session key (key shown when placement != path). - Sequence placement + sequence key (same pattern). - packet-up mode: scMinPostsIntervalMs, scMaxEachPostBytes, uplink data placement + key + chunk size (key/chunk-size shown when placement != body). - stream-up / stream-one mode: noGRPCHeader Switch. - XMUX Switch + 6 nested fields when on. * feat(frontend): inbound TCP HTTP camouflage response fields + request headers Complete the TCP HTTP camouflage UI on the inbound side. Already there from the previous symmetric host/path commit: - Request host (string[] via comma-string) - Request path (string[] via comma-string) This commit adds: - Request headers (V2 map: name -> string[]) via HeaderMapEditor. - Response version (defaults to '1.1' when camouflage toggles on). - Response status (defaults to '200'). - Response reason (defaults to 'OK'). - Response headers (V2 map) via HeaderMapEditor. The HTTP camouflage Switch seeds both request and response sub-objects on toggle-on so xray-core sees a valid TcpHeader.http shape from the first save. Without the response seed, partial fills would emit a schema-incomplete response block that xray-core might reject. * feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land. * feat(frontend): inbound Hysteria stream sub-form (auth + udpIdleTimeout + masquerade) Restore the inbound side of Hysteria stream configuration that was previously hidden — the legacy modal exposed these knobs but the Pattern A rewrite gated them out. schemas/protocols/stream/hysteria.ts: - HysteriaMasqueradeSchema covers the inbound-only masquerade wire shape: type ('proxy'|'file'|'string'), dir, url, rewriteHost, insecure, content, headers, statusCode. The three masquerade types cover the spectrum: reverse-proxy upstream, serve static files, or return a fixed string body. - HysteriaStreamSettingsSchema gains 3 inbound-side optional fields: protocol, udpIdleTimeout, masquerade. Outbound side is untouched (the legacy class accepted both wire shapes via the same struct). InboundFormModal.tsx: - New hysteria stream sub-form section in streamTab, gated by protocol === HYSTERIA. Fields: version (disabled, locked to 2), auth, udpIdleTimeout, masquerade Switch + nested type-Select with three conditional sub-blocks (proxy URL+rewriteHost+insecure, file dir, string statusCode+body+headers). - onValuesChange cascade: switching TO hysteria seeds streamSettings with the hysteria branch (forcing network='hysteria' + TLS); switching AWAY from hysteria snaps back to TCP so the standard network selector has a valid starting point. masquerade headers use the HeaderMapEditor v1 component. * feat(frontend): complete outbound sockopt section with remaining knobs Add the four remaining SockoptStreamSettings fields that were edit-via-JSON-only after the initial outbound modal rewrite: - TCP keep-alive idle (s) — tcpKeepAliveIdle, time before sending the first probe on an idle TCP connection. - TCP max segment — tcpMaxSeg, override the default MSS. - TCP window clamp — tcpWindowClamp, cap the TCP receive window. - Trusted X-Forwarded-For — trustedXForwardedFor, list of trusted proxy hostnames/CIDRs whose XFF headers Xray will honor. The outbound sockopt section now exposes all 17 SockoptStreamSettings fields from the schema. The InboundFormModal's sockopt section has its own field list (closer to the legacy class) and is unchanged. * feat(frontend): outbound TCP HTTP camouflage parity with inbound Add method/version inputs, request header map, and full response sub-section (version/status/reason/headers) to OutboundFormModal so the outbound side can configure the same HTTP-1.1 obfuscation knobs the inbound side already exposed. * feat(frontend): round-trip XHTTP advanced fields in outbound link parser Pick up xPaddingBytes, scMaxEachPostBytes, scMinPostsIntervalMs, uplinkChunkSize, and noGRPCHeader from both vmess:// JSON and the URL query-param parsers (vless/trojan). The advanced xmux/padding-obfs/ reality-shortId knobs still wait on a follow-up; this slice unblocks the common case where a phone-issued xhttp link carries non-default padding or post sizes. * feat(frontend): round-trip XHTTP padding-obfs + remaining advanced knobs Extract the XHTTP key-mapping into typed string/number/bool key arrays applied by both the URL query-param branch and the vmess JSON branch. The parser now covers xPaddingObfsMode + xPaddingKey/Header/Placement/ Method, sessionKey/seqKey/uplinkData{Placement,Key}, noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes, and uplinkHTTPMethod alongside the previous five XHTTP fields. Two new round-trip tests cover the padding-obfs surface on both link forms. * feat(frontend): FinalMaskForm rewrite to Pattern A + wire into both modals Rewrite FinalMaskForm.tsx from a class-coupled component (mutated stream.finalmask.tcp[] via .addTcpMask/.delTcpMask methods, notified parent via onChange callback) into a Pattern A sub-form: takes a NamePath base, a FormInstance, and the surrounding network/protocol, then composes Form.List + Form.Item at absolute paths under that base. All array structures use nested Form.List — tcp/udp mask arrays, the clients/servers groups in header-custom (Form.List of Form.List of ItemEditor), and the noise list. Type Selects use onChange to reset the settings sub-object via form.setFieldValue, mirroring the legacy changeMaskType behavior. The kcp.mtu side effect on xdns type change is preserved. Wired into both InboundFormModal and OutboundFormModal stream tabs, placed after the sockopt section. The component is the first Pattern A consumer of nested Form.List inside another Form.List, so it stands as the reference for future nested-array sub-forms. * docs(frontend): record FinalMaskForm rewrite + hookup in status doc Mainline migration goal — replace class-based xray models with Zod schemas as the single source of truth + drive all forms through AntD `Form.useForm` + `antdRule(schema.shape.X)` — is complete. Remaining items are incremental polish. * fix(frontend): Phase 2 Inbound form reactivity bugs (B1-B9, consolidated) A run of resets dropped the per-bug commits 1401d833 / 5b1ae450 / 5bce0dc5 / 4007eec7. Re-landing all fixes against the same files in one commit to avoid another rebase-style drop. B1 — Transmission Select / External Proxy + Sockopt switches didn't react after click. AntD 6.4.3 Form.useWatch on nested paths doesn't re-fire reliably after `setFieldValue('streamSettings', cleaned)` on the parent. Bound Transmission via `name={['streamSettings', 'network']}` and wrapped the two switches in `` blocks that read state via getFieldValue. B2 — Security regressed from `Radio.Group buttonStyle="solid"` to a Select dropdown, and disable state didn't refresh because tlsAllowed/ realityAllowed were derived at the top of the component. Restored Radio.Button group and moved canEnableTls/canEnableReality evaluation inside the shouldUpdate render prop. B3 — Advanced tab "All" sub-tab was missing. Added it as the first item with a new AdvancedAllEditor that round-trips top-level fields + the three nested slices on edit. B4 — Advanced tab title/subtitle and per-section help text were gone. Wrapped the Tabs in the existing `.advanced-shell` / `.advanced-panel` structure and restored the `.advanced-editor-meta` help under each sub-tab using existing i18n keys. B5 — TLS / Reality sub-forms didn't render when selecting tls or reality on the Security tab. The `{security === 'tls' && ...}` and `{security === 'reality' && ...}` conditionals used a stale top-level useWatch value. Wrapped both in blocks that read `security` via getFieldValue. B6 — Advanced JSON editors stale after Stream/Sniffing changes. The editors seeded text via lazy useState and AntD Tabs renders all panes upfront, so the Advanced tab was already mounted with stale data. Both AdvancedSliceEditor and AdvancedAllEditor now subscribe via Form.useWatch and re-sync the text buffer when the watched JSON differs from a lastEmitRef (the serialization at the moment of our own last accepted write). User typing doesn't trigger re-sync because setFieldValue updates lastEmitRef too. (A prior attempt added `destroyOnHidden` to the outer Tabs but broke conditional tab items when the unmounted Form.Item for `protocol` lost its value — abandoned in favor of useWatch reactivity.) B7 — HeaderMapEditor + button did nothing. addRow() appended a blank {name:'', value:''} row, but commit() filtered it via rowsToMap before reaching the form, so AntD saw no change and didn't re-render. The editor now keeps a local rows state so blank rows survive during editing; only filled rows are emitted to onChange. B9 — Sniffing destOverride defaults (HTTP/TLS/QUIC/FAKEDNS) were not pre-checked on a fresh Add Inbound. buildAddModeValues() seeded sniffing: {} which left destOverride undefined. Now seeds with SniffingSchema.parse({}) so the Zod defaults populate. * fix(frontend): FinalMaskForm TCP Mask sub-forms + Advanced JSON wrap (B10/B11) B10 — FinalMaskForm TCP Mask: after adding a mask and picking a Type (Fragment/Header Custom/Sudoku), the type-specific sub-forms didn't render. TcpMaskItem read `type` via Form.useWatch on a path inside Form.List, which doesn't re-fire reliably in AntD 6.4.3 — same root cause as the earlier B1/B2/B5 reactivity issues. Replaced with a wrapper that reads `type` via getFieldValue inside the render prop. B11 — Advanced sub-tabs (settings / streamSettings / sniffing) showed just the inner value (e.g. `{clients:[],decryption:"none",...}`), but the legacy modal wrapped each slice with its key envelope (e.g. `{settings:{...}}`) so the JSON matches the wire shape's slice and round-trips cleanly from copy-pasted inbound configs. Added a `wrapKey` prop to AdvancedSliceEditor that wraps/unwraps the value on render/write; the three sub-tabs now pass settings / streamSettings / sniffing as their wrapKey. * fix(frontend): import InboundFormModal.css so layout classes apply (B12) The file InboundFormModal.css existed but was never imported, so every class in it had no effect — including: - .vless-auth-state — the "Selected: " caption next to the X25519/ ML-KEM/Clear button row stayed inline next to Clear instead of display:block beneath the row - .advanced-shell / .advanced-panel — the Advanced tab's header / panel framing was missing - .advanced-editor-meta — the per-section help text under each Advanced sub-tab had no spacing - .wg-peer — wireguard peer rows had no top margin Add a side-effect import of the CSS file at the top of the modal. No other change needed; the legacy modal must have either imported it or had a global import that the new modal didn't inherit. * fix(frontend): FinalMaskForm relative paths + network-switch defaults (B13/B14) B13 — FinalMaskForm used absolute paths like ['streamSettings', 'finalmask', 'tcp', 0, 'type'] for Form.Item names inside Form.List render props. AntD's Form.List prefixes Form.Item names with the list's own name, so the actual storage path became ['streamSettings', 'finalmask', 'tcp', 'streamSettings', 'finalmask', 'tcp', 0, 'type'] — total nonsense. Symptoms: Type Select didn't show the 'fragment' default after add(), and the sub-form for the picked type never rendered (Fragment/Sudoku/HeaderCustom). Rewrote FinalMaskForm to use RELATIVE names inside every Form.List context (TCP/UDP outer list + nested clients/servers/noise inner lists). Added a `listPath` prop on the items so the shouldUpdate guard and the side-effect setFieldValue calls (resetting `settings` when type changes) can still address the absolute path; the displayed Form.Items use the relative form (`[fieldName, 'type']`). Replaced top-level Form.useWatch on nested paths with blocks reading via getFieldValue, same pattern as the earlier B5 fix — Form.useWatch on paths inside Form.List doesn't re-fire reliably in AntD 6.4.3. B14 — Switching network (KCP, WS, gRPC, XHTTP, ...) seeded the new XSettings blob as `{}` so every field showed as empty. The legacy `newStreamSlice` populated mtu=1350, tti=20, etc. Restored those defaults in onNetworkChange and seeded the initial tcpSettings.header in buildAddModeValues so even the default TCP state shows the HTTP-camouflage Switch in the correct off state instead of an undefined header object. * fix(frontend): inbound TCP HTTP camouflage drops request fields + KCP UI field rename (B15/B16) B15 — Inbound TCP HTTP camouflage exposed Host / Path / Method / Version / request-headers inputs. Per Xray docs (https://xtls.github.io/config/transports/raw.html#httpheaderobject), the `request` object is honored only by outbound proxies; the inbound listener reads `response`. Those inputs were writing dead data the server ignored. Removed them from the inbound modal; only Response {version, status, reason, headers} remain. The toggle still seeds an empty request object so the wire shape stays valid against the schema. B16 — KCP Uplink / Downlink inputs bound to non-existent form fields `upCap` / `downCap`, while the schema (and wire) use `uplinkCapacity` / `downlinkCapacity`. Renamed the Form.Items to the schema names so defaults populate and saves persist. Also corrected newStreamSlice('kcp') to seed the four KCP defaults (uplinkCapacity / downlinkCapacity / cwndMultiplier / maxSendingWindow) — the missing two were why "CWND Multiplier" and "Max Sending Window" still showed empty after switching to KCP. * fix(frontend): seed full Zod-schema defaults for stream slices + QUIC params (B17) XHTTP showed blank Selects for Session Placement / Sequence Placement / Padding Method / Uplink HTTP Method (and several other knobs). Those fields have a literal "" (empty string) value in the schema, which the Select renders as "Default (path)" / "Default (repeat-x)" / etc. The form field was `undefined`, not `""`, so the Select showed blank instead of the labelled default option. newStreamSlice in InboundFormModal hand-rolled per-network seed objects with only a handful of fields. Replaced with {Tcp,Kcp,Ws,Grpc,HttpUpgrade,XHttp}StreamSettingsSchema.parse({}) so every default declared in the schema populates the form on network switch. Same change in buildAddModeValues for the initial TCP state. QUIC Params (FinalMaskForm) had the same shape on a smaller scale — defaultQuicParams() only seeded congestion + debug + udpHop. The schema's other fields are .optional() (no Zod default) so a schema parse won't help. Hard-coded the xray-core / hysteria recommended values (maxIdleTimeout 30, keepAlivePeriod 10, brutalUp/Down 0, maxIncomingStreams 1024, four window sizes) so the InputNumber controls render with usable starting values instead of blank. * fix(frontend): forceRender all tabs so fields register at modal open (B18) AntD Tabs with the `items` API lazy-mounts inactive tab panes by default. The Form.Items inside an unvisited tab never register, so: - Form.useWatch on a parent path (e.g. 'sniffing') returns a partial view containing only registered children. Until the user clicked the Sniffing tab, Advanced > Sniffing JSON showed `{sniffing: {}}` instead of the full default object set by setFieldsValue. - After visiting the Sniffing tab once, the `sniffing.enabled` Form.Item registered, so useWatch suddenly returned `{enabled: false}` — still partial, because the rest of the sniffing children only register when their Form.Items mount in conditional sub-sections. Setting `forceRender: true` on every tab item forces all tab panes to mount at modal open. Every Form.Item registers immediately; the watch result reflects the full form value seeded by buildAddModeValues. This also likely resolves the earlier "Invalid discriminator value" error on submit, which surfaced when streamSettings had an unregistered security field whose Form.Item hadn't mounted yet. * refactor(frontend): align hysteria with new docs + drop hysteria2 protocol Phase 2 smoke fixes on the Inbound add flow surfaced that hysteria2 was modeled as a separate top-level protocol when it's really just hysteria v2. The xray transports/hysteria.html docs also pin the hysteria stream to a minimal shape (version/auth/udpIdleTimeout/masquerade) — the previous schema carried legacy congestion/up/down/udphop/window knobs that aren't part of the wire contract. Hysteria2 removal: - Drop 'hysteria2' from ProtocolSchema enum and Protocols const - Drop hysteria2 branches from inbound/outbound discriminated unions - Drop createDefaultHysteria2InboundSettings / OutboundSettings - Delete schemas/protocols/inbound/hysteria2.ts and outbound/hysteria2.ts - Drop hysteria2 case in getInboundClients / genLink (fell through to the hysteria handler anyway) - Update client form modals' MULTI_CLIENT_PROTOCOLS sets - Remove hysteria2-basic fixture + snapshot entries (14 capability cases, 1 protocols fixture, 1 inbound-defaults factory) - Keep parseHysteria2Link() outbound parser since hysteria2:// is the share-link URI prefix for hysteria v2 Hysteria stream alignment with xtls docs: - HysteriaStreamSettingsSchema reduced to version/auth/udpIdleTimeout/ masquerade per transports/hysteria.html - Masquerade type adds '' (default 404 page) and defaults to it - Outbound form drops Congestion/Upload/Download/UDP hop/Max idle/ Keep alive/Disable Path MTU controls and the receive-window note - newStreamSlice('hysteria') in OutboundFormModal mirrors the trimmed shape; outbound-link-parser emits the trimmed shape too - InboundFormModal Masquerade Select gains the default option New TUN inbound schema: - Add schemas/protocols/inbound/tun.ts with name/mtu/gateway/dns/ userLevel/autoSystemRoutingTable/autoOutboundsInterface - Wire into ProtocolSchema enum, InboundSettingsSchema discriminated union, createDefaultInboundSettings dispatcher Other Phase 2 smoke fixes folded in: - Tunnel portMap UI swaps Form.List for HeaderMapEditor v1 — wire shape is Record and the List was producing arrays - Hysteria onValuesChange seeds full TLS schema defaults + one empty certificate row (Cipher Suites/Min/Max Version/uTLS/ALPN were undefined before) - HTTP/Mixed accounts Add button auto-fills user/pass with RandomUtil.randomLowerAndNum - Hysteria security tab gates the 'none' radio out — TLS only - Hysteria stream tab drops the inbound Auth password field (xray inbound auth is per-user via 'users', not stream-level) - Reality onSecurityChange auto-randomizes target/serverNames/ shortIds and fetches an X25519 keypair - Tag and DB-side fields (up/down/total/expiryTime/ lastTrafficResetTime/clientStats/security) gain hidden Form.Items so validateFields keeps them in the wire payload (rc-component form strips unregistered fields) - WireGuard inbound auto-seeds one peer with generated keypair, allowedIPs ['10.0.0.2/32'], keepAlive 0 — matches legacy - WireGuard peer rows separated by Divider with the Peer N title and a small inline remove button (titlePlacement="center") * refactor(frontend): retire class-based xray models (Step 5) Delete models/inbound.ts (3,359 lines) and outbound.ts (2,405). The Inbound/Outbound classes and ~50 sub-classes are replaced by Zod-typed data + pure functions in lib/xray/*. Consumer migration off dbInbound.toInbound(): - useInbounds: isSSMultiUser({protocol, settings}) directly - QrCodeModal: genWireguardConfigs/Links/AllLinks from lib/xray - InboundList: derives tags from streamSettings raw fields - InboundsPage: clone via raw JSON, fallback projection via schema-shape stream object, exports via genInboundLinks - InboundInfoModal: builds an InboundInfo facade locally from raw streamSettings (host/path/serverName/serviceName per network), canEnableTlsFlow + isSS2022 from lib/xray New helper: lib/xray/inbound-from-db.ts exposes inboundFromDb(raw) converting a raw DBInbound row into a schema-typed Inbound for the link-generation orchestrators. DBInbound trimmed: drops toInbound, isMultiUser, hasLink, genInboundLinks, _cachedInbound. Imports Protocols from @/schemas/primitives now that ./inbound is gone. Bundled Phase 2 fixes: - Outbound modal: Form.useWatch with preserve: true so the stream block doesn't gate itself out when network is unmounted - Inbound form adapter: pruneEmpty preserves empty objects; per-protocol client field projection via Zod safeParse; sniffing collapse to {enabled:false} - useClients invalidateAll also invalidates inbounds.root() - IndexPage Config modal top/maxHeight polish Tests: 283/283 pass. typecheck/lint clean. * fix(frontend): inboundFromDb fills Zod defaults for stream + settings Smoke-testing the new inboundFromDb helper surfaced two regressions that the strict lib/xray link generators expose when fed raw DB streamSettings without per-network sub-keys. 1. genVlessLink / genTrojanLink crash on `stream.tcpSettings.header` when streamSettings lacks `tcpSettings` (true for slim list rows and for handcrafted minimal-JSON inbounds). The legacy Inbound.fromJson chain populated TcpStreamSettings via its own constructor; the new helper now does the same by parsing the raw Settings sub-object through the matching Zod schema and merging schema defaults onto whatever the DB stored. 2. genVlessLink writes `encryption=undefined` into the share URL when settings lacks the `encryption: 'none'` literal that vless wire JSON normally carries. Fixed by running raw settings through InboundSettingsSchema.safeParse() to populate per-protocol defaults (encryption, decryption, fallbacks, etc.) the same way the legacy class fromJson chain did. Same pattern applied to security branch (tls/realitySettings). Tests: src/test/inbound-from-db.test.ts covers - JSON-string / object / empty settings coercion - genInboundLinks vless (TCP/none, with encryption=none) - genWireguardConfigs + genWireguardLinks peer fanout - genAllLinks trojan with TLS sub-defaults applied - protocol-capability helpers with raw shapes - getInboundClients across vless/SS-single/non-client protocols 296/296 pass. * fix(frontend): QUIC udpHop.interval is a range string, not a number (B19) User report: "streamSettings.finalmask.quicParams.udpHop.interval: Invalid input: expected string, received number". Three-part fix: - FinalMaskForm: Hop Interval input changed from InputNumber to Input with "e.g. 5-10" placeholder. xray-core spec says interval is a range string like '5-10' (seconds between min-max hops), not a single number. - FinalMaskForm: defaultQuicParams() seeds interval: '5-10' instead of the broken `interval: 5`. - QuicUdpHopSchema: preprocess coerces number → string for legacy DB rows that were written by the now-fixed buggy UI. Stops the load-time validation crash on existing inbounds. Tests still 296/296. * fix(frontend): outbound link parser handles extra/fm/x_padding_bytes (B20) User-reported vless share link with full xhttp + reality + finalmask config failed to round-trip on outbound import. The inbound link generator emits three payloads the outbound parser was ignoring: 1. `extra=` — bundles advanced xhttp knobs (xPaddingBytes, scMaxEachPostBytes, scMinPostsIntervalMs, padding-obfs keys, etc.). applyXhttpStringFromParams now JSON.parses this and merges the fields into xhttpSettings via the same JSON-branch logic used by vmess. 2. `x_padding_bytes=` — snake_case alias the inbound emits alongside the camelCase form. Now applied before camelCase so explicit `xPaddingBytes` URL params still win. 3. `fm=` — full finalmask object including quicParams.udpHop and tcp/udp mask arrays. New applyFinalMaskParam attaches the decoded object to streamSettings.finalmask. Wired into both parseVlessLink and parseTrojanLink. Tests: - Real B20 link parses with xhttp + reality + finalmask all populated - Precedence: camelCase URL > extra JSON > snake_case alias > default - Malformed extra JSON falls through without crashing the parser 300/300 pass. * fix(frontend): Outbound submit crash on non-mux protocols + tab a11y (B21) Two issues surfaced on Outbound save: 1. Crash: `Cannot read properties of undefined (reading 'enabled')` at formValuesToWirePayload. The modal hides the Mux switch entirely for non-stream protocols (dns/freedom/blackhole/loopback) and for stream protocols when isMuxAllowed gates it out (xhttp, vless+flow). With the field never registered, validateFields() returns no `mux` key — `values.mux.enabled` then dereferences undefined. Fix: optional chain `values.mux?.enabled` so missing mux skips the mux clause silently. Documented why mux can be absent. 2. Chrome a11y warning: "Blocked aria-hidden on an element because its descendant retained focus" — when the user has an input focused inside one Tab panel and switches to another tab, AntD marks the outgoing panel aria-hidden while focus is still inside. The browser warns, but the focused control is now invisible to AT users. Fix: blur the active element before setActiveKey in onTabChange. * fix(frontend): blur active element on every tab switch path (B21 follow-up) The previous B21 patch only blurred on user-initiated tab clicks via onTabChange. Two other paths still set activeKey while a JSON-tab input retained focus: - importLink: after a successful share-link parse, setActiveKey('1') switched to the form tab while the user's focus was still on the Input.Search they just pressed Enter in. Chrome logged the same "Blocked aria-hidden" warning because the panel they were leaving became aria-hidden synchronously, with their input still focused. - onTabChange entering the JSON tab: also did a bare setActiveKey with no blur, so going from a focused form input INTO the JSON tab could trip the warning in reverse. Fix: centralized switchTab(key) that blurs document.activeElement sync before calling setActiveKey. Every internal tab transition (importLink, onTabChange both directions) now routes through it. The single setActiveKey('1') in the open-modal useEffect is left as a plain setter because there's no focused input at modal-open time. * refactor(frontend): extract fillStreamDefaults to shared helper Move the network/security schema-default filler out of inbound-from-db.ts into stream-defaults.ts so other consumers can reuse it without dragging in the DBInbound-specific code path. * fix(frontend): derive QUIC/UDP-hop switch state from data presence (B22) The QUIC Params and UDP Hop toggles previously persisted as separate boolean flags (enableQuicParams / hasUdpHop) which weren't part of the xray wire format and weren't restored when a config was pasted into the modal. Use data presence as the single source of truth: the switch is on iff the corresponding sub-object exists. Switching off clears it back to undefined. * fix(frontend): xhttp form binding + drop empty strings from JSON (B23) uplinkHTTPMethod was wrapped Form.Item -> Form.Item(shouldUpdate) -> Select, which broke AntD's value/onChange injection (AntD only clones the immediate child). Restructured so shouldUpdate is the outer wrapper and Form.Item(name) directly wraps the Select. Also drop empty-string fields from xhttpSettings in the wire payload — fields like uplinkHTTPMethod, sessionPlacement, seqPlacement, xPaddingKey default to '' meaning "use server default", so they shouldn't appear in JSON as "field": "". Adds placeholder text to the 3 xhttp Selects so the form reflects the current value after JSON paste. * feat(frontend): align finalmask + sockopt with xray docs, add golden fixtures Schema fixes per https://xtls.github.io/config/transports/finalmask.html and https://xtls.github.io/config/transports/sockopt.html: finalmask: - QuicCongestionSchema: remove non-doc 'cubic', keep reno/bbr/brutal/force-brutal - Add BbrProfileSchema (conservative/standard/aggressive) and bbrProfile field - brutalUp/brutalDown: number -> string per docs (units like '60 mbps') - Tighten ranges: maxIdleTimeout 4-120, keepAlivePeriod 2-60, maxIncomingStreams min 8 - UdpMaskTypeSchema: add missing 'sudoku' - udpHop.interval stays as preprocessed string-range per intentional B19 divergence sockopt: - tcpFastOpen: boolean -> union(boolean, number) per docs (number tunes queue size) - mark: drop min(0) (can be any int) - domainStrategy default: 'UseIP' -> 'AsIs' per docs - tcpKeepAlive Interval/Idle defaults: 0/300 -> 45/45 per docs (outbound) - Add AddressPortStrategySchema enum (7 values) + addressPortStrategy field - Add HappyEyeballsSchema (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry) - Add CustomSockoptSchema (system/type/level/opt/value) + customSockopt array Bug fixes: - options.ts: Address_Port_Strategy values were lowercase ('srvportonly'); xray-core requires camelCase ('SrvPortOnly'). Fixed all 6 entries. - OutboundFormModal: domainStrategy Select was mistakenly populated from ADDRESS_PORT_STRATEGY_OPTIONS; now uses DOMAIN_STRATEGY_OPTION. - OutboundFormModal: inline sockopt defaults (hardcoded {acceptProxyProtocol: false, domainStrategy: 'UseIP', ...}) replaced with SockoptStreamSettingsSchema.parse({}) so schema is the single source. Form additions (both InboundFormModal + OutboundFormModal): - Address+port strategy Select - Happy Eyeballs Switch + sub-form (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry) - Custom sockopt Form.List (system/type/level/opt/value) - FinalMaskForm: BBR Profile Select (visible when congestion='bbr'), Brutal Up/Down placeholders updated to string format Golden fixtures (8 new + 4 xhttp extras): - finalmask/{tcp-mask, udp-mask, quic-params, combined}.json — cover all TCP mask types, 7 UDP mask types including new sudoku, full QUIC params shape - sockopt/{defaults, tcp-tuning, tproxy, full}.json — full sockopt knobs - stream/xhttp-{basic, extra-padding, extra-placement, extra-tuning}.json — cover the extra-blob fields bundled into share-link extra= Tests now at 312 (up from 300); typecheck/lint clean. * feat(frontend): migrate DNS + Routing to Zod, align with xray docs Adds first-class Zod schemas for the xray-core DNS block and routing sub-objects (Balancer, Rule) matching the documented shape at https://xtls.github.io/config/dns.html and https://xtls.github.io/config/routing.html, then wires the DnsServerModal and BalancerFormModal up to those schemas. schemas/dns.ts (new): - DnsQueryStrategySchema enum (UseIP/UseIPv4/UseIPv6/UseSystem) - DnsHostsSchema record(string -> string | string[]) - DnsServerObjectInnerSchema + DnsServerObjectSchema (with preprocess to migrate legacy `expectIPs` -> `expectedIPs` alias) - DnsServerEntrySchema = string | DnsServerObject (xray accepts both) - DnsObjectSchema with all documented fields and defaults schemas/routing.ts (new): - RuleProtocolSchema enum (http/tls/quic/bittorrent) - RuleWebhookSchema (url/deduplication/headers) - RuleObjectSchema covering every documented field (domain/ip/port/ sourcePort/localPort/network/sourceIP/localIP/user/vlessRoute/ inboundTag/protocol/attrs/process/outboundTag/balancerTag/ruleTag/ webhook) with type=literal('field').default('field') - BalancerStrategyTypeSchema enum (random/roundRobin/leastPing/leastLoad) - BalancerCostObjectSchema {regexp,match,value} - BalancerStrategySettingsSchema (expected/maxRTT/tolerance/baselines/costs) - BalancerStrategySchema + BalancerObjectSchema schemas/xray.ts: - routing.rules: was loose 3-field object, now z.array(RuleObjectSchema) - routing.balancers: was z.array(z.unknown()), now z.array(BalancerObjectSchema) - dns: was 2-field loose, now full DnsObjectSchema - BalancerFormSchema: strategy now BalancerStrategyTypeSchema (enum) instead of z.string(); fallbackTag defaults to ''; settings? added for leastLoad DnsServerModal (full Pattern A rewrite): - useState/DnsForm interface -> Form.useForm() - manual domain/expectedIP/unexpectedIP list -> Form.List - antdRule on address/port/timeoutMs for inline validation - preserves legacy collapse-to-bare-string behavior on submit BalancerFormModal: - Adds conditional leastLoad sub-form (Expected/MaxRTT/Tolerance/ Baselines/Costs) wired to BalancerStrategySettingsSchema - Strategy options derived from schema enum - Cost rows with regexp/literal switch + match + value - required prop on Tag and Selector for red asterisk visual BalancersTab: - BalancerRecord interface -> type alias to BalancerObject - onConfirm now propagates strategy.settings to wire when leastLoad - Removes useMemo wrapping `columns` array. The memo had deps [t, isMobile] (with an eslint-disable) so the column render functions kept their original closure over `openEdit`. Once a balancer was created and the user clicked the edit button, the stale openEdit fired with empty `rows`, so rows[idx] was undefined and the modal opened blank. Columns are cheap to rebuild each render, so dropping the memo is the right fix. DnsTab + RoutingTab: switch ad-hoc interfaces to schema-derived types. translations (en-US, fa-IR): add the previously-missing pages.xray.balancerTagRequired and pages.xray.balancerSelectorRequired keys so antdRule surfaces a real message instead of the raw i18n key. * test(frontend): golden fixtures for DNS, Balancer, Rule schemas Adds JSON fixtures under golden/fixtures/{dns,dns-server,balancer,rule} plus three vitest files that parse them through the new schemas and snapshot the result. dns/: minimal (servers as strings) + full (every top-level field plus hosts with geosite/domain/full prefixes and 5 mixed string/object servers covering fakedns, localhost, https://, tcp://, quic+local://). dns-server/: full (every DnsServerObject field) + legacy-expectips (asserts the z.preprocess that migrates the legacy `expectIPs` key into the canonical `expectedIPs`). balancer/: random-minimal (default strategy by omission), roundrobin, leastping, leastload-full (covers all StrategySettings fields and both regexp=true|false costs). rule/: minimal, full (exercises every RuleObject field including localPort, localIP, process aliases like `self/`, all four protocol enum values, ip negation `!geoip:`, attrs with regexp value, and the WebhookObject with deduplication+headers), balancer-routed (uses balancerTag instead of outboundTag), port-number (port as a number to prove the union(number,string) accepts both). * fix(frontend): serialize bulk client delete + drop deprecated Alert.message useClients.removeMany was firing all DELETEs in parallel via Promise.all. The 3x-ui backend mutates a single config JSON per request (read / modify / write), so 20 concurrent deletes raced on the same file: every request reported success, but only the last writer's copy stuck — about half the selected clients reappeared after the toast. Replace the parallel fan-out with a sequential for-of loop so each delete sees the committed state of the previous one. The trade-off is total latency (20 * ~250ms = ~5s) which is the correct behavior until the backend grows a proper /bulkDel endpoint. Also rename the Alert `message` prop to `title` in ClientBulkAdjustModal to clear the AntD v6 deprecation warning. * feat(clients): server-side bulk create/delete with per-inbound batching Replace the panel-side fan-out (Promise.all of single /add and /del calls) that raced on the shared inbound config and capped throughput at roughly one round-trip per client. New endpoints batch the work on the server: - POST /panel/api/clients/bulkDel { emails, keepTraffic } - POST /panel/api/clients/bulkCreate [ {client, inboundIds}, ... ] BulkDelete groups emails by inbound and performs a single read-modify-write per inbound (one JSON parse, one marshal, one Save) instead of N. Per-row DB cleanups (ClientInbound, ClientTraffic, InboundClientIps, ClientRecord) are batched with WHERE...IN queries. Per-email failures are reported via Skipped[] and processing continues. BulkCreate iterates payloads sequentially through the same Create path single-add uses, so heterogeneous batches (different inboundIds, plans) remain valid in one round-trip. Frontend bulkDelete/bulkCreate hooks parse the new response shape ({ deleted|created, skipped[] }) and the bulk-add modal now posts a single request instead of fanning out emails. * perf(clients): batch BulkAdjust per inbound, skip no-op xray calls on local Same per-inbound batching strategy as BulkDelete. The previous code called Update once per email, which itself looped through each inbound the client belonged to — reparsing the same settings JSON, calling RemoveUser+AddUser on xray, and running SyncInbound for every single email. For 200 emails in one inbound that's 200 JSON read/write cycles and 400 xray runtime calls. The new BulkAdjust groups emails by inbound and per inbound: - locks once, reads settings JSON once - mutates expiryTime/totalGB in place for every target client - writes the inbound and runs SyncInbound once ClientTraffic rows are updated with a single per-email query at the end (values differ per client so they can't be folded into one statement). For local-node inbounds the xray runtime calls are skipped entirely. The AddUser payload only contains email/id/security/flow/auth/password/ cipher — none of which change in an adjust — so RemoveUser+AddUser was a no-op that briefly flapped active users. Limit enforcement is driven by the panel's traffic loop reading ClientTraffic, not by xray-core. For remote-node inbounds rt.UpdateUser is preserved so the remote panel receives the new totals/expiry. Skip+report semantics match BulkDelete: any per-email error leaves that email's record/traffic untouched and is returned in Skipped[]. * refactor(backend): retire hysteria2 as a top-level protocol Hysteria v2 is not a separate xray protocol — it is plain "hysteria" with streamSettings.version = 2. The frontend already dropped hysteria2 from the protocol enum in 5a90f7e3; the backend was still carrying the literal as a compat alias. Removed: - model.Hysteria2 constant - model.IsHysteria helper (only callers were buildProxy + genHysteriaLink) - TestIsHysteria - "hysteria2" from the Inbound.Protocol validate oneof enum - All `case model.Hysteria, model.Hysteria2:` and `case "hysteria", "hysteria2":` branches across client.go, inbound.go, outbound.go, xray.go, port_conflict.go, xray/api.go, subService.go, subJsonService.go, subClashService.go - Stale #4081 comments Kept (correctly — these are client-side URI/config schemes that are independent of the xray protocol type): - hysteria2:// share-link URI in subService.genHysteriaLink - "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy - Comments referring to Hysteria v2 as a transport version Note: this change does not include a DB migration. Existing rows with protocol = 'hysteria2' will fall through to the default switch arms after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria' WHERE protocol = 'hysteria2'` is required for installs that still hold legacy data. * refactor(frontend): retire all AntD + Zod deprecations Swept the codebase for @deprecated APIs using a one-off type-aware ESLint config (eslint.deprecated.config.js) and fixed every hit: - 78 instances of `` JSX in InboundFormModal, LogModal, XrayLogModal converted to the `options` prop. - Zod's `z.ZodTypeAny` (deprecated for `z.ZodType` in zod v4) replaced in _envelope.ts, zodForm.ts, zodValidate.ts, and inbound-form-adapter.ts. - Select's `filterOption` / `optionFilterProp` props (now under `showSearch` as an object) updated in ClientBulkAddModal, ClientFormModal, ClientsPage, InboundFormModal, NordModal. - `Input.Group compact` swapped for `Space.Compact` in FinalMaskForm. - Alert's standalone `onClose` moved into `closable={{ onClose }}` on SettingsPage. - `document.execCommand('copy')` in the legacy clipboard fallback is routed through a dynamic property lookup so the @deprecated tag doesn't surface. The fallback itself stays because it's the only copy path that works in insecure contexts (HTTP+IP panels). The dropped ClientFormModal.css was already unimported. eslint.deprecated.config.js loads the type-aware ruleset and turns everything off except `@typescript-eslint/no-deprecated`, so future scans are a single command: npx eslint --config eslint.deprecated.config.js src Not wired into `npm run lint` because typed linting roughly triples the run time. Verified clean: typecheck, lint, and the deprecated scan all 0 warnings. * feat(clients): show comment under email in the Client column The clients table's Client cell already stacks email + subId; add the admin comment as a third muted line so notes like "VIP" or "friend of X" are visible in the list view without opening the info modal. Renders only when set, so rows without a comment look unchanged. * docs(frontend): refresh README + simplify deprecated-scan config README rewrite reflects the post-Zod-migration state: - 3 Vite entries (index/login/subpage), not "one per panel route" - New folders: schemas/, lib/xray/, generated/, test/, layouts/ - Scripts table covers test/gen:api/gen:zod alongside the existing dev/build/lint/typecheck - New sections on the Zod schema tree, the three validation layers, the unified Form.useForm + antdRule pattern, and the golden fixture testing setup - "Adding a new page" updated to reflect that most additions are just react-router entries in routes.tsx, not new Vite bundles - Explicit note that `@deprecated` in the prose is a JSDoc tag, not a shell command — comes with the exact one-line npx invocation eslint.deprecated.config.js trimmed: dropping the recommendedTypeChecked spread + the ~28 rule overrides that came with it. The config now wires the @typescript-eslint and react-hooks plugins manually and enables exactly one rule (`@typescript-eslint/no-deprecated`). 45 lines → 30, same output: zero false-positives, zero noise, zero deprecations on the current tree. * chore(frontend): bump deps + refresh lockfile `npm update` within the existing semver ranges, plus a Vite bump the user explicitly accepted: - vite 8.0.13 → 8.0.14 (exact pin kept) - dayjs 1.11.20 → 1.11.21 - i18next 26.2.0 → 26.3.0 - typescript-eslint 8.59.4 → 8.60.0 - @rc-component/table + a handful of other transitive antd deps resolved to newer patch versions in the lockfile The earlier 8.0.13 pin was carried over from an esbuild dep-optimizer regression that broke vue-i18n in Vite 8.0.14 dev mode. This codebase uses react-i18next, doesn't hit the same chunking edge case, and `npm run dev` was smoked clean on 8.0.14 before accepting the bump. * feat(clients): compact link + inbound rows in the info modal and table ClientInfoModal — Copy URL section reskinned: - Each link is a single row: [PROTOCOL] [remark] [copy] [QR] instead of a card with the raw 200-char URL printed inline - Remark is parsed per-protocol — VMess pulls it from the base64-JSON `ps` field, the rest from the `#fragment` - The row title strips the client email suffix so the same string isn't repeated three times in the modal; the QR popover still uses the full remark (it's the QR's own name for the download file) - QR button opens an inline Popover with the existing QrPanel, size 220, destroyed on close - Subscription section uses the same row layout (SUB / JSON tags, clickable subId, copy + QR actions) - New per-protocol Tag colors so the protocol is identifiable at a glance ClientInfoModal — Attached inbounds + ClientsPage table column: - Chip format changed from `${remark} (${proto}:${port})` to just `${proto}:${port}` — when an admin attaches 5 inbounds to one client the remark was repeated 5 times and wrapped onto two lines - Only the first inbound chip is shown; the rest collapse into a `+N` chip that opens a Popover with the full list (remark included). INBOUND_CHIP_LIMIT = 1 - Per-protocol Tag colors - Tooltip on each chip shows the full `${remark} (${proto}:${port})` - Table column pinned to width: 170 so the row doesn't reserve the old 300px of whitespace next to the compact chip Comment row in the info table is always shown now (renders `-` when unset) so the layout doesn't jump per-client. VmessSecuritySchema gets a preprocess pass that maps legacy `security: ""` (persisted on pre-enum-lock VMess inbounds) back to `'auto'`. z.enum's `.default()` only fires on a missing field, not on an empty string — without this, old rows fail validation with "expected one of aes-128-gcm|chacha20-poly1305| auto|none|zero". `z.infer` is taken from the raw enum so the inferred type stays the union, not `unknown`. i18n adds a `more` key (en-US + fa-IR) used by the overflow chip label. * fix(xray): heal shadowsocks per-client method across all start paths xray-core's multi-user shadowsocks insists the per-client `method` matches the inbound's top-level cipher exactly for legacy ciphers, and is empty for 2022-blake3-*. The previous code (xray.go) copied `Client.Security` into the per-client `method` blindly, so a multi-protocol client created with the VMess default `"auto"` poisoned the SS config with `method: "auto"` → "unsupported cipher method: auto". Fix in two parts: - GetXrayConfig no longer projects `Client.Security` into the SS entry; the inbound's top-level method is now the single source of truth. - HealShadowsocksClientMethods moves to `database/model` and is invoked from `Inbound.GenXrayInboundConfig`, so the runtime add/update path (runtime.AddInbound) is normalised in addition to the full-restart path. For legacy ciphers heal now overwrites mismatched per-client methods rather than preserving them, so stale DB rows are also healed. * feat(sub): compact subscription rows with per-link email + PQ QR hide Mirror the ClientInfoModal redesign on the public SubPage so the subscription viewer reads as a tight `[PROTO] [remark] [copy] [QR]` row per link instead of raw URL cards. - subService.GetSubs now returns the per-link email list alongside the links, threaded through subController and BuildPageData into the `emails` field on subData (env.d.ts updated). Public links.go is updated to ignore the new return. - SubPage strips the client email from each row title using the matched per-link email (same trimEmail behaviour as the modal), and hides the QR button for post-quantum links (`pqv=`, `mlkem768`, `mldsa65`) since the encoded URL won't fit in a single QR. * feat(clients): hide QR for post-quantum links in client info modal Post-quantum keys (mldsa65 / ML-KEM-768) blow the encoded URL past what a single QR can hold. Detect them by the markers VLESS share links actually carry — `pqv=` for mldsa65Verify and `encryption=mlkem768x25519plus.*` for ML-KEM-768 — and drop the QR button for those rows. Copy still works. * fix(schemas): widen VLESS decryption/encryption to accept PQ values The post-quantum auth blocks (ML-KEM-768, X25519) populate `settings.decryption` / `settings.encryption` with values like `mlkem768x25519plus.` and `xchacha20-poly1305.aead.x25519`, but the schema pinned both fields to z.literal('none') so saving an inbound after picking "ML-KEM-768 auth" failed with `Invalid input: expected "none"`. Relax both fields (inbound + outbound + outbound form) to z.string().min(1) keeping the 'none' default. xray-core does its own validation server-side so a string check at the form boundary is enough. * feat(sub): clash row + reorganise SubPage around Subscription info ClientInfoModal: - Add a Clash / Mihomo row to the subscription section, gated on subClashEnable + subClashURI from /panel/setting/defaultSettings. Defaults payload schema is widened to carry subClashURI/subClashEnable. SubPage: - Drop the rectangular QR-codes header that used to sit at the very top of the card. The subscription info table now leads, followed by Divider("Copy URL") + per-protocol link rows (already converted to the compact ClientInfoModal pattern), then a new Divider("Subscription") + compact rows for the SUB / JSON / CLASH URLs with copy + QR-popover actions. The apps dropdown row remains the footer. CSS clean-up: removed the now-unused .qr-row/.qr-col/.qr-box/.qr-code rules; kept .qr-tag and trimmed the info-table top gap. Added a .sub-link-anchor underline-on-hover style for the new URL rows. * fix(sub): multi-inbound traffic + trojan/hysteria userinfo + utf-8 vmess remark Three bugs surfaced by the new SubPage and the recent client-record refactor: - xray.ClientTraffic.Email is globally unique, so a multi-inbound client has exactly one traffic row attached to whichever inbound claimed it. Iterating inbound.ClientStats per inbound dedup-locked the first lookup to zero for clients that lived under any other inbound, so the SubPage info table read 0 B for all the multi- inbound subs. Replaced appendUniqueTraffic with a single AggregateTrafficByEmails(emails) helper that runs one WHERE email IN (?) over xray.ClientTraffic and folds the rows. GetSubs / SubClashService.GetClash / SubJsonService.GetJson all share it. - Trojan and Hysteria share-links embedded the raw password/auth into the userinfo (scheme://@host) without percent-encoding, so passwords containing `/` or `=` (e.g., base64-with-padding) broke popular trojan clients with parse errors. Added encodeUserinfo() that wraps url.QueryEscape and rewrites the `+` (space) back to `%20` for parity with encodeURIComponent on the frontend; applied to trojan.password and hysteria.auth. Same fix on the frontend's genTrojanLink. - VMess link remarks ride inside a base64-encoded JSON payload, but the SubPage / ClientInfoModal parser used JSON.parse(atob(body)), which treats the binary string as Latin-1 and shreds any multi-byte UTF-8 sequence. Most visible on the emoji decorations (genRemark appends 📊/⏳), so a remark like `test-1.00GB📊` rendered as `test-1.00GBð…`. Routed through Uint8Array + TextDecoder('utf-8') so multi-byte codepoints survive. * feat(settings): drop email leg from default remark model Change the default remarkModel from "-ieo" to "-io" so a freshly installed panel composes share-link remarks from the inbound name + optional extra only, leaving out the client email. Existing panels keep whatever value they have saved — only fresh installs and fallback paths (parse failure, missing setting) pick up the new default. Touched everywhere the literal "-ieo" lived: the canonical default map, the two sub-package fallback constants, the four frontend defaults (model class, link generator, two inbound modals, useInbounds hook). Two snapshot tests regenerated and one obsolete "contains email" assertion in inbound-from-db.test.ts removed. To migrate an existing panel that wants the new behaviour, edit Settings → Remark Model and remove the email leg. * feat(sub): usage summary card + remark-email on QR popover labels SubPage now opens with a clear quota panel directly under the info table: large `used / total` numbers, gradient progress bar (green ≤ 75%, orange to 90%, red above), `remained` and `%` on the foot, plus a Tag chip for unlimited subscriptions and a coloured chip for days left until expiry (blue >3d, orange ≤3d, red on expiry). Driven entirely off existing subData fields — no backend changes. While the row title in the link list stays email-stripped (default remark model omits email now), the QR popover label folds it back in so the rendered QR card identifies the client unambiguously. Tag content becomes `-` in both SubPage and ClientInfoModal — the encoded link itself is unchanged. SubPage section order is now: info table → usage summary → SUB / JSON / CLASH endpoints → per-protocol Copy URL rows → apps row, so the most-glanceable status sits above the fold. --- .github/workflows/ci.yml | 6 + database/model/model.go | 97 +- database/model/model_test.go | 18 - frontend/README.md | 220 +- frontend/eslint.config.js | 6 + frontend/eslint.deprecated.config.js | 26 + frontend/package-lock.json | 993 ++-- frontend/package.json | 24 +- frontend/public/openapi.json | 138 +- frontend/src/api/queries/useAllSettings.ts | 26 +- frontend/src/api/queries/useNodeMutations.ts | 37 +- frontend/src/api/queries/useNodesQuery.ts | 41 +- frontend/src/api/queries/useStatusQuery.ts | 5 +- frontend/src/components/FinalMaskForm.tsx | 1253 ++--- frontend/src/components/HeaderMapEditor.tsx | 141 + frontend/src/env.d.ts | 1 + frontend/src/generated/types.ts | 359 ++ frontend/src/generated/zod.ts | 380 ++ frontend/src/hooks/useClients.ts | 215 +- frontend/src/hooks/useDatepicker.ts | 10 +- frontend/src/hooks/useXraySetting.ts | 100 +- frontend/src/lib/xray/headers.ts | 78 + frontend/src/lib/xray/inbound-defaults.ts | 277 + frontend/src/lib/xray/inbound-form-adapter.ts | 271 + frontend/src/lib/xray/inbound-from-db.ts | 55 + frontend/src/lib/xray/inbound-link.ts | 922 ++++ frontend/src/lib/xray/outbound-defaults.ts | 167 + .../src/lib/xray/outbound-form-adapter.ts | 619 +++ frontend/src/lib/xray/outbound-link-parser.ts | 439 ++ .../src/lib/xray/protocol-capabilities.ts | 74 + frontend/src/lib/xray/stream-defaults.ts | 69 + frontend/src/models/dbinbound.ts | 59 +- frontend/src/models/inbound.ts | 3359 ------------ frontend/src/models/outbound.ts | 2405 --------- frontend/src/models/setting.ts | 2 +- frontend/src/pages/api-docs/endpoints.ts | 16 +- .../src/pages/clients/ClientBulkAddModal.tsx | 295 +- .../pages/clients/ClientBulkAdjustModal.tsx | 15 +- .../src/pages/clients/ClientFormModal.css | 1 - .../src/pages/clients/ClientFormModal.tsx | 382 +- .../src/pages/clients/ClientInfoModal.css | 61 + .../src/pages/clients/ClientInfoModal.tsx | 567 +- frontend/src/pages/clients/ClientsPage.tsx | 79 +- .../src/pages/inbounds/InboundFormModal.tsx | 4791 ++++++++++------- .../src/pages/inbounds/InboundInfoModal.tsx | 238 +- frontend/src/pages/inbounds/InboundList.tsx | 79 +- frontend/src/pages/inbounds/InboundsPage.tsx | 73 +- frontend/src/pages/inbounds/QrCodeModal.tsx | 58 +- frontend/src/pages/inbounds/useInbounds.ts | 86 +- .../src/pages/index/CustomGeoFormModal.tsx | 32 +- frontend/src/pages/index/CustomGeoSection.tsx | 2 +- frontend/src/pages/index/IndexPage.tsx | 12 +- frontend/src/pages/index/LogModal.tsx | 42 +- frontend/src/pages/index/VersionModal.tsx | 2 +- frontend/src/pages/index/XrayLogModal.tsx | 22 +- frontend/src/pages/index/XrayMetricsModal.tsx | 19 +- frontend/src/pages/login/LoginPage.tsx | 14 +- frontend/src/pages/nodes/NodeFormModal.tsx | 338 +- frontend/src/pages/settings/SettingsPage.tsx | 18 +- .../src/pages/settings/TwoFactorModal.tsx | 12 +- frontend/src/pages/sub/SubPage.css | 101 +- frontend/src/pages/sub/SubPage.tsx | 333 +- frontend/src/pages/sub/SubUsageSummary.css | 87 + frontend/src/pages/sub/SubUsageSummary.tsx | 96 + frontend/src/pages/xray/BalancerFormModal.tsx | 269 +- frontend/src/pages/xray/BalancersTab.tsx | 170 +- frontend/src/pages/xray/BasicsTab.tsx | 4 +- frontend/src/pages/xray/DnsServerModal.tsx | 332 +- frontend/src/pages/xray/DnsTab.tsx | 18 +- frontend/src/pages/xray/NordModal.tsx | 27 +- frontend/src/pages/xray/OutboundFormModal.tsx | 3527 +++++++----- frontend/src/pages/xray/OutboundsTab.tsx | 4 +- frontend/src/pages/xray/RoutingTab.tsx | 6 +- frontend/src/pages/xray/RuleFormModal.tsx | 46 +- frontend/src/pages/xray/WarpModal.tsx | 14 +- frontend/src/schemas/_envelope.ts | 10 + frontend/src/schemas/api/inbound.ts | 64 + frontend/src/schemas/client.ts | 158 + frontend/src/schemas/defaults.ts | 20 + frontend/src/schemas/dns.ts | 64 + frontend/src/schemas/forms/inbound-form.ts | 83 + frontend/src/schemas/forms/outbound-form.ts | 265 + frontend/src/schemas/inbound.ts | 32 + frontend/src/schemas/index.ts | 2 + frontend/src/schemas/login.ts | 15 + frontend/src/schemas/node.ts | 53 + frontend/src/schemas/primitives/flow.ts | 16 + frontend/src/schemas/primitives/index.ts | 6 + frontend/src/schemas/primitives/options.ts | 111 + .../schemas/primitives/outbound-protocol.ts | 30 + frontend/src/schemas/primitives/port.ts | 4 + frontend/src/schemas/primitives/protocol.ts | 34 + frontend/src/schemas/primitives/sniffing.ts | 16 + .../src/schemas/protocols/inbound/http.ts | 17 + .../src/schemas/protocols/inbound/hysteria.ts | 26 + .../src/schemas/protocols/inbound/index.ts | 42 + .../src/schemas/protocols/inbound/mixed.ts | 21 + .../schemas/protocols/inbound/shadowsocks.ts | 45 + .../src/schemas/protocols/inbound/trojan.ts | 32 + frontend/src/schemas/protocols/inbound/tun.ts | 12 + .../src/schemas/protocols/inbound/tunnel.ts | 19 + .../src/schemas/protocols/inbound/vless.ts | 50 + .../src/schemas/protocols/inbound/vmess.ts | 41 + .../schemas/protocols/inbound/wireguard.ts | 23 + frontend/src/schemas/protocols/index.ts | 13 + .../schemas/protocols/outbound/blackhole.ts | 13 + .../src/schemas/protocols/outbound/dns.ts | 27 + .../src/schemas/protocols/outbound/freedom.ts | 59 + .../src/schemas/protocols/outbound/http.ts | 25 + .../schemas/protocols/outbound/hysteria.ts | 12 + .../src/schemas/protocols/outbound/index.ts | 47 + .../schemas/protocols/outbound/loopback.ts | 8 + .../schemas/protocols/outbound/shadowsocks.ts | 21 + .../src/schemas/protocols/outbound/socks.ts | 24 + .../src/schemas/protocols/outbound/trojan.ts | 18 + .../src/schemas/protocols/outbound/vless.ts | 22 + .../src/schemas/protocols/outbound/vmess.ts | 25 + .../schemas/protocols/outbound/wireguard.ts | 36 + .../src/schemas/protocols/security/index.ts | 23 + .../src/schemas/protocols/security/none.ts | 7 + .../src/schemas/protocols/security/reality.ts | 41 + .../src/schemas/protocols/security/tls.ts | 72 + .../protocols/stream/external-proxy.ts | 23 + .../src/schemas/protocols/stream/finalmask.ts | 94 + frontend/src/schemas/protocols/stream/grpc.ts | 11 + .../schemas/protocols/stream/httpupgrade.ts | 14 + .../src/schemas/protocols/stream/hysteria.ts | 31 + .../src/schemas/protocols/stream/index.ts | 59 + frontend/src/schemas/protocols/stream/kcp.ts | 14 + .../src/schemas/protocols/stream/sockopt.ts | 74 + frontend/src/schemas/protocols/stream/tcp.ts | 47 + frontend/src/schemas/protocols/stream/ws.ts | 17 + .../src/schemas/protocols/stream/xhttp.ts | 63 + frontend/src/schemas/routing.ts | 77 + frontend/src/schemas/setting.ts | 94 + frontend/src/schemas/status.ts | 56 + frontend/src/schemas/xray.ts | 129 + .../test/__snapshots__/balancer.test.ts.snap | 73 + .../src/test/__snapshots__/dns.test.ts.snap | 116 + .../test/__snapshots__/finalmask.test.ts.snap | 174 + .../test/__snapshots__/headers.test.ts.snap | 118 + .../inbound-defaults.test.ts.snap | 160 + .../__snapshots__/inbound-full.test.ts.snap | 517 ++ .../__snapshots__/inbound-link.test.ts.snap | 60 + .../protocol-capabilities.test.ts.snap | 1513 ++++++ .../test/__snapshots__/protocols.test.ts.snap | 196 + .../src/test/__snapshots__/rule.test.ts.snap | 91 + .../test/__snapshots__/security.test.ts.snap | 72 + .../test/__snapshots__/sockopt.test.ts.snap | 100 + .../test/__snapshots__/stream.test.ts.snap | 181 + frontend/src/test/balancer.test.ts | 26 + frontend/src/test/dns.test.ts | 43 + frontend/src/test/finalmask.test.ts | 26 + .../fixtures/balancer/leastload-full.json | 18 + .../golden/fixtures/balancer/leastping.json | 8 + .../fixtures/balancer/random-minimal.json | 4 + .../golden/fixtures/balancer/roundrobin.json | 8 + .../test/golden/fixtures/dns-server/full.json | 25 + .../fixtures/dns-server/legacy-expectips.json | 6 + .../src/test/golden/fixtures/dns/full.json | 42 + .../src/test/golden/fixtures/dns/minimal.json | 6 + .../golden/fixtures/finalmask/combined.json | 15 + .../fixtures/finalmask/quic-params.json | 16 + .../golden/fixtures/finalmask/tcp-mask.json | 30 + .../golden/fixtures/finalmask/udp-mask.json | 29 + .../inbound-full/hysteria-v1-tls.json | 67 + .../inbound-full/shadowsocks-tcp-2022.json | 49 + .../fixtures/inbound-full/trojan-ws-tls.json | 73 + .../inbound-full/vless-tcp-reality.json | 67 + .../fixtures/inbound-full/vless-ws-tls.json | 76 + .../fixtures/inbound-full/vmess-tcp-tls.json | 69 + .../inbound-full/wireguard-server.json | 34 + .../golden/fixtures/inbound/http-basic.json | 10 + .../fixtures/inbound/hysteria-basic.json | 20 + .../golden/fixtures/inbound/mixed-basic.json | 11 + .../fixtures/inbound/shadowsocks-2022.json | 24 + .../golden/fixtures/inbound/trojan-basic.json | 20 + .../golden/fixtures/inbound/tunnel-basic.json | 13 + .../fixtures/inbound/vless-tcp-none.json | 23 + .../golden/fixtures/inbound/vmess-basic.json | 20 + .../fixtures/inbound/wireguard-basic.json | 16 + .../golden/fixtures/rule/balancer-routed.json | 6 + .../src/test/golden/fixtures/rule/full.json | 60 + .../test/golden/fixtures/rule/minimal.json | 4 + .../golden/fixtures/rule/port-number.json | 6 + .../test/golden/fixtures/security/none.json | 3 + .../fixtures/security/reality-basic.json | 22 + .../fixtures/security/tls-cert-file.json | 27 + .../golden/fixtures/sockopt/defaults.json | 1 + .../test/golden/fixtures/sockopt/full.json | 19 + .../golden/fixtures/sockopt/tcp-tuning.json | 10 + .../test/golden/fixtures/sockopt/tproxy.json | 7 + .../golden/fixtures/stream/grpc-basic.json | 8 + .../test/golden/fixtures/stream/tcp-none.json | 4 + .../golden/fixtures/stream/ws-default.json | 12 + .../golden/fixtures/stream/xhttp-basic.json | 8 + .../fixtures/stream/xhttp-extra-padding.json | 14 + .../stream/xhttp-extra-placement.json | 14 + .../fixtures/stream/xhttp-extra-tuning.json | 29 + frontend/src/test/headers.test.ts | 85 + frontend/src/test/inbound-defaults.test.ts | 139 + .../src/test/inbound-form-adapter.test.ts | 190 + frontend/src/test/inbound-from-db.test.ts | 217 + frontend/src/test/inbound-full.test.ts | 31 + frontend/src/test/inbound-link.test.ts | 251 + frontend/src/test/outbound-defaults.test.ts | 231 + .../src/test/outbound-form-adapter.test.ts | 302 ++ .../src/test/outbound-link-parser.test.ts | 328 ++ .../src/test/protocol-capabilities.test.ts | 74 + frontend/src/test/protocols.test.ts | 29 + frontend/src/test/rule.test.ts | 26 + frontend/src/test/security.test.ts | 26 + frontend/src/test/setup.ts | 8 + frontend/src/test/sockopt.test.ts | 26 + frontend/src/test/stream.test.ts | 26 + frontend/src/utils/index.ts | 22 +- frontend/src/utils/zodForm.ts | 15 + frontend/src/utils/zodValidate.ts | 18 + frontend/vite.config.js | 50 +- frontend/vitest.config.ts | 17 + go.mod | 2 +- sub/links.go | 4 +- sub/sub.go | 2 +- sub/subClashService.go | 35 +- sub/subController.go | 5 +- sub/subJsonService.go | 33 +- sub/subService.go | 133 +- tools/openapigen/emit_types.go | 72 + tools/openapigen/emit_zod.go | 158 + tools/openapigen/main.go | 115 + tools/openapigen/schema.go | 172 + tools/openapigen/walker.go | 244 + web/controller/client.go | 43 + web/controller/inbound.go | 11 +- web/controller/node.go | 11 +- web/controller/setting.go | 9 +- web/entity/entity.go | 44 +- web/middleware/validate.go | 111 + web/middleware/validate_test.go | 207 + web/service/client.go | 729 ++- web/service/client_sync_multiprotocol_test.go | 2 +- web/service/inbound.go | 5 +- web/service/outbound.go | 6 +- web/service/port_conflict.go | 4 +- web/service/port_conflict_test.go | 17 +- web/service/setting.go | 2 +- web/service/xray.go | 50 +- web/translation/en-US.json | 3 + web/translation/fa-IR.json | 3 + xray/api.go | 2 +- 250 files changed, 24445 insertions(+), 12206 deletions(-) create mode 100644 frontend/eslint.deprecated.config.js create mode 100644 frontend/src/components/HeaderMapEditor.tsx create mode 100644 frontend/src/generated/types.ts create mode 100644 frontend/src/generated/zod.ts create mode 100644 frontend/src/lib/xray/headers.ts create mode 100644 frontend/src/lib/xray/inbound-defaults.ts create mode 100644 frontend/src/lib/xray/inbound-form-adapter.ts create mode 100644 frontend/src/lib/xray/inbound-from-db.ts create mode 100644 frontend/src/lib/xray/inbound-link.ts create mode 100644 frontend/src/lib/xray/outbound-defaults.ts create mode 100644 frontend/src/lib/xray/outbound-form-adapter.ts create mode 100644 frontend/src/lib/xray/outbound-link-parser.ts create mode 100644 frontend/src/lib/xray/protocol-capabilities.ts create mode 100644 frontend/src/lib/xray/stream-defaults.ts delete mode 100644 frontend/src/models/inbound.ts delete mode 100644 frontend/src/models/outbound.ts delete mode 100644 frontend/src/pages/clients/ClientFormModal.css create mode 100644 frontend/src/pages/sub/SubUsageSummary.css create mode 100644 frontend/src/pages/sub/SubUsageSummary.tsx create mode 100644 frontend/src/schemas/_envelope.ts create mode 100644 frontend/src/schemas/api/inbound.ts create mode 100644 frontend/src/schemas/client.ts create mode 100644 frontend/src/schemas/defaults.ts create mode 100644 frontend/src/schemas/dns.ts create mode 100644 frontend/src/schemas/forms/inbound-form.ts create mode 100644 frontend/src/schemas/forms/outbound-form.ts create mode 100644 frontend/src/schemas/inbound.ts create mode 100644 frontend/src/schemas/index.ts create mode 100644 frontend/src/schemas/login.ts create mode 100644 frontend/src/schemas/node.ts create mode 100644 frontend/src/schemas/primitives/flow.ts create mode 100644 frontend/src/schemas/primitives/index.ts create mode 100644 frontend/src/schemas/primitives/options.ts create mode 100644 frontend/src/schemas/primitives/outbound-protocol.ts create mode 100644 frontend/src/schemas/primitives/port.ts create mode 100644 frontend/src/schemas/primitives/protocol.ts create mode 100644 frontend/src/schemas/primitives/sniffing.ts create mode 100644 frontend/src/schemas/protocols/inbound/http.ts create mode 100644 frontend/src/schemas/protocols/inbound/hysteria.ts create mode 100644 frontend/src/schemas/protocols/inbound/index.ts create mode 100644 frontend/src/schemas/protocols/inbound/mixed.ts create mode 100644 frontend/src/schemas/protocols/inbound/shadowsocks.ts create mode 100644 frontend/src/schemas/protocols/inbound/trojan.ts create mode 100644 frontend/src/schemas/protocols/inbound/tun.ts create mode 100644 frontend/src/schemas/protocols/inbound/tunnel.ts create mode 100644 frontend/src/schemas/protocols/inbound/vless.ts create mode 100644 frontend/src/schemas/protocols/inbound/vmess.ts create mode 100644 frontend/src/schemas/protocols/inbound/wireguard.ts create mode 100644 frontend/src/schemas/protocols/index.ts create mode 100644 frontend/src/schemas/protocols/outbound/blackhole.ts create mode 100644 frontend/src/schemas/protocols/outbound/dns.ts create mode 100644 frontend/src/schemas/protocols/outbound/freedom.ts create mode 100644 frontend/src/schemas/protocols/outbound/http.ts create mode 100644 frontend/src/schemas/protocols/outbound/hysteria.ts create mode 100644 frontend/src/schemas/protocols/outbound/index.ts create mode 100644 frontend/src/schemas/protocols/outbound/loopback.ts create mode 100644 frontend/src/schemas/protocols/outbound/shadowsocks.ts create mode 100644 frontend/src/schemas/protocols/outbound/socks.ts create mode 100644 frontend/src/schemas/protocols/outbound/trojan.ts create mode 100644 frontend/src/schemas/protocols/outbound/vless.ts create mode 100644 frontend/src/schemas/protocols/outbound/vmess.ts create mode 100644 frontend/src/schemas/protocols/outbound/wireguard.ts create mode 100644 frontend/src/schemas/protocols/security/index.ts create mode 100644 frontend/src/schemas/protocols/security/none.ts create mode 100644 frontend/src/schemas/protocols/security/reality.ts create mode 100644 frontend/src/schemas/protocols/security/tls.ts create mode 100644 frontend/src/schemas/protocols/stream/external-proxy.ts create mode 100644 frontend/src/schemas/protocols/stream/finalmask.ts create mode 100644 frontend/src/schemas/protocols/stream/grpc.ts create mode 100644 frontend/src/schemas/protocols/stream/httpupgrade.ts create mode 100644 frontend/src/schemas/protocols/stream/hysteria.ts create mode 100644 frontend/src/schemas/protocols/stream/index.ts create mode 100644 frontend/src/schemas/protocols/stream/kcp.ts create mode 100644 frontend/src/schemas/protocols/stream/sockopt.ts create mode 100644 frontend/src/schemas/protocols/stream/tcp.ts create mode 100644 frontend/src/schemas/protocols/stream/ws.ts create mode 100644 frontend/src/schemas/protocols/stream/xhttp.ts create mode 100644 frontend/src/schemas/routing.ts create mode 100644 frontend/src/schemas/setting.ts create mode 100644 frontend/src/schemas/status.ts create mode 100644 frontend/src/schemas/xray.ts create mode 100644 frontend/src/test/__snapshots__/balancer.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/dns.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/finalmask.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/headers.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/inbound-full.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/inbound-link.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/protocols.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/rule.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/security.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/sockopt.test.ts.snap create mode 100644 frontend/src/test/__snapshots__/stream.test.ts.snap create mode 100644 frontend/src/test/balancer.test.ts create mode 100644 frontend/src/test/dns.test.ts create mode 100644 frontend/src/test/finalmask.test.ts create mode 100644 frontend/src/test/golden/fixtures/balancer/leastload-full.json create mode 100644 frontend/src/test/golden/fixtures/balancer/leastping.json create mode 100644 frontend/src/test/golden/fixtures/balancer/random-minimal.json create mode 100644 frontend/src/test/golden/fixtures/balancer/roundrobin.json create mode 100644 frontend/src/test/golden/fixtures/dns-server/full.json create mode 100644 frontend/src/test/golden/fixtures/dns-server/legacy-expectips.json create mode 100644 frontend/src/test/golden/fixtures/dns/full.json create mode 100644 frontend/src/test/golden/fixtures/dns/minimal.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/combined.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/quic-params.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/tcp-mask.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/udp-mask.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/wireguard-server.json create mode 100644 frontend/src/test/golden/fixtures/inbound/http-basic.json create mode 100644 frontend/src/test/golden/fixtures/inbound/hysteria-basic.json create mode 100644 frontend/src/test/golden/fixtures/inbound/mixed-basic.json create mode 100644 frontend/src/test/golden/fixtures/inbound/shadowsocks-2022.json create mode 100644 frontend/src/test/golden/fixtures/inbound/trojan-basic.json create mode 100644 frontend/src/test/golden/fixtures/inbound/tunnel-basic.json create mode 100644 frontend/src/test/golden/fixtures/inbound/vless-tcp-none.json create mode 100644 frontend/src/test/golden/fixtures/inbound/vmess-basic.json create mode 100644 frontend/src/test/golden/fixtures/inbound/wireguard-basic.json create mode 100644 frontend/src/test/golden/fixtures/rule/balancer-routed.json create mode 100644 frontend/src/test/golden/fixtures/rule/full.json create mode 100644 frontend/src/test/golden/fixtures/rule/minimal.json create mode 100644 frontend/src/test/golden/fixtures/rule/port-number.json create mode 100644 frontend/src/test/golden/fixtures/security/none.json create mode 100644 frontend/src/test/golden/fixtures/security/reality-basic.json create mode 100644 frontend/src/test/golden/fixtures/security/tls-cert-file.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/defaults.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/full.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/tcp-tuning.json create mode 100644 frontend/src/test/golden/fixtures/sockopt/tproxy.json create mode 100644 frontend/src/test/golden/fixtures/stream/grpc-basic.json create mode 100644 frontend/src/test/golden/fixtures/stream/tcp-none.json create mode 100644 frontend/src/test/golden/fixtures/stream/ws-default.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-basic.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json create mode 100644 frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json create mode 100644 frontend/src/test/headers.test.ts create mode 100644 frontend/src/test/inbound-defaults.test.ts create mode 100644 frontend/src/test/inbound-form-adapter.test.ts create mode 100644 frontend/src/test/inbound-from-db.test.ts create mode 100644 frontend/src/test/inbound-full.test.ts create mode 100644 frontend/src/test/inbound-link.test.ts create mode 100644 frontend/src/test/outbound-defaults.test.ts create mode 100644 frontend/src/test/outbound-form-adapter.test.ts create mode 100644 frontend/src/test/outbound-link-parser.test.ts create mode 100644 frontend/src/test/protocol-capabilities.test.ts create mode 100644 frontend/src/test/protocols.test.ts create mode 100644 frontend/src/test/rule.test.ts create mode 100644 frontend/src/test/security.test.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/test/sockopt.test.ts create mode 100644 frontend/src/test/stream.test.ts create mode 100644 frontend/src/utils/zodForm.ts create mode 100644 frontend/src/utils/zodValidate.ts create mode 100644 frontend/vitest.config.ts create mode 100644 tools/openapigen/emit_types.go create mode 100644 tools/openapigen/emit_zod.go create mode 100644 tools/openapigen/main.go create mode 100644 tools/openapigen/schema.go create mode 100644 tools/openapigen/walker.go create mode 100644 web/middleware/validate.go create mode 100644 web/middleware/validate_test.go 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' && ( <> -