From d14eb6923f5656d765b3ba3469d39f03361aa8ba Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 00:00:34 +0200 Subject: [PATCH] feat(frontend): stream extras + full InboundSchema with DU intersection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3d's last scaffolding piece before link generators. Three new stream-extras schemas land alongside the network/security DUs: - finalmask: TcpMask[] + UdpMask[] + QuicParams. Mask `settings` stays record for now — there are 13 UDP mask types and 3 TCP mask types with distinct per-type setting shapes, and modeling them all as DUs would dwarf the rest of stream/ without buying anything the shadow harness doesn't already catch. Tightened in Step 6. - sockopt: 17 socket-tuning knobs (TCP keepalive, TFO, mark, tproxy, mptcp, dialer proxy, IPv6-only, congestion). `interfaceName` field matches the panel class naming; serializers rename to `interface` on the wire. - external-proxy: rows ship per inbound describing edge fronts (CDN mirrors). Used by link generators to fan out share URLs. schemas/api/inbound.ts composes the top-level wire shape with intersection-of-DUs: StreamSettingsSchema = NetworkSettingsSchema .and(SecuritySettingsSchema) .and(StreamExtrasSchema) InboundSchema = InboundCoreSchema.and(InboundSettingsSchema) A fixture (vless-ws-tls.json) exercises the full shape — protocol DU, network DU, security DU, and TLS cert file branch in one round trip. The snapshot pins the canonical parsed form so the upcoming link extractor consumes typed input with no class hierarchy underneath. Suite: 65 tests across 7 files; typecheck + lint clean. Zod 4 intersection-of-DUs works. --- frontend/src/schemas/api/inbound.ts | 64 ++++++++++++++ .../protocols/stream/external-proxy.ts | 23 +++++ .../src/schemas/protocols/stream/finalmask.ts | 83 +++++++++++++++++ .../src/schemas/protocols/stream/index.ts | 17 ++++ .../src/schemas/protocols/stream/sockopt.ts | 53 +++++++++++ .../__snapshots__/inbound-full.test.ts.snap | 88 +++++++++++++++++++ .../fixtures/inbound-full/vless-ws-tls.json | 76 ++++++++++++++++ frontend/src/test/inbound-full.test.ts | 31 +++++++ 8 files changed, 435 insertions(+) create mode 100644 frontend/src/schemas/api/inbound.ts create mode 100644 frontend/src/schemas/protocols/stream/external-proxy.ts create mode 100644 frontend/src/schemas/protocols/stream/finalmask.ts create mode 100644 frontend/src/schemas/protocols/stream/sockopt.ts create mode 100644 frontend/src/test/__snapshots__/inbound-full.test.ts.snap create mode 100644 frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls.json create mode 100644 frontend/src/test/inbound-full.test.ts 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(); + }); + } +});