Explorar o código

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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Rouzbeh† hai 12 horas
pai
achega
4002be4ade

+ 41 - 8
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -96,8 +96,12 @@ function defaultUdpHop(): Record<string, unknown> {
 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 && <TcpMasksList base={base} form={form} />}
-      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
+      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} isWireguard={isWireguard} network={network} />}
       {showQuic && (
         <>
           <Form.Item label="QUIC Params">
@@ -275,6 +279,22 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
   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<void> {
+  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 (
     <Form.List name={[...base, 'udp']}>
       {(fields, { add, remove }) => (
@@ -357,7 +377,7 @@ function UdpMasksList({
               size="small"
               icon={<PlusOutlined />}
               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({
                     <InputNumber min={0} />
                   )}
                 </Form.Item>
-                <Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
+                {/* 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. */}
+                <Form.Item
+                  label="Rand Range"
+                  name={[fieldName, 'randRange']}
+                  normalize={(v) => (v === '' ? undefined : v)}
+                  rules={[{ validator: validateRandRange }]}
+                >
                   <Input placeholder="0-255" />
                 </Form.Item>
               </>

+ 1 - 1
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';

+ 17 - 4
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 && (
         <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
           <Select
             style={{ width: '75%' }}
@@ -683,7 +689,10 @@ export default function InboundFormModal({
 
       {network === 'kcp' && <KcpForm />}
 
-      <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
+      {/* externalProxy only feeds client share links, and wireguard's
+          per-peer .conf fanout resolves its host elsewhere — the section
+          would be dead weight on a wireguard inbound. */}
+      {protocol !== Protocols.WIREGUARD && <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />}
 
       <SockoptForm toggleSockopt={toggleSockopt} />
 
@@ -897,7 +906,11 @@ export default function InboundFormModal({
             ...(streamEnabled
               ? [
                 { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
-                { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
+                // Wireguard can't do TLS/Reality (canEnableTls is false), so
+                // the security tab would only show a fully disabled radio.
+                ...(protocol !== Protocols.WIREGUARD
+                  ? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
+                  : []),
               ]
               : []),
             ...(sniffingSupported

+ 16 - 1
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Form, Input, InputNumber, Space, Switch } from 'antd';
+import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import { Wireguard } from '@/utils';
@@ -62,6 +62,21 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
       >
         <Switch />
       </Form.Item>
+      <Form.Item name={['settings', 'workers']} label='Workers'>
+        <InputNumber min={1} />
+      </Form.Item>
+      <Form.Item name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
+        <Select
+          allowClear
+          options={[
+            { value: 'ForceIP', label: 'ForceIP' },
+            { value: 'ForceIPv4', label: 'ForceIPv4' },
+            { value: 'ForceIPv4v6', label: 'ForceIPv4v6' },
+            { value: 'ForceIPv6', label: 'ForceIPv6' },
+            { value: 'ForceIPv6v4', label: 'ForceIPv6v4' },
+          ]}
+        />
+      </Form.Item>
       <Form.List name={['settings', 'peers']}>
         {(fields, { add, remove }) => (
           <>

+ 9 - 2
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 && <RealityForm />}
 
-                    {((streamAllowed && network) || !streamAllowed) && (
+                    {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
                       <SockoptForm form={form} outboundTags={existingTags} />
                     )}
 

+ 19 - 2
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<typeof WireguardDomainStrategySchema>;
+
+// 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<typeof WireguardInboundPeerSchema>;
 
 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<typeof WireguardInboundSettingsSchema>;

+ 14 - 14
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,

+ 3 - 1
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] {