diff --git a/weed/storage/blockvol/net_util.go b/weed/storage/blockvol/net_util.go new file mode 100644 index 000000000..4c3f3fc73 --- /dev/null +++ b/weed/storage/blockvol/net_util.go @@ -0,0 +1,53 @@ +package blockvol + +import ( + "net" + "strconv" +) + +// canonicalizeListenerAddr resolves wildcard listener addresses to a routable +// ip:port string using the provided advertised host as the preferred IP. +// +// If the listener bound to a wildcard (":0", "0.0.0.0:port", "[::]:port"), +// the returned address uses advertisedHost instead of the wildcard. +// +// If advertisedHost is empty, falls back to preferredOutboundIP() as a +// best-effort guess. On multi-NIC hosts, the advertised host should always +// be provided to avoid selecting the wrong network interface. +func canonicalizeListenerAddr(addr net.Addr, advertisedHost string) string { + tcpAddr, ok := addr.(*net.TCPAddr) + if !ok { + return addr.String() + } + ip := tcpAddr.IP + if ip != nil && !ip.IsUnspecified() { + // Already bound to a specific IP — return as-is. + return net.JoinHostPort(ip.String(), strconv.Itoa(tcpAddr.Port)) + } + // Wildcard bind — use advertised host or fallback. + host := advertisedHost + if host == "" { + host = preferredOutboundIP() + } + if host == "" { + // Last resort: return raw address (will be ":port"). + return addr.String() + } + return net.JoinHostPort(host, strconv.Itoa(tcpAddr.Port)) +} + +// preferredOutboundIP returns the machine's preferred outbound IP as a string. +// Uses the standard Go pattern: dial a UDP socket (no data sent), read the +// local address. Returns "" if discovery fails. +// +// This is a fallback — callers should prefer an explicitly configured +// advertised host when available. +func preferredOutboundIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "" + } + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP.String() +} diff --git a/weed/storage/blockvol/net_util_test.go b/weed/storage/blockvol/net_util_test.go new file mode 100644 index 000000000..d6ff488b7 --- /dev/null +++ b/weed/storage/blockvol/net_util_test.go @@ -0,0 +1,73 @@ +package blockvol + +import ( + "net" + "strings" + "testing" +) + +func TestCanonicalizeAddr_WildcardIPv4_UsesAdvertised(t *testing.T) { + addr := &net.TCPAddr{IP: net.IPv4zero, Port: 5099} + result := canonicalizeListenerAddr(addr, "192.168.1.184") + if result != "192.168.1.184:5099" { + t.Fatalf("expected 192.168.1.184:5099, got %q", result) + } +} + +func TestCanonicalizeAddr_WildcardIPv6_UsesAdvertised(t *testing.T) { + addr := &net.TCPAddr{IP: net.IPv6zero, Port: 5099} + result := canonicalizeListenerAddr(addr, "10.0.0.3") + if result != "10.0.0.3:5099" { + t.Fatalf("expected 10.0.0.3:5099, got %q", result) + } +} + +func TestCanonicalizeAddr_NilIP_UsesAdvertised(t *testing.T) { + addr := &net.TCPAddr{IP: nil, Port: 5099} + result := canonicalizeListenerAddr(addr, "192.168.1.184") + if result != "192.168.1.184:5099" { + t.Fatalf("expected 192.168.1.184:5099, got %q", result) + } +} + +func TestCanonicalizeAddr_AlreadyCanonical_Unchanged(t *testing.T) { + addr := &net.TCPAddr{IP: net.ParseIP("192.168.1.5"), Port: 5099} + result := canonicalizeListenerAddr(addr, "10.0.0.1") + if result != "192.168.1.5:5099" { + t.Fatalf("expected 192.168.1.5:5099 (unchanged), got %q", result) + } +} + +func TestCanonicalizeAddr_Loopback_Unchanged(t *testing.T) { + addr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 3260} + result := canonicalizeListenerAddr(addr, "192.168.1.184") + if result != "127.0.0.1:3260" { + t.Fatalf("expected 127.0.0.1:3260 (loopback intentional), got %q", result) + } +} + +func TestCanonicalizeAddr_NoAdvertised_FallsBackToOutbound(t *testing.T) { + addr := &net.TCPAddr{IP: net.IPv4zero, Port: 5099} + result := canonicalizeListenerAddr(addr, "") + // Should not be wildcard. + if strings.HasPrefix(result, "0.0.0.0:") || strings.HasPrefix(result, "[::]:") || strings.HasPrefix(result, ":") { + t.Fatalf("fallback should produce routable addr, got %q", result) + } + if !strings.Contains(result, ":5099") { + t.Fatalf("port should be preserved, got %q", result) + } +} + +func TestPreferredOutboundIP_NotEmpty(t *testing.T) { + ip := preferredOutboundIP() + if ip == "" { + t.Skip("no network interface available") + } + parsed := net.ParseIP(ip) + if parsed == nil { + t.Fatalf("not a valid IP: %q", ip) + } + if parsed.IsUnspecified() { + t.Fatalf("returned unspecified IP: %q", ip) + } +} diff --git a/weed/storage/blockvol/replica_apply.go b/weed/storage/blockvol/replica_apply.go index 06ca54558..dd964ef7a 100644 --- a/weed/storage/blockvol/replica_apply.go +++ b/weed/storage/blockvol/replica_apply.go @@ -20,6 +20,7 @@ var ( type ReplicaReceiver struct { vol *BlockVol barrierTimeout time.Duration + advertisedHost string // canonical IP for address reporting; empty = auto-detect mu sync.Mutex receivedLSN uint64 @@ -38,7 +39,10 @@ type ReplicaReceiver struct { const defaultBarrierTimeout = 5 * time.Second // NewReplicaReceiver creates and starts listening on the data and control ports. -func NewReplicaReceiver(vol *BlockVol, dataAddr, ctrlAddr string) (*ReplicaReceiver, error) { +// advertisedHost is the canonical IP for this server (from VS listen addr or +// heartbeat identity). If empty, DataAddr()/CtrlAddr() will fall back to +// outbound-IP detection. On multi-NIC hosts, always provide advertisedHost. +func NewReplicaReceiver(vol *BlockVol, dataAddr, ctrlAddr string, advertisedHost ...string) (*ReplicaReceiver, error) { dataLn, err := net.Listen("tcp", dataAddr) if err != nil { return nil, fmt.Errorf("replica: listen data %s: %w", dataAddr, err) @@ -49,9 +53,14 @@ func NewReplicaReceiver(vol *BlockVol, dataAddr, ctrlAddr string) (*ReplicaRecei return nil, fmt.Errorf("replica: listen ctrl %s: %w", ctrlAddr, err) } + var advHost string + if len(advertisedHost) > 0 { + advHost = advertisedHost[0] + } r := &ReplicaReceiver{ vol: vol, barrierTimeout: defaultBarrierTimeout, + advertisedHost: advHost, dataListener: dataLn, ctrlListener: ctrlLn, stopCh: make(chan struct{}), @@ -293,12 +302,14 @@ func (r *ReplicaReceiver) ReceivedLSN() uint64 { return r.receivedLSN } -// DataAddr returns the data listener's address (useful for tests with :0 ports). +// DataAddr returns the data listener's canonical address (ip:port). +// Wildcard listener addresses are resolved using the advertised host +// or outbound-IP fallback. func (r *ReplicaReceiver) DataAddr() string { - return r.dataListener.Addr().String() + return canonicalizeListenerAddr(r.dataListener.Addr(), r.advertisedHost) } -// CtrlAddr returns the control listener's address. +// CtrlAddr returns the control listener's canonical address (ip:port). func (r *ReplicaReceiver) CtrlAddr() string { - return r.ctrlListener.Addr().String() + return canonicalizeListenerAddr(r.ctrlListener.Addr(), r.advertisedHost) }