diff --git a/frontend/src/schemas/protocols/index.ts b/frontend/src/schemas/protocols/index.ts index 097920d1..388b755c 100644 --- a/frontend/src/schemas/protocols/index.ts +++ b/frontend/src/schemas/protocols/index.ts @@ -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'; diff --git a/frontend/src/schemas/protocols/security/index.ts b/frontend/src/schemas/protocols/security/index.ts new file mode 100644 index 00000000..19841c6a --- /dev/null +++ b/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; + +// 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; diff --git a/frontend/src/schemas/protocols/security/none.ts b/frontend/src/schemas/protocols/security/none.ts new file mode 100644 index 00000000..77c8d462 --- /dev/null +++ b/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; diff --git a/frontend/src/schemas/protocols/security/reality.ts b/frontend/src/schemas/protocols/security/reality.ts new file mode 100644 index 00000000..efe4e77b --- /dev/null +++ b/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; + +// 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; diff --git a/frontend/src/schemas/protocols/security/tls.ts b/frontend/src/schemas/protocols/security/tls.ts new file mode 100644 index 00000000..31f30c52 --- /dev/null +++ b/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; + +// 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; + +export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']); +export type Alpn = z.infer; + +export const TlsCertUsageSchema = z.enum(['encipherment', 'verify', 'issue']); +export type TlsCertUsage = z.infer; + +// 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; + +export const TlsClientSettingsSchema = z.object({ + fingerprint: UtlsFingerprintSchema.default('chrome'), + echConfigList: z.string().default(''), +}); +export type TlsClientSettings = z.infer; + +// `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; diff --git a/frontend/src/schemas/protocols/stream/grpc.ts b/frontend/src/schemas/protocols/stream/grpc.ts new file mode 100644 index 00000000..645dd35c --- /dev/null +++ b/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; diff --git a/frontend/src/schemas/protocols/stream/httpupgrade.ts b/frontend/src/schemas/protocols/stream/httpupgrade.ts new file mode 100644 index 00000000..25b12677 --- /dev/null +++ b/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; diff --git a/frontend/src/schemas/protocols/stream/index.ts b/frontend/src/schemas/protocols/stream/index.ts new file mode 100644 index 00000000..e8ed5518 --- /dev/null +++ b/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; + +// 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; diff --git a/frontend/src/schemas/protocols/stream/kcp.ts b/frontend/src/schemas/protocols/stream/kcp.ts new file mode 100644 index 00000000..3cf0cbab --- /dev/null +++ b/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; diff --git a/frontend/src/schemas/protocols/stream/tcp.ts b/frontend/src/schemas/protocols/stream/tcp.ts new file mode 100644 index 00000000..05778e28 --- /dev/null +++ b/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; + +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; + +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; + +// 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; + +// 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; diff --git a/frontend/src/schemas/protocols/stream/ws.ts b/frontend/src/schemas/protocols/stream/ws.ts new file mode 100644 index 00000000..4244832d --- /dev/null +++ b/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; + +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; diff --git a/frontend/src/schemas/protocols/stream/xhttp.ts b/frontend/src/schemas/protocols/stream/xhttp.ts new file mode 100644 index 00000000..56e38eca --- /dev/null +++ b/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; + +// 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;