Explorar o código

fix(xray): test UDP outbounds via xray probe (#4657) + Vision testseed & Flow form fixes

Outbound connection tester (#4657): UDP-based outbounds (wireguard,
hysteria, kcp/quic transports) were probed with a raw UDP dial that
treated the inevitable read timeout as success, so every one reported a
fake ~5s 'alive'. Route them through the authoritative xray
burstObservatory probe and drop the broken raw-UDP path. Test All now
runs a parallel TCP lane and a serial HTTP lane so xray-probe outbounds
don't collide on the test semaphore.

Vision testseed: the [900, 500, 900, 256] default repeats 900, and a
tags Select keys each tag by value -> 'two children with the same key,
900'. Render it as four InputNumbers (inbound + outbound forms); the
field is a fixed 4-tuple where repeats are valid.

Inbound form: drop the null-valued 'Local Panel' Select option (AntD
rejects null option values; placeholder + allowClear already cover it).

Outbound form: add an explicit 'None' option to the Flow selector.
MHSanaei hai 20 horas
pai
achega
cb7af04cd3

+ 36 - 25
frontend/src/hooks/useXraySetting.ts

@@ -17,6 +17,13 @@ import {
 const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 
+export function isUdpOutbound(outbound: unknown): boolean {
+  const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
+  const p = o?.protocol;
+  const n = o?.streamSettings?.network;
+  return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
+}
+
 export type { OutboundTrafficRow, OutboundTestResult };
 
 export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
@@ -243,15 +250,16 @@ export function useXraySetting(): UseXraySettingResult {
   const testOutbound = useCallback(
     async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
       if (!outbound) return null;
+      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
       setOutboundTestStates((prev) => ({
         ...prev,
-        [index]: { testing: true, result: null, mode },
+        [index]: { testing: true, result: null, mode: effMode },
       }));
       try {
         const raw = await HttpUtil.post('/panel/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
           allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
-          mode,
+          mode: effMode,
         });
         const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
         if (msg?.success && msg.obj) {
@@ -265,7 +273,7 @@ export function useXraySetting(): UseXraySettingResult {
           ...prev,
           [index]: {
             testing: false,
-            result: { success: false, error: msg?.msg || 'Unknown error', mode },
+            result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
           },
         }));
       } catch (e) {
@@ -273,7 +281,7 @@ export function useXraySetting(): UseXraySettingResult {
           ...prev,
           [index]: {
             testing: false,
-            result: { success: false, error: String(e), mode },
+            result: { success: false, error: String(e), mode: effMode },
           },
         }));
       }
