From 8d45cd8c68fdec95541dbbe515683df91649130d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 25 May 2026 23:02:08 +0200 Subject: [PATCH] feat(frontend): protocol-leaf Zod schemas with discriminated unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up schemas/primitives (Port, Flow, Protocol, Sniffing) and per-protocol leaf schemas for all 10 inbound and 13 outbound xray protocols. The leaves omit any inner `protocol` literal — the discriminator lives at the parent level so consumers narrow on `.protocol` without redundant projection. Wire shape is preserved per protocol: vmess outbound stays in `vnext[]`, trojan and shadowsocks outbound in `servers[]`, vless outbound flat, http/socks outbound in `servers[].users[]`. Cross-protocol atoms (port, flow, sniffing dest, protocol enum) live in primitives. Protocol-specific enums (vmess security, ss method/network, hysteria version, freedom domain strategy, dns rule action) stay with their leaves. Tagged-wrapper `z.discriminatedUnion('protocol', [...])` composes both InboundSettingsSchema and OutboundSettingsSchema; existing class-based models in src/models/ are untouched and will be retired in Step 3 once the golden-file safety net is in place. --- frontend/src/schemas/index.ts | 2 + frontend/src/schemas/primitives/flow.ts | 8 +++ frontend/src/schemas/primitives/index.ts | 4 ++ frontend/src/schemas/primitives/port.ts | 4 ++ frontend/src/schemas/primitives/protocol.ts | 15 +++++ frontend/src/schemas/primitives/sniffing.ts | 16 +++++ .../src/schemas/protocols/inbound/http.ts | 17 ++++++ .../src/schemas/protocols/inbound/hysteria.ts | 26 ++++++++ .../schemas/protocols/inbound/hysteria2.ts | 13 ++++ .../src/schemas/protocols/inbound/index.ts | 42 +++++++++++++ .../src/schemas/protocols/inbound/mixed.ts | 21 +++++++ .../schemas/protocols/inbound/shadowsocks.ts | 45 ++++++++++++++ .../src/schemas/protocols/inbound/trojan.ts | 32 ++++++++++ .../src/schemas/protocols/inbound/tunnel.ts | 19 ++++++ .../src/schemas/protocols/inbound/vless.ts | 50 ++++++++++++++++ .../src/schemas/protocols/inbound/vmess.ts | 32 ++++++++++ .../schemas/protocols/inbound/wireguard.ts | 23 ++++++++ frontend/src/schemas/protocols/index.ts | 7 +++ .../schemas/protocols/outbound/blackhole.ts | 13 ++++ .../src/schemas/protocols/outbound/dns.ts | 27 +++++++++ .../src/schemas/protocols/outbound/freedom.ts | 59 +++++++++++++++++++ .../src/schemas/protocols/outbound/http.ts | 25 ++++++++ .../schemas/protocols/outbound/hysteria.ts | 12 ++++ .../schemas/protocols/outbound/hysteria2.ts | 12 ++++ .../src/schemas/protocols/outbound/index.ts | 50 ++++++++++++++++ .../schemas/protocols/outbound/loopback.ts | 8 +++ .../schemas/protocols/outbound/shadowsocks.ts | 21 +++++++ .../src/schemas/protocols/outbound/socks.ts | 24 ++++++++ .../src/schemas/protocols/outbound/trojan.ts | 18 ++++++ .../src/schemas/protocols/outbound/vless.ts | 22 +++++++ .../src/schemas/protocols/outbound/vmess.ts | 25 ++++++++ .../schemas/protocols/outbound/wireguard.ts | 36 +++++++++++ 32 files changed, 728 insertions(+) create mode 100644 frontend/src/schemas/index.ts create mode 100644 frontend/src/schemas/primitives/flow.ts create mode 100644 frontend/src/schemas/primitives/index.ts create mode 100644 frontend/src/schemas/primitives/port.ts create mode 100644 frontend/src/schemas/primitives/protocol.ts create mode 100644 frontend/src/schemas/primitives/sniffing.ts create mode 100644 frontend/src/schemas/protocols/inbound/http.ts create mode 100644 frontend/src/schemas/protocols/inbound/hysteria.ts create mode 100644 frontend/src/schemas/protocols/inbound/hysteria2.ts create mode 100644 frontend/src/schemas/protocols/inbound/index.ts create mode 100644 frontend/src/schemas/protocols/inbound/mixed.ts create mode 100644 frontend/src/schemas/protocols/inbound/shadowsocks.ts create mode 100644 frontend/src/schemas/protocols/inbound/trojan.ts create mode 100644 frontend/src/schemas/protocols/inbound/tunnel.ts create mode 100644 frontend/src/schemas/protocols/inbound/vless.ts create mode 100644 frontend/src/schemas/protocols/inbound/vmess.ts create mode 100644 frontend/src/schemas/protocols/inbound/wireguard.ts create mode 100644 frontend/src/schemas/protocols/index.ts create mode 100644 frontend/src/schemas/protocols/outbound/blackhole.ts create mode 100644 frontend/src/schemas/protocols/outbound/dns.ts create mode 100644 frontend/src/schemas/protocols/outbound/freedom.ts create mode 100644 frontend/src/schemas/protocols/outbound/http.ts create mode 100644 frontend/src/schemas/protocols/outbound/hysteria.ts create mode 100644 frontend/src/schemas/protocols/outbound/hysteria2.ts create mode 100644 frontend/src/schemas/protocols/outbound/index.ts create mode 100644 frontend/src/schemas/protocols/outbound/loopback.ts create mode 100644 frontend/src/schemas/protocols/outbound/shadowsocks.ts create mode 100644 frontend/src/schemas/protocols/outbound/socks.ts create mode 100644 frontend/src/schemas/protocols/outbound/trojan.ts create mode 100644 frontend/src/schemas/protocols/outbound/vless.ts create mode 100644 frontend/src/schemas/protocols/outbound/vmess.ts create mode 100644 frontend/src/schemas/protocols/outbound/wireguard.ts diff --git a/frontend/src/schemas/index.ts b/frontend/src/schemas/index.ts new file mode 100644 index 00000000..940f43fd --- /dev/null +++ b/frontend/src/schemas/index.ts @@ -0,0 +1,2 @@ +export * from './primitives'; +export * from './protocols'; diff --git a/frontend/src/schemas/primitives/flow.ts b/frontend/src/schemas/primitives/flow.ts new file mode 100644 index 00000000..faf17eab --- /dev/null +++ b/frontend/src/schemas/primitives/flow.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const FlowSchema = z.enum([ + '', + 'xtls-rprx-vision', + 'xtls-rprx-vision-udp443', +]); +export type Flow = z.infer; diff --git a/frontend/src/schemas/primitives/index.ts b/frontend/src/schemas/primitives/index.ts new file mode 100644 index 00000000..714b80ba --- /dev/null +++ b/frontend/src/schemas/primitives/index.ts @@ -0,0 +1,4 @@ +export * from './port'; +export * from './protocol'; +export * from './sniffing'; +export * from './flow'; diff --git a/frontend/src/schemas/primitives/port.ts b/frontend/src/schemas/primitives/port.ts new file mode 100644 index 00000000..d73ed2c6 --- /dev/null +++ b/frontend/src/schemas/primitives/port.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +export const PortSchema = z.number().int().min(1).max(65535); +export type Port = z.infer; diff --git a/frontend/src/schemas/primitives/protocol.ts b/frontend/src/schemas/primitives/protocol.ts new file mode 100644 index 00000000..e77b0653 --- /dev/null +++ b/frontend/src/schemas/primitives/protocol.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ProtocolSchema = z.enum([ + 'vmess', + 'vless', + 'trojan', + 'shadowsocks', + 'wireguard', + 'hysteria', + 'hysteria2', + 'http', + 'mixed', + 'tunnel', +]); +export type Protocol = z.infer; diff --git a/frontend/src/schemas/primitives/sniffing.ts b/frontend/src/schemas/primitives/sniffing.ts new file mode 100644 index 00000000..7b9941c1 --- /dev/null +++ b/frontend/src/schemas/primitives/sniffing.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const SniffingDestSchema = z.enum(['http', 'tls', 'quic', 'fakedns']); +export type SniffingDest = z.infer; + +export const SniffingSchema = z.object({ + enabled: z.boolean().default(false), + destOverride: z + .array(SniffingDestSchema) + .default(['http', 'tls', 'quic', 'fakedns']), + metadataOnly: z.boolean().default(false), + routeOnly: z.boolean().default(false), + ipsExcluded: z.array(z.string()).default([]), + domainsExcluded: z.array(z.string()).default([]), +}); +export type Sniffing = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/http.ts b/frontend/src/schemas/protocols/inbound/http.ts new file mode 100644 index 00000000..3fde3ab7 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/http.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +// HTTP proxy inbound — a classic forward proxy. Accounts are user/pass pairs; +// `allowTransparent` exposes Xray's option to forward requests with the +// original Host header. No client tracking (no email/limits) at the Xray +// settings level — the panel doesn't model HTTP users as billable clients. +export const HttpAccountSchema = z.object({ + user: z.string().min(1), + pass: z.string().min(1), +}); +export type HttpAccount = z.infer; + +export const HttpInboundSettingsSchema = z.object({ + accounts: z.array(HttpAccountSchema).default([]), + allowTransparent: z.boolean().default(false), +}); +export type HttpInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/hysteria.ts b/frontend/src/schemas/protocols/inbound/hysteria.ts new file mode 100644 index 00000000..c6e9fbc9 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/hysteria.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the +// panel defaults to v2). Each client supplies an `auth` token instead of a +// UUID/password. +export const HysteriaClientSchema = z.object({ + auth: z.string().min(1), + email: z.string().min(1), + limitIp: z.number().int().min(0).default(0), + totalGB: z.number().int().min(0).default(0), + expiryTime: z.number().int().default(0), + enable: z.boolean().default(true), + tgId: z.number().int().default(0), + subId: z.string().default(''), + comment: z.string().default(''), + reset: z.number().int().min(0).default(0), + created_at: z.number().int().optional(), + updated_at: z.number().int().optional(), +}); +export type HysteriaClient = z.infer; + +export const HysteriaInboundSettingsSchema = z.object({ + version: z.number().int().min(1).default(2), + clients: z.array(HysteriaClientSchema).default([]), +}); +export type HysteriaInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/hysteria2.ts b/frontend/src/schemas/protocols/inbound/hysteria2.ts new file mode 100644 index 00000000..135bee03 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/hysteria2.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { HysteriaClientSchema } from '@/schemas/protocols/inbound/hysteria'; + +// hysteria2 is wire-distinct from hysteria (different parent protocol literal, +// different Go validate tag) but the panel's settings payload is structurally +// identical — same client shape, same auth-based clients. We pin `version` to +// the literal 2 here so a hysteria2 inbound can never silently downgrade. +export const Hysteria2InboundSettingsSchema = z.object({ + version: z.literal(2).default(2), + clients: z.array(HysteriaClientSchema).default([]), +}); +export type Hysteria2InboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/index.ts b/frontend/src/schemas/protocols/inbound/index.ts new file mode 100644 index 00000000..984579c5 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/index.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { HttpInboundSettingsSchema } from './http'; +import { Hysteria2InboundSettingsSchema } from './hysteria2'; +import { HysteriaInboundSettingsSchema } from './hysteria'; +import { MixedInboundSettingsSchema } from './mixed'; +import { ShadowsocksInboundSettingsSchema } from './shadowsocks'; +import { TrojanInboundSettingsSchema } from './trojan'; +import { TunnelInboundSettingsSchema } from './tunnel'; +import { VlessInboundSettingsSchema } from './vless'; +import { VmessInboundSettingsSchema } from './vmess'; +import { WireguardInboundSettingsSchema } from './wireguard'; + +export * from './http'; +export * from './hysteria'; +export * from './hysteria2'; +export * from './mixed'; +export * from './shadowsocks'; +export * from './trojan'; +export * from './tunnel'; +export * from './vless'; +export * from './vmess'; +export * from './wireguard'; + +// Tagged-wrapper discriminated union. The discriminator (`protocol`) lives on +// the wrapper, not inside `settings`, mirroring the wire format Xray emits: +// { protocol: 'vless', settings: { clients: [...], ... }, ... } +// Consumers narrow on `.protocol` and TypeScript narrows `.settings` to the +// matching leaf type. +export const InboundSettingsSchema = z.discriminatedUnion('protocol', [ + z.object({ protocol: z.literal('vmess'), settings: VmessInboundSettingsSchema }), + z.object({ protocol: z.literal('vless'), settings: VlessInboundSettingsSchema }), + z.object({ protocol: z.literal('trojan'), settings: TrojanInboundSettingsSchema }), + z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksInboundSettingsSchema }), + z.object({ protocol: z.literal('wireguard'), settings: WireguardInboundSettingsSchema }), + z.object({ protocol: z.literal('hysteria'), settings: HysteriaInboundSettingsSchema }), + z.object({ protocol: z.literal('hysteria2'), settings: Hysteria2InboundSettingsSchema }), + z.object({ protocol: z.literal('http'), settings: HttpInboundSettingsSchema }), + z.object({ protocol: z.literal('mixed'), settings: MixedInboundSettingsSchema }), + z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }), +]); +export type InboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/mixed.ts b/frontend/src/schemas/protocols/inbound/mixed.ts new file mode 100644 index 00000000..b63c51cf --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/mixed.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const MixedAuthSchema = z.enum(['password', 'noauth']); +export type MixedAuth = z.infer; + +// SOCKS/HTTP combined inbound. When auth==='noauth' the `accounts` field is +// omitted from the wire payload (the panel writes `undefined`), so we accept +// either an array or absence here. +export const MixedAccountSchema = z.object({ + user: z.string().min(1), + pass: z.string().min(1), +}); +export type MixedAccount = z.infer; + +export const MixedInboundSettingsSchema = z.object({ + auth: MixedAuthSchema.default('password'), + accounts: z.array(MixedAccountSchema).optional(), + udp: z.boolean().default(false), + ip: z.string().default('127.0.0.1'), +}); +export type MixedInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/shadowsocks.ts b/frontend/src/schemas/protocols/inbound/shadowsocks.ts new file mode 100644 index 00000000..e431a2e1 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/shadowsocks.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +export const SSMethodSchema = z.enum([ + 'aes-256-gcm', + 'chacha20-poly1305', + 'chacha20-ietf-poly1305', + 'xchacha20-ietf-poly1305', + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-chacha20-poly1305', +]); +export type SSMethod = z.infer; + +export const SSNetworkSchema = z.enum(['tcp', 'udp', 'tcp,udp']); +export type SSNetwork = z.infer; + +// On a single-user shadowsocks inbound the client carries no method/password +// of its own — the inbound-level method+password are authoritative. On a +// 2022-blake3 multi-user setup each client provides its own password (and +// optionally a per-client method). +export const ShadowsocksClientSchema = z.object({ + method: z.string().default(''), + password: z.string().default(''), + email: z.string().min(1), + limitIp: z.number().int().min(0).default(0), + totalGB: z.number().int().min(0).default(0), + expiryTime: z.number().int().default(0), + enable: z.boolean().default(true), + tgId: z.number().int().default(0), + subId: z.string().default(''), + comment: z.string().default(''), + reset: z.number().int().min(0).default(0), + created_at: z.number().int().optional(), + updated_at: z.number().int().optional(), +}); +export type ShadowsocksClient = z.infer; + +export const ShadowsocksInboundSettingsSchema = z.object({ + method: SSMethodSchema.default('2022-blake3-aes-256-gcm'), + password: z.string().default(''), + network: SSNetworkSchema.default('tcp'), + clients: z.array(ShadowsocksClientSchema).default([]), + ivCheck: z.boolean().default(false), +}); +export type ShadowsocksInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/trojan.ts b/frontend/src/schemas/protocols/inbound/trojan.ts new file mode 100644 index 00000000..587da3ea --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/trojan.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const TrojanFallbackSchema = z.object({ + name: z.string().default(''), + alpn: z.string().default(''), + path: z.string().default(''), + dest: z.union([z.string(), z.number()]).default(''), + xver: z.number().int().min(0).default(0), +}); +export type TrojanFallback = z.infer; + +export const TrojanClientSchema = z.object({ + password: z.string().min(1), + email: z.string().min(1), + limitIp: z.number().int().min(0).default(0), + totalGB: z.number().int().min(0).default(0), + expiryTime: z.number().int().default(0), + enable: z.boolean().default(true), + tgId: z.number().int().default(0), + subId: z.string().default(''), + comment: z.string().default(''), + reset: z.number().int().min(0).default(0), + created_at: z.number().int().optional(), + updated_at: z.number().int().optional(), +}); +export type TrojanClient = z.infer; + +export const TrojanInboundSettingsSchema = z.object({ + clients: z.array(TrojanClientSchema).default([]), + fallbacks: z.array(TrojanFallbackSchema).default([]), +}); +export type TrojanInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/tunnel.ts b/frontend/src/schemas/protocols/inbound/tunnel.ts new file mode 100644 index 00000000..5ce839ad --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/tunnel.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +export const TunnelNetworkSchema = z.enum(['tcp', 'udp', 'tcp,udp']); +export type TunnelNetwork = z.infer; + +// Tunnel inbound (Xray's `dokodemo-door`-style transparent forwarder). +// `portMap` is persisted as Record on the wire — the panel +// flattens an internal array-of-{name,value} into that map via toV2Headers +// with arr=false. +export const TunnelInboundSettingsSchema = z.object({ + rewriteAddress: z.string().optional(), + rewritePort: PortSchema.optional(), + portMap: z.record(z.string(), z.string()).default({}), + allowedNetwork: TunnelNetworkSchema.default('tcp,udp'), + followRedirect: z.boolean().default(false), +}); +export type TunnelInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/vless.ts b/frontend/src/schemas/protocols/inbound/vless.ts new file mode 100644 index 00000000..9badf0d4 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/vless.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { FlowSchema, SniffingSchema } from '@/schemas/primitives'; + +export const VlessFallbackSchema = z.object({ + name: z.string().default(''), + alpn: z.string().default(''), + path: z.string().default(''), + dest: z.union([z.string(), z.number()]).default(''), + xver: z.number().int().min(0).default(0), +}); +export type VlessFallback = z.infer; + +export const VlessClientSchema = z.object({ + id: z.uuid(), + email: z.string().min(1), + flow: FlowSchema.default(''), + limitIp: z.number().int().min(0).default(0), + totalGB: z.number().int().min(0).default(0), + expiryTime: z.number().int().default(0), + enable: z.boolean().default(true), + tgId: z.number().int().default(0), + subId: z.string().default(''), + comment: z.string().default(''), + reset: z.number().int().min(0).default(0), + // VLESS simple reverse-proxy: which reverse tag this client routes to, + // plus an optional sniffing override for that path. Distinct from the + // inbound-level `fallbacks` feature. + reverse: z + .object({ + tag: z.string(), + sniffing: SniffingSchema.optional(), + }) + .optional(), + created_at: z.number().int().optional(), + updated_at: z.number().int().optional(), +}); +export type VlessClient = z.infer; + +export const VlessInboundSettingsSchema = z.object({ + clients: z.array(VlessClientSchema).default([]), + decryption: z.literal('none').default('none'), + encryption: z.literal('none').default('none'), + fallbacks: z.array(VlessFallbackSchema).default([]), + // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator + // exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses + // safe defaults when omitted. + testseed: z.array(z.number().int().positive()).length(4).optional(), +}); +export type VlessInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/vmess.ts b/frontend/src/schemas/protocols/inbound/vmess.ts new file mode 100644 index 00000000..d91c537f --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/vmess.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const VmessSecuritySchema = z.enum([ + 'aes-128-gcm', + 'chacha20-poly1305', + 'auto', + 'none', + 'zero', +]); +export type VmessSecurity = z.infer; + +export const VmessClientSchema = z.object({ + id: z.uuid(), + security: VmessSecuritySchema.default('auto'), + email: z.string().min(1), + limitIp: z.number().int().min(0).default(0), + totalGB: z.number().int().min(0).default(0), + expiryTime: z.number().int().default(0), + enable: z.boolean().default(true), + tgId: z.number().int().default(0), + subId: z.string().default(''), + comment: z.string().default(''), + reset: z.number().int().min(0).default(0), + created_at: z.number().int().optional(), + updated_at: z.number().int().optional(), +}); +export type VmessClient = z.infer; + +export const VmessInboundSettingsSchema = z.object({ + clients: z.array(VmessClientSchema).default([]), +}); +export type VmessInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/wireguard.ts b/frontend/src/schemas/protocols/inbound/wireguard.ts new file mode 100644 index 00000000..1baa89de --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/wireguard.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +// Wireguard inbound is peer-based (no clients). Each peer is a client device +// the server accepts; secretKey is the server-side private key and pubKey is +// derived from it at runtime (not persisted on the wire). Inbound peers +// optionally store the client's privateKey so the panel can render configs +// for the user — outbound peers never have a privateKey. +export const WireguardInboundPeerSchema = z.object({ + privateKey: z.string().optional(), + publicKey: z.string().min(1), + preSharedKey: z.string().optional(), + allowedIPs: z.array(z.string()).default([]), + keepAlive: z.number().int().min(0).optional(), +}); +export type WireguardInboundPeer = z.infer; + +export const WireguardInboundSettingsSchema = z.object({ + mtu: z.number().int().min(1).optional(), + secretKey: z.string().min(1), + peers: z.array(WireguardInboundPeerSchema).default([]), + noKernelTun: z.boolean().default(false), +}); +export type WireguardInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/index.ts b/frontend/src/schemas/protocols/index.ts new file mode 100644 index 00000000..097920d1 --- /dev/null +++ b/frontend/src/schemas/protocols/index.ts @@ -0,0 +1,7 @@ +export * as Inbound from './inbound'; +export * as Outbound from './outbound'; + +export { InboundSettingsSchema } from './inbound'; +export type { InboundSettings } from './inbound'; +export { OutboundSettingsSchema } from './outbound'; +export type { OutboundSettings } from './outbound'; diff --git a/frontend/src/schemas/protocols/outbound/blackhole.ts b/frontend/src/schemas/protocols/outbound/blackhole.ts new file mode 100644 index 00000000..beba5fe4 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/blackhole.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const BlackholeResponseTypeSchema = z.enum(['none', 'http']); +export type BlackholeResponseType = z.infer; + +// Blackhole drops traffic. `response.type` is the only knob — when set, Xray +// returns the canned 403 HTTP response before closing; when omitted it +// silently drops. The panel stores it as { response: { type } } or omits the +// whole `response` key when type is empty. +export const BlackholeOutboundSettingsSchema = z.object({ + response: z.object({ type: BlackholeResponseTypeSchema }).optional(), +}); +export type BlackholeOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/dns.ts b/frontend/src/schemas/protocols/outbound/dns.ts new file mode 100644 index 00000000..58dcecb2 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/dns.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +export const DNSRuleActionSchema = z.enum(['direct', 'reject', 'rejectIPv4', 'rejectIPv6']); + +// On the wire `qtype` is either a number (DNS type code) or a string like +// "A"/"AAAA"/"TXT"; the panel normalizes numeric strings to numbers in +// toJson. `domain` is a string[] (split from a comma-joined input). +export const DNSRuleSchema = z.object({ + action: DNSRuleActionSchema.default('direct'), + qtype: z.union([z.string(), z.number().int()]).optional(), + domain: z.array(z.string()).optional(), +}); +export type DNSRule = z.infer; + +// DNS outbound rewrites DNS queries onto a different transport. All five +// fields are emitted conditionally — empty/zero values are omitted from the +// wire payload entirely (handled at the caller, not here). +export const DNSOutboundSettingsSchema = z.object({ + rewriteNetwork: z.string().optional(), + rewriteAddress: z.string().optional(), + rewritePort: PortSchema.optional(), + userLevel: z.number().int().min(0).optional(), + rules: z.array(DNSRuleSchema).optional(), +}); +export type DNSOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/freedom.ts b/frontend/src/schemas/protocols/outbound/freedom.ts new file mode 100644 index 00000000..c09d00be --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/freedom.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +export const OutboundDomainStrategySchema = z.enum([ + 'AsIs', + 'UseIP', + 'UseIPv4', + 'UseIPv6', + 'UseIPv6v4', + 'UseIPv4v6', + 'ForceIP', + 'ForceIPv6v4', + 'ForceIPv6', + 'ForceIPv4v6', + 'ForceIPv4', +]); +export type OutboundDomainStrategy = z.infer; + +// Fragment knobs are TCP-level splitting controls; all four fields are +// dash-range strings (e.g. '1-3', '10-20'). +export const FreedomFragmentSchema = z.object({ + packets: z.string().default('1-3'), + length: z.string().default(''), + interval: z.string().default(''), + maxSplit: z.string().default(''), +}); +export type FreedomFragment = z.infer; + +export const FreedomNoiseTypeSchema = z.enum(['rand', 'str', 'base64', 'hex']); +export const FreedomNoiseApplyToSchema = z.enum(['ip', 'host', 'all']); + +export const FreedomNoiseSchema = z.object({ + type: FreedomNoiseTypeSchema.default('rand'), + packet: z.string().default('10-20'), + delay: z.string().default('10-16'), + applyTo: FreedomNoiseApplyToSchema.default('ip'), +}); +export type FreedomNoise = z.infer; + +export const FreedomFinalRuleActionSchema = z.enum(['allow', 'block']); + +// Final rules express the legacy ipsBlocked behavior plus generalized +// allow/block per network+port+ip combinations. +export const FreedomFinalRuleSchema = z.object({ + action: FreedomFinalRuleActionSchema.default('block'), + network: z.string().optional(), + port: z.string().optional(), + ip: z.array(z.string()).default([]), + blockDelay: z.string().optional(), +}); +export type FreedomFinalRule = z.infer; + +export const FreedomOutboundSettingsSchema = z.object({ + domainStrategy: OutboundDomainStrategySchema.optional(), + redirect: z.string().optional(), + fragment: FreedomFragmentSchema.optional(), + noises: z.array(FreedomNoiseSchema).optional(), + finalRules: z.array(FreedomFinalRuleSchema).optional(), +}); +export type FreedomOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/http.ts b/frontend/src/schemas/protocols/outbound/http.ts new file mode 100644 index 00000000..83aa143f --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/http.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +// HTTP outbound persists in Xray's `servers[].users[]` shape. The panel only +// supports a single server with at most one user (the constructor reads +// servers[0] / users[0]). We model the wire shape rather than the panel's +// flattened class fields so saves round-trip exactly. +export const HttpOutboundUserSchema = z.object({ + user: z.string().min(1), + pass: z.string().min(1), +}); +export type HttpOutboundUser = z.infer; + +export const HttpOutboundServerSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + users: z.array(HttpOutboundUserSchema).default([]), +}); +export type HttpOutboundServer = z.infer; + +export const HttpOutboundSettingsSchema = z.object({ + servers: z.array(HttpOutboundServerSchema).min(1), +}); +export type HttpOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/hysteria.ts b/frontend/src/schemas/protocols/outbound/hysteria.ts new file mode 100644 index 00000000..dd0e441c --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/hysteria.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +// Hysteria outbound is a thin connect-target descriptor — the actual auth and +// transport knobs live on the stream/transport layer, not in settings. +export const HysteriaOutboundSettingsSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + version: z.number().int().min(1).default(2), +}); +export type HysteriaOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/hysteria2.ts b/frontend/src/schemas/protocols/outbound/hysteria2.ts new file mode 100644 index 00000000..aae86d87 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/hysteria2.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +// Outbound counterpart to hysteria2 — same {address, port} connect descriptor +// as hysteria, but version locked to 2. +export const Hysteria2OutboundSettingsSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + version: z.literal(2).default(2), +}); +export type Hysteria2OutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/index.ts b/frontend/src/schemas/protocols/outbound/index.ts new file mode 100644 index 00000000..abe42752 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/index.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { BlackholeOutboundSettingsSchema } from './blackhole'; +import { DNSOutboundSettingsSchema } from './dns'; +import { FreedomOutboundSettingsSchema } from './freedom'; +import { HttpOutboundSettingsSchema } from './http'; +import { Hysteria2OutboundSettingsSchema } from './hysteria2'; +import { HysteriaOutboundSettingsSchema } from './hysteria'; +import { LoopbackOutboundSettingsSchema } from './loopback'; +import { ShadowsocksOutboundSettingsSchema } from './shadowsocks'; +import { SocksOutboundSettingsSchema } from './socks'; +import { TrojanOutboundSettingsSchema } from './trojan'; +import { VlessOutboundSettingsSchema } from './vless'; +import { VmessOutboundSettingsSchema } from './vmess'; +import { WireguardOutboundSettingsSchema } from './wireguard'; + +export * from './blackhole'; +export * from './dns'; +export * from './freedom'; +export * from './http'; +export * from './hysteria'; +export * from './hysteria2'; +export * from './loopback'; +export * from './shadowsocks'; +export * from './socks'; +export * from './trojan'; +export * from './vless'; +export * from './vmess'; +export * from './wireguard'; + +// Outbound discriminated union spans 13 protocols (mixed/tunnel are +// inbound-only; freedom/blackhole/dns/loopback are outbound-only). The wire +// shape is `{ protocol, settings }` — same wrapper pattern as the inbound +// union, even though some leaf schemas (freedom, blackhole) are sparse. +export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [ + z.object({ protocol: z.literal('vmess'), settings: VmessOutboundSettingsSchema }), + z.object({ protocol: z.literal('vless'), settings: VlessOutboundSettingsSchema }), + z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundSettingsSchema }), + z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }), + z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }), + z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }), + z.object({ protocol: z.literal('hysteria2'), settings: Hysteria2OutboundSettingsSchema }), + z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }), + z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }), + z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundSettingsSchema }), + z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundSettingsSchema }), + z.object({ protocol: z.literal('dns'), settings: DNSOutboundSettingsSchema }), + z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundSettingsSchema }), +]); +export type OutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/loopback.ts b/frontend/src/schemas/protocols/outbound/loopback.ts new file mode 100644 index 00000000..f7079df8 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/loopback.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +// Loopback outbound reinjects traffic back into a named inbound for chained +// routing. The single `inboundTag` field references an inbound tag by name. +export const LoopbackOutboundSettingsSchema = z.object({ + inboundTag: z.string().optional(), +}); +export type LoopbackOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/shadowsocks.ts b/frontend/src/schemas/protocols/outbound/shadowsocks.ts new file mode 100644 index 00000000..64ed0094 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/shadowsocks.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; +import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks'; + +// Shadowsocks outbound persists as { servers: [{ ... }] }, with UDP-over-TCP +// knobs (uot, UoTVersion) attached per-server when the user enabled them. +export const ShadowsocksOutboundServerSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + password: z.string().min(1), + method: SSMethodSchema, + uot: z.boolean().optional(), + UoTVersion: z.number().int().min(1).max(2).optional(), +}); +export type ShadowsocksOutboundServer = z.infer; + +export const ShadowsocksOutboundSettingsSchema = z.object({ + servers: z.array(ShadowsocksOutboundServerSchema).min(1), +}); +export type ShadowsocksOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/socks.ts b/frontend/src/schemas/protocols/outbound/socks.ts new file mode 100644 index 00000000..d778df53 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/socks.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +// SOCKS outbound persists in Xray's `servers[].users[]` shape — wire-identical +// to HTTP outbound but with `socks` as the parent protocol literal. The panel +// only supports a single server with at most one user. +export const SocksOutboundUserSchema = z.object({ + user: z.string().min(1), + pass: z.string().min(1), +}); +export type SocksOutboundUser = z.infer; + +export const SocksOutboundServerSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + users: z.array(SocksOutboundUserSchema).default([]), +}); +export type SocksOutboundServer = z.infer; + +export const SocksOutboundSettingsSchema = z.object({ + servers: z.array(SocksOutboundServerSchema).min(1), +}); +export type SocksOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/trojan.ts b/frontend/src/schemas/protocols/outbound/trojan.ts new file mode 100644 index 00000000..37a10be5 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/trojan.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +// Trojan outbound persists as { servers: [{ address, port, password }] } +// — distinct from VLESS outbound which stores the connect target flat at +// the settings root. The wrapping mirrors what Xray expects. +export const TrojanOutboundServerSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + password: z.string().min(1), +}); +export type TrojanOutboundServer = z.infer; + +export const TrojanOutboundSettingsSchema = z.object({ + servers: z.array(TrojanOutboundServerSchema).min(1), +}); +export type TrojanOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/vless.ts b/frontend/src/schemas/protocols/outbound/vless.ts new file mode 100644 index 00000000..792e4226 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/vless.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { FlowSchema, SniffingSchema } from '@/schemas/primitives'; + +export const VlessOutboundSettingsSchema = z.object({ + address: z.string(), + port: z.number().int().min(1).max(65535), + id: z.uuid(), + flow: FlowSchema.default(''), + encryption: z.literal('none').default('none'), + reverse: z + .object({ + tag: z.string(), + sniffing: SniffingSchema.optional(), + }) + .optional(), + testpre: z.number().int().min(0).optional(), + // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator + // exists. + testseed: z.array(z.number().int().positive()).length(4).optional(), +}); +export type VlessOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/vmess.ts b/frontend/src/schemas/protocols/outbound/vmess.ts new file mode 100644 index 00000000..d381a913 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/vmess.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; +import { VmessSecuritySchema } from '@/schemas/protocols/inbound/vmess'; + +// Vmess outbound persists in the standard Xray `vnext` shape: +// { vnext: [{ address, port, users: [{ id, security }] }] } +// — distinct from VLESS outbound which the panel stores flat. +export const VmessOutboundUserSchema = z.object({ + id: z.uuid(), + security: VmessSecuritySchema.default('auto'), +}); +export type VmessOutboundUser = z.infer; + +export const VmessOutboundServerSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + users: z.array(VmessOutboundUserSchema).min(1), +}); +export type VmessOutboundServer = z.infer; + +export const VmessOutboundSettingsSchema = z.object({ + vnext: z.array(VmessOutboundServerSchema).min(1), +}); +export type VmessOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/wireguard.ts b/frontend/src/schemas/protocols/outbound/wireguard.ts new file mode 100644 index 00000000..6080b1a1 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/wireguard.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const WireguardDomainStrategySchema = z.enum([ + 'ForceIP', + 'ForceIPv4', + 'ForceIPv4v6', + 'ForceIPv6', + 'ForceIPv6v4', +]); +export type WireguardDomainStrategy = z.infer; + +// Outbound peer is the remote server we connect to: no privateKey, but an +// `endpoint` (host:port) the inbound side does not need. +export const WireguardOutboundPeerSchema = z.object({ + publicKey: z.string().min(1), + preSharedKey: z.string().optional(), + allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']), + endpoint: z.string().min(1), + keepAlive: z.number().int().min(0).optional(), +}); +export type WireguardOutboundPeer = z.infer; + +// Wire format: address is a string[] (Xray expects an array even though the +// panel UI stores it comma-joined); reserved is number[] (panel splits the +// comma string and Number()-coerces each entry). +export const WireguardOutboundSettingsSchema = z.object({ + mtu: z.number().int().min(1).optional(), + secretKey: z.string().min(1), + address: z.array(z.string()).default([]), + workers: z.number().int().min(1).optional(), + domainStrategy: WireguardDomainStrategySchema.optional(), + reserved: z.array(z.number().int()).optional(), + peers: z.array(WireguardOutboundPeerSchema).min(1), + noKernelTun: z.boolean().default(false), +}); +export type WireguardOutboundSettings = z.infer;