Jelajahi Sumber

fix: expose streamSettings for Tunnel inbounds to support TProxy (#5171)

* fix: expose streamSettings for Tunnel inbounds to support TProxy

* fix(ui): hide security tab for tunnel inbounds when stream is enabled

tunnel (dokodemo-door) does not support TLS or Reality, so showing the
security tab only results in a fully-disabled radio group. Exclude tunnel
alongside wireguard from the security tab.

* fix(tunnel): restrict stream tab to sockopt-only and fix transportless schema

Tunnel (dokodemo-door) only needs sockopt.tproxy for TProxy mode — no
user-selectable transport. Add hasSelectableTransport flag to hide the
network picker, per-network sub-forms, ExternalProxy, and FinalMask for
both tunnel and wireguard, matching the pattern already used for Hysteria.

Fix a pre-existing Zod schema bug where NetworkSettingsSchema was a bare
discriminatedUnion requiring `network` to be present. Wireguard and
tunnel submit streamSettings without a `network` key, causing
"Invalid discriminator value. Expected 'tcp' | ..." on every save. Fix
by adding a transportless union branch (z.never().optional()) alongside
the transport DU; also add ?? 'tcp' fallback in inbound-link.ts where
stream.network is now string | undefined. Three regression tests added.

---------

Co-authored-by: rqzbeh <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
Rouzbeh† 23 jam lalu
induk
melakukan
1ad483ede6

+ 3 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -317,7 +317,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
 
   const security = forceTls === 'same' ? stream.security : forceTls;
   const params = new URLSearchParams();
-  params.set('type', stream.network);
+  params.set('type', stream.network ?? 'tcp');
   params.set('encryption', inbound.settings.encryption);
 
   if (stream.network === 'tcp') {
@@ -501,7 +501,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
 
   const security = forceTls === 'same' ? stream.security : forceTls;
   const params = new URLSearchParams();
-  params.set('type', stream.network);
+  params.set('type', stream.network ?? 'tcp');
 
   writeNetworkParams(stream, params);
   applyFinalMaskToParams(stream.finalmask, params);
@@ -558,7 +558,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
 
   const security = forceTls === 'same' ? stream.security : forceTls;
   const params = new URLSearchParams();
-  params.set('type', stream.network);
+  params.set('type', stream.network ?? 'tcp');
 
   writeNetworkParams(stream, params);
   applyFinalMaskToParams(stream.finalmask, params);

+ 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', 'wireguard'];
+const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria', 'wireguard', 'tunnel'];
 const VISION_FLOW = 'xtls-rprx-vision';
 const SS_2022_PREFIX = '2022';
 const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';

+ 45 - 24
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -162,6 +162,15 @@ export default function InboundFormModal({
   const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
   const streamEnabled = canEnableStream({ protocol });
   const sniffingSupported = canEnableSniffing({ protocol });
+  // Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
+  // user-selectable transport — their stream tab is just sockopt, which is all
+  // Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
+  // own dedicated transport form. For all of these the RAW/mKCP/WS/... network
+  // picker and the per-network sub-forms are hidden.
+  const hasSelectableTransport =
+    protocol !== Protocols.HYSTERIA
+    && protocol !== Protocols.WIREGUARD
+    && protocol !== Protocols.TUNNEL;
 
   const wPort = Form.useWatch('port', form);
   const wListen = (Form.useWatch('listen', form) ?? '') as string;
@@ -372,11 +381,13 @@ 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.
+      } else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
+        // Wireguard and Tunnel (dokodemo-door) have no user-selectable
+        // transport: wireguard is always a UDP listener, and tunnel only needs
+        // `sockopt.tproxy` for its TProxy/redirect mode. 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 — the
+        // sockopt section (with TProxy) stays available.
         form.setFieldValue('streamSettings', { security: 'none' });
       } else {
         const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
@@ -651,7 +662,7 @@ export default function InboundFormModal({
 
   const streamTab = (
     <>
-      {protocol !== Protocols.HYSTERIA && protocol !== Protocols.WIREGUARD && (
+      {hasSelectableTransport && (
         <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
           <Select
             style={{ width: '75%' }}
@@ -677,31 +688,41 @@ export default function InboundFormModal({
           HTTP server when probed. */}
       {protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
 
-      {network === 'tcp' && <RawForm />}
+      {hasSelectableTransport && (
+        <>
+          {network === 'tcp' && <RawForm />}
 
-      {network === 'ws' && <WsForm />}
+          {network === 'ws' && <WsForm />}
 
-      {network === 'grpc' && <GrpcForm />}
+          {network === 'grpc' && <GrpcForm />}
 
-      {network === 'xhttp' && <XhttpForm form={form} />}
+          {network === 'xhttp' && <XhttpForm form={form} />}
 
-      {network === 'httpupgrade' && <HttpUpgradeForm />}
+          {network === 'httpupgrade' && <HttpUpgradeForm />}
 
-      {network === 'kcp' && <KcpForm />}
+          {network === 'kcp' && <KcpForm />}
+        </>
+      )}
 
-      {/* 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} />}
+      {/* externalProxy only feeds client share links. Wireguard's per-peer
+          .conf fanout resolves its host elsewhere, and tunnel (dokodemo-door)
+          has no clients at all — the section is dead weight on both. */}
+      {protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL && (
+        <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
+      )}
 
       <SockoptForm toggleSockopt={toggleSockopt} />
 
-      <FinalMaskForm
-        name={['streamSettings', 'finalmask']}
-        network={network as string}
-        protocol={protocol}
-        form={form}
-      />
+      {/* Transport masks don't apply to tunnel (a transparent forwarder), so
+          its stream tab is just sockopt + TProxy. */}
+      {protocol !== Protocols.TUNNEL && (
+        <FinalMaskForm
+          name={['streamSettings', 'finalmask']}
+          network={network as string}
+          protocol={protocol}
+          form={form}
+        />
+      )}
     </>
   );
 
@@ -906,9 +927,9 @@ export default function InboundFormModal({
             ...(streamEnabled
               ? [
                 { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
-                // Wireguard can't do TLS/Reality (canEnableTls is false), so
+                // Wireguard and Tunnel can't do TLS/Reality (canEnableTls is false), so
                 // the security tab would only show a fully disabled radio.
-                ...(protocol !== Protocols.WIREGUARD
+                ...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
                   ? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
                   : []),
               ]

+ 12 - 1
frontend/src/schemas/protocols/stream/index.ts

@@ -36,7 +36,7 @@ export type Network = z.infer<typeof NetworkSchema>;
 // `hysteria` is only valid when the parent protocol is hysteria — the
 // network selector hides it for other protocols. xray-core enforces
 // the constraint server-side too.
-export const NetworkSettingsSchema = z.discriminatedUnion('network', [
+const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [
   z.object({ network: z.literal('tcp'),         tcpSettings:         TcpStreamSettingsSchema }),
   z.object({ network: z.literal('kcp'),         kcpSettings:         KcpStreamSettingsSchema }),
   z.object({ network: z.literal('ws'),          wsSettings:          WsStreamSettingsSchema }),
@@ -45,6 +45,17 @@ export const NetworkSettingsSchema = z.discriminatedUnion('network', [
   z.object({ network: z.literal('xhttp'),       xhttpSettings:       XHttpStreamSettingsSchema }),
   z.object({ network: z.literal('hysteria'),    hysteriaSettings:    HysteriaStreamSettingsSchema }),
 ]);
+
+// Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
+// user-selectable transport: their streamSettings carries no `network` key —
+// only security/sockopt, and Tunnel relies on `sockopt.tproxy` for its TProxy
+// mode. The transportless branch accepts that shape (network absent), while a
+// present-but-invalid network still fails both branches so a typo can't slip
+// through. `network: never().optional()` reads as "this key must be absent".
+export const NetworkSettingsSchema = z.union([
+  TransportNetworkSettingsSchema,
+  z.object({ network: z.never().optional() }),
+]);
 export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
 
 // Orthogonal extras that ride alongside the network and security branches.

+ 14 - 14
frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap

@@ -1179,7 +1179,7 @@ exports[`protocol capability predicates > trojan-basic :: xhttp/tls 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: grpc/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1191,7 +1191,7 @@ exports[`protocol capability predicates > tunnel-basic :: grpc/none 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: grpc/reality 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1203,7 +1203,7 @@ exports[`protocol capability predicates > tunnel-basic :: grpc/reality 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: grpc/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1215,7 +1215,7 @@ exports[`protocol capability predicates > tunnel-basic :: grpc/tls 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: httpupgrade/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1227,7 +1227,7 @@ exports[`protocol capability predicates > tunnel-basic :: httpupgrade/none 1`] =
 exports[`protocol capability predicates > tunnel-basic :: httpupgrade/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1239,7 +1239,7 @@ exports[`protocol capability predicates > tunnel-basic :: httpupgrade/tls 1`] =
 exports[`protocol capability predicates > tunnel-basic :: kcp/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1251,7 +1251,7 @@ exports[`protocol capability predicates > tunnel-basic :: kcp/none 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: tcp/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1263,7 +1263,7 @@ exports[`protocol capability predicates > tunnel-basic :: tcp/none 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: tcp/reality 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1275,7 +1275,7 @@ exports[`protocol capability predicates > tunnel-basic :: tcp/reality 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: tcp/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1287,7 +1287,7 @@ exports[`protocol capability predicates > tunnel-basic :: tcp/tls 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: ws/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1299,7 +1299,7 @@ exports[`protocol capability predicates > tunnel-basic :: ws/none 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: ws/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1311,7 +1311,7 @@ exports[`protocol capability predicates > tunnel-basic :: ws/tls 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: xhttp/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1323,7 +1323,7 @@ exports[`protocol capability predicates > tunnel-basic :: xhttp/none 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: xhttp/reality 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1335,7 +1335,7 @@ exports[`protocol capability predicates > tunnel-basic :: xhttp/reality 1`] = `
 exports[`protocol capability predicates > tunnel-basic :: xhttp/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,

+ 50 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -7,6 +7,7 @@ import {
   type RawInboundRow,
 } from '@/lib/xray/inbound-form-adapter';
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
+import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 
 // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
 // that the JSON-stringified settings/streamSettings/sniffing in the
@@ -113,6 +114,55 @@ describe('rawInboundToFormValues', () => {
   });
 });
 
+// Regression: wireguard (UDP-only) and tunnel (dokodemo-door) have no
+// user-selectable transport, so the modal submits streamSettings WITHOUT a
+// `network` key — just `security`, plus `sockopt` for tunnel's TProxy. The
+// network schema must accept that transportless shape; before the transportless
+// union branch landed it failed with "Invalid discriminator value. Expected
+// 'tcp' | ..." and blocked every wireguard/tunnel save.
+describe('transportless streamSettings (wireguard / tunnel)', () => {
+  it('accepts wireguard with a network-less streamSettings', () => {
+    const result = InboundFormSchema.safeParse({
+      port: 51820,
+      protocol: 'wireguard',
+      settings: { secretKey: 'cE9mYWtlLXNlY3JldC1rZXktZm9yLXVuaXQtdGVzdA==', peers: [] },
+      streamSettings: { security: 'none' },
+    });
+    expect(result.success).toBe(true);
+  });
+
+  it('accepts tunnel with sockopt.tproxy and no network', () => {
+    const result = InboundFormSchema.safeParse({
+      port: 12345,
+      protocol: 'tunnel',
+      settings: { allowedNetwork: 'tcp,udp', followRedirect: true, portMap: {} },
+      streamSettings: {
+        security: 'none',
+        sockopt: SockoptStreamSettingsSchema.parse({ tproxy: 'tproxy' }),
+      },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      const stream = result.data.streamSettings as {
+        network?: unknown;
+        sockopt?: { tproxy?: string };
+      };
+      expect(stream.network).toBeUndefined();
+      expect(stream.sockopt?.tproxy).toBe('tproxy');
+    }
+  });
+
+  it('still rejects a present-but-invalid network value', () => {
+    const result = InboundFormSchema.safeParse({
+      port: 12345,
+      protocol: 'tunnel',
+      settings: { allowedNetwork: 'tcp,udp', followRedirect: true, portMap: {} },
+      streamSettings: { network: 'bogus', security: 'none' },
+    });
+    expect(result.success).toBe(false);
+  });
+});
+
 describe('formValuesToWirePayload', () => {
   it('stringifies settings/streamSettings/sniffing with empty-array/default pruning', () => {
     const values = rawInboundToFormValues(vlessRow);