@@ -287,28 +295,31 @@ export function useXraySetting(): UseXraySettingResult {
     if (list.length === 0 || testingAll) return;
     setTestingAll(true);
     try {
-      const concurrency = mode === 'tcp' ? 8 : 1;
-      const queue = list
-        .map((ob, i) => ({ index: i, outbound: ob }))
-        .filter(({ outbound }) => {
-          const tag = outbound?.tag;
-          const proto = outbound?.protocol;
-          if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
-          if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
-          return true;
-        });
-      async function worker() {
-        while (queue.length > 0) {
-          const item = queue.shift();
-          if (!item) break;
-          await testOutbound(item.index, item.outbound, mode);
+      const tcpQueue: { index: number; outbound: unknown }[] = [];
+      const httpQueue: { index: number; outbound: unknown }[] = [];
+      list.forEach((ob, i) => {
+        const tag = ob?.tag;
+        const proto = ob?.protocol;
+        if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
+        if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
+        if (mode === 'http' || isUdpOutbound(ob)) {
+          httpQueue.push({ index: i, outbound: ob });
+        } else {
+          tcpQueue.push({ index: i, outbound: ob });
         }
-      }
-      const workers = Array.from(
-        { length: Math.min(concurrency, queue.length) },
-        () => worker(),
-      );
-      await Promise.all(workers);
+      });
+      const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
+        const worker = async () => {
+          while (queue.length > 0) {
+            const item = queue.shift();
+            if (!item) break;
+            await testOutbound(item.index, item.outbound, mode);
+          }
+        };
+        const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
+        await Promise.all(workers);
+      };
+      await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
     } finally {
       setTestingAll(false);
     }

+ 12 - 16
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -931,14 +931,11 @@ export default function InboundFormModal({
             disabled={mode === 'edit'}
             placeholder={t('pages.inbounds.localPanel')}
             allowClear
-            options={[
-              { value: null, label: t('pages.inbounds.localPanel') },
-              ...selectableNodes.map((n) => ({
-                value: n.id,
-                label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
-                disabled: n.status === 'offline',
-              })),
-            ]}
+            options={selectableNodes.map((n) => ({
+              value: n.id,
+              label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
+              disabled: n.status === 'offline',
+            }))}
           />
         </Form.Item>
       )}
@@ -1498,16 +1495,15 @@ export default function InboundFormModal({
           {network === 'tcp' && (security === 'tls' || security === 'reality') && (
             <Form.Item
               label={t('pages.inbounds.form.visionTestseed')}
-              name={['settings', 'testseed']}
-              initialValue={[900, 500, 900, 256]}
-              normalize={(v: unknown) =>
-                Array.isArray(v)
-                  ? v.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n > 0)
-                  : []
-              }
               extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise."
             >
-              <Select mode="tags" tokenSeparators={[',', ' ']} placeholder="four positive integers" />
+              <Space.Compact block>
+                {[900, 500, 900, 256].map((def, i) => (
+                  <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
+                    <InputNumber min={1} style={{ width: '25%' }} />
+                  </Form.Item>
+                ))}
+              </Space.Compact>
             </Form.Item>
           )}
         </>

+ 12 - 20
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -191,8 +191,8 @@ export default function OutboundFormModal({
   const [linkInput, setLinkInput] = useState('');
 
   // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
-  // hysteria2://) and replace form state with the result. The current
-  // tag is preserved when the parsed link doesn't carry one.
+  // hysteria2:// / wireguard://) and replace form state with the result.
+  // The current tag is preserved when the parsed link doesn't carry one.
   function importLink() {
     const link = linkInput.trim();
     if (!link) return;
@@ -1743,7 +1743,7 @@ export default function OutboundFormModal({
                         <Select
                           allowClear
                           placeholder={t('none')}
-                          options={FLOW_OPTIONS}
+                          options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
                         />
                       </Form.Item>
                     )}
@@ -1762,22 +1762,14 @@ export default function OutboundFormModal({
                             <Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
                               <InputNumber min={0} style={{ width: '100%' }} />
                             </Form.Item>
-                            <Form.Item
-                              label={t('pages.inbounds.form.visionTestseed')}
-                              name={['settings', 'testseed']}
-                              normalize={(v: unknown) =>
-                                Array.isArray(v)
-                                  ? v
-                                    .map((x) => Number(x))
-                                    .filter((n) => Number.isInteger(n) && n > 0)
-                                  : []
-                              }
-                            >
-                              <Select
-                                mode="tags"
-                                tokenSeparators={[',', ' ']}
-                                placeholder="four positive integers"
-                              />
+                            <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
+                              <Space.Compact block>
+                                {[900, 500, 900, 256].map((def, i) => (
+                                  <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
+                                    <InputNumber min={1} style={{ width: '25%' }} />
+                                  </Form.Item>
+                                ))}
+                              </Space.Compact>
                             </Form.Item>
                           </>
                         );
@@ -2215,7 +2207,7 @@ export default function OutboundFormModal({
                   <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
                     <Input.Search
                       value={linkInput}
-                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
+                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
                       enterButton="Import"
                       onChange={(e) => setLinkInput(e.target.value)}
                       onSearch={importLink}

+ 2 - 1
frontend/src/pages/xray/OutboundsTab.tsx

@@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table';
 import { SizeFormatter } from '@/utils';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import OutboundFormModal from './OutboundFormModal';
+import { isUdpOutbound } from '@/hooks/useXraySetting';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
 
@@ -361,7 +362,7 @@ export default function OutboundsTab({
         align: 'center',
         width: 80,
         render: (_v, record, index) => (
-          <Tooltip title={`${t('check')} (${testMode.toUpperCase()})`}>
+          <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
             <Button
               type="primary"
               shape="circle"

+ 26 - 71
web/service/outbound.go

@@ -151,6 +151,14 @@ type TestEndpointResult struct {
 // sockopt.dialerProxy chains during test).
 func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
 	if mode == "tcp" {
+		// A bare TCP dial only proves reachability for TCP-based proxies.
+		// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
+		// unauthenticated packets, so a raw dial can't tell "reachable" from
+		// "dead" — route them through the authoritative xray handshake probe.
+		var ob map[string]any
+		if json.Unmarshal([]byte(outboundJSON), &ob) == nil && outboundTransportIsUDP(ob) {
+			return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
+		}
 		return s.testOutboundTCP(outboundJSON)
 	}
 	return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
@@ -178,7 +186,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		wg.Add(1)
 		go func(i int) {
 			defer wg.Done()
-			results[i] = probeEndpoint(endpoints[i], 5*time.Second)
+			results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
 		}(i)
 	}
 	wg.Wait()
@@ -195,11 +203,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		}
 	}
 
-	mode := "tcp"
-	if endpoints[0].Network == "udp" {
-		mode = "udp"
-	}
-	out := &TestOutboundResult{Mode: mode, Endpoints: results}
+	out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
 	if bestDelay >= 0 {
 		out.Success = true
 		out.Delay = bestDelay
@@ -212,22 +216,6 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 	return out, nil
 }
 
-// outboundEndpoint is a host:port plus the transport its proxy actually
-// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
-// TCP dial to its peer endpoint always times out — the probe must match
-// the transport of the outbound being tested.
-type outboundEndpoint struct {
-	Address string
-	Network string
-}
-
-func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
-	if ep.Network == "udp" {
-		return probeUDPEndpoint(ep.Address, timeout)
-	}
-	return probeTCPEndpoint(ep.Address, timeout)
-}
-
 func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
 	r := TestEndpointResult{Address: endpoint}
 	start := time.Now()
@@ -242,69 +230,36 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 	return r
 }
 
-// probeUDPEndpoint sends a single byte and waits briefly for a reply or
-// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
-// so a read timeout is the normal "endpoint reachable" outcome; a
-// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
-func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
-	r := TestEndpointResult{Address: endpoint}
-	start := time.Now()
-	conn, err := net.DialTimeout("udp", endpoint, timeout)
-	if err != nil {
-		r.Delay = time.Since(start).Milliseconds()
-		r.Error = err.Error()
-		return r
-	}
-	defer conn.Close()
-
-	if _, werr := conn.Write([]byte{0}); werr != nil {
-		r.Delay = time.Since(start).Milliseconds()
-		r.Error = werr.Error()
-		return r
+// outboundTransportIsUDP reports whether the outbound's proxy speaks UDP
+// (wireguard, hysteria, or a kcp/quic/hysteria stream transport). A bare
+// UDP dial can't probe these — they ignore unauthenticated packets, so a
+// dial neither proves reachability nor measures latency. Such outbounds
+// must go through the real xray handshake probe instead.
+func outboundTransportIsUDP(ob map[string]any) bool {
+	if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
+		return true
 	}
-
-	_ = conn.SetReadDeadline(time.Now().Add(timeout))
-	buf := make([]byte, 64)
-	_, rerr := conn.Read(buf)
-	r.Delay = time.Since(start).Milliseconds()
-	if rerr != nil {
-		if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
-			r.Success = true
-			return r
+	if stream, ok := ob["streamSettings"].(map[string]any); ok {
+		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
+			return true
 		}
-		r.Error = rerr.Error()
-		return r
 	}
-	r.Success = true
-	return r
+	return false
 }
 
-func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
+func extractOutboundEndpoints(ob map[string]any) []string {
 	protocol, _ := ob["protocol"].(string)
 	settings, _ := ob["settings"].(map[string]any)
 	if settings == nil {
 		return nil
 	}
 
-	// Hysteria is QUIC/UDP — detect via the outer protocol or via
-	// streamSettings.network so a trojan-with-hysteria transport gets
-	// probed over UDP too. kcp and quic are also UDP-based.
-	network := "tcp"
-	if protocol == "hysteria" || protocol == "wireguard" {
-		network = "udp"
-	}
-	if stream, ok := ob["streamSettings"].(map[string]any); ok {
-		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
-			network = "udp"
-		}
-	}
-
-	var out []outboundEndpoint
+	var out []string
 	addServer := func(addr any, port any) {
 		host, _ := addr.(string)
 		p := numAsInt(port)
 		if host != "" && p > 0 {
-			out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
+			out = append(out, fmt.Sprintf("%s:%d", host, p))
 		}
 	}
 	switch protocol {
@@ -333,7 +288,7 @@ func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
 			for _, p := range peers {
 				if pm, ok := p.(map[string]any); ok {
 					if ep, _ := pm["endpoint"].(string); ep != "" {
-						out = append(out, outboundEndpoint{Address: ep, Network: network})
+						out = append(out, ep)
 					}
 				}
 			}