feat(frontend): stream and security Zod families with discriminated unions

Stand up the remaining Step 2 families. NetworkSettingsSchema is a
6-branch DU on `network` covering tcp/kcp/ws/grpc/httpupgrade/xhttp, with
asymmetric per-network wire keys (tcpSettings, wsSettings, ...) preserved
exactly so fixtures round-trip byte-identical. SecuritySettingsSchema is a
3-branch DU on `security` covering none/tls/reality. TLS certs use a
file-vs-inline union; uTLS fingerprints are shared between TLS and Reality
via a single primitive enum.

Hysteria-as-network, finalmask, and sockopt are not in the plan's Step 2
inventory and are deferred to Step 6 (Tighten) - they're orthogonal extras
on the stream root, not network-discriminated branches.

Resolves a Security identifier collision in protocols/index.ts by
re-exporting the type alias as SecurityKind (the `Security` name is taken
by the namespace re-export).
This commit is contained in:
MHSanaei
2026-05-25 23:13:29 +02:00
parent 8d45cd8c68
commit 9721dae2b6
12 changed files with 324 additions and 0 deletions

View File

@@ -1,7 +1,13 @@
export * as Inbound from './inbound';
export * as Outbound from './outbound';
export * as Stream from './stream';
export * as Security from './security';
export { InboundSettingsSchema } from './inbound';
export type { InboundSettings } from './inbound';
export { OutboundSettingsSchema } from './outbound';
export type { OutboundSettings } from './outbound';
export { NetworkSchema, NetworkSettingsSchema } from './stream';
export type { Network, NetworkSettings } from './stream';
export { SecuritySchema, SecuritySettingsSchema } from './security';
export type { Security as SecurityKind, SecuritySettings } from './security';

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
import { RealityStreamSettingsSchema } from './reality';
import { TlsStreamSettingsSchema } from './tls';
export * from './none';
export * from './reality';
export * from './tls';
export const SecuritySchema = z.enum(['none', 'tls', 'reality']);
export type Security = z.infer<typeof SecuritySchema>;
// Tagged-wrapper DU on `security`. Wire shape: when security==='tls' only
// `tlsSettings` is present, when 'reality' only `realitySettings`, when
// 'none' neither key appears. The Xray panel's StreamSettings class emits
// `undefined` for the inactive branch which strips the key during JSON
// serialization, so this DU faithfully describes what's on disk.
export const SecuritySettingsSchema = z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
]);
export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
// `security: 'none'` carries no payload — the streamSettings root just omits
// both `tlsSettings` and `realitySettings`. This empty leaf is kept for
// symmetry so the discriminated union has a branch for every security value.
export const NoneSecuritySettingsSchema = z.object({});
export type NoneSecuritySettings = z.infer<typeof NoneSecuritySettingsSchema>;

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
import { UtlsFingerprintSchema } from '@/schemas/protocols/security/tls';
// Reality client-side handshake config (sits under the inbound's
// realitySettings.settings on the wire — the panel's class names the field
// `settings` even though it's the "client" half of Reality).
export const RealityClientSettingsSchema = z.object({
publicKey: z.string().default(''),
fingerprint: UtlsFingerprintSchema.default('chrome'),
serverName: z.string().default(''),
spiderX: z.string().default('/'),
mldsa65Verify: z.string().default(''),
});
export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
// Reality stream payload. `serverNames` and `shortIds` are stored as
// comma-joined strings in the panel class but ship as string[] on the wire
// — fixtures round-trip through the array form. `target` is the dest host
// Reality piggybacks on; the panel auto-generates random target+SNI when
// blank.
export const RealityStreamSettingsSchema = z.object({
show: z.boolean().default(false),
xver: z.number().int().min(0).default(0),
target: z.string().default(''),
serverNames: z.array(z.string()).default([]),
privateKey: z.string().default(''),
minClientVer: z.string().default(''),
maxClientVer: z.string().default(''),
maxTimediff: z.number().int().min(0).default(0),
shortIds: z.array(z.string()).default([]),
mldsa65Seed: z.string().default(''),
settings: RealityClientSettingsSchema.default({
publicKey: '',
fingerprint: 'chrome',
serverName: '',
spiderX: '/',
mldsa65Verify: '',
}),
});
export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;

View File

