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) + } + }) + } +}