Преглед изворни кода

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).
MHSanaei пре 23 часа
родитељ
комит
9721dae2b6

+ 6 - 0
frontend/src/schemas/protocols/index.ts

@@ -1,7 +1,13 @@
 export * as Inbound from './inbound';
 export * as Inbound from './inbound';
 export * as Outbound from './outbound';
 export * as Outbound from './outbound';
+export * as Stream from './stream';
+export * as Security from './security';
 
 
 export { InboundSettingsSchema } from './inbound';
 export { InboundSettingsSchema } from './inbound';
 export type { InboundSettings } from './inbound';
 export type { InboundSettings } from './inbound';
 export { OutboundSettingsSchema } from './outbound';
 export { OutboundSettingsSchema } from './outbound';
 export type { OutboundSettings } 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';

+ 23 - 0
frontend/src/schemas/protocols/security/index.ts

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

+ 7 - 0
frontend/src/schemas/protocols/security/none.ts

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

+ 41 - 0
frontend/src/schemas/protocols/security/reality.ts

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

+ 72 - 0
frontend/src/schemas/protocols/security/tls.ts

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

+ 11 - 0
frontend/src/schemas/protocols/stream/grpc.ts

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

+ 14 - 0
frontend/src/schemas/protocols/stream/httpupgrade.ts

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

+ 33 - 0
frontend/src/schemas/protocols/stream/index.ts

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

+ 14 - 0
frontend/src/schemas/protocols/stream/kcp.ts

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

+ 47 - 0
frontend/src/schemas/protocols/stream/tcp.ts

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

+ 17 - 0
frontend/src/schemas/protocols/stream/ws.ts

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

+ 39 - 0
frontend/src/schemas/protocols/stream/xhttp.ts

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