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
|
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
@@ -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==",
|
||||||
|
|||||||
Reference in New Issue
Block a user