feat(frontend): protocol-leaf Zod schemas with discriminated unions

Stand up schemas/primitives (Port, Flow, Protocol, Sniffing) and per-protocol
leaf schemas for all 10 inbound and 13 outbound xray protocols. The leaves
omit any inner `protocol` literal — the discriminator lives at the parent
level so consumers narrow on `.protocol` without redundant projection. Wire
shape is preserved per protocol: vmess outbound stays in `vnext[]`, trojan
and shadowsocks outbound in `servers[]`, vless outbound flat, http/socks
outbound in `servers[].users[]`.

Cross-protocol atoms (port, flow, sniffing dest, protocol enum) live in
primitives. Protocol-specific enums (vmess security, ss method/network,
hysteria version, freedom domain strategy, dns rule action) stay with their
leaves. Tagged-wrapper `z.discriminatedUnion('protocol', [...])` composes
both InboundSettingsSchema and OutboundSettingsSchema; existing class-based
models in src/models/ are untouched and will be retired in Step 3 once the
golden-file safety net is in place.
This commit is contained in:
MHSanaei
2026-05-25 23:02:08 +02:00
parent 31845fa8f6
commit 8d45cd8c68
32 changed files with 728 additions and 0 deletions

View File

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

View File

@@ -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<typeof FlowSchema>;

View File

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

View File

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

View File

@@ -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<typeof ProtocolSchema>;

View File

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

View File

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

View File

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

View File

@@ -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<typeof Hysteria2InboundSettingsSchema>;

View File

@@ -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<typeof InboundSettingsSchema>;

View File

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

View File

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

View File

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

View File

@@ -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<typeof TunnelNetworkSchema>;
// Tunnel inbound (Xray's `dokodemo-door`-style transparent forwarder).
// `portMap` is persisted as Record<string, string> 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<typeof TunnelInboundSettingsSchema>;

View File

@@ -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<typeof VlessFallbackSchema>;
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<typeof VlessClientSchema>;
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<typeof VlessInboundSettingsSchema>;

View File

@@ -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<typeof VmessSecuritySchema>;
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<typeof VmessClientSchema>;
export const VmessInboundSettingsSchema = z.object({
clients: z.array(VmessClientSchema).default([]),
});
export type VmessInboundSettings = z.infer<typeof VmessInboundSettingsSchema>;

View File

@@ -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<typeof WireguardInboundPeerSchema>;
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<typeof WireguardInboundSettingsSchema>;

View File

@@ -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';

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const BlackholeResponseTypeSchema = z.enum(['none', 'http']);
export type BlackholeResponseType = z.infer<typeof BlackholeResponseTypeSchema>;
// 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<typeof BlackholeOutboundSettingsSchema>;

View File

@@ -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<typeof DNSRuleSchema>;
// 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<typeof DNSOutboundSettingsSchema>;

View File

@@ -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<typeof OutboundDomainStrategySchema>;
// 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<typeof FreedomFragmentSchema>;
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<typeof FreedomNoiseSchema>;
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<typeof FreedomFinalRuleSchema>;
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<typeof FreedomOutboundSettingsSchema>;

View File

@@ -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<typeof HttpOutboundUserSchema>;
export const HttpOutboundServerSchema = z.object({
address: z.string().min(1),
port: PortSchema,
users: z.array(HttpOutboundUserSchema).default([]),
});
export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
export const HttpOutboundSettingsSchema = z.object({
servers: z.array(HttpOutboundServerSchema).min(1),
});
export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;

View File

@@ -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<typeof HysteriaOutboundSettingsSchema>;

View File

@@ -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<typeof Hysteria2OutboundSettingsSchema>;

View File

@@ -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<typeof OutboundSettingsSchema>;

View File

@@ -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<typeof LoopbackOutboundSettingsSchema>;

View File

@@ -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<typeof ShadowsocksOutboundServerSchema>;
export const ShadowsocksOutboundSettingsSchema = z.object({
servers: z.array(ShadowsocksOutboundServerSchema).min(1),
});
export type ShadowsocksOutboundSettings = z.infer<typeof ShadowsocksOutboundSettingsSchema>;

View File

@@ -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<typeof SocksOutboundUserSchema>;
export const SocksOutboundServerSchema = z.object({
address: z.string().min(1),
port: PortSchema,
users: z.array(SocksOutboundUserSchema).default([]),
});
export type SocksOutboundServer = z.infer<typeof SocksOutboundServerSchema>;
export const SocksOutboundSettingsSchema = z.object({
servers: z.array(SocksOutboundServerSchema).min(1),
});
export type SocksOutboundSettings = z.infer<typeof SocksOutboundSettingsSchema>;

View File

@@ -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<typeof TrojanOutboundServerSchema>;
export const TrojanOutboundSettingsSchema = z.object({
servers: z.array(TrojanOutboundServerSchema).min(1),
});
export type TrojanOutboundSettings = z.infer<typeof TrojanOutboundSettingsSchema>;

View File

@@ -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<typeof VlessOutboundSettingsSchema>;

View File

@@ -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<typeof VmessOutboundUserSchema>;
export const VmessOutboundServerSchema = z.object({
address: z.string().min(1),
port: PortSchema,
users: z.array(VmessOutboundUserSchema).min(1),
});
export type VmessOutboundServer = z.infer<typeof VmessOutboundServerSchema>;
export const VmessOutboundSettingsSchema = z.object({
vnext: z.array(VmessOutboundServerSchema).min(1),
});
export type VmessOutboundSettings = z.infer<typeof VmessOutboundSettingsSchema>;

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
export const WireguardDomainStrategySchema = z.enum([
'ForceIP',
'ForceIPv4',
'ForceIPv4v6',
'ForceIPv6',
'ForceIPv6v4',
]);
export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
// 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<typeof WireguardOutboundPeerSchema>;
// 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<typeof WireguardOutboundSettingsSchema>;