diff --git a/frontend/src/schemas/api/inbound.ts b/frontend/src/schemas/api/inbound.ts new file mode 100644 index 00000000..81e27cf6 --- /dev/null +++ b/frontend/src/schemas/api/inbound.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { PortSchema, SniffingSchema } from '@/schemas/primitives'; +import { InboundSettingsSchema } from '@/schemas/protocols/inbound'; +import { SecuritySettingsSchema } from '@/schemas/protocols/security'; +import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream'; + +// Top-level inbound shape on the wire. Composes: +// - Per-protocol settings via the InboundSettingsSchema discriminated +// union (10 protocols, tagged-wrapper {protocol, settings}). +// - StreamSettings as an intersection of the network DU (6 branches), +// security DU (3 branches), and the orthogonal extras (finalmask, +// sockopt, externalProxy). Zod 4 supports DU intersection — each +// branch validates its slice of the same input object. +// +// The id/up/down/total/expiryTime fields are int64 on the Go side but +// the panel ships them as JS numbers. Numbers above Number.MAX_SAFE_INTEGER +// (~9e15) lose precision; the panel works around this for the traffic +// counters by stringifying them at the API edge. Not modeled here. + +export const StreamSettingsSchema = NetworkSettingsSchema + .and(SecuritySettingsSchema) + .and(StreamExtrasSchema); +export type StreamSettings = z.infer; + +export const InboundCoreSchema = z.object({ + id: z.number().int().optional(), + up: z.number().int().min(0).default(0), + down: z.number().int().min(0).default(0), + total: z.number().int().min(0).default(0), + remark: z.string().default(''), + enable: z.boolean().default(true), + expiryTime: z.number().int().default(0), + listen: z.string().default(''), + port: PortSchema, + tag: z.string().default(''), + sniffing: SniffingSchema.default({ + enabled: false, + destOverride: ['http', 'tls', 'quic', 'fakedns'], + metadataOnly: false, + routeOnly: false, + ipsExcluded: [], + domainsExcluded: [], + }), + streamSettings: StreamSettingsSchema.optional(), + clientStats: z.string().optional(), +}); +export type InboundCore = z.infer; + +// Full Inbound = core fields + the protocol/settings discriminated union. +// Consumers narrow on `.protocol` to access the matching `.settings` +// branch with full type safety. +export const InboundSchema = InboundCoreSchema.and(InboundSettingsSchema); +export type Inbound = z.infer; + +// SlimInbound is the list-view projection — same shape minus settings +// and streamSettings (the list endpoint omits both to keep payload +// small). Used by InboundsPage list rendering. +export const SlimInboundSchema = InboundCoreSchema.omit({ + streamSettings: true, +}).extend({ + protocol: z.string(), +}); +export type SlimInbound = z.infer; diff --git a/frontend/src/schemas/protocols/stream/external-proxy.ts b/frontend/src/schemas/protocols/stream/external-proxy.ts new file mode 100644 index 00000000..2026f460 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/external-proxy.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +import { AlpnSchema, UtlsFingerprintSchema } from '@/schemas/protocols/security/tls'; + +export const ExternalProxyForceTlsSchema = z.enum(['same', 'tls', 'none']); +export type ExternalProxyForceTls = z.infer; + +// An inbound can advertise external proxy fronts (CDN edges, mirror nodes) +// that share its config but vary the dest+port+SNI for the share link. The +// panel form ships rows of this shape; link generators iterate them when +// stream.externalProxy is non-empty. +export const ExternalProxyEntrySchema = z.object({ + forceTls: ExternalProxyForceTlsSchema.default('same'), + dest: z.string().default(''), + port: PortSchema.default(443), + remark: z.string().default(''), + sni: z.string().optional(), + fingerprint: UtlsFingerprintSchema.optional(), + alpn: z.array(AlpnSchema).optional(), +}); +export type ExternalProxyEntry = z.infer; diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts new file mode 100644 index 00000000..79326978 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +// FinalMask is xray-core's late-layer obfuscation wrapper applied AFTER +// the network/security layers. It models per-type masks on TCP and UDP +// plus optional QUIC tuning. The `settings` sub-object is polymorphic on +// `type`; we model the wire-faithful shape with a permissive +// record-of-unknown for `settings` and leave per-type tightening to +// Step 6 — there are ~13 UDP mask types plus 3 TCP mask types, each with +// distinct setting fields, and modeling them all as discriminated unions +// here would dwarf the rest of the stream module without buying anything +// the safety net doesn't already cover. + +export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom']); +export type TcpMaskType = z.infer; + +export const TcpMaskSchema = z.object({ + type: TcpMaskTypeSchema, + settings: z.record(z.string(), z.unknown()).optional(), +}); +export type TcpMask = z.infer; + +export const UdpMaskTypeSchema = z.enum([ + 'salamander', + 'mkcp-aes128gcm', + 'mkcp-original', + 'header-dns', + 'header-dtls', + 'header-srtp', + 'header-utp', + 'header-wechat', + 'header-wireguard', + 'header-custom', + 'xdns', + 'xicmp', + 'noise', +]); +export type UdpMaskType = z.infer; + +export const UdpMaskSchema = z.object({ + type: UdpMaskTypeSchema, + settings: z.record(z.string(), z.unknown()).optional(), +}); +export type UdpMask = z.infer; + +export const QuicCongestionSchema = z.enum(['bbr', 'cubic', 'reno', 'brutal', 'force-brutal']); +export type QuicCongestion = z.infer; + +// udpHop randomizes the QUIC port between a range every `interval` seconds +// to dodge port-based blocking. Both fields are dash-range strings on the +// wire (e.g. '20000-50000', '5-10'). +export const QuicUdpHopSchema = z.object({ + ports: z.string().default('20000-50000'), + interval: z.string().default('5-10'), +}); +export type QuicUdpHop = z.infer; + +export const QuicParamsSchema = z.object({ + congestion: QuicCongestionSchema.default('bbr'), + debug: z.boolean().optional(), + brutalUp: z.number().int().min(0).optional(), + brutalDown: z.number().int().min(0).optional(), + udpHop: QuicUdpHopSchema.optional(), + initStreamReceiveWindow: z.number().int().min(0).optional(), + maxStreamReceiveWindow: z.number().int().min(0).optional(), + initConnectionReceiveWindow: z.number().int().min(0).optional(), + maxConnectionReceiveWindow: z.number().int().min(0).optional(), + maxIdleTimeout: z.number().int().min(0).optional(), + keepAlivePeriod: z.number().int().min(0).optional(), + disablePathMTUDiscovery: z.boolean().optional(), + maxIncomingStreams: z.number().int().min(0).optional(), +}); +export type QuicParams = z.infer; + +// `tcp` and `udp` are omitted from the wire entirely when their arrays +// are empty (legacy toJson() drops them). Our default([]) here mirrors +// the parsed-in shape; the shadow harness already treats empty arrays as +// equivalent to absence so both pipelines converge. +export const FinalMaskStreamSettingsSchema = z.object({ + tcp: z.array(TcpMaskSchema).default([]), + udp: z.array(UdpMaskSchema).default([]), + quicParams: QuicParamsSchema.optional(), +}); +export type FinalMaskStreamSettings = z.infer; diff --git a/frontend/src/schemas/protocols/stream/index.ts b/frontend/src/schemas/protocols/stream/index.ts index e8ed5518..25acf9ab 100644 --- a/frontend/src/schemas/protocols/stream/index.ts +++ b/frontend/src/schemas/protocols/stream/index.ts @@ -1,15 +1,21 @@ import { z } from 'zod'; +import { ExternalProxyEntrySchema } from './external-proxy'; +import { FinalMaskStreamSettingsSchema } from './finalmask'; import { GrpcStreamSettingsSchema } from './grpc'; import { HttpUpgradeStreamSettingsSchema } from './httpupgrade'; import { KcpStreamSettingsSchema } from './kcp'; +import { SockoptStreamSettingsSchema } from './sockopt'; import { TcpStreamSettingsSchema } from './tcp'; import { WsStreamSettingsSchema } from './ws'; import { XHttpStreamSettingsSchema } from './xhttp'; +export * from './external-proxy'; +export * from './finalmask'; export * from './grpc'; export * from './httpupgrade'; export * from './kcp'; +export * from './sockopt'; export * from './tcp'; export * from './ws'; export * from './xhttp'; @@ -31,3 +37,14 @@ export const NetworkSettingsSchema = z.discriminatedUnion('network', [ z.object({ network: z.literal('xhttp'), xhttpSettings: XHttpStreamSettingsSchema }), ]); export type NetworkSettings = z.infer; + +// Orthogonal extras that ride alongside the network and security branches. +// All optional on the wire — legacy toJson() omits any field whose value +// is empty. The shadow harness treats absent and empty-array as the same +// canonical state. +export const StreamExtrasSchema = z.object({ + externalProxy: z.array(ExternalProxyEntrySchema).optional(), + finalmask: FinalMaskStreamSettingsSchema.optional(), + sockopt: SockoptStreamSettingsSchema.optional(), +}); +export type StreamExtras = z.infer; diff --git a/frontend/src/schemas/protocols/stream/sockopt.ts b/frontend/src/schemas/protocols/stream/sockopt.ts new file mode 100644 index 00000000..30f3a0a1 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/sockopt.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +export const SockoptDomainStrategySchema = z.enum([ + 'AsIs', + 'UseIP', + 'UseIPv6v4', + 'UseIPv6', + 'UseIPv4v6', + 'UseIPv4', + 'ForceIP', + 'ForceIPv6v4', + 'ForceIPv6', + 'ForceIPv4v6', + 'ForceIPv4', +]); +export type SockoptDomainStrategy = z.infer; + +export const TcpCongestionSchema = z.enum(['bbr', 'cubic', 'reno']); +export type TcpCongestion = z.infer; + +export const TproxyModeSchema = z.enum(['off', 'redirect', 'tproxy']); +export type TproxyMode = z.infer; + +// Sockopt knobs are an orthogonal layer on streamSettings — they tune +// the underlying socket (TCP keepalive, TFO, mark, tproxy, dialer proxy, +// IPv6-only, MPTCP). The wire field is `interface` (single word) but the +// panel class names it `interfaceName` internally to avoid the JS +// reserved keyword. We use `interfaceName` here too and document the +// renames; serializers writing back to wire must rename. +// +// trustedXForwardedFor is omitted from the wire payload when empty +// (legacy toJson() filters it); our default([]) lets parsing succeed but +// the shadow canonicalize step treats [] and absence as equivalent. +export const SockoptStreamSettingsSchema = z.object({ + acceptProxyProtocol: z.boolean().default(false), + tcpFastOpen: z.boolean().default(false), + mark: z.number().int().min(0).default(0), + tproxy: TproxyModeSchema.default('off'), + tcpMptcp: z.boolean().default(false), + penetrate: z.boolean().default(false), + domainStrategy: SockoptDomainStrategySchema.default('UseIP'), + tcpMaxSeg: z.number().int().min(0).default(1440), + dialerProxy: z.string().default(''), + tcpKeepAliveInterval: z.number().int().min(0).default(0), + tcpKeepAliveIdle: z.number().int().min(0).default(300), + tcpUserTimeout: z.number().int().min(0).default(10000), + tcpcongestion: TcpCongestionSchema.default('bbr'), + V6Only: z.boolean().default(false), + tcpWindowClamp: z.number().int().min(0).default(600), + interfaceName: z.string().default(''), + trustedXForwardedFor: z.array(z.string()).default([]), +}); +export type SockoptStreamSettings = z.infer; diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap new file mode 100644 index 00000000..960b0646 --- /dev/null +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -0,0 +1,88 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = ` +{ + "down": 0, + "enable": true, + "expiryTime": 0, + "id": 42, + "listen": "", + "port": 443, + "protocol": "vless", + "remark": "alice-vless-ws-tls", + "settings": { + "clients": [ + { + "comment": "", + "email": "alice@example.test", + "enable": true, + "expiryTime": 0, + "flow": "", + "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02", + "limitIp": 0, + "reset": 0, + "subId": "abc123def", + "tgId": 0, + "totalGB": 0, + }, + ], + "decryption": "none", + "encryption": "none", + "fallbacks": [], + }, + "sniffing": { + "destOverride": [ + "http", + "tls", + "quic", + "fakedns", + ], + "domainsExcluded": [], + "enabled": true, + "ipsExcluded": [], + "metadataOnly": false, + "routeOnly": false, + }, + "streamSettings": { + "network": "ws", + "security": "tls", + "tlsSettings": { + "alpn": [ + "h2", + "http/1.1", + ], + "certificates": [ + { + "buildChain": false, + "certificateFile": "/etc/ssl/certs/cdn.example.test.crt", + "keyFile": "/etc/ssl/private/cdn.example.test.key", + "oneTimeLoading": false, + "usage": "encipherment", + }, + ], + "cipherSuites": "", + "disableSystemRoot": false, + "echServerKeys": "", + "enableSessionResumption": false, + "maxVersion": "1.3", + "minVersion": "1.2", + "rejectUnknownSni": false, + "serverName": "cdn.example.test", + "settings": { + "echConfigList": "", + "fingerprint": "chrome", + }, + }, + "wsSettings": { + "acceptProxyProtocol": false, + "headers": {}, + "heartbeatPeriod": 0, + "host": "cdn.example.test", + "path": "/ws", + }, + }, + "tag": "inbound-vless-1", + "total": 0, + "up": 0, +} +`; diff --git a/frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json b/frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json new file mode 100644 index 00000000..6fbe07e4 --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json @@ -0,0 +1,76 @@ +{ + "id": 42, + "up": 0, + "down": 0, + "total": 0, + "remark": "alice-vless-ws-tls", + "enable": true, + "expiryTime": 0, + "listen": "", + "port": 443, + "tag": "inbound-vless-1", + "sniffing": { + "enabled": true, + "destOverride": ["http", "tls", "quic", "fakedns"], + "metadataOnly": false, + "routeOnly": false, + "ipsExcluded": [], + "domainsExcluded": [] + }, + "protocol": "vless", + "settings": { + "clients": [ + { + "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02", + "email": "alice@example.test", + "flow": "", + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": true, + "tgId": 0, + "subId": "abc123def", + "comment": "", + "reset": 0 + } + ], + "decryption": "none", + "encryption": "none", + "fallbacks": [] + }, + "streamSettings": { + "network": "ws", + "wsSettings": { + "acceptProxyProtocol": false, + "path": "/ws", + "host": "cdn.example.test", + "headers": {}, + "heartbeatPeriod": 0 + }, + "security": "tls", + "tlsSettings": { + "serverName": "cdn.example.test", + "minVersion": "1.2", + "maxVersion": "1.3", + "cipherSuites": "", + "rejectUnknownSni": false, + "disableSystemRoot": false, + "enableSessionResumption": false, + "certificates": [ + { + "certificateFile": "/etc/ssl/certs/cdn.example.test.crt", + "keyFile": "/etc/ssl/private/cdn.example.test.key", + "oneTimeLoading": false, + "usage": "encipherment", + "buildChain": false + } + ], + "alpn": ["h2", "http/1.1"], + "echServerKeys": "", + "settings": { + "fingerprint": "chrome", + "echConfigList": "" + } + } + } +} diff --git a/frontend/src/test/inbound-full.test.ts b/frontend/src/test/inbound-full.test.ts new file mode 100644 index 00000000..ad5e1a06 --- /dev/null +++ b/frontend/src/test/inbound-full.test.ts @@ -0,0 +1,31 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { InboundSchema } from '@/schemas/api/inbound'; + +// Full Inbound parse tests — exercises the intersection of network DU, +// security DU, settings DU, and orthogonal extras in a single +// round-trip. These fixtures are the input the link generators in +// lib/xray/inbound-link.ts will consume once extracted. + +const fixtures = import.meta.glob( + './golden/fixtures/inbound-full/*.json', + { eager: true, import: 'default' }, +); + +function fixtureName(path: string): string { + const file = path.split('/').pop() ?? path; + return file.replace(/\.json$/, ''); +} + +describe('InboundSchema (full) fixtures', () => { + const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b)); + expect(entries.length, 'expected at least one fixture under golden/fixtures/inbound-full').toBeGreaterThan(0); + + for (const [path, raw] of entries) { + it(`parses ${fixtureName(path)} byte-stably`, () => { + const parsed = InboundSchema.parse(raw); + expect(parsed).toMatchSnapshot(); + }); + } +});