hotfix(panel): bind inbounds to stack wildcard instead of registered public address

Inbounds were pinned to the server's registered public address
(ipv4: PublicAddress, ipv6: PublicAddressV6), so addresses not
registered as 'the' public address - intranet NICs used for relay
landings, additional v6 addresses - had nothing listening and
intranet relays were unreachable (field-verified: ss showed the
landing port bound to the public IP only). 3x-ui/xray bind the
wildcard and serve these paths fine.

- listenAddress: explicit bindAddress still wins; ipv4 -> 0.0.0.0,
  ipv6 -> ::, dual -> :: (ipv6 wildcard per user decision
  2026-10-21: multi-v6 machines must serve all their v6 addresses).
- ResolveBindAddress: no longer resolves the registered address
  into a bind pin (that resolution was the production path of the
  pinning via resourceForNode/resourceForNodeExit); returns
  ("", nil) for all stacks, as it already did for dual/absent.
- serverAddress (client connection address / subscription host)
  is unchanged.
- Golden fixtures updated for the three listen fields that
  intentionally change; new listen_hotfix_test.go covers the
  listenAddress cases and the resourceForNode/exit deploy chains.
This commit is contained in:
aaaamoooo
2026-10-21 07:16:38 +00:00
parent 79639ac338
commit aa2ee62d32
3 changed files with 183 additions and 62 deletions
+157
View File
@@ -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"])
}
}
+22 -58
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"crypto/ecdh" "crypto/ecdh"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
@@ -284,20 +283,20 @@ func listenAddress(networkMode string, server *Server, bindAddress string) strin
if bindAddress != "" { if bindAddress != "" {
return bindAddress return bindAddress
} }
if networkMode == "ipv6" { // Hotfix 2026-10-21: inbounds bind the wildcard address of their
if server != nil && server.PublicAddressV6 != "" { // stack. The registered public address is a *connection* address
return server.PublicAddressV6 // (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 "::" return "::"
} default: // ipv4
if networkMode == "dual" {
return "::"
}
if server != nil && server.PublicAddress != "" {
return server.PublicAddress
}
return "0.0.0.0" return "0.0.0.0"
} }
}
func serverAddress(server *Server, networkMode string) (string, error) { func serverAddress(server *Server, networkMode string) (string, error) {
if server == nil { if server == nil {
@@ -309,55 +308,20 @@ func serverAddress(server *Server, networkMode string) (string, error) {
return server.PublicAddress, nil return server.PublicAddress, nil
} }
// ResolveBindAddress mirrors resolveBindAddress: dual/absent -> ("", nil). // ResolveBindAddress mirrors resolveBindAddress. Since the 2026-10-21
// DNS failures are HTTPError 400; the parenthesized code approximates // listen hotfix it always returns ("", nil): inbounds bind the wildcard
// err.code with the Go resolver's short error text (e.g. "no such host"), // address of their stack (see listenAddress), and the server's
// since Go exposes no getaddrinfo code. // 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) { func ResolveBindAddress(networkMode string, server *Server) (string, error) {
if networkMode == "dual" {
return "", nil 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
}
// ---------- sing-box resource builders ---------- // ---------- sing-box resource builders ----------
+3 -3
View File
@@ -12,7 +12,7 @@
{ {
"type": "vless", "type": "vless",
"tag": "ng-nodrf-in", "tag": "ng-nodrf-in",
"listen": "203.0.113.10", "listen": "0.0.0.0",
"listen_port": 23456, "listen_port": 23456,
"users": [ "users": [
{ {
@@ -76,7 +76,7 @@
{ {
"type": "shadowsocks", "type": "shadowsocks",
"tag": "ng-nodrf-exit-in", "tag": "ng-nodrf-exit-in",
"listen": "203.0.113.20", "listen": "0.0.0.0",
"listen_port": 23457, "listen_port": 23457,
"method": "2022-blake3-aes-256-gcm", "method": "2022-blake3-aes-256-gcm",
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI=" "password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
@@ -111,7 +111,7 @@
{ {
"type": "shadowsocks", "type": "shadowsocks",
"tag": "ng-nodss-in", "tag": "ng-nodss-in",
"listen": "203.0.113.11", "listen": "0.0.0.0",
"listen_port": 23458, "listen_port": 23458,
"method": "2022-blake3-aes-128-gcm", "method": "2022-blake3-aes-128-gcm",
"password": "CwwNDg8QERITFBUWFxgZGg==", "password": "CwwNDg8QERITFBUWFxgZGg==",