This is an automated email from the ASF dual-hosted git repository.

spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 74fb5e2a9 fix(server-ng): bind cluster TCP to [tcp].address, not the 
roster ip (#3783)
74fb5e2a9 is described below

commit 74fb5e2a9ae22c9ea51a5bc12820e9d67f30579f
Author: Maciej Modzelewski <[email protected]>
AuthorDate: Fri Jul 31 15:08:27 2026 +0200

    fix(server-ng): bind cluster TCP to [tcp].address, not the roster ip (#3783)
    
    In cluster mode the TCP listener bound the roster entry's 'ip'
    directly while ws/quic/http kept the interface from their own
    'address' config. The roster ip is an advertised address, so a node
    advertising 10.0.0.5 stopped answering on loopback, leaving every
    co-located dialer (sidecars, health probes, on-host consumers) on
    ECONNREFUSED, and an operator's explicit [tcp].address was silently
    discarded.
    
    TCP now goes through the same merge as the other transports: the
    roster supplies the port, the transport config supplies the bind
    interface. A bind that cannot serve the advertised ip still warns at
    startup rather than being rebound, and the warning stays quiet for a
    wildcard bind or a hostname roster entry, neither of which is a
    misconfiguration.
---
 core/configs/src/server_ng_config/cluster.rs |   2 +-
 core/server-ng/config.toml                   |  11 +-
 core/server-ng/src/bootstrap.rs              | 171 +++++++++++++++++++++++----
 3 files changed, 153 insertions(+), 31 deletions(-)

diff --git a/core/configs/src/server_ng_config/cluster.rs 
b/core/configs/src/server_ng_config/cluster.rs
index 613f4bf7c..d2d097549 100644
--- a/core/configs/src/server_ng_config/cluster.rs
+++ b/core/configs/src/server_ng_config/cluster.rs
@@ -359,7 +359,7 @@ pub struct ClusterNodeConfig {
 /// the roster is the single source of ports: every enabled transport needs
 /// an explicit per-node port (validated at startup, no fallback to the
 /// transport's top-level `address` port). The roster entry's `ip` is the
-/// advertised address only: ws/quic/http bind the interface from their own
+/// advertised address only: tcp/ws/quic/http bind the interface from their own
 /// `address` config, and followers forward HTTP requests to the primary at
 /// `ip:http`.
 #[derive(Debug, Deserialize, Serialize, Clone, Default, ConfigEnv)]
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index c9ef5aa47..5aa6d075b 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -715,10 +715,13 @@ ca_file = ""
 # flag, which selects the entry in this list that describes the current
 # node. All other entries are remote peers.
 #
-# 'ip' is the node's roster address. Replica-to-replica traffic,
-# follower-to-primary HTTP forwarding, and the TCP listener use it. It is not
-# the bind interface for quic/http/websocket, which comes from each transport's
-# own 'address' setting above.
+# 'ip' is the node's roster address. Replica-to-replica traffic and
+# follower-to-primary HTTP forwarding use it. It is not the bind interface for
+# tcp/quic/http/websocket, which comes from each transport's own 'address'
+# setting above; the roster supplies those transports their port only. A
+# cluster spread across hosts therefore needs each transport's 'address' set to
+# '0.0.0.0' or the routable NIC; the defaults below listen on loopback only,
+# and a bind that cannot serve the advertised 'ip' is warned about at startup.
 #
 # Each node may also set 'advertised_address': the client-facing address
 # handed out in cluster metadata and leader redirects. Set it when 'ip' is
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index db31f2903..d80e59213 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -2271,6 +2271,7 @@ fn resolve_tcp_topology(
         http: http_listen_addr,
     } = resolve_cluster_client_addrs(
         self_node,
+        default_client_addr,
         default_ws_addr,
         default_quic_addr,
         default_http_addr,
@@ -2317,8 +2318,8 @@ fn resolve_optional_listener_addr(
 /// Client-facing listener addresses resolved for this cluster node. Each port
 /// comes from the node's roster entry; there is no fallback to the top-level
 /// listener port, an enabled transport without a roster port refuses to boot.
-/// ws/quic/http keep the bind interface from their own `address` config (the
-/// roster ip is advertised, not bound); tcp binds the roster ip directly.
+/// Every transport keeps the bind interface from its own `address` config: the
+/// roster ip is advertised, not bound.
 struct ClusterClientAddrs {
     client: SocketAddr,
     ws: Option<SocketAddr>,
@@ -2328,6 +2329,7 @@ struct ClusterClientAddrs {
 
 fn resolve_cluster_client_addrs(
     self_node: &configs::ng_cluster::ClusterNodeConfig,
+    default_tcp_addr: SocketAddr,
     default_ws_addr: Option<SocketAddr>,
     default_quic_addr: Option<SocketAddr>,
     default_http_addr: Option<SocketAddr>,
@@ -2339,7 +2341,8 @@ fn resolve_cluster_client_addrs(
             transport: "tcp",
             replica_id: self_node.replica_id,
         })?;
-    let client = socket_addr_from_parts("cluster.nodes[*].ports.tcp", 
&self_node.ip, client_port)?;
+    let client =
+        merge_roster_port_with_bind_ip("tcp", &self_node.ip, default_tcp_addr, 
client_port);
     let ws = resolve_cluster_optional_addr(self_node, "websocket", 
default_ws_addr, |ports| {
         ports.websocket
     })?;
@@ -2371,25 +2374,48 @@ fn resolve_cluster_optional_addr(
         transport,
         replica_id: self_node.replica_id,
     })?;
-    // The roster ip is what the cluster advertises (metadata, follower-to-
-    // primary HTTP forwarding targets); the transport's own `address` decides
-    // the bind interface. Merging keeps a loopback-only `127.0.0.1` private
-    // and a `0.0.0.0` wide in cluster mode instead of silently rebinding to
-    // the roster interface.
-    let listen_addr = SocketAddr::new(default_addr.ip(), port);
-    if !listen_addr.ip().is_unspecified()
-        && self_node
-            .ip
-            .parse::<IpAddr>()
-            .is_ok_and(|roster_ip| roster_ip != listen_addr.ip())
-    {
+    Ok(Some(merge_roster_port_with_bind_ip(
+        transport,
+        &self_node.ip,
+        default_addr,
+        port,
+    )))
+}
+
+/// Combine the roster-supplied `port` with the bind interface the transport's
+/// own `address` config asked for.
+///
+/// The roster ip is what the cluster advertises (metadata, follower-to-primary
+/// HTTP forwarding targets); the transport's own `address` decides the bind
+/// interface. Merging keeps a loopback-only `127.0.0.1` private and a
+/// `0.0.0.0` wide in cluster mode instead of silently rebinding to the roster
+/// interface, which would strand every co-located dialer (sidecars, health
+/// probes, on-host consumers) on `ECONNREFUSED`.
+fn merge_roster_port_with_bind_ip(
+    transport: &'static str,
+    roster_ip: &str,
+    bind_addr: SocketAddr,
+    port: u16,
+) -> SocketAddr {
+    let listen_addr = SocketAddr::new(bind_addr.ip(), port);
+    if roster_ip_unreachable_from_bind_addr(roster_ip, listen_addr) {
         warn!(
-            "{transport} listener binds {listen_addr} but the roster 
advertises {}:{port}; \
-             peers and clients dialing the advertised endpoint will not reach 
this node",
-            self_node.ip
+            "{transport} listener binds {listen_addr} but the roster 
advertises {roster_ip}:{port}; \
+             peers and clients dialing the advertised endpoint may not reach 
this node"
         );
     }
-    Ok(Some(listen_addr))
+    listen_addr
+}
+
+/// Whether a dialer aiming at the advertised roster ip misses `listen_addr`. 
An
+/// unspecified bind covers every interface, and a roster ip that parses as
+/// neither IPv4 nor IPv6 (a DNS name, say) can resolve to the bound interface,
+/// so both cases stay quiet.
+fn roster_ip_unreachable_from_bind_addr(roster_ip: &str, listen_addr: 
SocketAddr) -> bool {
+    !listen_addr.ip().is_unspecified()
+        && roster_ip
+            .parse::<IpAddr>()
+            .is_ok_and(|parsed| parsed != listen_addr.ip())
 }
 
 fn resolve_cluster_replica_peers(
@@ -3804,13 +3830,21 @@ mod tests {
     }
 
     fn cluster_node(ip: &str, http: Option<u16>) -> 
configs::ng_cluster::ClusterNodeConfig {
+        cluster_node_with_ports(ip, Some(18070), http)
+    }
+
+    fn cluster_node_with_ports(
+        ip: &str,
+        tcp: Option<u16>,
+        http: Option<u16>,
+    ) -> configs::ng_cluster::ClusterNodeConfig {
         configs::ng_cluster::ClusterNodeConfig {
             name: "node".to_owned(),
             ip: ip.to_owned(),
             advertised_address: None,
             replica_id: 0,
             ports: configs::ng_cluster::TransportPorts {
-                tcp: Some(18070),
+                tcp,
                 http,
                 ..Default::default()
             },
@@ -3827,8 +3861,14 @@ mod tests {
         // one host; the per-node roster port is the only port source so each
         // node binds a distinct HTTP socket.
         let node = cluster_node("127.0.0.1", Some(18090));
-        let addrs = resolve_cluster_client_addrs(&node, None, None, 
Some(addr("127.0.0.1:3000")))
-            .expect("cluster address resolution must succeed");
+        let addrs = resolve_cluster_client_addrs(
+            &node,
+            addr("127.0.0.1:8090"),
+            None,
+            None,
+            Some(addr("127.0.0.1:3000")),
+        )
+        .expect("cluster address resolution must succeed");
         assert_eq!(addrs.http, Some(addr("127.0.0.1:18090")));
     }
 
@@ -3838,8 +3878,14 @@ mod tests {
         // only the advertised address. Cluster mode must keep the configured
         // interface and take just the port from the roster.
         let node = cluster_node("10.0.0.5", Some(18090));
-        let addrs = resolve_cluster_client_addrs(&node, None, None, 
Some(addr("0.0.0.0:3000")))
-            .expect("cluster address resolution must succeed");
+        let addrs = resolve_cluster_client_addrs(
+            &node,
+            addr("0.0.0.0:8090"),
+            None,
+            None,
+            Some(addr("0.0.0.0:3000")),
+        )
+        .expect("cluster address resolution must succeed");
         assert_eq!(addrs.http, Some(addr("0.0.0.0:18090")));
     }
 
@@ -3849,7 +3895,13 @@ mod tests {
         // with another same-host node, so a missing roster port for an
         // enabled transport must refuse to boot.
         let node = cluster_node("10.0.0.5", None);
-        let result = resolve_cluster_client_addrs(&node, None, None, 
Some(addr("127.0.0.1:3000")));
+        let result = resolve_cluster_client_addrs(
+            &node,
+            addr("127.0.0.1:8090"),
+            None,
+            None,
+            Some(addr("127.0.0.1:3000")),
+        );
         assert!(matches!(
             result,
             Err(ServerNgError::ClusterPortMissing {
@@ -3864,8 +3916,75 @@ mod tests {
         // http.enabled = false collapses default_http_addr to None; no roster
         // port can revive a listener the operator turned off.
         let node = cluster_node("127.0.0.1", Some(18090));
-        let addrs = resolve_cluster_client_addrs(&node, None, None, None)
+        let addrs = resolve_cluster_client_addrs(&node, 
addr("127.0.0.1:8090"), None, None, None)
             .expect("cluster address resolution must succeed");
         assert_eq!(addrs.http, None);
     }
+
+    #[test]
+    fn cluster_tcp_addr_takes_port_from_roster() {
+        // Same rule as the other transports: the roster owns the port so
+        // same-host nodes sharing one [tcp].address still bind distinct
+        // sockets.
+        let node = cluster_node("127.0.0.1", None);
+        let addrs = resolve_cluster_client_addrs(&node, 
addr("127.0.0.1:8090"), None, None, None)
+            .expect("cluster address resolution must succeed");
+        assert_eq!(addrs.client, addr("127.0.0.1:18070"));
+    }
+
+    #[test]
+    fn cluster_tcp_addr_merges_config_ip_with_roster_port() {
+        // The roster ip is advertised, not bound. Binding it directly would
+        // strand every co-located dialer (sidecars, health probes, on-host
+        // consumers) that reaches this node over loopback.
+        let node = cluster_node("10.0.0.5", None);
+        let addrs = resolve_cluster_client_addrs(&node, addr("0.0.0.0:8090"), 
None, None, None)
+            .expect("cluster address resolution must succeed");
+        assert_eq!(addrs.client, addr("0.0.0.0:18070"));
+    }
+
+    #[test]
+    fn cluster_tcp_addr_requires_roster_port() {
+        // tcp is always enabled in cluster mode, so a roster entry without a
+        // tcp port refuses to boot rather than falling back to [tcp].address.
+        let node = cluster_node_with_ports("10.0.0.5", None, None);
+        let result = resolve_cluster_client_addrs(&node, 
addr("127.0.0.1:8090"), None, None, None);
+        assert!(matches!(
+            result,
+            Err(ServerNgError::ClusterPortMissing {
+                transport: "tcp",
+                replica_id: 0,
+            })
+        ));
+    }
+
+    #[test]
+    fn cluster_tcp_addr_keeps_loopback_bind_and_warns_on_roster_mismatch() {
+        // A loopback [tcp].address under a routable roster ip is honoured
+        // as configured; remote peers cannot reach it, so the mismatch is
+        // warned about instead of silently rebinding.
+        let node = cluster_node("10.0.0.5", None);
+        let addrs = resolve_cluster_client_addrs(&node, 
addr("127.0.0.1:8090"), None, None, None)
+            .expect("cluster address resolution must succeed");
+        assert_eq!(addrs.client, addr("127.0.0.1:18070"));
+        assert!(roster_ip_unreachable_from_bind_addr(&node.ip, addrs.client));
+    }
+
+    #[test]
+    fn roster_mismatch_warning_is_silent_for_wildcard_and_hostname_rosters() {
+        // A wildcard bind covers the roster interface, and a DNS roster entry
+        // can resolve to the bound one; neither is a misconfiguration.
+        assert!(!roster_ip_unreachable_from_bind_addr(
+            "10.0.0.5",
+            addr("0.0.0.0:18070")
+        ));
+        assert!(!roster_ip_unreachable_from_bind_addr(
+            "node-1.example.com",
+            addr("127.0.0.1:18070")
+        ));
+        assert!(!roster_ip_unreachable_from_bind_addr(
+            "10.0.0.5",
+            addr("10.0.0.5:18070")
+        ));
+    }
 }

Reply via email to