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.
This commit is contained in:
Hamed
2026-10-24 06:09:36 +02:00
committed by GitHub
parent 49c0600f35
commit 07910bf6c0
2 changed files with 150 additions and 1 deletions
+7 -1
View File
@@ -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 {
@@ -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)
}
})
}
}