From 07910bf6c0cba082811fcd8ce8c7d479c748ce32 Mon Sep 17 00:00:00 2001 From: Hamed Date: Sat, 24 Oct 2026 07:39:36 +0330 Subject: [PATCH] fix(outbound): dial IPv6 literals correctly in TCP probes (#6850) The TCP probe built its dial address as host:port with string formatting, so an outbound whose server is an unbracketed IPv6 literal got an invalid target and was reported unreachable although it carried traffic. The address is now built with net.JoinHostPort, and an already-bracketed IPv6 literal is unwrapped first so it is not bracketed twice. --- internal/web/service/outbound/outbound.go | 8 +- .../outbound/outbound_endpoints_test.go | 143 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index ae5e77607..1f917551f 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net" + "net/netip" "slices" "strconv" "strings" @@ -271,7 +272,12 @@ func extractOutboundEndpoints(ob map[string]any) []string { host, _ := addr.(string) p := numAsInt(port) if host != "" && p > 0 { - out = append(out, fmt.Sprintf("%s:%d", host, p)) + if len(host) > 2 && host[0] == '[' && host[len(host)-1] == ']' { + if ip, err := netip.ParseAddr(host[1 : len(host)-1]); err == nil && ip.Is6() { + host = host[1 : len(host)-1] + } + } + out = append(out, net.JoinHostPort(host, strconv.Itoa(p))) } } switch protocol { diff --git a/internal/web/service/outbound/outbound_endpoints_test.go b/internal/web/service/outbound/outbound_endpoints_test.go index 225d03487..c92758d42 100644 --- a/internal/web/service/outbound/outbound_endpoints_test.go +++ b/internal/web/service/outbound/outbound_endpoints_test.go @@ -1,7 +1,10 @@ package outbound import ( + "fmt" + "net" "reflect" + "strings" "testing" ) @@ -64,3 +67,143 @@ func TestExtractOutboundEndpointsMasque(t *testing.T) { t.Fatalf("extractOutboundEndpoints() = %v, want %v", got, want) } } + +func endpointOutbound(tag, protocol, host string, port int, flat bool) map[string]any { + server := map[string]any{"address": host, "port": port} + var settings map[string]any + switch { + case flat: + settings = server + case protocol == "vless" || protocol == "vmess": + settings = map[string]any{"vnext": []any{server}} + default: + settings = map[string]any{"servers": []any{server}} + } + return map[string]any{"tag": tag, "protocol": protocol, "settings": settings} +} + +func TestOutboundEndpointsIPv6DialAddresses(t *testing.T) { + tests := []struct { + protocol string + host string + wantHost string + flat bool + }{ + {"vless", "2001:3040:1:100::301:2020", "2001:3040:1:100::301:2020", false}, + {"vless", "[2001:3040:1:100::301:2020]", "2001:3040:1:100::301:2020", false}, + {"vmess", "::1", "::1", false}, + {"trojan", "[::1]", "::1", false}, + {"shadowsocks", "192.0.2.1", "192.0.2.1", false}, + {"socks", "proxy.example.com", "proxy.example.com", false}, + {"http", "::1", "::1", false}, + {"hysteria", "::1", "::1", true}, + {"masque", "[::1]", "::1", true}, + {"vless", "fe80::1%eth0", "fe80::1%eth0", true}, + {"vless", "[fe80::1%eth0]", "fe80::1%eth0", true}, + } + for _, tt := range tests { + t.Run(tt.protocol+"/"+tt.host, func(t *testing.T) { + ob := endpointOutbound("probe", tt.protocol, tt.host, 8443, tt.flat) + endpoints := extractOutboundEndpoints(ob) + if len(endpoints) != 1 { + t.Fatalf("endpoints = %v, want one dial address", endpoints) + } + gotHost, gotPort, err := net.SplitHostPort(endpoints[0]) + if err != nil { + t.Fatalf("endpoint %q cannot be dialed: %v", endpoints[0], err) + } + if gotHost != tt.wantHost || gotPort != "8443" { + t.Fatalf("dial target = (%q, %q), want (%q, 8443)", gotHost, gotPort, tt.wantHost) + } + }) + } +} + +func TestOutboundTCPIPv6ReachesListener(t *testing.T) { + listener, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback unavailable: %v", err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + port := listener.Addr().(*net.TCPAddr).Port + for _, tc := range []struct { + protocol string + flat bool + }{ + {protocol: "vless"}, + {protocol: "vless", flat: true}, + {protocol: "vmess"}, + {protocol: "socks"}, + } { + t.Run(fmt.Sprintf("%s/flat=%t", tc.protocol, tc.flat), func(t *testing.T) { + items := []any{ + endpointOutbound("raw", tc.protocol, "::1", port, tc.flat), + endpointOutbound("bracketed", tc.protocol, "[::1]", port, tc.flat), + } + svc := &OutboundService{} + for _, ob := range items { + t.Run(ob.(map[string]any)["tag"].(string), func(t *testing.T) { + r, err := svc.TestOutbound(mustJSON(t, ob), "", "", "tcp") + if err != nil { + t.Fatal(err) + } + assertTCPListenerResult(t, r, listener.Addr().String()) + }) + } + t.Run("batch", func(t *testing.T) { + results, err := svc.TestOutbounds(mustJSON(t, items), "", "", "tcp") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 || results[0].Tag != "raw" || results[1].Tag != "bracketed" { + t.Fatalf("batch results lost input ordering: %+v", results) + } + for _, r := range results { + assertTCPListenerResult(t, r, listener.Addr().String()) + } + }) + }) + } +} + +func assertTCPListenerResult(t *testing.T, r *TestOutboundResult, address string) { + t.Helper() + if !r.Success || r.Error != "" || r.Mode != "tcp" || len(r.Endpoints) != 1 { + t.Fatalf("TCP probe failed to reach %s: %+v", address, r) + } + endpoint := r.Endpoints[0] + if !endpoint.Success || endpoint.Error != "" || endpoint.Address != address { + t.Fatalf("endpoint = %+v, want successful dial to %s", endpoint, address) + } +} + +func TestOutboundTCPReportsRefusedIPv6Connection(t *testing.T) { + listener, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback unavailable: %v", err) + } + address := listener.Addr().String() + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + for _, host := range []string{"::1", "[::1]"} { + t.Run(host, func(t *testing.T) { + ob := endpointOutbound("closed", "vless", host, port, false) + r, err := (&OutboundService{}).TestOutbound(mustJSON(t, ob), "", "", "tcp") + if err != nil { + t.Fatal(err) + } + if r.Success || len(r.Endpoints) != 1 || r.Endpoints[0].Success || r.Endpoints[0].Address != address || !strings.Contains(r.Error, "connectex:") && !strings.Contains(r.Error, "connection refused") { + t.Fatalf("closed IPv6 listener did not produce a connection error: %+v", r) + } + }) + } +}