Browse Source

fix(outbound): probe UDP-based outbounds over UDP instead of TCP

The fast-probe mode hard-coded net.DialTimeout("tcp", ...), so testing a
WARP/WireGuard or Hysteria outbound always failed with an i/o timeout —
those transports only listen on UDP, never on TCP.

Probe is now transport-aware: extractOutboundEndpoints tags each endpoint
with the network the proxy actually listens on (UDP for wireguard,
hysteria, and any outbound whose streamSettings.network is hysteria, kcp,
or quic; TCP otherwise). probeUDPEndpoint dials UDP, writes a single
sentinel byte so the kernel can surface ICMP errors, and treats a read
timeout as success (WireGuard ignores invalid packets, so silence is the
expected reply from a reachable server). The result's mode field now
reflects what was probed, so the UI badge shows UDP for these outbounds
instead of mislabelling them as TCP.
MHSanaei 13 hours ago
parent
commit
f00f82b392
1 changed files with 79 additions and 6 deletions
  1. 79 6
      web/service/outbound.go

+ 79 - 6
web/service/outbound.go

@@ -190,7 +190,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		wg.Add(1)
 		go func(i int) {
 			defer wg.Done()
-			results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
+			results[i] = probeEndpoint(endpoints[i], 5*time.Second)
 		}(i)
 	}
 	wg.Wait()
@@ -207,7 +207,11 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		}
 	}
 
-	out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
+	mode := "tcp"
+	if endpoints[0].Network == "udp" {
+		mode = "udp"
+	}
+	out := &TestOutboundResult{Mode: mode, Endpoints: results}
 	if bestDelay >= 0 {
 		out.Success = true
 		out.Delay = bestDelay
@@ -220,6 +224,22 @@ 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()
@@ -234,18 +254,69 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 	return r
 }
 
-func extractOutboundEndpoints(ob map[string]any) []string {
+// 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
+	}
+
+	_ = 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
+		}
+		r.Error = rerr.Error()
+		return r
+	}
+	r.Success = true
+	return r
+}
+
+func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
 	protocol, _ := ob["protocol"].(string)
 	settings, _ := ob["settings"].(map[string]any)
 	if settings == nil {
 		return nil
 	}
-	var out []string
+
+	// Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
+	// outer protocol or via streamSettings.network so trojan-with-hysteria2
+	// 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
 	addServer := func(addr any, port any) {
 		host, _ := addr.(string)
 		p := numAsInt(port)
 		if host != "" && p > 0 {
-			out = append(out, fmt.Sprintf("%s:%d", host, p))
+			out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
 		}
 	}
 	switch protocol {
@@ -259,6 +330,8 @@ func extractOutboundEndpoints(ob map[string]any) []string {
 		}
 	case "vless":
 		addServer(settings["address"], settings["port"])
+	case "hysteria":
+		addServer(settings["address"], settings["port"])
 	case "trojan", "shadowsocks", "http", "socks":
 		if servers, ok := settings["servers"].([]any); ok {
 			for _, sv := range servers {
@@ -272,7 +345,7 @@ func extractOutboundEndpoints(ob map[string]any) []string {
 			for _, p := range peers {
 				if pm, ok := p.(map[string]any); ok {
 					if ep, _ := pm["endpoint"].(string); ep != "" {
-						out = append(out, ep)
+						out = append(out, outboundEndpoint{Address: ep, Network: network})
 					}
 				}
 			}