feat(xray): add loopback sniffing and per-segment fragment masks

- Loopback outbound: add sniffing support (xray-core #6320)

- FinalMask fragment: support per-segment lengths/delays arrays with legacy length/delay migration (xray-core #6334)

- Consolidate sniffing into a shared SniffingFields component and the canonical SniffingSchema across inbound, VLESS reverse, and loopback
This commit is contained in:
MHSanaei
2026-06-23 13:24:16 +02:00
parent 42cd351e4e
commit 852b53db79
18 changed files with 343 additions and 255 deletions
@@ -5,19 +5,6 @@ import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
// InboundFormValues = the values shape Form.useForm<T>() carries in
// InboundFormModal. Mirrors the wire shape (so submission can hand
// values straight to Schema.parse + POST) plus the DB-side fields that
// the panel's /panel/api/inbounds/add endpoint expects alongside.
//
// Differences from schemas/api/inbound.ts InboundSchema:
// - settings/streamSettings/sniffing are nested OBJECTS here, not the
// JSON strings the endpoint accepts. The form holds typed data; the
// submit handler stringifies right before POSTing.
// - Adds DB fields not in InboundSchema: up, down, total, trafficReset,
// lastTrafficResetTime, nodeId. These flow through the DBInbound row,
// not the xray-config slice.
export const InboundStreamFormSchema = NetworkSettingsSchema
.and(SecuritySettingsSchema)
.and(StreamExtrasSchema);
@@ -43,9 +30,6 @@ export const InboundDbFieldsSchema = z.object({
});
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
// Base fields that apply to every inbound regardless of protocol or
// transport. The protocol-specific `settings` and the transport-specific
// `streamSettings` are layered on via intersection below.
export const InboundFormBaseSchema = z.object({
remark: z.string().default(''),
enable: z.boolean().default(true),
@@ -73,10 +57,6 @@ export const InboundFormSchema = InboundFormBaseSchema
.and(InboundSettingsSchema);
export type InboundFormValues = z.infer<typeof InboundFormSchema>;
// Fallback rows ride alongside the inbound submission for VLESS/Trojan
// hosts. They're saved via a separate endpoint after the main inbound
// POST returns, so the schema lives here but is not part of the wire
// inbound payload.
export const FallbackRowSchema = z.object({
rowKey: z.string(),
childId: z.number().int().nullable(),
+27 -55
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
import { PortSchema, SniffingSchema, type Sniffing } from '@/schemas/primitives';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
@@ -15,28 +15,6 @@ import {
WireguardDomainStrategySchema,
} from '@/schemas/protocols/outbound';
// OutboundFormValues = the shape Form.useForm<T>() carries inside
// OutboundFormModal. Differences from schemas/api wire schemas:
//
// - vmess vnext / trojan-ss-socks-http servers are FLATTENED into
// {address, port, ...auth} at settings root. The adapter handles
// nesting on submit.
// - wireguard `address` (string[] wire) and `reserved` (number[] wire)
// are comma-joined STRINGS in the form. The adapter splits + coerces.
// - wireguard `pubKey` is a UI-only field derived from `secretKey`. Not
// emitted on the wire — the adapter strips it.
// - VLESS `reverseTag` and `reverseSniffing` are flat at settings root;
// the adapter wraps them as { reverse: { tag, sniffing } } on the wire.
// - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it
// as { response: { type } } on the wire (omitted when empty).
// - DNS rules carry `qType` and `domain` as comma-joined strings (matches
// the legacy DNSRule UI). The adapter normalizes them on submit.
//
// All flat-form settings types are documented inline so the adapter has a
// single source of truth for the shape it converts between.
// VMess outbound: connect target (address+port) + first user (id+security).
// Wire: { vnext: [{ address, port, users: [{ id, security }] }] }.
export const VmessOutboundFormSettingsSchema = z.object({
address: z.string().default(''),
port: PortSchema.default(443),
@@ -45,20 +23,18 @@ export const VmessOutboundFormSettingsSchema = z.object({
});
export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults
// match legacy ReverseSniffing constructor.
export const ReverseSniffingFormSchema = z.object({
enabled: z.boolean().default(false),
destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']),
metadataOnly: z.boolean().default(false),
routeOnly: z.boolean().default(false),
ipsExcluded: z.array(z.string()).default([]),
domainsExcluded: z.array(z.string()).default([]),
});
export type ReverseSniffingForm = z.infer<typeof ReverseSniffingFormSchema>;
// Reverse sniffing (VLESS) and loopback sniffing share the canonical
// SniffingSchema — the same definition the inbound Sniffing tab uses — so
// there is one source of truth for an xray SniffingConfig across the panel.
const DEFAULT_SNIFFING: Sniffing = {
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
};
// VLESS outbound: flat connect target + auth + Vision-specific knobs +
// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed.
export const VlessOutboundFormSettingsSchema = z.object({
address: z.string().default(''),
port: PortSchema.default(443),
@@ -66,14 +42,7 @@ export const VlessOutboundFormSettingsSchema = z.object({
flow: z.string().default(''),
encryption: z.string().min(1).default('none'),
reverseTag: z.string().default(''),
reverseSniffing: ReverseSniffingFormSchema.default({
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
}),
reverseSniffing: SniffingSchema.default(DEFAULT_SNIFFING),
testpre: z.number().int().min(0).default(0),
testseed: z.array(z.number().int().positive()).default([]),
});
@@ -205,26 +174,29 @@ export const DnsOutboundFormSettingsSchema = z.object({
});
export type DnsOutboundFormSettings = z.infer<typeof DnsOutboundFormSettingsSchema>;
// Loopback reinjects into a named inbound; `sniffing` (same flat shape as
// VLESS reverse-sniffing) is only emitted when enabled — see the adapter.
export const LoopbackOutboundFormSettingsSchema = z.object({
inboundTag: z.string().default(''),
sniffing: SniffingSchema.default(DEFAULT_SNIFFING),
});
export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
// inbound side: each branch is { protocol: literal, settings: <flat> }.
export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
]);
export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
@@ -23,9 +23,6 @@ export const VlessClientSchema = z.object({
subId: z.string().default(''),
comment: z.string().default(''),
reset: z.number().int().min(0).default(0),
// VLESS simple reverse-proxy: which reverse tag this client routes to,
// plus an optional sniffing override for that path. Distinct from the
// inbound-level `fallbacks` feature.
reverse: z
.object({
tag: z.string(),
@@ -42,9 +39,6 @@ export const VlessInboundSettingsSchema = z.object({
decryption: z.string().min(1).default('none'),
encryption: z.string().min(1).default('none'),
fallbacks: z.array(VlessFallbackSchema).default([]),
// TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
// exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses
// safe defaults when omitted.
testseed: z.array(z.number().int().positive()).length(4).optional(),
});
export type VlessInboundSettings = z.infer<typeof VlessInboundSettingsSchema>;
@@ -26,10 +26,6 @@ export * from './vless';
export * from './vmess';
export * from './wireguard';
// Outbound discriminated union spans 13 protocols (mixed/tunnel are
// inbound-only; freedom/blackhole/dns/loopback are outbound-only). The wire
// shape is `{ protocol, settings }` — same wrapper pattern as the inbound
// union, even though some leaf schemas (freedom, blackhole) are sparse.
export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundSettingsSchema }),
z.object({ protocol: z.literal('vless'), settings: VlessOutboundSettingsSchema }),
@@ -1,8 +1,9 @@
import { z } from 'zod';
// Loopback outbound reinjects traffic back into a named inbound for chained
// routing. The single `inboundTag` field references an inbound tag by name.
import { SniffingSchema } from '@/schemas/primitives';
export const LoopbackOutboundSettingsSchema = z.object({
inboundTag: z.string().optional(),
sniffing: SniffingSchema.optional(),
});
export type LoopbackOutboundSettings = z.infer<typeof LoopbackOutboundSettingsSchema>;
@@ -2,9 +2,6 @@ import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
// Trojan outbound persists as { servers: [{ address, port, password }] }
// — distinct from VLESS outbound which stores the connect target flat at
// the settings root. The wrapping mirrors what Xray expects.
export const TrojanOutboundServerSchema = z.object({
address: z.string().min(1),
port: PortSchema,
@@ -3,9 +3,6 @@ import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
// Vmess outbound persists in the standard Xray `vnext` shape:
// { vnext: [{ address, port, users: [{ id, security }] }] }
// — distinct from VLESS outbound which the panel stores flat.
export const VmessOutboundUserSchema = z.object({
id: z.uuid(),
security: VmessSecuritySchema.default('auto'),
@@ -9,8 +9,6 @@ export const WireguardDomainStrategySchema = z.enum([
]);
export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
// Outbound peer is the remote server we connect to: no privateKey, but an
// `endpoint` (host:port) the inbound side does not need.
export const WireguardOutboundPeerSchema = z.object({
publicKey: z.string().min(1),
preSharedKey: z.string().optional(),
@@ -20,9 +18,6 @@ export const WireguardOutboundPeerSchema = z.object({
});
export type WireguardOutboundPeer = z.infer<typeof WireguardOutboundPeerSchema>;
// Wire format: address is a string[] (Xray expects an array even though the
// panel UI stores it comma-joined); reserved is number[] (panel splits the
// comma string and Number()-coerces each entry).
export const WireguardOutboundSettingsSchema = z.object({
mtu: z.number().int().min(1).optional(),
secretKey: z.string().min(1),