Răsfoiți Sursa

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.
MHSanaei 1 zi în urmă
părinte
comite
8d45cd8c68
32 a modificat fișierele cu 728 adăugiri și 0 ștergeri
  1. 2 0
      frontend/src/schemas/index.ts
  2. 8 0
      frontend/src/schemas/primitives/flow.ts
  3. 4 0
      frontend/src/schemas/primitives/index.ts
  4. 4 0
      frontend/src/schemas/primitives/port.ts
  5. 15 0
      frontend/src/schemas/primitives/protocol.ts
  6. 16 0
      frontend/src/schemas/primitives/sniffing.ts
  7. 17 0
      frontend/src/schemas/protocols/inbound/http.ts
  8. 26 0
      frontend/src/schemas/protocols/inbound/hysteria.ts
  9. 13 0
      frontend/src/schemas/protocols/inbound/hysteria2.ts
  10. 42 0
      frontend/src/schemas/protocols/inbound/index.ts
  11. 21 0
      frontend/src/schemas/protocols/inbound/mixed.ts
  12. 45 0
      frontend/src/schemas/protocols/inbound/shadowsocks.ts
  13. 32 0
      frontend/src/schemas/protocols/inbound/trojan.ts
  14. 19 0
      frontend/src/schemas/protocols/inbound/tunnel.ts
  15. 50 0
      frontend/src/schemas/protocols/inbound/vless.ts
  16. 32 0
      frontend/src/schemas/protocols/inbound/vmess.ts
  17. 23 0
      frontend/src/schemas/protocols/inbound/wireguard.ts
  18. 7 0
      frontend/src/schemas/protocols/index.ts
  19. 13 0
      frontend/src/schemas/protocols/outbound/blackhole.ts
  20. 27 0
      frontend/src/schemas/protocols/outbound/dns.ts
  21. 59 0
      frontend/src/schemas/protocols/outbound/freedom.ts
  22. 25 0
      frontend/src/schemas/protocols/outbound/http.ts
  23. 12 0
      frontend/src/schemas/protocols/outbound/hysteria.ts
  24. 12 0
      frontend/src/schemas/protocols/outbound/hysteria2.ts
  25. 50 0
      frontend/src/schemas/protocols/outbound/index.ts
  26. 8 0
      frontend/src/schemas/protocols/outbound/loopback.ts
  27. 21 0
      frontend/src/schemas/protocols/outbound/shadowsocks.ts
  28. 24 0
      frontend/src/schemas/protocols/outbound/socks.ts
  29. 18 0
      frontend/src/schemas/protocols/outbound/trojan.ts
  30. 22 0
      frontend/src/schemas/protocols/outbound/vless.ts
  31. 25 0
      frontend/src/schemas/protocols/outbound/vmess.ts
  32. 36 0
      frontend/src/schemas/protocols/outbound/wireguard.ts

+ 2 - 0
frontend/src/schemas/index.ts

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

+ 8 - 0
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<typeof FlowSchema>;

+ 4 - 0
frontend/src/schemas/primitives/index.ts

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

+ 4 - 0
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<typeof PortSchema>;

+ 15 - 0
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<typeof ProtocolSchema>;

+ 16 - 0
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<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>;

+ 17 - 0
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<typeof HttpAccountSchema>;
+
+export const HttpInboundSettingsSchema = z.object({
+  accounts: z.array(HttpAccountSchema).default([]),
+  allowTransparent: z.boolean().default(false),
+});
+export type HttpInboundSettings = z.infer<typeof HttpInboundSettingsSchema>;

+ 26 - 0
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<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>;

+ 13 - 0
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<typeof Hysteria2InboundSettingsSchema>;

+ 42 - 0
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<typeof InboundSettingsSchema>;

+ 21 - 0
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<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>;

+ 45 - 0
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<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>;

+ 32 - 0
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<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>;

+ 19 - 0
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<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>;

+ 50 - 0
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<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>;

+ 32 - 0
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<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>;

+ 23 - 0
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<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>;

+ 7 - 0
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';

+ 13 - 0
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<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>;

+ 27 - 0
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<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>;

+ 59 - 0
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<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>;

+ 25 - 0
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<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>;

+ 12 - 0
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<typeof HysteriaOutboundSettingsSchema>;

+ 12 - 0
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<typeof Hysteria2OutboundSettingsSchema>;

+ 50 - 0
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<typeof OutboundSettingsSchema>;

+ 8 - 0
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<typeof LoopbackOutboundSettingsSchema>;

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

+ 24 - 0
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<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>;

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

+ 22 - 0
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<typeof VlessOutboundSettingsSchema>;

+ 25 - 0
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<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>;

+ 36 - 0
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<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>;