@@ -0,0 +1,72 @@
import { z } from 'zod';
export const TlsVersionSchema = z.enum(['1.0', '1.1', '1.2', '1.3']);
export type TlsVersion = z.infer<typeof TlsVersionSchema>;
// Xray's uTLS fingerprints — used both for TLS and Reality. Kept here (not
// in primitives/) because the only consumer is security/tls.ts and
// security/reality.ts via re-import.
export const UtlsFingerprintSchema = z.enum([
'chrome',
'firefox',
'safari',
'ios',
'android',
'edge',
'360',
'qq',
'random',
'randomized',
'randomizednoalpn',
'unsafe',
]);
export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
export type Alpn = z.infer<typeof AlpnSchema>;
export const TlsCertUsageSchema = z.enum(['encipherment', 'verify', 'issue']);
export type TlsCertUsage = z.infer<typeof TlsCertUsageSchema>;
// TLS certs on the wire come in two shapes — file-backed or inline. The
// panel class collapses them into one with a `useFile` boolean; we model
// the wire shape as a DU so saves round-trip without the boolean leaking.
export const TlsCertFileSchema = z.object({
certificateFile: z.string().min(1),
keyFile: z.string().min(1),
oneTimeLoading: z.boolean().default(false),
usage: TlsCertUsageSchema.default('encipherment'),
buildChain: z.boolean().default(false),
});
export const TlsCertInlineSchema = z.object({
certificate: z.array(z.string()),
key: z.array(z.string()),
oneTimeLoading: z.boolean().default(false),
usage: TlsCertUsageSchema.default('encipherment'),
buildChain: z.boolean().default(false),
});
export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
export const TlsClientSettingsSchema = z.object({
fingerprint: UtlsFingerprintSchema.default('chrome'),
echConfigList: z.string().default(''),
});
export type TlsClientSettings = z.infer<typeof TlsClientSettingsSchema>;
// `serverName` is the SNI; the class field is `sni` internally but on the
// wire stays `serverName` to match Xray's config schema.
export const TlsStreamSettingsSchema = z.object({
serverName: z.string().default(''),
minVersion: TlsVersionSchema.default('1.2'),
maxVersion: TlsVersionSchema.default('1.3'),
cipherSuites: z.string().default(''),
rejectUnknownSni: z.boolean().default(false),
disableSystemRoot: z.boolean().default(false),
enableSessionResumption: z.boolean().default(false),
certificates: z.array(TlsCertSchema).default([]),
alpn: z.array(AlpnSchema).default(['h2', 'http/1.1']),
echServerKeys: z.string().default(''),
settings: TlsClientSettingsSchema.default({ fingerprint: 'chrome', echConfigList: '' }),
});
export type TlsStreamSettings = z.infer<typeof TlsStreamSettingsSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
// gRPC stream is the lightest transport — three booleans/strings, no
// header obfuscation. `multiMode` enables multi-stream gRPC (multiple
// concurrent streams over one connection).
export const GrpcStreamSettingsSchema = z.object({
serviceName: z.string().default(''),
authority: z.string().default(''),
multiMode: z.boolean().default(false),
});
export type GrpcStreamSettings = z.infer<typeof GrpcStreamSettingsSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
import { WsHeaderMapSchema } from '@/schemas/protocols/stream/ws';
// HTTP Upgrade transport reuses the flat WS-style header map (string values,
// not arrays — toV2Headers with arr=false). No heartbeat field — that's
// websocket-specific.
export const HttpUpgradeStreamSettingsSchema = z.object({
acceptProxyProtocol: z.boolean().default(false),
path: z.string().default('/'),
host: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
});
export type HttpUpgradeStreamSettings = z.infer<typeof HttpUpgradeStreamSettingsSchema>;

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { GrpcStreamSettingsSchema } from './grpc';
import { HttpUpgradeStreamSettingsSchema } from './httpupgrade';
import { KcpStreamSettingsSchema } from './kcp';
import { TcpStreamSettingsSchema } from './tcp';
import { WsStreamSettingsSchema } from './ws';
import { XHttpStreamSettingsSchema } from './xhttp';
export * from './grpc';
export * from './httpupgrade';
export * from './kcp';
export * from './tcp';
export * from './ws';
export * from './xhttp';
export const NetworkSchema = z.enum(['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp']);
export type Network = z.infer<typeof NetworkSchema>;
// Tagged-wrapper DU on `network`. The wire shape uses an asymmetric per-
// network key (`tcpSettings`, `wsSettings`, ...) rather than a single
// `settings` object — same pattern Xray ships and the panel's StreamSettings
// class flattens via toJson. Each branch carries only the matching key so
// fixtures round-trip byte-identical.
export const NetworkSettingsSchema = z.discriminatedUnion('network', [
z.object({ network: z.literal('tcp'), tcpSettings: TcpStreamSettingsSchema }),
z.object({ network: z.literal('kcp'), kcpSettings: KcpStreamSettingsSchema }),
z.object({ network: z.literal('ws'), wsSettings: WsStreamSettingsSchema }),
z.object({ network: z.literal('grpc'), grpcSettings: GrpcStreamSettingsSchema }),
z.object({ network: z.literal('httpupgrade'), httpupgradeSettings: HttpUpgradeStreamSettingsSchema }),
z.object({ network: z.literal('xhttp'), xhttpSettings: XHttpStreamSettingsSchema }),
]);
export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
// mKCP transport (Xray's reliable UDP). The panel renames upCap/downCap on
// the JS side back to uplinkCapacity/downlinkCapacity on the wire. Defaults
// match xray-core's recommended values.
export const KcpStreamSettingsSchema = z.object({
mtu: z.number().int().min(576).max(1460).default(1350),
tti: z.number().int().min(10).max(100).default(20),
uplinkCapacity: z.number().int().min(0).default(5),
downlinkCapacity: z.number().int().min(0).default(20),
cwndMultiplier: z.number().int().min(1).default(1),
maxSendingWindow: z.number().int().min(0).default(2097152),
});
export type KcpStreamSettings = z.infer<typeof KcpStreamSettingsSchema>;

