From f00f82b3920f759f3f4830bbde89cb95a4f525e6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 15 May 2026 12:29:53 +0200 Subject: [PATCH] fix(outbound): probe UDP-based outbounds over UDP instead of TCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/service/outbound.go | 85 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/web/service/outbound.go b/web/service/outbound.go index 4cef5247..7a56dc7d 100644 --- a/web/service/outbound.go +++ b/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}) } } }