diff --git a/panel-go/listen_hotfix_test.go b/panel-go/listen_hotfix_test.go new file mode 100644 index 0000000..3b4e30b --- /dev/null +++ b/panel-go/listen_hotfix_test.go @@ -0,0 +1,157 @@ +package main + +// Tests for the 2026-10-21 listen hotfix: inbounds bind the wildcard +// address of their stack instead of the server's registered public +// address, so intranet addresses (relay landings) and additional v6 +// addresses are served. The registered address remains the connection +// address (serverAddress) and is untouched by these tests. + +import "testing" + +func TestListenAddressWildcardHotfix(t *testing.T) { + v4Server := &Server{ID: "s4", PublicAddress: "203.0.113.10"} + v6Server := &Server{ID: "s6", PublicAddressV6: "2001:db8::10"} + dualServer := &Server{ID: "sd", PublicAddress: "203.0.113.10", PublicAddressV6: "2001:db8::10"} + cases := []struct { + name string + mode string + srv *Server + bind string + want string + }{ + {"ipv4 with registered public address binds wildcard", "ipv4", v4Server, "", "0.0.0.0"}, + {"ipv4 explicit bind wins", "ipv4", v4Server, "192.0.2.5", "192.0.2.5"}, + {"ipv6 with registered v6 address binds wildcard", "ipv6", v6Server, "", "::"}, + {"ipv6 without registered address binds wildcard", "ipv6", nil, "", "::"}, + {"dual binds wildcard", "dual", dualServer, "", "::"}, + {"ipv6 explicit bind wins", "ipv6", v6Server, "2001:db8::99", "2001:db8::99"}, + } + for _, c := range cases { + if got := listenAddress(c.mode, c.srv, c.bind); got != c.want { + t.Errorf("%s: listenAddress(%q) = %q, want %q", c.name, c.mode, got, c.want) + } + } +} + +// ResolveBindAddress fed the pin into every deploy: it resolved the +// registered public address into the bind address. It must no longer +// return a pin for any stack. +func TestResolveBindAddressNoPin(t *testing.T) { + srv := &Server{ID: "s", PublicAddress: "203.0.113.10", PublicAddressV6: "2001:db8::10"} + for _, mode := range []string{"ipv4", "ipv6", "dual"} { + got, err := ResolveBindAddress(mode, srv) + if err != nil { + t.Fatalf("mode %s: unexpected error: %v", mode, err) + } + if got != "" { + t.Errorf("mode %s: ResolveBindAddress = %q, want empty (no pin)", mode, got) + } + } +} + +func firstInbound(t *testing.T, res map[string]any) map[string]any { + t.Helper() + inbounds, ok := res["inbounds"].([]any) + if !ok || len(inbounds) == 0 { + t.Fatalf("resource has no inbounds: %v", res) + } + ib, ok := inbounds[0].(map[string]any) + if !ok { + t.Fatalf("inbound is not a map: %T", inbounds[0]) + } + return ib +} + +// Resource-level smoke through BuildNodeResource (the builder the +// brief names): a SOCKS5 landing node on a server with a registered +// public v4 address must come out listening on 0.0.0.0. +func TestBuildNodeResourceSocksListenWildcard(t *testing.T) { + server := &Server{ID: "srv_l1", Name: "L1", PublicAddress: "203.0.113.10"} + node := &Node{ + ID: "node_sk5", Name: "SK5", ServerID: "srv_l1", + Protocol: "socks5-auth", Topology: "direct", + NetworkMode: "ipv4", Port: 47814, + } + res, err := BuildNodeResource(BuildNodeResourceParams{ + ResourceID: "res_sk5", TagPrefix: "sk5", + Node: node, Server: server, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + ib := firstInbound(t, res) + if ib["listen"] != "0.0.0.0" { + t.Errorf("listen = %v, want 0.0.0.0", ib["listen"]) + } + if ib["listen_port"] != 47814 { + t.Errorf("listen_port = %v, want 47814", ib["listen_port"]) + } +} + +// The true deploy chain: resourceForNode runs ResolveBindAddress +// before BuildNodeResource. With the hotfix the produced resources +// must carry the wildcard listen for both stacks — an ipv4 SOCKS5 +// landing (the reported intranet-relay case) and an ipv6 SS2022 node +// on a server with a registered v6 address. +func TestResourceForNodeListenWildcard(t *testing.T) { + d := &Data{Servers: []*Server{ + {ID: "srv_l1", Name: "L1", PublicAddress: "203.0.113.10"}, + {ID: "srv_v6", Name: "V6", PublicAddressV6: "2001:db8::10"}, + }} + + socks := &Node{ + ID: "node_sk5", Name: "SK5", ServerID: "srv_l1", + Protocol: "socks5-auth", Topology: "direct", + NetworkMode: "ipv4", Port: 47814, ResourceID: "res_sk5", + } + res, err := resourceForNode(d, socks, nil) + if err != nil { + t.Fatalf("resourceForNode socks: %v", err) + } + ib := firstInbound(t, res) + if ib["listen"] != "0.0.0.0" || ib["listen_port"] != 47814 { + t.Errorf("socks inbound listen=%v port=%v, want 0.0.0.0:47814", ib["listen"], ib["listen_port"]) + } + + ss := &Node{ + ID: "node_ss6", Name: "SS6", ServerID: "srv_v6", + Protocol: "shadowsocks-2022-aes256", Topology: "direct", + NetworkMode: "ipv6", Port: 47815, ResourceID: "res_ss6", + } + res, err = resourceForNode(d, ss, nil) + if err != nil { + t.Fatalf("resourceForNode ss2022: %v", err) + } + ib = firstInbound(t, res) + if ib["listen"] != "::" || ib["listen_port"] != 47815 { + t.Errorf("ss2022 inbound listen=%v port=%v, want :::47815", ib["listen"], ib["listen_port"]) + } +} + +// The exit-resource chain (resourceForNodeExit -> BuildNodeExitResource) +// is the other listenAddress call site: an ipv4 exit server with a +// registered public address must also produce a wildcard listen. +func TestResourceForNodeExitListenWildcard(t *testing.T) { + exitSrv := "srv_exit" + exitPort := 47816 + exitProto := "socks5-auth" + d := &Data{Servers: []*Server{ + {ID: "srv_entry", Name: "Entry", PublicAddress: "198.51.100.20"}, + {ID: exitSrv, Name: "Exit", PublicAddress: "203.0.113.10"}, + }} + node := &Node{ + ID: "node_fwd", Name: "FWD", ServerID: "srv_entry", + Protocol: "vless-tcp", Topology: "forward", + NetworkMode: "ipv4", Port: 47817, ResourceID: "res_fwd", + ExitServerID: &exitSrv, ExitPort: &exitPort, ExitProtocol: &exitProto, + ExitResourceID: "res_fwd_exit", + } + res, err := resourceForNodeExit(d, node) + if err != nil { + t.Fatalf("resourceForNodeExit: %v", err) + } + ib := firstInbound(t, res) + if ib["listen"] != "0.0.0.0" || ib["listen_port"] != 47816 { + t.Errorf("exit inbound listen=%v port=%v, want 0.0.0.0:47816", ib["listen"], ib["listen_port"]) + } +} diff --git a/panel-go/protocols.go b/panel-go/protocols.go index f173daf..ffe47ac 100644 --- a/panel-go/protocols.go +++ b/panel-go/protocols.go @@ -1,7 +1,6 @@ package main import ( - "context" "crypto/ecdh" "crypto/rand" "encoding/base64" @@ -284,19 +283,19 @@ func listenAddress(networkMode string, server *Server, bindAddress string) strin if bindAddress != "" { return bindAddress } - if networkMode == "ipv6" { - if server != nil && server.PublicAddressV6 != "" { - return server.PublicAddressV6 - } + // Hotfix 2026-10-21: inbounds bind the wildcard address of their + // stack. The registered public address is a *connection* address + // (see serverAddress); pinning the listener to it left the + // machine's other addresses (intranet NICs, additional v6 + // addresses) unserved and broke intranet relay landings, which + // 3x-ui/xray serve by binding the wildcard. The server parameter + // is kept for signature parity with the Node implementation. + switch networkMode { + case "ipv6", "dual": return "::" + default: // ipv4 + return "0.0.0.0" } - if networkMode == "dual" { - return "::" - } - if server != nil && server.PublicAddress != "" { - return server.PublicAddress - } - return "0.0.0.0" } func serverAddress(server *Server, networkMode string) (string, error) { @@ -309,54 +308,19 @@ func serverAddress(server *Server, networkMode string) (string, error) { return server.PublicAddress, nil } -// ResolveBindAddress mirrors resolveBindAddress: dual/absent -> ("", nil). -// DNS failures are HTTPError 400; the parenthesized code approximates -// err.code with the Go resolver's short error text (e.g. "no such host"), -// since Go exposes no getaddrinfo code. +// ResolveBindAddress mirrors resolveBindAddress. Since the 2026-10-21 +// listen hotfix it always returns ("", nil): inbounds bind the wildcard +// address of their stack (see listenAddress), and the server's +// registered public address is a connection address (serverAddress), +// never a bind pin — resolving it into a bind address here is what +// pinned every inbound to the public NIC and broke intranet relays. +// It previously also returned ("", nil) for dual and for servers +// without a registered address, so wildcard binding was already the +// fallback semantics. The function is kept so the orchestrator's +// deploy path keeps its shape and an explicit per-deploy bind address +// can be reintroduced here if one is ever needed. func ResolveBindAddress(networkMode string, server *Server) (string, error) { - if networkMode == "dual" { - return "", nil - } - var raw string - if server != nil { - if networkMode == "ipv6" { - raw = server.PublicAddressV6 - } else { - raw = server.PublicAddress - } - } - if raw == "" { - return "", nil - } - text := strings.TrimSpace(raw) - family := 4 - record := "A" - network := "ip4" - if networkMode == "ipv6" { - family = 6 - record = "AAAA" - network = "ip6" - } - _ = family - if ip := net.ParseIP(text); ip != nil { - return text, nil - } - addrs, err := net.DefaultResolver.LookupIP(context.Background(), network, text) - if err != nil || len(addrs) == 0 { - code := "" - if err != nil { - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) && dnsErr.Err != "" { - code = dnsErr.Err - } else { - code = err.Error() - } - } else { - code = "no addresses" - } - return "", httpErr(400, "绑定地址 DNS 解析失败:%s 没有 %s 记录(%s),请检查该服务器登记的地址", text, record, code) - } - return addrs[0].String(), nil + return "", nil } // ---------- sing-box resource builders ---------- diff --git a/panel-go/testdata/golden_resources.json b/panel-go/testdata/golden_resources.json index d6c8c05..196b3d8 100644 --- a/panel-go/testdata/golden_resources.json +++ b/panel-go/testdata/golden_resources.json @@ -12,7 +12,7 @@ { "type": "vless", "tag": "ng-nodrf-in", - "listen": "203.0.113.10", + "listen": "0.0.0.0", "listen_port": 23456, "users": [ { @@ -76,7 +76,7 @@ { "type": "shadowsocks", "tag": "ng-nodrf-exit-in", - "listen": "203.0.113.20", + "listen": "0.0.0.0", "listen_port": 23457, "method": "2022-blake3-aes-256-gcm", "password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI=" @@ -111,7 +111,7 @@ { "type": "shadowsocks", "tag": "ng-nodss-in", - "listen": "203.0.113.11", + "listen": "0.0.0.0", "listen_port": 23458, "method": "2022-blake3-aes-128-gcm", "password": "CwwNDg8QERITFBUWFxgZGg==",