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:
@@ -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
@@ -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 "::"
|
||||
}
|
||||
if networkMode == "dual" {
|
||||
return "::"
|
||||
}
|
||||
if server != nil && server.PublicAddress != "" {
|
||||
return server.PublicAddress
|
||||
}
|
||||
default: // ipv4
|
||||
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
|
||||
}
|
||||
|
||||
// ---------- sing-box resource builders ----------
|
||||
|
||||
+3
-3
@@ -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==",
|
||||
|
||||
Reference in New Issue
Block a user