From 4002be4ade49a2df45894716324fcd07e8c55cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:02:41 +0200 Subject: [PATCH] feat: support latest Wireguard features from Xray-core (PRs #5643, #5833, #5850) (#5131) * feat: support latest Wireguard features from Xray-core Implements support for Xray-core PRs #5833, #5643, and #5850 for Wireguard Inbounds: - Adds 'domainStrategy' and 'workers' to Wireguard inbound configuration. - Enables the Stream Settings tab for Wireguard inbounds to configure 'sockopt' and 'finalmask', hiding the irrelevant 'network' transmission dropdown. - Adds the 'randRange' field to the 'noise' UDP Finalmask obfuscation settings. * fix --------- Co-authored-by: Rqzbeh Co-authored-by: Sanaei --- .../xray/forms/transport/FinalMaskForm.tsx | 49 ++++++++++++++++--- .../src/lib/xray/protocol-capabilities.ts | 2 +- .../pages/inbounds/form/InboundFormModal.tsx | 21 ++++++-- .../inbounds/form/protocols/wireguard.tsx | 17 ++++++- .../xray/outbounds/OutboundFormModal.tsx | 11 ++++- .../schemas/protocols/inbound/wireguard.ts | 21 +++++++- .../protocol-capabilities.test.ts.snap | 28 +++++------ internal/web/service/inbound.go | 4 +- 8 files changed, 120 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 0531387ad..ee9a1edb0 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -96,8 +96,12 @@ function defaultUdpHop(): Record { export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) { const base = asPath(name); const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; - const showTcp = showAll || TCP_NETWORKS.includes(network); - const showUdp = showAll || isHysteria || network === 'kcp'; + // Wireguard carries no user-selectable transport (always a UDP listener/ + // dialer), so only the UDP mask section applies — TCP masks would never + // wrap anything even though the leftover network value may be 'tcp'. + const isWireguard = protocol === 'wireguard'; + const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network)); + const showUdp = showAll || isHysteria || isWireguard || network === 'kcp'; const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; @@ -107,7 +111,7 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll = return ( <> {showTcp && } - {showUdp && } + {showUdp && } {showQuic && ( <> @@ -275,6 +279,22 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise { return Promise.resolve(); } +// randRange bytes must sit in 0-255 — xray rejects the whole config with +// "invalid randRange" otherwise (reversed ranges like "200-100" are fine, +// xray reorders them). +function validateRandRange(_rule: unknown, value: unknown): Promise { + const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); + if (str.length === 0) return Promise.resolve(); + const m = /^(\d{1,3})(?:-(\d{1,3}))?$/.exec(str); + if (!m) return Promise.reject(new Error('Use a byte value or range like 0-255')); + const from = Number(m[1]); + const to = m[2] !== undefined ? Number(m[2]) : from; + if (from > 255 || to > 255) { + return Promise.reject(new Error('randRange bytes must be within 0-255')); + } + return Promise.resolve(); +} + function getDeep(obj: unknown, path: (string | number)[]): unknown { let cur: unknown = obj; for (const key of path) { @@ -345,8 +365,8 @@ function HeaderCustomGroups({ } function UdpMasksList({ - base, form, isHysteria, network, -}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) { + base, form, isHysteria, isWireguard, network, +}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) { return ( {(fields, { add, remove }) => ( @@ -357,7 +377,7 @@ function UdpMasksList({ size="small" icon={} onClick={() => { - const def = isHysteria ? 'salamander' : 'mkcp-legacy'; + const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy'; add({ type: def, settings: defaultUdpMaskSettings(def) }); }} /> @@ -370,6 +390,7 @@ function UdpMasksList({ form={form} listPath={[...base, 'udp']} isHysteria={isHysteria} + isWireguard={isWireguard} network={network} onRemove={() => remove(field.name)} /> @@ -381,13 +402,14 @@ function UdpMasksList({ } function UdpMaskItem({ - fieldName, displayIndex, form, listPath, isHysteria, network, onRemove, + fieldName, displayIndex, form, listPath, isHysteria, isWireguard, network, onRemove, }: { fieldName: number; displayIndex: number; form: FormInstance; listPath: (string | number)[]; isHysteria: boolean; + isWireguard: boolean; network: string; onRemove: () => void; }) { @@ -404,6 +426,9 @@ function UdpMaskItem({ const options = isHysteria ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] : [ + // Salamander is the mask xray-core's own wireguard finalmask example + // uses; it stays hysteria-only elsewhere to keep legacy parity. + ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []), { value: 'mkcp-legacy', label: 'mKCP Legacy' }, { value: 'xdns', label: 'xDNS' }, { value: 'xicmp', label: 'xICMP' }, @@ -674,7 +699,15 @@ function ItemEditor({ )} - + {/* Cleared must become undefined, not '': xray parses an + explicit "" as the range 0-0 (all-zero fill bytes), while + an omitted randRange falls back to the 0-255 default. */} + (v === '' ? undefined : v)} + rules={[{ validator: validateRandRange }]} + > diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index 168199ba2..574f513ea 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -9,7 +9,7 @@ const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks']; const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp']; const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan']; const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp']; -const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']; +const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria', 'wireguard']; const VISION_FLOW = 'xtls-rprx-vision'; const SS_2022_PREFIX = '2022'; const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305'; diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 952d50208..725107d69 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -372,9 +372,15 @@ export default function InboundFormModal({ }], }, }); + } else if (next === Protocols.WIREGUARD) { + // Wireguard has no user-selectable transport: the listener is always + // UDP and only finalmask/sockopt from streamSettings apply. Drop the + // leftover network/transport slices so the stream tab doesn't render + // a TCP sub-form and the wire payload carries no dead tcpSettings. + form.setFieldValue('streamSettings', { security: 'none' }); } else { const current = form.getFieldValue('streamSettings') as { network?: string } | undefined; - if (current?.network === 'hysteria') { + if (current?.network === 'hysteria' || !current?.network) { form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} }); } } @@ -645,7 +651,7 @@ export default function InboundFormModal({ const streamTab = ( <> - {protocol !== Protocols.HYSTERIA && ( + {protocol !== Protocols.HYSTERIA && protocol !== Protocols.WIREGUARD && ( + {(fields, { add, remove }) => ( <> diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index ea1a8db62..801a534ea 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -156,10 +156,17 @@ export default function OutboundFormModal({ useEffect(() => { if (!streamAllowed) return; + // Wireguard dials its own UDP — only finalmask/sockopt apply, never a + // transport. Don't seed network 'tcp'; clear a leftover one (from a + // protocol switch) so the transmission/security blocks stay hidden. + if (protocol === 'wireguard') { + if (network) form.setFieldValue('streamSettings', { security: 'none' }); + return; + } if (network) return; form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [streamAllowed, network]); + }, [streamAllowed, network, protocol]); useEffect(() => { if (protocol !== 'hysteria') return; @@ -565,7 +572,7 @@ export default function OutboundFormModal({ {security === 'reality' && realityAllowed && } - {((streamAllowed && network) || !streamAllowed) && ( + {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && ( )} diff --git a/frontend/src/schemas/protocols/inbound/wireguard.ts b/frontend/src/schemas/protocols/inbound/wireguard.ts index 1baa89de0..88c4a4a36 100644 --- a/frontend/src/schemas/protocols/inbound/wireguard.ts +++ b/frontend/src/schemas/protocols/inbound/wireguard.ts @@ -1,5 +1,20 @@ import { z } from 'zod'; +export const WireguardDomainStrategySchema = z.enum([ + 'ForceIP', + 'ForceIPv4', + 'ForceIPv4v6', + 'ForceIPv6', + 'ForceIPv6v4', +]); +export type WireguardDomainStrategy = z.infer; + +// AntD InputNumber emits null (not undefined) when the user clears it, and +// the form store hands that null straight to safeParse on submit — a bare +// .optional() would reject it and block the save. +const optionalClearedInt = (schema: z.ZodNumber) => + z.preprocess((v) => (v == null ? undefined : v), schema.optional()); + // Wireguard inbound is peer-based (no clients). Each peer is a client device // the server accepts; secretKey is the server-side private key and pubKey is // derived from it at runtime (not persisted on the wire). Inbound peers @@ -10,14 +25,16 @@ export const WireguardInboundPeerSchema = z.object({ publicKey: z.string().min(1), preSharedKey: z.string().optional(), allowedIPs: z.array(z.string()).default([]), - keepAlive: z.number().int().min(0).optional(), + keepAlive: optionalClearedInt(z.number().int().min(0)), }); export type WireguardInboundPeer = z.infer; export const WireguardInboundSettingsSchema = z.object({ - mtu: z.number().int().min(1).optional(), + mtu: optionalClearedInt(z.number().int().min(1)), secretKey: z.string().min(1), peers: z.array(WireguardInboundPeerSchema).default([]), noKernelTun: z.boolean().default(false), + workers: optionalClearedInt(z.number().int().min(1)), + domainStrategy: WireguardDomainStrategySchema.optional(), }); export type WireguardInboundSettings = z.infer; diff --git a/frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap b/frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap index 5d9d41c49..33e713163 100644 --- a/frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap +++ b/frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap @@ -1683,7 +1683,7 @@ exports[`protocol capability predicates > vmess-basic :: xhttp/tls 1`] = ` exports[`protocol capability predicates > wireguard-basic :: grpc/none 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1695,7 +1695,7 @@ exports[`protocol capability predicates > wireguard-basic :: grpc/none 1`] = ` exports[`protocol capability predicates > wireguard-basic :: grpc/reality 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1707,7 +1707,7 @@ exports[`protocol capability predicates > wireguard-basic :: grpc/reality 1`] = exports[`protocol capability predicates > wireguard-basic :: grpc/tls 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1719,7 +1719,7 @@ exports[`protocol capability predicates > wireguard-basic :: grpc/tls 1`] = ` exports[`protocol capability predicates > wireguard-basic :: httpupgrade/none 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1731,7 +1731,7 @@ exports[`protocol capability predicates > wireguard-basic :: httpupgrade/none 1` exports[`protocol capability predicates > wireguard-basic :: httpupgrade/tls 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1743,7 +1743,7 @@ exports[`protocol capability predicates > wireguard-basic :: httpupgrade/tls 1`] exports[`protocol capability predicates > wireguard-basic :: kcp/none 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1755,7 +1755,7 @@ exports[`protocol capability predicates > wireguard-basic :: kcp/none 1`] = ` exports[`protocol capability predicates > wireguard-basic :: tcp/none 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1767,7 +1767,7 @@ exports[`protocol capability predicates > wireguard-basic :: tcp/none 1`] = ` exports[`protocol capability predicates > wireguard-basic :: tcp/reality 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1779,7 +1779,7 @@ exports[`protocol capability predicates > wireguard-basic :: tcp/reality 1`] = ` exports[`protocol capability predicates > wireguard-basic :: tcp/tls 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1791,7 +1791,7 @@ exports[`protocol capability predicates > wireguard-basic :: tcp/tls 1`] = ` exports[`protocol capability predicates > wireguard-basic :: ws/none 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1803,7 +1803,7 @@ exports[`protocol capability predicates > wireguard-basic :: ws/none 1`] = ` exports[`protocol capability predicates > wireguard-basic :: ws/tls 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1815,7 +1815,7 @@ exports[`protocol capability predicates > wireguard-basic :: ws/tls 1`] = ` exports[`protocol capability predicates > wireguard-basic :: xhttp/none 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1827,7 +1827,7 @@ exports[`protocol capability predicates > wireguard-basic :: xhttp/none 1`] = ` exports[`protocol capability predicates > wireguard-basic :: xhttp/reality 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, @@ -1839,7 +1839,7 @@ exports[`protocol capability predicates > wireguard-basic :: xhttp/reality 1`] = exports[`protocol capability predicates > wireguard-basic :: xhttp/tls 1`] = ` { "canEnableReality": false, - "canEnableStream": false, + "canEnableStream": true, "canEnableTls": false, "canEnableTlsFlow": false, "canEnableVisionSeed": false, diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index e2caad574..0f705c6c5 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -289,7 +289,8 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) { } // normalizeStreamSettings clears StreamSettings for protocols that don't use it. -// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings. +// Only vmess, vless, trojan, shadowsocks, hysteria, and wireguard protocols use +// streamSettings (wireguard for finalmask UDP masks and sockopt on its listener). func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { protocolsWithStream := map[model.Protocol]bool{ model.VMESS: true, @@ -297,6 +298,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { model.Trojan: true, model.Shadowsocks: true, model.Hysteria: true, + model.WireGuard: true, } if !protocolsWithStream[inbound.Protocol] {