View File

@@ -0,0 +1,47 @@
import { z } from 'zod';
// Xray's V2-style header map: { Host: ['example.com', ...], ... }. Each
// header name maps to a string[] because HTTP allows repeated headers
// (Accept, Cookie, etc.). The panel renders these as a flat name/value
// table internally and flattens to this map on save via toV2Headers.
export const V2HeaderMapSchema = z.record(z.string(), z.array(z.string()));
export type V2HeaderMap = z.infer<typeof V2HeaderMapSchema>;
export const TcpRequestSchema = z.object({
version: z.string().default('1.1'),
method: z.string().default('GET'),
path: z.array(z.string()).min(1).default(['/']),
headers: V2HeaderMapSchema.default({}),
});
export type TcpRequest = z.infer<typeof TcpRequestSchema>;
export const TcpResponseSchema = z.object({
version: z.string().default('1.1'),
status: z.string().default('200'),
reason: z.string().default('OK'),
headers: V2HeaderMapSchema.default({}),
});
export type TcpResponse = z.infer<typeof TcpResponseSchema>;
// TCP stream `header` is the obfuscation header. type='none' (the wire
// representation just omits `header` entirely) or type='http' (HTTP-1.1
// camouflage with request/response sub-objects).
export const TcpHeaderHttpSchema = z.object({
type: z.literal('http'),
request: TcpRequestSchema.optional(),
response: TcpResponseSchema.optional(),
});
export const TcpHeaderNoneSchema = z.object({ type: z.literal('none') });
export const TcpHeaderSchema = z.discriminatedUnion('type', [
TcpHeaderNoneSchema,
TcpHeaderHttpSchema,
]);
export type TcpHeader = z.infer<typeof TcpHeaderSchema>;
// Top-level TCP stream payload. `acceptProxyProtocol` only appears on the
// wire when true (panel omits it when false), so we treat it as optional.
export const TcpStreamSettingsSchema = z.object({
acceptProxyProtocol: z.literal(true).optional(),
header: TcpHeaderSchema.optional(),
});
export type TcpStreamSettings = z.infer<typeof TcpStreamSettingsSchema>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
// WebSocket stream uses the flat V1-style header map (string values only,
// not arrays — the panel calls toV2Headers with arr=false). `path` and
// `host` are the WS request line / Host header overrides. `heartbeatPeriod`
// in seconds; 0 disables heartbeats.
export const WsHeaderMapSchema = z.record(z.string(), z.string());
export type WsHeaderMap = z.infer<typeof WsHeaderMapSchema>;
export const WsStreamSettingsSchema = z.object({
acceptProxyProtocol: z.boolean().default(false),
path: z.string().default('/'),
host: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
heartbeatPeriod: z.number().int().min(0).default(0),
});
export type WsStreamSettings = z.infer<typeof WsStreamSettingsSchema>;

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
import { WsHeaderMapSchema } from '@/schemas/protocols/stream/ws';
export const XHttpModeSchema = z.enum(['auto', 'packet-up', 'stream-up', 'stream-one']);
export type XHttpMode = z.infer<typeof XHttpModeSchema>;
// xHTTP (SplitHTTPConfig) is xray-core's modern stream-multiplexed transport.
// The field set is large because the schema mirrors what the server-side
// listener reads — plus a few client-only fields (`uplinkHTTPMethod`,
// `headers`) the panel embeds into share-link `extra` blobs even though the
// server ignores them at runtime. Outbound has additional fields (uplinkChunk
// sizes, noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) which
// belong on the outbound class instead, not modeled here.
export const XHttpStreamSettingsSchema = z.object({
path: z.string().default('/'),
host: z.string().default(''),
mode: XHttpModeSchema.default('auto'),
xPaddingBytes: z.string().default('100-1000'),
xPaddingObfsMode: z.boolean().default(false),
xPaddingKey: z.string().default(''),
xPaddingHeader: z.string().default(''),
xPaddingPlacement: z.string().default(''),
xPaddingMethod: z.string().default(''),
sessionPlacement: z.string().default(''),
sessionKey: z.string().default(''),
seqPlacement: z.string().default(''),
seqKey: z.string().default(''),
uplinkDataPlacement: z.string().default(''),
uplinkDataKey: z.string().default(''),
scMaxEachPostBytes: z.string().default('1000000'),
noSSEHeader: z.boolean().default(false),
scMaxBufferedPosts: z.number().int().min(0).default(30),
scStreamUpServerSecs: z.string().default('20-80'),
serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
});
export type XHttpStreamSettings = z.infer<typeof XHttpStreamSettingsSchema>;