diff --git a/frontend/ZOD_MIGRATION_STATUS.md b/frontend/ZOD_MIGRATION_STATUS.md deleted file mode 100644 index 024c437b..00000000 --- a/frontend/ZOD_MIGRATION_STATUS.md +++ /dev/null @@ -1,132 +0,0 @@ -# 3x-ui Frontend Zod Migration — Status - -Branch: `feat/frontend-zod-validation` · 83 commits ahead of `main` - -Last updated: 2026-05-26 - -## What this is - -The work tracked here is the migration described in -`C:\Users\Hossein Sanaei\.claude\plans\zod-soft-feather.md` — replacing the -class-based xray models (`models/inbound.ts`, `models/outbound.ts`) with Zod -schemas as the single source of truth, standardizing every form on AntD -`Form.useForm` + `antdRule(schema.shape.X)`, and tightening -`@typescript-eslint/no-explicit-any` to `error`. - -Verify state: `npm run typecheck` clean, `npm run lint` clean, -`npm run test` 302/302, snapshot baselines 172/172. - ---- - -## Done - -### Foundations - -- API-boundary Zod validation in TanStack Query hooks (`parseMsg` helper) -- Backend request-body validation via `go-playground/validator` -- Go-first codegen tool (`tools/openapigen`) emitting `zod.ts` + `types.ts` -- `antdRule(schema)` helper bridging Zod issues to AntD form rules -- Five secondary modals migrated to Pattern A (Login, 2FA, Geo, Balancer, Rule) -- Pre-save schema guard on Inbound/Outbound form submits - -### Schemas — `frontend/src/schemas/` - -- `primitives/` — port, protocol, sniffing, atomic dictionaries -- `protocols/inbound/*` — 10 protocols as leaf schemas -- `protocols/outbound/*` — 11 protocols as leaf schemas -- `protocols/stream/*` — 7 networks (tcp/kcp/ws/grpc/httpupgrade/xhttp/hysteria) -- `protocols/security/*` — 3 securities (none/tls/reality) -- `forms/inbound-form.ts` — `InboundFormValues` discriminated union -- `forms/outbound-form.ts` — `OutboundFormValues` discriminated union -- Stream + security families wired as `z.discriminatedUnion` with intersection - -### Pure-function ports — `frontend/src/lib/xray/` - -- `headers.ts` — `toHeaders`, `toV2Headers`, `getHeaderValue` -- `inbound-link.ts` — `genVmessLink`, `genVlessLink`, `genTrojanLink`, - `genShadowsocksLink`, `genHysteriaLink`, Wireguard link/config -- `outbound-link-parser.ts` — vmess/vless/trojan/shadowsocks/hysteria2 -- `inbound-defaults.ts` — `createDefault{Vmess,Vless,...}{Client,InboundSettings}` -- `outbound-defaults.ts` — settings factories + dispatcher -- `outbound-form-adapter.ts` — raw ↔ `OutboundFormValues` round-trip -- `protocol-capabilities.ts` — capability predicates as pure functions - -### Form modals on Pattern A - -- `InboundFormModal.tsx` — full rewrite, atomic-swapped from `.new.tsx` - - Tabs: Basic, Sniffing, Protocol, Stream, Security, Advanced JSON, - Fallbacks - - All 10 protocols (VLESS, VMess, Trojan, Shadowsocks, HTTP, Mixed, - Tunnel, TUN, Wireguard, Hysteria) - - Full Stream tab (TCP, KCP, WS, gRPC, HTTPUpgrade, XHTTP, Hysteria) - - Full Security tab (TLS list, Reality, ECH, mldsa65) - - 18-field sockopt section, full TLS cert list, external-proxy section -- `OutboundFormModal.tsx` — full rewrite, atomic-swapped from `.new.tsx` - - All 12 protocols (vmess/vless/trojan/shadowsocks/socks/http/hysteria/ - freedom/blackhole/dns/loopback/wireguard) - - Full Stream tab with XHTTP advanced fields + xmux sub-form - - Full Security tab (TLS + Reality + Vision flow) - - Sockopt section (17 knobs) - - Mux section - - JSON tab for advanced fields - - Link import (vmess/vless/trojan/ss/hysteria2) with full XHTTP - round-trip (padding obfs + session/seq/uplink keys + all post-size - knobs) -- `FinalMaskForm` rewritten to Pattern A (Form.List-driven) and wired - into both stream tabs (Inbound + Outbound). Covers TCP/UDP mask - arrays, all 13 UDP mask types, header-custom nested groups, noise - items, and the QUIC params sub-form. - -### Tests - -- Golden-file fixture suite (`test/golden/fixtures/`) -- Snapshot-baseline regression tests for inbound-full / outbound / stream / - security DUs -- Shadow-parse harness asserting legacy class and Zod converge -- Link-parser tests (15 round-trip cases including XHTTP padding-obfs) -- Outbound form-adapter tests (15 round-trip cases) -- 302 tests across 12 files, 172 snapshots - -### Build infrastructure - -- `@typescript-eslint/no-explicit-any: 'error'` enforced -- `.github/workflows/ci.yml` runs `typecheck` + `test` before `build` -- Vite pinned to 8.0.13 (dev-mode dep-optimizer regression in 8.0.14) - ---- - -## Remaining - -### Out of migration scope (per plan) - -- `DBInbound`, `Status`, `AllSetting` legacy classes — flagged as out of - scope in `zod-soft-feather.md`. The mainline migration of - `models/inbound.ts` / `models/outbound.ts` cannot delete them entirely - while `DBInbound.toInbound()` still imports. -- The plan accepts this and treats parity via snapshot baselines instead. - -### Nice-to-haves — would not block ship - -- Reality `sid=` multi-value parsing in share-link import - (outbound reality only carries a single shortId — this is server-side - state) -- `fm=` (FinalMask) param in share-link import -- VMess link `xmux` nested JSON parsing (currently round-trips at the - XHTTP top level; nested xmux object is left empty) -- Tighter `.loose()` removal in `schemas/api/inbound.ts`, - `schemas/api/client.ts`, `schemas/xray.ts` — gated on Step 6 of the plan - (currently held because the codegen tool still emits one or two loose - fields the panel writes back) - ---- - -## How to pick up where this left off - -1. `git checkout feat/frontend-zod-validation` -2. `cd frontend && npm install && npm run typecheck && npm run test` -3. Open `C:\Users\Hossein Sanaei\.claude\plans\zod-soft-feather.md` — - Steps 1–5 are done. Step 6 (tighten `.loose()`) and Step 7 (lint/CI - tightening) are partially done. -4. Nothing in this list blocks ship. The mainline migration goal - (replace class-based models with Zod schemas + Pattern A forms) is - done; remaining work is incremental polish. diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index fd6fdc61..51901c77 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -161,8 +161,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], ); diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 02678688..596266a3 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -1,7 +1,15 @@ 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 @@ -79,6 +87,31 @@ function coerceTrafficReset(v: unknown): 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 @@ -90,6 +123,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { 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 { @@ -112,7 +148,107 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { } as InboundFormValues; } +// Recursively strip undefined leaves from the wire payload. Empty arrays +// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept +// shells like `tcpSettings: {}` so xray-core picks up its built-in +// defaults, and stripping them led the FE to lose required-but-empty +// arrays (vless clients, wireguard peers, etc.) which the Go side then +// serialized back as `null`. Primitive values (including 0, false, '') +// are kept verbatim. +export function pruneEmpty(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(pruneEmpty); + } + if (value !== null && typeof value === 'object') { + const out: Record = {}; + 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.ZodTypeAny | 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, @@ -125,9 +261,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP listen: values.listen, port: values.port, protocol: values.protocol, - settings: JSON.stringify(values.settings ?? {}), - streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '', - sniffing: JSON.stringify(values.sniffing ?? {}), + 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; 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..56db32f5 --- /dev/null +++ b/frontend/src/lib/xray/inbound-from-db.ts @@ -0,0 +1,39 @@ +import type { Inbound } from '@/schemas/api/inbound'; +import { coerceInboundJsonField } from '@/models/dbinbound'; + +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; +} + +export function inboundFromDb(raw: DbInboundLike): Inbound { + const settings = coerceInboundJsonField(raw.settings); + const streamSettings = coerceInboundJsonField(raw.streamSettings); + const sniffing = coerceInboundJsonField(raw.sniffing); + 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/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/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index 80fb7ed8..a5bf707b 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -33,6 +33,10 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from import { rawInboundToFormValues, formValuesToWirePayload, + pruneEmpty, + normalizeSniffing, + normalizeClients, + dropLegacyOptionalEmpties, } from '@/lib/xray/inbound-form-adapter'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; import { @@ -82,7 +86,7 @@ import type { FormInstance } from 'antd'; import type { NamePath } from 'antd/es/form/interface'; const { TextArea } = Input; -import type { DBInbound } from '@/models/dbinbound'; +import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; // Pattern A rewrite of InboundFormModal. Built as a sibling file so the @@ -121,7 +125,12 @@ function AdvancedSliceEditor({ return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2); }; - const watched = Form.useWatch(path, form); + // preserve: true so useWatch returns the full subtree from the form + // store — without it, useWatch goes through getFieldsValue() which + // filters out unregistered fields. Slices like `settings` would lose + // their `clients` / `fallbacks` sub-trees because those aren't bound + // to any Form.Item. + const watched = Form.useWatch(path, { form, preserve: true }); const lastEmitRef = useRef(''); const [text, setText] = useState(() => { const initial = serialize(form.getFieldValue(path)); @@ -172,24 +181,40 @@ function AdvancedAllEditor({ form: FormInstance; streamEnabled: boolean; }) { - const wListen = Form.useWatch('listen', form); - const wPort = Form.useWatch('port', form); - const wProtocol = Form.useWatch('protocol', form); - const wTag = Form.useWatch('tag', form); - const wSettings = Form.useWatch('settings', form); - const wSniffing = Form.useWatch('sniffing', form); - const wStream = Form.useWatch('streamSettings', form); + // preserve: true — default useWatch returns only registered fields, so + // sub-trees we never bound (settings.clients/fallbacks, sniffing + // defaults, etc.) wouldn't show up. preserve switches the read to + // getFieldsValue(true) which returns the full form store. + const wListen = Form.useWatch('listen', { form, preserve: true }); + const wPort = Form.useWatch('port', { form, preserve: true }); + const wProtocol = Form.useWatch('protocol', { form, preserve: true }); + const wTag = Form.useWatch('tag', { form, preserve: true }); + const wSettings = Form.useWatch('settings', { form, preserve: true }); + const wSniffing = Form.useWatch('sniffing', { form, preserve: true }); + const wStream = Form.useWatch('streamSettings', { form, preserve: true }); const serialize = () => { + // Apply the same prune/normalize as the wire payload so the JSON + // shown here is what the panel actually POSTs (no empty defaults, + // disabled sniffing as { enabled: false }, finalmask dropped when + // there are no masks). + const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; + if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { + settingsView.clients = normalizeClients(wProtocol, settingsView.clients); + } + const streamView = streamEnabled + ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) + : undefined; + dropLegacyOptionalEmpties(settingsView, streamView); const out: Record = { listen: wListen ?? '', port: wPort ?? 0, protocol: wProtocol ?? '', tag: wTag ?? '', - settings: wSettings ?? {}, - sniffing: wSniffing ?? {}, + settings: settingsView, + sniffing: normalizeSniffing(wSniffing as Parameters[0]), }; - if (streamEnabled) out.streamSettings = wStream ?? {}; + if (streamView) out.streamSettings = streamView; return JSON.stringify(out, null, 2); }; @@ -368,6 +393,39 @@ export default function InboundFormModal({ return !!msg?.success; }; + // Derive a fallback row's SNI / ALPN / Path / xver from a child + // inbound's streamSettings — what the legacy panel auto-filled when an + // operator wired a fallback target. SNI/ALPN come straight off the + // child's TLS block; path depends on the child's transport (ws/grpc + // /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of + // their own). xver stays 0 unless the child explicitly opts in via + // PROXY-protocol sockopt. + const deriveFallbackDefaults = (childId: number): Partial => { + const child = (dbInbounds || []).find((ib) => ib.id === childId); + if (!child) return {}; + const stream = coerceInboundJsonField(child.streamSettings); + const tls = (stream.tlsSettings as Record | undefined) ?? {}; + const network = typeof stream.network === 'string' ? stream.network : ''; + const sni = typeof tls.serverName === 'string' ? tls.serverName : ''; + const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : []; + const alpn = alpnArr.filter((v) => typeof v === 'string').join(','); + let path = ''; + if (network === 'ws') { + const ws = (stream.wsSettings as Record | undefined) ?? {}; + if (typeof ws.path === 'string') path = ws.path; + } else if (network === 'grpc') { + const grpc = (stream.grpcSettings as Record | undefined) ?? {}; + if (typeof grpc.serviceName === 'string') path = grpc.serviceName; + } else if (network === 'httpupgrade') { + const hu = (stream.httpupgradeSettings as Record | undefined) ?? {}; + if (typeof hu.path === 'string') path = hu.path; + } else if (network === 'xhttp') { + const xh = (stream.xhttpSettings as Record | undefined) ?? {}; + if (typeof xh.path === 'string') path = xh.path; + } + return { name: sni, alpn, path, xver: 0 }; + }; + const addFallback = () => { setFallbacks((prev) => [...prev, { rowKey: `fb-${++fallbackKeyRef.current}`, @@ -380,7 +438,18 @@ export default function InboundFormModal({ }; const updateFallback = (rowKey: string, patch: Partial) => { - setFallbacks((prev) => prev.map((r) => r.rowKey === rowKey ? { ...r, ...patch } : r)); + setFallbacks((prev) => prev.map((r) => { + if (r.rowKey !== rowKey) return r; + // When the picker selects a new child inbound and the row hasn't + // been hand-edited yet (sni/alpn/path all blank, xver = 0), pull + // the SNI/ALPN/Path defaults off that child. Operators who + // intentionally typed values keep them — we only fill the empties. + if (typeof patch.childId === 'number' && patch.childId !== r.childId) { + const isPristine = !r.name && !r.alpn && !r.path && r.xver === 0; + if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) }; + } + return { ...r, ...patch }; + })); }; const removeFallback = (idx: number) => { @@ -409,14 +478,17 @@ export default function InboundFormModal({ const alreadyHave = new Set(prev.map((r) => r.childId)); const additions = fallbackChildOptions .filter((opt) => !alreadyHave.has(opt.value)) - .map((opt) => ({ - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: opt.value, - name: '', - alpn: '', - path: '', - xver: 0, - })); + .map((opt) => { + const derived = deriveFallbackDefaults(opt.value); + return { + rowKey: `fb-${++fallbackKeyRef.current}`, + childId: opt.value, + name: derived.name ?? '', + alpn: derived.alpn ?? '', + path: derived.path ?? '', + xver: derived.xver ?? 0, + }; + }); if (additions.length === 0) return prev; return [...prev, ...additions]; }); @@ -697,20 +769,34 @@ export default function InboundFormModal({ }; const submit = async () => { - let values: InboundFormValues; try { - values = await form.validateFields(); + await form.validateFields(); } catch { return; } + // Why getFieldsValue(true) instead of the validateFields return value: + // rc-component/form's validateFields filters its output by REGISTERED + // name paths. settings.clients and settings.fallbacks have no Form.Item + // bound to them (clients are managed via the standalone Client modal, + // not inside this inbound modal) — so validateFields would drop them + // and the update wire payload would silently delete every client on + // every save. getFieldsValue(true) returns the entire form store and + // keeps those sub-trees intact. + const values = form.getFieldsValue(true) as InboundFormValues; const parsed = InboundFormSchema.safeParse(values); if (!parsed.success) { const issue = parsed.error.issues[0]; - messageApi.error( - t(issue?.message ?? 'somethingWentWrong', { - defaultValue: issue?.message ?? 'invalid', - }), - ); + const path = Array.isArray(issue?.path) && issue.path.length > 0 + ? issue.path.join('.') + : ''; + const baseMsg = issue?.message ?? 'somethingWentWrong'; + const display = path ? `${path}: ${baseMsg}` : baseMsg; + messageApi.error(t(baseMsg, { defaultValue: display })); + console.error('[InboundFormModal] schema validation failed', { + path: issue?.path, + message: issue?.message, + values, + }); return; } setSaving(true); diff --git a/frontend/src/pages/inbounds/InboundInfoModal.tsx b/frontend/src/pages/inbounds/InboundInfoModal.tsx index 366e445d..823e2363 100644 --- a/frontend/src/pages/inbounds/InboundInfoModal.tsx +++ b/frontend/src/pages/inbounds/InboundInfoModal.tsx @@ -15,9 +15,93 @@ import { import { Protocols } from '@/schemas/primitives'; import InfinityIcon from '@/components/InfinityIcon'; import { useDatepicker } from '@/hooks/useDatepicker'; +import { coerceInboundJsonField } from '@/models/dbinbound'; +import { + canEnableTlsFlow, + isSS2022 as isSS2022Helper, + isSSMultiUser as isSSMultiUserHelper, +} from '@/lib/xray/protocol-capabilities'; +import { + genAllLinks, + genWireguardConfigs, + genWireguardLinks, +} from '@/lib/xray/inbound-link'; +import { inboundFromDb } from '@/lib/xray/inbound-from-db'; import type { SubSettings } from './useInbounds'; import './InboundInfoModal.css'; +const LINK_PROTOCOLS: ReadonlySet = new Set([ + Protocols.VMESS, + Protocols.VLESS, + Protocols.TROJAN, + Protocols.SHADOWSOCKS, + Protocols.HYSTERIA, +]); + +function hasShareLink(protocol: string): boolean { + return LINK_PROTOCOLS.has(protocol); +} + +function readHeader(headers: unknown, name: string): string { + const needle = name.toLowerCase(); + if (Array.isArray(headers)) { + for (const h of headers) { + if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) { + return String((h as { value?: unknown }).value ?? ''); + } + } + return ''; + } + if (headers && typeof headers === 'object') { + for (const [k, v] of Object.entries(headers as Record)) { + if (k.toLowerCase() === needle) { + return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''); + } + } + } + return ''; +} + +function readNetworkHost(stream: Record, network: string): string | null { + switch (network) { + case 'tcp': { + const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined; + return readHeader(tcp?.header?.request?.headers, 'host'); + } + case 'ws': { + const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined; + return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host'); + } + case 'httpupgrade': { + const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined; + return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host'); + } + case 'xhttp': { + const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined; + return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host'); + } + default: + return null; + } +} + +function readNetworkPath(stream: Record, network: string): string | null { + switch (network) { + case 'tcp': { + const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined; + return tcp?.header?.request?.path?.[0] ?? null; + } + case 'ws': + return (stream.wsSettings as { path?: string } | undefined)?.path ?? null; + case 'httpupgrade': + return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null; + case 'xhttp': + return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null; + default: + return null; + } +} + interface ClientStats { email: string; up: number; @@ -44,37 +128,35 @@ interface ClientSetting { updated_at?: number; } -interface InboundLike { +interface InboundInfo { protocol: string; - clients?: ClientSetting[]; - settings?: Record; - serverName?: string; - isTcp?: boolean; - isWs?: boolean; - isHttpupgrade?: boolean; - isXHTTP?: boolean; - isGrpc?: boolean; - isSSMultiUser?: boolean; - isSS2022?: boolean; - host?: string; - path?: string; - serviceName?: string; - stream?: { - network?: string; - security?: string; + clients: ClientSetting[]; + settings: Record; + isTcp: boolean; + isWs: boolean; + isHttpupgrade: boolean; + isXHTTP: boolean; + isGrpc: boolean; + isSSMultiUser: boolean; + isSS2022: boolean; + isVlessTlsFlow: boolean; + host: string | null; + path: string | null; + serviceName: string; + serverName: string; + stream: { + network: string; + security: string; xhttp?: { mode?: string }; grpc?: { multiMode?: boolean }; }; - canEnableTlsFlow?: () => boolean; - genWireguardConfigs: (remark: string, model: string, host: string) => string; - genWireguardLinks: (remark: string, model: string, host: string) => string; - genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[]; } interface DBInboundLike { id: number; address: string; port: number; + listen: string; protocol: string; remark: string; enable?: boolean; @@ -85,9 +167,64 @@ interface DBInboundLike { isMixed?: boolean; isHTTP?: boolean; isWireguard?: boolean; + settings: unknown; + streamSettings: unknown; + sniffing: unknown; clientStats?: ClientStats[]; - hasLink: () => boolean; - toInbound: () => InboundLike; +} + +function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo { + const settings = coerceInboundJsonField(dbInbound.settings) as Record; + const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record; + const network = (stream.network as string | undefined) ?? ''; + const security = (stream.security as string | undefined) ?? 'none'; + const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : []; + const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined; + const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined; + let serverName = ''; + if (security === 'tls') { + const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined; + serverName = tls?.sni ?? tls?.serverName ?? ''; + } else if (security === 'reality') { + const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined; + if (Array.isArray(reality?.serverNames)) { + serverName = reality.serverNames.join(', '); + } else if (reality?.serverName) { + serverName = reality.serverName; + } + } + return { + protocol: dbInbound.protocol, + clients, + settings, + isTcp: network === 'tcp', + isWs: network === 'ws', + isHttpupgrade: network === 'httpupgrade', + isXHTTP: network === 'xhttp', + isGrpc: network === 'grpc', + isSSMultiUser: isSSMultiUserHelper({ + protocol: dbInbound.protocol, + settings: settings as { method?: string }, + }), + isSS2022: isSS2022Helper({ + protocol: dbInbound.protocol, + settings: settings as { method?: string }, + }), + isVlessTlsFlow: canEnableTlsFlow({ + protocol: dbInbound.protocol, + streamSettings: { network, security }, + }), + host: readNetworkHost(stream, network), + path: readNetworkPath(stream, network), + serviceName: grpcSettings?.serviceName ?? '', + serverName, + stream: { + network, + security, + xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined, + grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined, + }, + }; } interface InboundInfoModalProps { @@ -155,7 +292,7 @@ export default function InboundInfoModal({ const { t } = useTranslation(); const { datepicker } = useDatepicker(); - const [inbound, setInbound] = useState(null); + const [inbound, setInbound] = useState(null); const [clientSettings, setClientSettings] = useState(null); const [clientStats, setClientStats] = useState(null); const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]); @@ -213,24 +350,51 @@ export default function InboundInfoModal({ useEffect(() => { if (!open || !dbInbound) return; - const parsed = dbInbound.toInbound(); - setInbound(parsed); - setActiveTab((parsed.clients?.length ?? 0) > 0 ? 'client' : 'inbound'); + const info = buildInboundInfo(dbInbound); + setInbound(info); + setActiveTab(info.clients.length > 0 ? 'client' : 'inbound'); const idx = clientIndex ?? 0; - const clientSet = (parsed.clients?.length ?? 0) > 0 ? (parsed.clients?.[idx] || null) : null; + const clientSet = info.clients.length > 0 ? (info.clients[idx] || null) : null; setClientSettings(clientSet); const stats = clientSet ? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null : null; setClientStats(stats); - if (parsed.protocol === Protocols.WIREGUARD) { - setWireguardConfigs(parsed.genWireguardConfigs(dbInbound.remark, '-ieo', nodeAddress).split('\r\n')); - setWireguardLinks(parsed.genWireguardLinks(dbInbound.remark, '-ieo', nodeAddress).split('\r\n')); + const inboundForLinks = inboundFromDb(dbInbound); + const fallbackHostname = window.location.hostname; + if (info.protocol === Protocols.WIREGUARD) { + setWireguardConfigs( + genWireguardConfigs({ + inbound: inboundForLinks, + remark: dbInbound.remark, + remarkModel: '-ieo', + hostOverride: nodeAddress, + fallbackHostname, + }).split('\r\n'), + ); + setWireguardLinks( + genWireguardLinks({ + inbound: inboundForLinks, + remark: dbInbound.remark, + remarkModel: '-ieo', + hostOverride: nodeAddress, + fallbackHostname, + }).split('\r\n'), + ); setLinks([]); } else { - setLinks(parsed.genAllLinks(dbInbound.remark, remarkModel, clientSet, nodeAddress)); + setLinks( + genAllLinks({ + inbound: inboundForLinks, + remark: dbInbound.remark, + remarkModel, + client: (clientSet ?? {}) as Parameters[0]['client'], + hostOverride: nodeAddress, + fallbackHostname, + }), + ); setWireguardConfigs([]); setWireguardLinks([]); } @@ -340,7 +504,7 @@ export default function InboundInfoModal({ {dbInbound.isVMess && ( {t('security')}{clientSettings?.security} )} - {inbound.canEnableTlsFlow?.() && ( + {inbound.isVlessTlsFlow && ( Flow @@ -484,7 +648,7 @@ export default function InboundInfoModal({ )} - {dbInbound.hasLink() && links.length > 0 && ( + {hasShareLink(dbInbound.protocol) && links.length > 0 && ( <> {t('pages.inbounds.copyLink')} {links.map((link, idx) => ( @@ -584,7 +748,7 @@ export default function InboundInfoModal({ )} - {dbInbound.hasLink() && ( + {hasShareLink(dbInbound.protocol) && ( <>
{t('security')}
diff --git a/frontend/src/pages/inbounds/InboundList.tsx b/frontend/src/pages/inbounds/InboundList.tsx index 690b204f..f9775171 100644 --- a/frontend/src/pages/inbounds/InboundList.tsx +++ b/frontend/src/pages/inbounds/InboundList.tsx @@ -34,8 +34,43 @@ import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; import InfinityIcon from '@/components/InfinityIcon'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import { isSSMultiUser } from '@/lib/xray/protocol-capabilities'; +import { coerceInboundJsonField } from '@/models/dbinbound'; import './InboundList.css'; +interface StreamHints { + network: string; + isTls: boolean; + isReality: boolean; +} + +function readStreamHints(streamSettings: unknown): StreamHints { + const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string }; + return { + network: stream.network ?? '', + isTls: stream.security === 'tls', + isReality: stream.security === 'reality', + }; +} + +function readSettings(settings: unknown): { method?: string } { + return coerceInboundJsonField(settings) as { method?: string }; +} + +function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean { + switch (record.protocol) { + case 'vmess': + case 'vless': + case 'trojan': + case 'hysteria': + return true; + case 'shadowsocks': + return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) }); + default: + return false; + } +} + type ProtocolFlags = { isVMess?: boolean; isVLess?: boolean; @@ -59,11 +94,8 @@ interface DBInboundRecord extends ProtocolFlags { expiryTime: number; _expiryTime: { valueOf(): number } | null; nodeId?: number | null; - toInbound: () => { - stream?: { network?: string; isTls?: boolean; isReality?: boolean }; - isSSMultiUser?: boolean; - }; - isMultiUser: () => boolean; + settings: unknown; + streamSettings: unknown; } export interface ClientCountEntry { @@ -137,11 +169,7 @@ const SORT_FNS: Record, label: t('qrCode') }); } - if (record.isMultiUser()) { + if (isInboundMultiUser(record)) { items.push({ key: 'export', icon: , label: t('pages.inbounds.export') }); if (subEnable) { items.push({ @@ -341,14 +369,14 @@ export default function InboundList({ render: (_, record) => { const tags: ReactElement[] = [{record.protocol}]; if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) { - const stream = record.toInbound().stream; + const stream = readStreamHints(record.streamSettings); tags.push( - {record.isHysteria ? 'UDP' : stream?.network} + {record.isHysteria ? 'UDP' : stream.network} , ); - if (stream?.isTls) tags.push(TLS); - if (stream?.isReality) tags.push(Reality); + if (stream.isTls) tags.push(TLS); + if (stream.isReality) tags.push(Reality); } return
{tags}
; }, @@ -578,15 +606,18 @@ export default function InboundList({
{t('pages.inbounds.protocol')} {statsRecord.protocol} - {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && ( - <> - - {statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network} - - {statsRecord.toInbound().stream?.isTls && TLS} - {statsRecord.toInbound().stream?.isReality && Reality} - - )} + {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (() => { + const stream = readStreamHints(statsRecord.streamSettings); + return ( + <> + + {statsRecord.isHysteria ? 'UDP' : stream.network} + + {stream.isTls && TLS} + {stream.isReality && Reality} + + ); + })()}
{t('pages.inbounds.port')} diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index d08fa3ac..42e24019 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -21,6 +21,8 @@ import { import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; +import { genInboundLinks } from '@/lib/xray/inbound-link'; +import { inboundFromDb } from '@/lib/xray/inbound-from-db'; import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; @@ -179,13 +181,13 @@ export default function InboundsPage() { const projected = JSON.parse(JSON.stringify(child)) as DBInbound; projected.listen = master.listen; projected.port = master.port; - const masterStream = master.toInbound().stream; - const childInbound = child.toInbound(); - childInbound.stream.security = masterStream.security; - childInbound.stream.tls = masterStream.tls; - childInbound.stream.reality = masterStream.reality; - childInbound.stream.externalProxy = masterStream.externalProxy; - projected.streamSettings = childInbound.stream.toString(); + const masterStream = coerceInboundJsonField(master.streamSettings) as Record; + const childStream = { ...(coerceInboundJsonField(child.streamSettings) as Record) }; + childStream.security = masterStream.security; + childStream.tlsSettings = masterStream.tlsSettings; + childStream.realitySettings = masterStream.realitySettings; + childStream.externalProxy = masterStream.externalProxy; + projected.streamSettings = JSON.stringify(childStream); const Ctor = child.constructor as new (data: DBInbound) => DBInbound; return new Ctor(projected); }, []); @@ -199,11 +201,12 @@ export default function InboundsPage() { if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound; for (const candidate of dbInbounds) { if (candidate.id === dbInbound.id) continue; - const parsed = candidate.toInbound(); - if (!parsed.isTcp) continue; - if (!['trojan', 'vless'].includes(parsed.protocol)) continue; - const fallbacks = parsed.settings.fallbacks || []; - if (!fallbacks.find((f: { dest?: string }) => f.dest === dbInbound.listen)) continue; + if (!['trojan', 'vless'].includes(candidate.protocol)) continue; + const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string }; + if (candStream.network !== 'tcp') continue; + const candSettings = coerceInboundJsonField(candidate.settings) as { fallbacks?: { dest?: string }[] }; + const fallbacks = candSettings.fallbacks || []; + if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue; return projectChildThroughMaster(dbInbound, candidate); } return dbInbound; @@ -211,8 +214,8 @@ export default function InboundsPage() { const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => { if (!client) return 0; - const inbound = dbInbound.toInbound(); - const clients = (inbound?.clients || []) as ClientMatchTarget[]; + const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: ClientMatchTarget[] }; + const clients = settings.clients || []; const idx = clients.findIndex((c) => { if (!c) return false; switch (dbInbound.protocol) { @@ -230,7 +233,13 @@ export default function InboundsPage() { const projected = checkFallback(dbInbound); openText({ title: t('pages.inbounds.exportLinksTitle'), - content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)), + content: genInboundLinks({ + inbound: inboundFromDb(projected), + remark: projected.remark, + remarkModel, + hostOverride: hostOverrideFor(dbInbound), + fallbackHostname: window.location.hostname, + }), fileName: projected.remark || 'inbound', }); }, [checkFallback, remarkModel, hostOverrideFor, openText, t]); @@ -240,8 +249,8 @@ export default function InboundsPage() { }, [openText, t]); const exportInboundSubs = useCallback((dbInbound: DBInbound) => { - const inbound = dbInbound.toInbound(); - const clients = (inbound?.clients || []) as { subId?: string }[]; + const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: { subId?: string }[] }; + const clients = settings.clients || []; const subLinks: string[] = []; for (const c of clients) { if (c.subId && subSettings.subURI) { @@ -262,7 +271,13 @@ export default function InboundsPage() { const out: string[] = []; for (const ib of hydrated) { const projected = checkFallback(ib); - out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib))); + out.push(genInboundLinks({ + inbound: inboundFromDb(projected), + remark: projected.remark, + remarkModel, + hostOverride: hostOverrideFor(ib), + fallbackHostname: window.location.hostname, + })); } openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' }); }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]); @@ -273,8 +288,8 @@ export default function InboundsPage() { ); const out: string[] = []; for (const ib of hydrated) { - const inbound = ib.toInbound(); - const clients = (inbound?.clients || []) as { subId?: string }[]; + const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] }; + const clients = settings.clients || []; for (const c of clients) { if (c.subId && subSettings.subURI) { out.push(subSettings.subURI + c.subId); @@ -347,16 +362,21 @@ export default function InboundsPage() { okText: t('pages.inbounds.clone'), cancelText: t('cancel'), onOk: async () => { - const baseInbound = dbInbound.toInbound(); let clonedSettings: string; try { const raw = coerceInboundJsonField(dbInbound.settings); raw.clients = []; clonedSettings = JSON.stringify(raw); } catch { - const fallback = createDefaultInboundSettings(baseInbound.protocol); + const fallback = createDefaultInboundSettings(dbInbound.protocol); clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}'; } + const streamSettingsString = typeof dbInbound.streamSettings === 'string' + ? dbInbound.streamSettings + : JSON.stringify(dbInbound.streamSettings ?? {}); + const sniffingString = typeof dbInbound.sniffing === 'string' + ? dbInbound.sniffing + : JSON.stringify(dbInbound.sniffing ?? {}); const data = { up: 0, down: 0, @@ -366,10 +386,10 @@ export default function InboundsPage() { expiryTime: 0, listen: '', port: RandomUtil.randomInteger(10000, 60000), - protocol: baseInbound.protocol, + protocol: dbInbound.protocol, settings: clonedSettings, - streamSettings: baseInbound.stream.toString(), - sniffing: baseInbound.sniffing.toString(), + streamSettings: streamSettingsString, + sniffing: sniffingString, }; const msg = await HttpUtil.post('/panel/api/inbounds/add', data); if (msg?.success) await refresh(); diff --git a/frontend/src/pages/inbounds/QrCodeModal.tsx b/frontend/src/pages/inbounds/QrCodeModal.tsx index 22ba564c..74d95462 100644 --- a/frontend/src/pages/inbounds/QrCodeModal.tsx +++ b/frontend/src/pages/inbounds/QrCodeModal.tsx @@ -4,6 +4,12 @@ import { Collapse, Modal } from 'antd'; import type { CollapseProps } from 'antd'; import { Protocols } from '@/schemas/primitives'; +import { + genAllLinks, + genWireguardConfigs, + genWireguardLinks, +} from '@/lib/xray/inbound-link'; +import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db'; import QrPanel from './QrPanel'; import type { SubSettings } from './useInbounds'; @@ -13,22 +19,10 @@ interface ClientSetting { [k: string]: unknown; } -interface DBInboundLike { - remark?: string; - toInbound: () => InboundLike; -} - -interface InboundLike { - protocol: string; - genWireguardConfigs: (remark: string, model: string, host: string) => string; - genWireguardLinks: (remark: string, model: string, host: string) => string; - genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[]; -} - interface QrCodeModalProps { open: boolean; onClose: () => void; - dbInbound: DBInboundLike | null; + dbInbound: (DbInboundLike & { remark?: string }) | null; client?: ClientSetting | null; remarkModel?: string; nodeAddress?: string; @@ -61,16 +55,42 @@ export default function QrCodeModal({ useEffect(() => { if (!open || !dbInbound) return; - const inbound = dbInbound.toInbound(); + const inbound = inboundFromDb(dbInbound); + const fallbackHostname = window.location.hostname; if (inbound.protocol === Protocols.WIREGUARD) { const peerRemark = client?.email ? `${dbInbound.remark}-${client.email}` : dbInbound.remark || ''; - setWireguardConfigs(inbound.genWireguardConfigs(peerRemark, '-ieo', nodeAddress).split('\r\n')); - setWireguardLinks(inbound.genWireguardLinks(peerRemark, '-ieo', nodeAddress).split('\r\n')); + setWireguardConfigs( + genWireguardConfigs({ + inbound, + remark: peerRemark, + remarkModel: '-ieo', + hostOverride: nodeAddress, + fallbackHostname, + }).split('\r\n'), + ); + setWireguardLinks( + genWireguardLinks({ + inbound, + remark: peerRemark, + remarkModel: '-ieo', + hostOverride: nodeAddress, + fallbackHostname, + }).split('\r\n'), + ); setLinks([]); } else { - setLinks(inbound.genAllLinks(dbInbound.remark || '', remarkModel, client, nodeAddress) as { remark?: string; link: string }[]); + setLinks( + genAllLinks({ + inbound, + remark: dbInbound.remark || '', + remarkModel, + client: client ?? {}, + hostOverride: nodeAddress, + fallbackHostname, + }), + ); setWireguardConfigs([]); setWireguardLinks([]); } diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index c4a6abb7..8bf982c7 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -3,8 +3,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { HttpUtil } from '@/utils'; import { parseMsg } from '@/utils/zodValidate'; -import { DBInbound } from '@/models/dbinbound'; +import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound'; import { Protocols } from '@/schemas/primitives'; +import { isSSMultiUser } from '@/lib/xray/protocol-capabilities'; import { setDatepicker } from '@/hooks/useDatepicker'; import { keys } from '@/api/queryKeys'; import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound'; @@ -201,12 +202,14 @@ export function useInbounds() { const rebuildClientCount = useCallback(() => { const counts: Record = {}; for (const dbInbound of dbInboundsRef.current) { - const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean }; isSS: boolean; protocol: string }).toInbound(); - const protocol = (dbInbound as unknown as { protocol: string }).protocol; + const protocol = dbInbound.protocol; if (!TRACKED_PROTOCOLS.includes(protocol)) continue; - const isSS = (dbInbound as unknown as { isSS: boolean }).isSS; - if (isSS && !parsed.isSSMultiUser) continue; - counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] }); + const settings = coerceInboundJsonField(dbInbound.settings) as { + method?: string; + clients?: Array<{ email?: string; enable?: boolean; comment?: string }>; + }; + if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue; + counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients }); } setClientCount(counts); }, [rollupClients]); @@ -219,11 +222,14 @@ export function useInbounds() { const counts: Record = {}; for (const row of slimQuery.data as { protocol: string; id: number }[]) { const dbInbound = new DBInbound(row) as DBInboundInstance; - const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound(); next.push(dbInbound); if (TRACKED_PROTOCOLS.includes(row.protocol)) { - if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue; - counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] }); + const settings = coerceInboundJsonField(dbInbound.settings) as { + method?: string; + clients?: Array<{ email?: string; enable?: boolean; comment?: string }>; + }; + if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue; + counts[row.id] = rollupClients(dbInbound, { clients: settings.clients }); } } dbInboundsRef.current = next; @@ -245,8 +251,12 @@ export function useInbounds() { const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined; const refresh = useCallback(async () => { + // Invalidate at the inbounds root so both `slim` (this page's list) + // and `options` (the Clients page's inbound picker) refetch. Without + // the options bucket, a freshly-created inbound stays invisible in + // the client add/edit modal until a full page reload. await Promise.all([ - queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }), + queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }), queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }), queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }), ]); diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index a5b41b5a..b6461194 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -480,7 +480,9 @@ export default function IndexPage() { open={configTextOpen} title={t('pages.index.config')} width={isMobile ? '100%' : 900} - style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined} + style={isMobile + ? { top: 20, maxWidth: 'calc(100vw - 16px)' } + : { top: 20 }} onCancel={() => setConfigTextOpen(false)} footer={[