Sfoglia il codice sorgente

feat(frontend): stream extras + full InboundSchema with DU intersection

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<string, unknown> 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.
MHSanaei 22 ore fa
parent
commit
d14eb6923f

+ 64 - 0
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<typeof StreamSettingsSchema>;
+
+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<typeof InboundCoreSchema>;
+
+// 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<typeof InboundSchema>;
+
+// 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<typeof SlimInboundSchema>;

+ 23 - 0
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<typeof ExternalProxyForceTlsSchema>;
+
+// 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<typeof ExternalProxyEntrySchema>;

+ 83 - 0
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<typeof TcpMaskTypeSchema>;
+
+export const TcpMaskSchema = z.object({
+  type: TcpMaskTypeSchema,
+  settings: z.record(z.string(), z.unknown()).optional(),
+});
+export type TcpMask = z.infer<typeof TcpMaskSchema>;
+
+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<typeof UdpMaskTypeSchema>;
+
+export const UdpMaskSchema = z.object({
+  type: UdpMaskTypeSchema,
+  settings: z.record(z.string(), z.unknown()).optional(),
+});
+export type UdpMask = z.infer<typeof UdpMaskSchema>;
+
+export const QuicCongestionSchema = z.enum(['bbr', 'cubic', 'reno', 'brutal', 'force-brutal']);
+export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
+
+// 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<typeof QuicUdpHopSchema>;
+
+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<typeof QuicParamsSchema>;
+
+// `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<typeof FinalMaskStreamSettingsSchema>;

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

@@ -1,15 +1,21 @@
 import { z } from 'zod';
 import { z } from 'zod';
 
 
+import { ExternalProxyEntrySchema } from './external-proxy';
+import { FinalMaskStreamSettingsSchema } from './finalmask';
 import { GrpcStreamSettingsSchema } from './grpc';
 import { GrpcStreamSettingsSchema } from './grpc';
 import { HttpUpgradeStreamSettingsSchema } from './httpupgrade';
 import { HttpUpgradeStreamSettingsSchema } from './httpupgrade';
 import { KcpStreamSettingsSchema } from './kcp';
 import { KcpStreamSettingsSchema } from './kcp';
+import { SockoptStreamSettingsSchema } from './sockopt';
 import { TcpStreamSettingsSchema } from './tcp';
 import { TcpStreamSettingsSchema } from './tcp';
 import { WsStreamSettingsSchema } from './ws';
 import { WsStreamSettingsSchema } from './ws';
 import { XHttpStreamSettingsSchema } from './xhttp';
 import { XHttpStreamSettingsSchema } from './xhttp';
 
 
+export * from './external-proxy';
+export * from './finalmask';
 export * from './grpc';
 export * from './grpc';
 export * from './httpupgrade';
 export * from './httpupgrade';
 export * from './kcp';
 export * from './kcp';
+export * from './sockopt';
 export * from './tcp';
 export * from './tcp';
 export * from './ws';
 export * from './ws';
 export * from './xhttp';
 export * from './xhttp';
@@ -31,3 +37,14 @@ export const NetworkSettingsSchema = z.discriminatedUnion('network', [
   z.object({ network: z.literal('xhttp'),       xhttpSettings:       XHttpStreamSettingsSchema }),
   z.object({ network: z.literal('xhttp'),       xhttpSettings:       XHttpStreamSettingsSchema }),
 ]);
 ]);
 export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
 export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
+
+// 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<typeof StreamExtrasSchema>;

+ 53 - 0
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<typeof SockoptDomainStrategySchema>;
+
+export const TcpCongestionSchema = z.enum(['bbr', 'cubic', 'reno']);
+export type TcpCongestion = z.infer<typeof TcpCongestionSchema>;
+
+export const TproxyModeSchema = z.enum(['off', 'redirect', 'tproxy']);
+export type TproxyMode = z.infer<typeof TproxyModeSchema>;
+
+// 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<typeof SockoptStreamSettingsSchema>;

+ 88 - 0
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": "[email protected]",
+        "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,
+}
+`;

+ 76 - 0
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": "[email protected]",
+        "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": ""
+      }
+    }
+  }
+}

+ 31 - 0
frontend/src/test/inbound-full.test.ts

@@ -0,0 +1,31 @@
+/// <reference types="vite/client" />
+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<unknown>(
+  './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();
+    });
+  }
+});