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

mmodzelewski pushed a commit to branch advertised-addresses
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit 8e13d5f5c8ebe13cf7886b268db2a0d1342605d1
Author: Maciej Modzelewski <[email protected]>
AuthorDate: Tue Aug 4 15:14:25 2026 +0200

    feat(server-ng): add per-client-network advertised address selectors
    
    A node has one advertised_address, so a split-network cluster must
    pick a side: advertise the public address and in-VPC clients route
    out through the public edge, or advertise the private one and
    external clients cannot reach the cluster at all.
    
    Each roster node now takes advertised_addresses selectors
    (client_cidr + address). The transport-level peer IP picks the
    address by longest-prefix match, first declared wins a tie, with
    fallback to the catch-all advertised_address and then the roster
    ip. The selection feeds binary and HTTP cluster metadata and the
    307 leader-redirect Location, so a redirected client stays on its
    own network.
    
    HTTP handlers see the peer address through axum connect-info on
    the plain listener; the TLS serve loop bypasses the make-service,
    so it stamps the same ConnectInfo extension per connection. Boot
    validation parses every selector strictly, rejects duplicate CIDRs
    within a node, and checks advertised endpoint conflicts per client
    network, since reusing one host:port across different networks is
    the point.
---
 Cargo.lock                                         |   1 +
 Cargo.toml                                         |   1 +
 core/configs/Cargo.toml                            |   1 +
 core/configs/src/server_ng_config/cluster.rs       | 654 ++++++++++++++++++++-
 core/configs/src/server_ng_config/defaults.rs      |   1 +
 core/configs/src/server_ng_config/validators.rs    |   1 +
 .../tests/server/cluster_metadata_vsr.rs           | 192 +++++-
 core/integration/tests/server/http_client.rs       |  19 +-
 core/server-ng/config.toml                         |  23 +
 core/server-ng/src/bootstrap.rs                    |   9 +-
 core/server-ng/src/cluster_meta.rs                 | 112 +++-
 core/server-ng/src/dispatch.rs                     |  24 +-
 core/server-ng/src/http.rs                         |  30 +-
 core/server-ng/src/http/error.rs                   | 100 +++-
 core/server-ng/src/http/extractor.rs               |  15 +-
 core/server-ng/src/http/handlers.rs                |   7 +-
 core/server-ng/src/http/reads.rs                   |   3 +-
 core/server-ng/src/http/state.rs                   |  22 +-
 core/server-ng/src/http/tls.rs                     |  18 +-
 core/server-ng/src/responses.rs                    |  12 +-
 core/server-ng/src/session_manager.rs              |  11 +
 21 files changed, 1157 insertions(+), 99 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 3cd7dccbc..c8e9a5a7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3189,6 +3189,7 @@ dependencies = [
  "err_trail",
  "figment",
  "iggy_common",
+ "ipnet",
  "jsonwebtoken",
  "serde",
  "serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 0bd07364f..4b327d8a3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -207,6 +207,7 @@ iggy_common = { path = "core/common", version = 
"0.10.3-edge.3" }
 iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.3.1-edge.1" }
 indexmap = "2.14.0"
 integration = { path = "core/integration" }
+ipnet = "2.12.0"
 journal = { path = "core/journal" }
 js-sys = "0.3"
 jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] }
diff --git a/core/configs/Cargo.toml b/core/configs/Cargo.toml
index 6e9c85d2b..858ea2b17 100644
--- a/core/configs/Cargo.toml
+++ b/core/configs/Cargo.toml
@@ -28,6 +28,7 @@ cpu_allocation = { workspace = true }
 err_trail = { workspace = true }
 figment = { workspace = true }
 iggy_common = { workspace = true }
+ipnet = { workspace = true }
 jsonwebtoken = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
diff --git a/core/configs/src/server_ng_config/cluster.rs 
b/core/configs/src/server_ng_config/cluster.rs
index a10a5384f..f262fc64b 100644
--- a/core/configs/src/server_ng_config/cluster.rs
+++ b/core/configs/src/server_ng_config/cluster.rs
@@ -27,6 +27,7 @@ use crate::ConfigurationError;
 use crate::http::HttpJwtConfig;
 use configs::ConfigEnv;
 use iggy_common::{IggyDuration, Validatable};
+use ipnet::{IpNet, Ipv4Net};
 use serde::{Deserialize, Serialize};
 use serde_with::{DisplayFromStr, serde_as};
 use std::fmt;
@@ -367,6 +368,12 @@ pub struct ClusterNodeConfig {
     /// to use [`Self::ip`].
     #[serde(default)]
     pub advertised_address: Option<String>,
+    /// Client-network-scoped overrides of [`Self::advertised_address`],
+    /// resolved by longest-prefix match over the client's IP (see
+    /// [`AdvertisedAddressSelector`]). Empty by default, so existing configs
+    /// keep the single catch-all address.
+    #[serde(default)]
+    pub advertised_addresses: Vec<AdvertisedAddressSelector>,
     /// Numeric replica ID for VSR consensus (0-based).
     ///
     /// Must be unique across [`ClusterConfig::nodes`] and strictly less than
@@ -375,6 +382,136 @@ pub struct ClusterNodeConfig {
     pub ports: TransportPorts,
 }
 
+/// One client-network-scoped advertised address: clients whose IP falls
+/// inside `client_cidr` are told `address` instead of the node's catch-all
+/// [`ClusterNodeConfig::advertised_address`].
+///
+/// Typical split-network case: the roster `ip` is VPC-private and
+/// `advertised_address` is public; a selector with the VPC CIDR keeps
+/// in-VPC clients on the private address while everyone else stays on the
+/// public one. Selection is longest-prefix match across a node's selectors;
+/// a tie on prefix length resolves to the first declared selector. Selection
+/// sees the transport-level peer address, so clients arriving through a
+/// proxy or load balancer match the proxy's network, not their own.
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
+#[serde(deny_unknown_fields)]
+pub struct AdvertisedAddressSelector {
+    /// Client network this selector matches, in CIDR notation
+    /// (`10.0.0.0/16`, `2001:db8::/32`). Must parse at boot; duplicate
+    /// networks within one node are rejected. A v4-mapped v6 network
+    /// (`::ffff:10.0.0.0/104`) canonicalizes to its v4 form (`10.0.0.0/8`),
+    /// matching how client IPs canonicalize before matching.
+    pub client_cidr: String,
+    /// Address advertised to matching clients: a literal IP or a DNS
+    /// hostname, validated as [`AdvertisedAddress`] at boot (no port; ports
+    /// come from [`ClusterNodeConfig::ports`]).
+    pub address: String,
+}
+
+/// A roster node with its advertised-address selectors parsed once, built
+/// wherever a roster is assembled for serving clients (listener/shard start).
+/// Per-request resolution must not re-parse selector strings: validation
+/// guarantees they parse only while the cluster is enabled, so on an
+/// unvalidated config a per-request parse would skip bad selectors silently
+/// on every call. Here they are dropped once, loudly, at build time.
+#[derive(Debug, Clone)]
+pub struct ResolvedClusterNode {
+    pub config: ClusterNodeConfig,
+    /// Truncated, canonicalized selector networks with their parsed
+    /// addresses, in declaration order (the longest-prefix tiebreak).
+    selectors: Vec<(IpNet, AdvertisedAddress)>,
+}
+
+impl From<ClusterNodeConfig> for ResolvedClusterNode {
+    fn from(config: ClusterNodeConfig) -> Self {
+        let selectors = config
+            .advertised_addresses
+            .iter()
+            .filter_map(|selector| {
+                // Parse failures only occur on an unvalidated config (the
+                // cluster validator rejects them while the cluster is
+                // enabled); drop the selector once here rather than skipping
+                // it silently on every request.
+                let network = match selector.client_cidr.parse::<IpNet>() {
+                    Ok(network) => canonical_ip_net(network.trunc()),
+                    Err(error) => {
+                        eprintln!(
+                            "Ignoring advertised_addresses selector for node 
'{}': client_cidr '{}': {error}",
+                            config.name, selector.client_cidr
+                        );
+                        return None;
+                    }
+                };
+                match selector.address.parse::<AdvertisedAddress>() {
+                    Ok(address) => Some((network, address)),
+                    Err(error) => {
+                        eprintln!(
+                            "Ignoring advertised_addresses selector for node 
'{}': address '{}': {error}",
+                            config.name, selector.address
+                        );
+                        None
+                    }
+                }
+            })
+            .collect();
+        Self { config, selectors }
+    }
+}
+
+impl ResolvedClusterNode {
+    /// The client-facing address for a client connecting from `client_ip`:
+    /// longest-prefix match over the selector networks, then the catch-all
+    /// [`ClusterNodeConfig::advertised_address`], then the roster
+    /// [`ClusterNodeConfig::ip`]. A configured catch-all that does not parse
+    /// fails closed (`None`) instead of leaking the roster ip - unreachable
+    /// after validation, but validation is skipped while the cluster is
+    /// disabled.
+    #[must_use]
+    pub fn advertised_for(&self, client_ip: Option<IpAddr>) -> 
Option<AdvertisedAddress> {
+        client_ip
+            .and_then(|client_ip| self.selector_address(client_ip))
+            .or_else(|| match self.config.advertised_address.as_deref() {
+                Some(advertised_address) => advertised_address.parse().ok(),
+                None => self.config.ip.parse().ok(),
+            })
+    }
+
+    /// Longest-prefix match over the boot-parsed selector networks; a tie on
+    /// prefix length resolves to the first declared selector. The client IP
+    /// is canonicalized first so a v4-mapped v6 peer (`::ffff:10.0.0.7`, the
+    /// shape a dual-stack listener reports) matches v4 networks.
+    fn selector_address(&self, client_ip: IpAddr) -> Option<AdvertisedAddress> 
{
+        let client_ip = client_ip.to_canonical();
+        let mut best: Option<(u8, &AdvertisedAddress)> = None;
+        for (network, address) in &self.selectors {
+            if !network.contains(&client_ip) {
+                continue;
+            }
+            if best.is_none_or(|(prefix_len, _)| network.prefix_len() > 
prefix_len) {
+                best = Some((network.prefix_len(), address));
+            }
+        }
+        best.map(|(_, address)| address.clone())
+    }
+}
+
+/// Network-side mirror of the `IpAddr::to_canonical` applied to client IPs
+/// before matching: a selector network written in v4-mapped v6 form
+/// (`::ffff:10.0.0.0/104`) becomes its v4 equivalent (`10.0.0.0/8`), since a
+/// canonicalized client could never match the v6 spelling. Prefixes shorter
+/// than 96 bits cannot drop the `::ffff:` mapping and stay v6 (they match
+/// native v6 clients only).
+fn canonical_ip_net(network: IpNet) -> IpNet {
+    if let IpNet::V6(v6_network) = network
+        && v6_network.prefix_len() >= 96
+        && let IpAddr::V4(v4_address) = v6_network.addr().to_canonical()
+        && let Ok(v4_network) = Ipv4Net::new(v4_address, 
v6_network.prefix_len() - 96)
+    {
+        return IpNet::V4(v4_network);
+    }
+    network
+}
+
 /// Per-node listener ports advertised in the cluster roster. In cluster mode
 /// the roster is the single source of ports: every enabled transport needs
 /// an explicit per-node port (validated at startup, no fallback to the
@@ -746,8 +883,7 @@ impl Validatable<ConfigurationError> for ClusterConfig {
         let mut seen_ids = std::collections::HashSet::new();
         let mut seen_names = std::collections::HashSet::new();
         let mut used_endpoints = std::collections::HashSet::new();
-        let mut used_advertised_endpoints = std::collections::HashSet::new();
-        let mut used_raw_advertised_endpoints = 
std::collections::HashSet::new();
+        let mut advertised_endpoints: Vec<AdvertisedEndpoint> = Vec::new();
 
         for node in &self.nodes {
             if node.name.trim().is_empty() {
@@ -838,26 +974,82 @@ impl Validatable<ConfigurationError> for ClusterConfig {
                 None => node.ip.parse::<AdvertisedAddress>().ok(),
             };
 
+            // Catch-all endpoints enter the same conflict pool as the
+            // selector endpoints below: a catch-all is an implicit
+            // match-every-client selector, so it can collide with another
+            // node's selector just as well as with another catch-all.
             for (name, port) in &client_ports {
                 if let Some(port) = port {
-                    let (endpoint, inserted) = match &client_address {
-                        Some(address) => (
-                            address.authority(*port),
-                            used_advertised_endpoints.insert((address.clone(), 
*port)),
-                        ),
-                        None => {
-                            let endpoint = format!("{}:{port}", node.ip);
-                            let inserted = 
used_raw_advertised_endpoints.insert(endpoint.clone());
-                            (endpoint, inserted)
-                        }
+                    let host = match &client_address {
+                        Some(address) => 
AdvertisedHost::Parsed(address.clone()),
+                        None => AdvertisedHost::Raw(&node.ip),
                     };
-                    if !inserted {
+                    insert_advertised_endpoint(
+                        &mut advertised_endpoints,
+                        AdvertisedEndpoint {
+                            node_name: &node.name,
+                            transport: name,
+                            network: None,
+                            host,
+                            port: *port,
+                        },
+                    )?;
+                }
+            }
+
+            // Selector CIDRs and addresses feed clients the same way the
+            // catch-all advertised address does, so they get the same strict
+            // parse. Endpoint conflicts are checked across every node's
+            // selectors and catch-alls: two nodes may reuse one host:port for
+            // DISJOINT client networks (that is the feature), but wherever
+            // two networks overlap - equal, nested, or a catch-all, which
+            // matches everything - a client in the overlap would resolve one
+            // endpoint to two nodes. Networks are compared truncated
+            // (`10.0.1.0/16` == `10.0.0.0/16`) and canonicalized
+            // (`::ffff:10.0.0.0/104` == `10.0.0.0/8`) since matching
+            // truncates and canonicalizes too.
+            let mut seen_selector_cidrs = std::collections::HashSet::new();
+            for selector in &node.advertised_addresses {
+                let client_cidr = match selector.client_cidr.parse::<IpNet>() {
+                    Ok(client_cidr) => canonical_ip_net(client_cidr.trunc()),
+                    Err(error) => {
                         eprintln!(
-                            "Invalid cluster configuration: advertised client 
endpoint conflict - {endpoint} is already used (node '{}', transport {name})",
-                            node.name
+                            "Invalid cluster configuration: 
advertised_addresses client_cidr '{}' for node '{}': {error}",
+                            selector.client_cidr, node.name
+                        );
+                        return 
Err(ConfigurationError::InvalidConfigurationValue);
+                    }
+                };
+                if !seen_selector_cidrs.insert(client_cidr) {
+                    eprintln!(
+                        "Invalid cluster configuration: duplicate 
advertised_addresses client_cidr '{}' for node '{}'",
+                        selector.client_cidr, node.name
+                    );
+                    return Err(ConfigurationError::InvalidConfigurationValue);
+                }
+                let address = match 
selector.address.parse::<AdvertisedAddress>() {
+                    Ok(address) => address,
+                    Err(error) => {
+                        eprintln!(
+                            "Invalid cluster configuration: 
advertised_addresses address '{}' for node '{}': {error}",
+                            selector.address, node.name
                         );
                         return 
Err(ConfigurationError::InvalidConfigurationValue);
                     }
+                };
+                for (name, port) in &client_ports {
+                    if let Some(port) = port {
+                        insert_advertised_endpoint(
+                            &mut advertised_endpoints,
+                            AdvertisedEndpoint {
+                                node_name: &node.name,
+                                transport: name,
+                                network: Some(client_cidr),
+                                host: AdvertisedHost::Parsed(address.clone()),
+                                port: *port,
+                            },
+                        )?;
+                    }
                 }
             }
         }
@@ -933,6 +1125,84 @@ impl Validatable<ConfigurationError> for ClusterConfig {
     }
 }
 
+/// One advertised client endpoint and the client network it serves, pooled by
+/// [`ClusterConfig::validate`] so selectors and catch-alls conflict-check
+/// against each other. `network: None` is the catch-all (`advertised_address`,
+/// or the roster `ip` as fallback): it matches every client, so it overlaps
+/// every network.
+struct AdvertisedEndpoint<'roster> {
+    node_name: &'roster str,
+    transport: &'static str,
+    network: Option<IpNet>,
+    host: AdvertisedHost<'roster>,
+    port: u16,
+}
+
+/// Advertised host in comparable form. `Raw` carries a roster `ip` that
+/// failed the strict [`AdvertisedAddress`] parse, so it can never equal a
+/// `Parsed` host.
+#[derive(PartialEq, Eq)]
+enum AdvertisedHost<'roster> {
+    Parsed(AdvertisedAddress),
+    Raw(&'roster str),
+}
+
+impl AdvertisedEndpoint<'_> {
+    /// True when some client would resolve both entries to one host:port on
+    /// two different nodes. Entries of one node never conflict: longest-prefix
+    /// match picks exactly one selector per transport, and the bind-endpoint
+    /// check already forces distinct ports across a node's transports.
+    fn conflicts_with(&self, other: &Self) -> bool {
+        if self.node_name == other.node_name || self.port != other.port || 
self.host != other.host {
+            return false;
+        }
+        match (self.network, other.network) {
+            (Some(network), Some(other_network)) => {
+                network.contains(&other_network) || 
other_network.contains(&network)
+            }
+            _ => true,
+        }
+    }
+
+    fn authority(&self) -> String {
+        match &self.host {
+            AdvertisedHost::Parsed(address) => address.authority(self.port),
+            AdvertisedHost::Raw(raw_ip) => format!("{raw_ip}:{}", self.port),
+        }
+    }
+
+    fn network_description(&self) -> String {
+        match self.network {
+            Some(network) => format!("client_cidr {network}"),
+            None => "every client network (catch-all)".to_owned(),
+        }
+    }
+}
+
+fn insert_advertised_endpoint<'roster>(
+    advertised_endpoints: &mut Vec<AdvertisedEndpoint<'roster>>,
+    endpoint: AdvertisedEndpoint<'roster>,
+) -> Result<(), ConfigurationError> {
+    if let Some(existing) = advertised_endpoints
+        .iter()
+        .find(|existing| existing.conflicts_with(&endpoint))
+    {
+        eprintln!(
+            "Invalid cluster configuration: advertised client endpoint 
conflict - {} is advertised for {} (node '{}', transport {}) and for {} (node 
'{}', transport {}); the networks overlap, so a client in the overlap would 
resolve both nodes to one endpoint",
+            endpoint.authority(),
+            endpoint.network_description(),
+            endpoint.node_name,
+            endpoint.transport,
+            existing.network_description(),
+            existing.node_name,
+            existing.transport,
+        );
+        return Err(ConfigurationError::InvalidConfigurationValue);
+    }
+    advertised_endpoints.push(endpoint);
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1075,6 +1345,191 @@ mod advertised_address_tests {
     }
 }
 
+#[cfg(test)]
+mod advertised_for_tests {
+    use super::*;
+
+    fn node_with_selectors(selectors: Vec<AdvertisedAddressSelector>) -> 
ClusterNodeConfig {
+        ClusterNodeConfig {
+            name: "node-0".to_owned(),
+            ip: "10.0.1.5".to_owned(),
+            advertised_address: Some("203.0.113.10".to_owned()),
+            advertised_addresses: selectors,
+            replica_id: 0,
+            ports: TransportPorts::default(),
+        }
+    }
+
+    fn selector(client_cidr: &str, address: &str) -> AdvertisedAddressSelector 
{
+        AdvertisedAddressSelector {
+            client_cidr: client_cidr.to_owned(),
+            address: address.to_owned(),
+        }
+    }
+
+    fn resolved(node: ClusterNodeConfig) -> ResolvedClusterNode {
+        node.into()
+    }
+
+    fn ip(address: &str) -> IpAddr {
+        address.parse().unwrap()
+    }
+
+    #[test]
+    fn falls_back_to_advertised_address_without_selectors() {
+        let node = node_with_selectors(Vec::new());
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("203.0.113.10")))
+        );
+    }
+
+    #[test]
+    fn falls_back_to_roster_ip_without_advertised_address() {
+        let mut node = node_with_selectors(Vec::new());
+        node.advertised_address = None;
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.0.1.5")))
+        );
+    }
+
+    #[test]
+    fn is_none_when_no_fallback_parses() {
+        let mut node = node_with_selectors(Vec::new());
+        node.advertised_address = None;
+        node.ip = "iggy_node".to_owned();
+        assert_eq!(resolved(node).advertised_for(Some(ip("10.0.0.7"))), None);
+    }
+
+    #[test]
+    fn unparsable_advertised_address_fails_closed() {
+        // Only reachable when validation was skipped (cluster disabled). The
+        // roster ip may be a private address, so leaking it as the catch-all
+        // would misroute clients; resolution fails closed instead.
+        let mut node = node_with_selectors(Vec::new());
+        node.advertised_address = Some("203.0.113.10:8090".to_owned());
+        assert_eq!(resolved(node).advertised_for(Some(ip("10.0.0.7"))), None);
+    }
+
+    #[test]
+    fn matching_selector_beats_advertised_address() {
+        let node = node_with_selectors(vec![selector("10.0.0.0/16", 
"10.0.1.5")]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.200.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.0.1.5")))
+        );
+    }
+
+    #[test]
+    fn unmatched_client_falls_back_to_advertised_address() {
+        let node = node_with_selectors(vec![selector("10.0.0.0/16", 
"10.0.1.5")]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("192.168.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("203.0.113.10")))
+        );
+    }
+
+    #[test]
+    fn unknown_client_ip_falls_back_to_advertised_address() {
+        let node = node_with_selectors(vec![selector("10.0.0.0/16", 
"10.0.1.5")]);
+        assert_eq!(
+            resolved(node).advertised_for(None),
+            Some(AdvertisedAddress::Ip(ip("203.0.113.10")))
+        );
+    }
+
+    #[test]
+    fn longest_prefix_wins_regardless_of_declaration_order() {
+        let node = resolved(node_with_selectors(vec![
+            selector("10.0.0.0/8", "10.255.255.1"),
+            selector("10.0.0.0/16", "10.0.1.5"),
+        ]));
+        assert_eq!(
+            node.advertised_for(Some(ip("10.0.200.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.0.1.5"))),
+            "the /16 must win over the /8 even though it is declared second"
+        );
+        assert_eq!(
+            node.advertised_for(Some(ip("10.9.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.255.255.1"))),
+            "a client outside the /16 but inside the /8 must match the /8"
+        );
+    }
+
+    #[test]
+    fn prefix_length_tie_resolves_to_first_declared() {
+        // Overlapping same-length networks (host bits differ, same masked
+        // network) both match; the first declared must win.
+        let node = node_with_selectors(vec![
+            selector("10.0.1.0/16", "10.0.1.5"),
+            selector("10.0.2.0/16", "10.0.2.5"),
+        ]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.200.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.0.1.5")))
+        );
+    }
+
+    #[test]
+    fn v4_mapped_v6_client_matches_v4_cidr() {
+        // A dual-stack listener reports v4 peers as `::ffff:a.b.c.d`.
+        let node = node_with_selectors(vec![selector("10.0.0.0/16", 
"10.0.1.5")]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("::ffff:10.0.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.0.1.5")))
+        );
+    }
+
+    #[test]
+    fn v4_mapped_v6_selector_cidr_matches_v4_client() {
+        // The mirror case: the CIDR side canonicalizes at build, so
+        // `::ffff:10.0.0.0/104` matches like `10.0.0.0/8` instead of being
+        // a silently dead selector.
+        let node = node_with_selectors(vec![selector("::ffff:10.0.0.0/104", 
"10.0.1.5")]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.0.1.5")))
+        );
+    }
+
+    #[test]
+    fn v6_selector_matches_v6_client() {
+        let node = node_with_selectors(vec![selector("2001:db8::/32", 
"2001:db8::1")]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("2001:db8::7"))),
+            Some(AdvertisedAddress::Ip(ip("2001:db8::1")))
+        );
+    }
+
+    #[test]
+    fn selector_address_may_be_a_hostname() {
+        let node = node_with_selectors(vec![selector("10.0.0.0/16", 
"Broker.Internal.Example")]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.0.7"))),
+            Some(AdvertisedAddress::Hostname(
+                "broker.internal.example".to_owned()
+            ))
+        );
+    }
+
+    #[test]
+    fn unparsable_selectors_are_dropped_at_build_not_per_request() {
+        // Only reachable when validation was skipped (cluster disabled). The
+        // bad entries fall out once at build, so LPM runs over the parsable
+        // rest: the broken /16 no longer shadows the /8.
+        let node = node_with_selectors(vec![
+            selector("not-a-cidr", "10.0.1.5"),
+            selector("10.0.0.0/16", "iggy_node"),
+            selector("10.0.0.0/8", "10.255.255.1"),
+        ]);
+        assert_eq!(
+            resolved(node).advertised_for(Some(ip("10.0.0.7"))),
+            Some(AdvertisedAddress::Ip(ip("10.255.255.1")))
+        );
+    }
+}
+
 #[cfg(test)]
 mod cluster_validate_tests {
     use super::*;
@@ -1084,11 +1539,19 @@ mod cluster_validate_tests {
             name: name.to_string(),
             ip: "127.0.0.1".to_string(),
             advertised_address: None,
+            advertised_addresses: Vec::new(),
             replica_id: id,
             ports: TransportPorts::default(),
         }
     }
 
+    fn selector(client_cidr: &str, address: &str) -> AdvertisedAddressSelector 
{
+        AdvertisedAddressSelector {
+            client_cidr: client_cidr.to_owned(),
+            address: address.to_owned(),
+        }
+    }
+
     fn cfg(nodes: Vec<ClusterNodeConfig>) -> ClusterConfig {
         ClusterConfig {
             enabled: true,
@@ -1506,6 +1969,167 @@ mod cluster_validate_tests {
         assert!(cfg(vec![n1, n2]).validate().is_ok());
     }
 
+    #[test]
+    fn validate_accepts_selectors_with_distinct_cidrs() {
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_address = Some("203.0.113.1".to_owned());
+        n1.advertised_addresses = vec![
+            selector("10.0.0.0/16", "10.0.0.1"),
+            selector("10.0.0.0/8", "broker-1.internal.example"),
+        ];
+        n1.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, node("n2", 1)]).validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_malformed_selector_cidr() {
+        for client_cidr in ["10.0.0.0", "10.0.0.0/33", "not-a-cidr", ""] {
+            let mut n1 = node("n1", 0);
+            n1.advertised_addresses = vec![selector(client_cidr, "10.0.0.1")];
+
+            assert!(
+                cfg(vec![n1, node("n2", 1)]).validate().is_err(),
+                "client_cidr '{client_cidr}' must be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn validate_rejects_malformed_selector_address() {
+        for address in ["", "10.0.0.1:8090", "10.0.0.256", "iggy_node"] {
+            let mut n1 = node("n1", 0);
+            n1.advertised_addresses = vec![selector("10.0.0.0/16", address)];
+
+            assert!(
+                cfg(vec![n1, node("n2", 1)]).validate().is_err(),
+                "selector address '{address}' must be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn validate_rejects_duplicate_selector_cidr_within_a_node() {
+        // `10.0.1.0/16` truncates to `10.0.0.0/16`: the two selectors match
+        // the identical client set, so the second could never win LPM.
+        let mut n1 = node("n1", 0);
+        n1.advertised_addresses = vec![
+            selector("10.0.0.0/16", "10.0.0.1"),
+            selector("10.0.1.0/16", "10.0.0.2"),
+        ];
+
+        assert!(cfg(vec![n1, node("n2", 1)]).validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_selector_endpoint_conflict_within_one_cidr() {
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_addresses = vec![selector("10.0.0.0/16", "10.0.7.7")];
+        n1.ports.tcp = Some(8090);
+        let mut n2 = node("n2", 1);
+        n2.ip = "10.0.0.2".to_owned();
+        n2.advertised_addresses = vec![selector("10.0.0.0/16", "10.0.7.7")];
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_identical_selector_endpoint_across_different_cidrs() {
+        // Reusing one host:port across DIFFERENT client networks is the
+        // feature (e.g. each network NATs the address to its local node).
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_addresses = vec![selector("10.1.0.0/16", "192.0.2.10")];
+        n1.ports.tcp = Some(8090);
+        let mut n2 = node("n2", 1);
+        n2.ip = "10.0.0.2".to_owned();
+        n2.advertised_addresses = vec![selector("10.2.0.0/16", "192.0.2.10")];
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_v4_mapped_v6_selector_cidr_duplicating_its_v4_form() {
+        // `::ffff:10.0.0.0/104` canonicalizes to `10.0.0.0/8` (matching how
+        // client IPs canonicalize before LPM), so these two selectors match
+        // the identical client set.
+        let mut n1 = node("n1", 0);
+        n1.advertised_addresses = vec![
+            selector("10.0.0.0/8", "10.0.0.1"),
+            selector("::ffff:10.0.0.0/104", "10.0.0.2"),
+        ];
+
+        assert!(cfg(vec![n1, node("n2", 1)]).validate().is_err());
+    }
+
+    #[test]
+    fn 
validate_rejects_selector_endpoint_clashing_with_another_nodes_catch_all() {
+        // The catch-all matches every client, so a 10.0.0.0/16 client would
+        // resolve both nodes to 192.0.2.10:8090.
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_addresses = vec![selector("10.0.0.0/16", "192.0.2.10")];
+        n1.ports.tcp = Some(8090);
+        let mut n2 = node("n2", 1);
+        n2.ip = "10.0.0.2".to_owned();
+        n2.advertised_address = Some("192.0.2.10".to_owned());
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_err());
+    }
+
+    #[test]
+    fn 
validate_rejects_selector_endpoint_clashing_with_another_nodes_roster_ip() {
+        // Without an advertised_address the roster ip backs the catch-all,
+        // so the same cross-set conflict applies to it.
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_addresses = vec![selector("10.0.0.0/16", "10.0.0.2")];
+        n1.ports.tcp = Some(8090);
+        let mut n2 = node("n2", 1);
+        n2.ip = "10.0.0.2".to_owned();
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_identical_selector_endpoint_across_nested_cidrs() {
+        // LPM runs per node, not cluster-wide: a 10.0.0.0/16 client matches
+        // n1 through /8 and n2 through /16, resolving both to 192.0.2.10:8090.
+        // Nested networks conflict just like equal ones.
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_addresses = vec![selector("10.0.0.0/8", "192.0.2.10")];
+        n1.ports.tcp = Some(8090);
+        let mut n2 = node("n2", 1);
+        n2.ip = "10.0.0.2".to_owned();
+        n2.advertised_addresses = vec![selector("10.0.0.0/16", "192.0.2.10")];
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_selector_reusing_its_own_nodes_catch_all_address() {
+        // Redundant but harmless: within one node the selector and the
+        // catch-all cannot resolve a client to two different nodes.
+        let mut n1 = node("n1", 0);
+        n1.ip = "10.0.0.1".to_owned();
+        n1.advertised_address = Some("192.0.2.10".to_owned());
+        n1.advertised_addresses = vec![selector("10.0.0.0/16", "192.0.2.10")];
+        n1.ports.tcp = Some(8090);
+        let mut n2 = node("n2", 1);
+        n2.ip = "10.0.0.2".to_owned();
+        n2.ports.tcp = Some(8091);
+
+        assert!(cfg(vec![n1, n2]).validate().is_ok());
+    }
+
     #[test]
     fn validate_rejects_zero_tcp_replica_port() {
         let ports = TransportPorts {
diff --git a/core/configs/src/server_ng_config/defaults.rs 
b/core/configs/src/server_ng_config/defaults.rs
index a3894aeef..e68f0889c 100644
--- a/core/configs/src/server_ng_config/defaults.rs
+++ b/core/configs/src/server_ng_config/defaults.rs
@@ -119,6 +119,7 @@ impl Default for ClusterConfig {
                     name: node.name.parse().unwrap(),
                     ip: node.ip.parse().unwrap(),
                     advertised_address: None,
+                    advertised_addresses: Vec::new(),
                     replica_id: u8::try_from(node.replica_id).expect(
                         "static_toml replica_id must fit in u8 (0..=255); \
                          fix core/server-ng/config.toml",
diff --git a/core/configs/src/server_ng_config/validators.rs 
b/core/configs/src/server_ng_config/validators.rs
index c00d55df8..8c6941f5e 100644
--- a/core/configs/src/server_ng_config/validators.rs
+++ b/core/configs/src/server_ng_config/validators.rs
@@ -745,6 +745,7 @@ mod tests {
             name: format!("node-{replica_id}"),
             ip: "127.0.0.1".to_string(),
             advertised_address: None,
+            advertised_addresses: Vec::new(),
             replica_id,
             ports: TransportPorts {
                 tcp: Some(8090 + u16::from(replica_id)),
diff --git a/core/integration/tests/server/cluster_metadata_vsr.rs 
b/core/integration/tests/server/cluster_metadata_vsr.rs
index f15ad4416..ca50564a0 100644
--- a/core/integration/tests/server/cluster_metadata_vsr.rs
+++ b/core/integration/tests/server/cluster_metadata_vsr.rs
@@ -17,10 +17,21 @@
 
 //! Binary `GetClusterMetadata` over a real VSR cluster. The reply must carry
 //! the full configured roster with real client ports and live leader/follower
-//! roles - not a single synthesized self node with zeroed ports.
+//! roles - not a single synthesized self node with zeroed ports. The
+//! selector suite below additionally proves per-client-network advertised
+//! addresses (`cluster.nodes.advertised_addresses`) end to end: a loopback
+//! client must see the selector address in binary metadata, HTTP metadata,
+//! and the 307 leader-redirect `Location`.
+
+use std::time::Instant;
 
 use iggy::prelude::*;
+use integration::harness::TestHarness;
 use integration::iggy_harness;
+use reqwest::StatusCode;
+use tokio::time::sleep;
+
+use crate::server::http_client::{HttpClient, LOGIN_RETRY_INTERVAL, 
LOGIN_TIMEOUT};
 
 /// One shard per node so the client connection is always served by shard 0,
 /// where the metadata consensus that marks the leader lives. A request
@@ -74,3 +85,182 @@ async fn 
given_two_node_cluster_when_getting_cluster_metadata_should_return_full
         "every other node must follow, got {metadata}"
     );
 }
+
+const SELECTOR_CLUSTER_NODES: usize = 2;
+
+/// Every test client in this file connects over loopback, so this CIDR is the
+/// one the server must match its peer address against.
+const LOOPBACK_CIDR: &str = "127.0.0.0/8";
+
+/// Selector marker: distinct from the harness roster ip (`127.0.0.1`), so it
+/// appears in metadata or a redirect only when the selector path ran - yet it
+/// still resolves, which matters because the leader-aware SDK client redials
+/// whatever address metadata advertises.
+const SELECTOR_ADDRESS: &str = "localhost";
+
+/// A 2-node cluster whose roster gives every node a loopback-CIDR selector.
+/// The roster (selectors included) must be identical on every node, so each
+/// server process gets the env vars for all nodes.
+fn selector_cluster() -> TestHarness {
+    let mut harness = TestHarness::builder()
+        .default_server()
+        .cluster_nodes(SELECTOR_CLUSTER_NODES)
+        .build()
+        .expect("build selector cluster harness");
+    for node in 0..SELECTOR_CLUSTER_NODES {
+        for roster_entry in 0..SELECTOR_CLUSTER_NODES {
+            harness.node_mut(node).add_env(
+                
format!("IGGY_CLUSTER_NODES_{roster_entry}_ADVERTISED_ADDRESSES_0_CLIENT_CIDR"),
+                LOOPBACK_CIDR,
+            );
+            harness.node_mut(node).add_env(
+                
format!("IGGY_CLUSTER_NODES_{roster_entry}_ADVERTISED_ADDRESSES_0_ADDRESS"),
+                SELECTOR_ADDRESS,
+            );
+        }
+    }
+    harness
+}
+
+/// Assert every roster node reports the selector address to this loopback
+/// client; without the selector the same roster publishes `127.0.0.1`.
+fn assert_selector_addresses(nodes: impl IntoIterator<Item = (String, 
String)>) {
+    let mut seen = 0;
+    for (name, ip) in nodes {
+        assert_eq!(
+            ip, SELECTOR_ADDRESS,
+            "node '{name}' must advertise its selector address to a loopback 
client"
+        );
+        seen += 1;
+    }
+    assert_eq!(seen, SELECTOR_CLUSTER_NODES, "full roster must be reported");
+}
+
+#[tokio::test]
+#[serial_test::parallel]
+async fn 
given_matching_client_cidr_when_getting_binary_cluster_metadata_should_return_selector_addresses()
+ {
+    let mut harness = selector_cluster();
+    harness.start().await.expect("start selector cluster");
+
+    let client = harness
+        .node(0)
+        .tcp_client()
+        .expect("tcp client")
+        .with_root_login()
+        .connect()
+        .await
+        .expect("connect to node 0");
+    let metadata = client
+        .get_cluster_metadata()
+        .await
+        .expect("get cluster metadata");
+
+    assert_selector_addresses(
+        metadata
+            .nodes
+            .iter()
+            .map(|node| (node.name.clone(), node.ip.clone())),
+    );
+}
+
+#[tokio::test]
+#[serial_test::parallel]
+async fn 
given_matching_client_cidr_when_getting_http_cluster_metadata_should_return_selector_addresses()
+ {
+    let mut harness = selector_cluster();
+    harness.start().await.expect("start selector cluster");
+
+    let http = HttpClient::login_root(&harness).await;
+    let response = http.get("/cluster/metadata").await;
+    assert_eq!(response.status(), StatusCode::OK);
+    let metadata: serde_json::Value = response.json().await.expect("decode 
cluster metadata");
+
+    let nodes = metadata["nodes"]
+        .as_array()
+        .expect("metadata must carry a nodes array")
+        .iter()
+        .map(|node| {
+            (
+                node["name"].as_str().expect("node name").to_owned(),
+                node["ip"].as_str().expect("node ip").to_owned(),
+            )
+        })
+        .collect::<Vec<_>>();
+    assert_selector_addresses(nodes);
+}
+
+/// A linearizable read on the follower must 307 to the primary, and the
+/// `Location` host must be the primary's SELECTOR address: the redirected
+/// client is the same loopback peer, so pointing it at the catch-all (or the
+/// roster ip) would route it off its network.
+#[tokio::test]
+#[serial_test::parallel]
+async fn 
given_matching_client_cidr_when_redirected_to_primary_should_target_selector_address()
 {
+    const READ_PATH: &str = "/streams?consistency=linearizable";
+
+    let mut harness = selector_cluster();
+    harness.start().await.expect("start selector cluster");
+
+    // Redirects must surface, not be followed: the `Location` itself is what
+    // is under test, and the selector hostname resolves by design (see
+    // SELECTOR_ADDRESS), so a redirect-following client would chase it to the
+    // primary, get a 200, and hide a wrong Location host. Bearers are
+    // node-local in this keyless cluster, so each node gets its own login.
+    let deadline = Instant::now() + LOGIN_TIMEOUT;
+    let mut verdicts: Vec<(usize, StatusCode, Option<String>)>;
+    loop {
+        verdicts = Vec::new();
+        for node in 0..SELECTOR_CLUSTER_NODES {
+            let addr = harness.node(node).http_addr().expect("node http 
address");
+            let session = 
HttpClient::login_root_no_redirect(format!("http://{addr}";)).await;
+            let response = session.get(READ_PATH).await;
+            let location = response
+                .headers()
+                .get(reqwest::header::LOCATION)
+                .map(|value| value.to_str().expect("Location 
header").to_owned());
+            verdicts.push((node, response.status(), location));
+        }
+        // Settled view: exactly one primary serves the read locally and every
+        // other node redirects. Anything else (a 503 follower that cannot
+        // resolve the primary yet, a double-307 round mid-election) means the
+        // view is still settling; retry within the shared warmup budget.
+        let primary_count = verdicts
+            .iter()
+            .filter(|(_, status, _)| *status == StatusCode::OK)
+            .count();
+        let redirect_count = verdicts
+            .iter()
+            .filter(|(_, status, _)| *status == StatusCode::TEMPORARY_REDIRECT)
+            .count();
+        if primary_count == 1 && redirect_count == SELECTOR_CLUSTER_NODES - 1 {
+            break;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "cluster did not settle on one primary and redirecting followers 
within {LOGIN_TIMEOUT:?}: {verdicts:?}"
+        );
+        sleep(LOGIN_RETRY_INTERVAL).await;
+    }
+
+    // The loop only breaks on one 200 with every other node at 307, so the
+    // non-redirected partition is exactly the primary.
+    let (redirected, primaries): (Vec<_>, Vec<_>) = verdicts
+        .iter()
+        .partition(|(_, status, _)| *status == StatusCode::TEMPORARY_REDIRECT);
+    let (primary_index, _, _) = primaries[0];
+
+    let primary_http_port = harness
+        .node(*primary_index)
+        .http_addr()
+        .expect("primary http address")
+        .port();
+    let expected_location = 
format!("http://{SELECTOR_ADDRESS}:{primary_http_port}{READ_PATH}";);
+    for (follower, _, location) in redirected {
+        assert_eq!(
+            location.as_deref(),
+            Some(expected_location.as_str()),
+            "follower node {follower} must redirect this loopback client to 
the primary's selector address"
+        );
+    }
+}
diff --git a/core/integration/tests/server/http_client.rs 
b/core/integration/tests/server/http_client.rs
index 7d9558494..e254fbfd7 100644
--- a/core/integration/tests/server/http_client.rs
+++ b/core/integration/tests/server/http_client.rs
@@ -53,12 +53,29 @@ impl HttpClient {
             .server()
             .http_addr()
             .expect("HTTP transport not configured on test server");
-        let base_url = format!("http://{addr}";);
         let client = reqwest::Client::builder()
             .timeout(REQUEST_TIMEOUT)
             .build()
             .expect("build reqwest client");
+        Self::login_root_with(client, format!("http://{addr}";)).await
+    }
+
+    /// Log in as root against an explicit listener base URL, with redirects
+    /// surfaced instead of followed - for suites that assert on `Location`
+    /// (reqwest follows a 307 transparently by default). The explicit URL also
+    /// reaches a follower node, which [`Self::login_root`] (pinned to node 0)
+    /// cannot.
+    pub async fn login_root_no_redirect(base_url: String) -> Self {
+        let client = reqwest::Client::builder()
+            .timeout(REQUEST_TIMEOUT)
+            .redirect(reqwest::redirect::Policy::none())
+            .build()
+            .expect("build reqwest client");
+        Self::login_root_with(client, base_url).await
+    }
 
+    /// Shared root-login retry loop over an arbitrary client + base URL.
+    async fn login_root_with(client: reqwest::Client, base_url: String) -> 
Self {
         let body = json!({
             "username": DEFAULT_ROOT_USERNAME,
             "password": DEFAULT_ROOT_PASSWORD,
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index af6d3c9ce..29d716576 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -739,6 +739,29 @@ ca_file = ""
 # (RFC 1123: ASCII letters, digits, '-' and '.'; no port, no trailing dot).
 # When unset, clients receive 'ip'.
 #
+# When different client networks need different addresses (a public
+# 'advertised_address' would route in-VPC clients out through the public
+# side), add per-network 'advertised_addresses' selectors: clients whose
+# peer IP falls inside 'client_cidr' are handed 'address' instead of the
+# catch-all. The longest matching prefix wins; a tie on prefix length goes
+# to the first declared selector; clients matching no selector fall back to
+# 'advertised_address', then 'ip'. Matching sees the transport-level peer
+# address, so clients behind a proxy or load balancer match the proxy's
+# network, not their own. Note for rolling upgrades: older server binaries
+# reject configs containing 'advertised_addresses' - upgrade every binary
+# first, then add selectors.
+#
+#   [[cluster.nodes]]
+#   name = "iggy-node-1"
+#   ip = "10.0.1.5"                        # replica plane + last-resort 
fallback
+#   advertised_address = "203.0.113.10"    # catch-all for unmatched clients
+#   replica_id = 0
+#   ports = { tcp = 8090, http = 3000, tcp_replica = 9090 }
+#
+#   [[cluster.nodes.advertised_addresses]]
+#   client_cidr = "10.0.0.0/16"            # in-VPC clients stay private
+#   address = "10.0.1.5"
+#
 # In cluster mode, 'ports' is the single source of listener ports: every
 # enabled transport needs an explicit per-node port, otherwise the server
 # refuses to start.
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index c1e196a1b..9fddb3886 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -1596,7 +1596,13 @@ fn build_cluster_roster(
     ClusterRoster {
         enabled: config.cluster.enabled,
         name: config.cluster.name.clone(),
-        nodes: config.cluster.nodes.clone(),
+        nodes: config
+            .cluster
+            .nodes
+            .iter()
+            .cloned()
+            .map(Into::into)
+            .collect(),
         self_ip: topology.client_listen_addr.ip().to_string(),
         self_ports: configs::ng_cluster::TransportPorts {
             tcp: Some(topology.client_listen_addr.port()),
@@ -3976,6 +3982,7 @@ mod tests {
             name: "node".to_owned(),
             ip: ip.to_owned(),
             advertised_address: None,
+            advertised_addresses: Vec::new(),
             replica_id: 0,
             ports: configs::ng_cluster::TransportPorts {
                 tcp,
diff --git a/core/server-ng/src/cluster_meta.rs 
b/core/server-ng/src/cluster_meta.rs
index 8a1bff535..aac36afe2 100644
--- a/core/server-ng/src/cluster_meta.rs
+++ b/core/server-ng/src/cluster_meta.rs
@@ -28,10 +28,11 @@
 //! leader, but the full roster is still returned). The self-synthesized single
 //! node is the cluster-disabled fallback, shared by both callers.
 
-use configs::ng_cluster::{AdvertisedAddress, ClusterNodeConfig, 
TransportPorts};
+use configs::ng_cluster::{ResolvedClusterNode, TransportPorts};
 use iggy_common::{
     ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, 
TransportEndpoints,
 };
+use std::net::IpAddr;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU64, Ordering};
 
@@ -50,7 +51,9 @@ const SINGLE_NODE_CLUSTER_NAME: &str = "single-node";
 pub struct ClusterRoster {
     pub enabled: bool,
     pub name: String,
-    pub nodes: Vec<ClusterNodeConfig>,
+    /// Roster nodes with selectors parsed once at roster build, so the
+    /// per-request address resolution never re-parses config strings.
+    pub nodes: Vec<ResolvedClusterNode>,
     /// This node's own address, reported for the synthesized self node.
     pub self_ip: String,
     /// This node's own client ports for the same self node (`None` = transport
@@ -98,17 +101,24 @@ impl ClusterRoster {
     /// emit one node per entry, marking the node at `primary_index` the leader
     /// and the rest followers; `None` (no on-shard consensus) leaves every 
node
     /// a follower. Otherwise synthesize the single self node as the sole
-    /// leader.
-    pub fn cluster_metadata(&self, primary_index: Option<u8>) -> 
ClusterMetadata {
+    /// leader. `client_ip` is the requesting client's transport-level peer
+    /// address, used to pick each node's advertised address from its
+    /// per-client-network selectors; `None` (unknown peer) serves the
+    /// catch-all address.
+    pub fn cluster_metadata(
+        &self,
+        primary_index: Option<u8>,
+        client_ip: Option<IpAddr>,
+    ) -> ClusterMetadata {
         if self.enabled && !self.nodes.is_empty() {
             let nodes = self
                 .nodes
                 .iter()
                 .map(|node| ClusterNode {
-                    name: node.name.clone(),
-                    ip: client_host(node),
-                    endpoints: ports_to_endpoints(&node.ports),
-                    role: role_for(primary_index, node.replica_id),
+                    name: node.config.name.clone(),
+                    ip: client_host(node, client_ip),
+                    endpoints: ports_to_endpoints(&node.config.ports),
+                    role: role_for(primary_index, node.config.replica_id),
                     status: ClusterNodeStatus::Healthy,
                 })
                 .collect();
@@ -137,14 +147,23 @@ impl ClusterRoster {
 
 /// Client-facing host in normalized form (lowercase hostname, canonical IP),
 /// matching what boot validation compared and what redirect URLs render, so
-/// textual config variants of one address publish identical metadata. A
-/// roster `ip` that parses as neither (boot only requires it non-empty)
-/// passes through verbatim; a configured `advertised_address` always parses,
-/// validation rejects it otherwise.
-fn client_host(node: &ClusterNodeConfig) -> String {
-    let host = node.advertised_address.as_deref().unwrap_or(&node.ip);
-    host.parse::<AdvertisedAddress>()
-        .map_or_else(|_| host.to_owned(), |address| address.to_string())
+/// textual config variants of one address publish identical metadata. The
+/// per-client-network selectors, the catch-all `advertised_address`, and the
+/// roster `ip` are consulted in that order 
([`ResolvedClusterNode::advertised_for`]);
+/// a host that parses as neither IP nor hostname (only reachable when boot
+/// validation was skipped, or for the roster `ip`, which boot only requires
+/// non-empty) passes through verbatim.
+fn client_host(node: &ResolvedClusterNode, client_ip: Option<IpAddr>) -> 
String {
+    node.advertised_for(client_ip).map_or_else(
+        || {
+            node.config
+                .advertised_address
+                .as_deref()
+                .unwrap_or(&node.config.ip)
+                .to_owned()
+        },
+        |address| address.to_string(),
+    )
 }
 
 const fn role_for(primary_index: Option<u8>, replica_id: u8) -> 
ClusterNodeRole {
@@ -167,40 +186,52 @@ fn ports_to_endpoints(ports: &TransportPorts) -> 
TransportEndpoints {
 mod tests {
     use super::*;
 
-    fn roster(advertised_address: Option<String>) -> ClusterRoster {
+    use configs::ng_cluster::{AdvertisedAddressSelector, ClusterNodeConfig};
+
+    fn node_config(advertised_address: Option<String>) -> ClusterNodeConfig {
+        ClusterNodeConfig {
+            name: "node-0".to_owned(),
+            ip: "10.0.0.1".to_owned(),
+            advertised_address,
+            advertised_addresses: Vec::new(),
+            replica_id: 0,
+            ports: TransportPorts::default(),
+        }
+    }
+
+    fn roster_of(node: ClusterNodeConfig) -> ClusterRoster {
         ClusterRoster {
             enabled: true,
             name: "test-cluster".to_owned(),
-            nodes: vec![ClusterNodeConfig {
-                name: "node-0".to_owned(),
-                ip: "10.0.0.1".to_owned(),
-                advertised_address,
-                replica_id: 0,
-                ports: TransportPorts::default(),
-            }],
+            nodes: vec![node.into()],
             self_ip: "127.0.0.1".to_owned(),
             self_ports: TransportPorts::default(),
             metadata_view: Arc::new(AtomicU64::new(METADATA_VIEW_UNKNOWN)),
         }
     }
 
+    fn roster(advertised_address: Option<String>) -> ClusterRoster {
+        roster_of(node_config(advertised_address))
+    }
+
     #[test]
     fn cluster_metadata_uses_advertised_address_when_configured() {
-        let metadata = 
roster(Some("203.0.113.10".to_owned())).cluster_metadata(Some(0));
+        let metadata = 
roster(Some("203.0.113.10".to_owned())).cluster_metadata(Some(0), None);
 
         assert_eq!(metadata.nodes[0].ip, "203.0.113.10");
     }
 
     #[test]
     fn cluster_metadata_falls_back_to_replica_ip() {
-        let metadata = roster(None).cluster_metadata(Some(0));
+        let metadata = roster(None).cluster_metadata(Some(0), None);
 
         assert_eq!(metadata.nodes[0].ip, "10.0.0.1");
     }
 
     #[test]
     fn cluster_metadata_normalizes_advertised_hostname_to_lowercase() {
-        let metadata = 
roster(Some("Broker.Example.COM".to_owned())).cluster_metadata(Some(0));
+        let metadata =
+            
roster(Some("Broker.Example.COM".to_owned())).cluster_metadata(Some(0), None);
 
         assert_eq!(metadata.nodes[0].ip, "broker.example.com");
     }
@@ -208,7 +239,8 @@ mod tests {
     #[test]
     fn cluster_metadata_canonicalizes_advertised_ipv6_address() {
         for equivalent_address in ["2001:DB8::1", "[2001:db8::1]"] {
-            let metadata = 
roster(Some(equivalent_address.to_owned())).cluster_metadata(Some(0));
+            let metadata =
+                
roster(Some(equivalent_address.to_owned())).cluster_metadata(Some(0), None);
 
             assert_eq!(
                 metadata.nodes[0].ip, "2001:db8::1",
@@ -219,11 +251,29 @@ mod tests {
 
     #[test]
     fn cluster_metadata_passes_unparsable_replica_ip_verbatim() {
-        let mut cluster_roster = roster(None);
-        cluster_roster.nodes[0].ip = "iggy_node".to_owned();
+        let mut node = node_config(None);
+        node.ip = "iggy_node".to_owned();
 
-        let metadata = cluster_roster.cluster_metadata(Some(0));
+        let metadata = roster_of(node).cluster_metadata(Some(0), None);
 
         assert_eq!(metadata.nodes[0].ip, "iggy_node");
     }
+
+    #[test]
+    fn cluster_metadata_serves_the_selector_address_to_a_matching_client() {
+        let mut node = node_config(Some("203.0.113.10".to_owned()));
+        node.advertised_addresses = vec![AdvertisedAddressSelector {
+            client_cidr: "10.0.0.0/16".to_owned(),
+            address: "10.0.0.1".to_owned(),
+        }];
+        let cluster_roster = roster_of(node);
+
+        let in_network =
+            cluster_roster.cluster_metadata(Some(0), 
Some("10.0.9.9".parse().unwrap()));
+        assert_eq!(in_network.nodes[0].ip, "10.0.0.1");
+
+        let out_of_network =
+            cluster_roster.cluster_metadata(Some(0), 
Some("198.51.100.7".parse().unwrap()));
+        assert_eq!(out_of_network.nodes[0].ip, "203.0.113.10");
+    }
 }
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 99908fd6e..26a4d68fc 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -105,6 +105,7 @@ use shard::{
 };
 use std::cell::RefCell;
 use std::collections::{HashMap, HashSet, VecDeque};
+use std::net::IpAddr;
 use std::rc::Rc;
 use std::sync::Arc;
 use tracing::{debug, warn};
@@ -1353,7 +1354,15 @@ async fn handle_non_replicated_request<B, MJ, S>(
             handle_sync_consumer_group(shard, transport_client_id, 
&request).await;
         }
         _ => {
-            let roster = sessions.borrow().cluster_roster();
+            let (roster, client_ip) = {
+                let sessions = sessions.borrow();
+                (
+                    sessions.cluster_roster(),
+                    sessions
+                        .connection_address(transport_client_id)
+                        .map(|address| address.ip()),
+                )
+            };
             handle_default_non_replicated(
                 shard,
                 transport_client_id,
@@ -1361,13 +1370,14 @@ async fn handle_non_replicated_request<B, MJ, S>(
                 &request,
                 user_id,
                 &roster,
+                client_ip,
             )
             .await;
         }
     }
 }
 
-#[allow(clippy::future_not_send)]
+#[allow(clippy::future_not_send, clippy::too_many_arguments)]
 async fn handle_default_non_replicated<B, MJ, S>(
     shard: &Rc<ShellShard<B, MJ, S>>,
     transport_client_id: u128,
@@ -1375,6 +1385,7 @@ async fn handle_default_non_replicated<B, MJ, S>(
     request: &Message<RequestHeader>,
     user_id: Option<u32>,
     roster: &ClusterRoster,
+    client_ip: Option<IpAddr>,
 ) where
     B: ShellBus,
     MJ: JournalHandle + 'static,
@@ -1388,7 +1399,14 @@ async fn handle_default_non_replicated<B, MJ, S>(
         send_non_replicated_deny(shard, request, transport_client_id, 
error.as_code()).await;
         return;
     }
-    match build_non_replicated_response(shard, code, request_body(request), 
user_id, roster) {
+    match build_non_replicated_response(
+        shard,
+        code,
+        request_body(request),
+        user_id,
+        roster,
+        client_ip,
+    ) {
         Ok(response) => {
             let commit = current_metadata_commit(shard);
             let reply = response.into_reply(
diff --git a/core/server-ng/src/http.rs b/core/server-ng/src/http.rs
index 23946535c..e36f1b674 100644
--- a/core/server-ng/src/http.rs
+++ b/core/server-ng/src/http.rs
@@ -48,11 +48,13 @@ use std::sync::Arc;
 use std::sync::atomic::AtomicU64;
 
 use axum::Router;
+use axum::extract::connect_info::Connected;
 use axum::extract::{DefaultBodyLimit, Request};
 use axum::http::{HeaderName, HeaderValue, Method, StatusCode, Version, 
header::CONNECTION};
 use axum::middleware::{Next, from_fn, from_fn_with_state};
 use axum::response::Response;
 use axum::routing::{delete, get, post, put};
+use compio::net::TcpListener;
 use configs::http::{HttpConfig, HttpCorsConfig};
 use configs::ng_cluster::{ClusterConfig, TransportPorts, 
http_forwarding_key_material};
 use configs::server_ng::NgSystemConfig;
@@ -146,7 +148,7 @@ pub async fn start(
         roster: ClusterRoster {
             enabled: cluster.enabled,
             name: cluster.name.clone(),
-            nodes: cluster.nodes.clone(),
+            nodes: cluster.nodes.iter().cloned().map(Into::into).collect(),
             self_ip: bound_addr.ip().to_string(),
             // The self node reports the live bound HTTP port; the other client
             // ports arrive resolved from the caller.
@@ -189,9 +191,12 @@ pub async fn start(
         info!(address = %bound_addr, "server-ng HTTP listener started");
         let shutdown = shard.bus.token();
         let handle = compio::runtime::spawn(async move {
-            if let Err(error) = cyper_axum::serve(listener, router)
-                .with_graceful_shutdown(async move { shutdown.wait().await })
-                .await
+            if let Err(error) = cyper_axum::serve(
+                listener,
+                router.into_make_service_with_connect_info::<ClientAddr>(),
+            )
+            .with_graceful_shutdown(async move { shutdown.wait().await })
+            .await
             {
                 error!(%error, "server-ng HTTP listener terminated with 
error");
             }
@@ -202,6 +207,23 @@ pub async fn start(
     Ok(())
 }
 
+/// Connect-info payload carrying the peer socket address of an HTTP client.
+///
+/// The plain listener records it through axum's connect-info make-service
+/// (the [`Connected`] impl below); the TLS path cannot (its hand-rolled serve
+/// loop bypasses the make-service), so `tls::serve` injects the identical
+/// `ConnectInfo<ClientAddr>` extension per connection instead. Handlers read
+/// it through the `Identity` extractor's `client_ip`, which picks the
+/// advertised address a client is told about - never authorization.
+#[derive(Debug, Clone, Copy)]
+pub struct ClientAddr(pub SocketAddr);
+
+impl Connected<cyper_axum::IncomingStream<'_, TcpListener>> for ClientAddr {
+    fn connect_info(stream: cyper_axum::IncomingStream<'_, TcpListener>) -> 
Self {
+        Self(*stream.remote_addr())
+    }
+}
+
 /// Health-probe path. Public and pre-auth, and the one success route reached
 /// without proving a credential, so the `Iggy-View` layer withholds the
 /// cluster-internal view number here (see the response layer below).
diff --git a/core/server-ng/src/http/error.rs b/core/server-ng/src/http/error.rs
index 28f5f56c2..33e14a6ec 100644
--- a/core/server-ng/src/http/error.rs
+++ b/core/server-ng/src/http/error.rs
@@ -25,7 +25,7 @@ use axum::Json;
 use axum::http::header::{LOCATION, RETRY_AFTER};
 use axum::http::{HeaderValue, StatusCode};
 use axum::response::{IntoResponse, Response};
-use configs::ng_cluster::{AdvertisedAddress, ClusterNodeConfig};
+use configs::ng_cluster::ResolvedClusterNode;
 use iggy_binary_protocol::Operation;
 use iggy_common::IggyError;
 use serde::{Deserialize, Serialize};
@@ -525,17 +525,20 @@ fn primary_redirect_response(location: &str) -> Response {
 /// Build the `Location` for a 307 redirect of a linearizable read to the VSR
 /// primary: `<scheme>://<host>:<http-port><path_and_query>`. The scheme is the
 /// redirecting node's own listener scheme (uniform cluster HTTP config, same
-/// assumption the forward hop makes). `None` when the primary does not resolve
-/// from the roster, so the caller fails closed to a 503 rather than pointing 
at
-/// an unreachable target. Pure (no consensus or axum dependency) so the
-/// redirect target is unit-tested in isolation.
+/// assumption the forward hop makes). `client_ip` is the redirected client's
+/// peer address, so the `Location` host comes from the primary's
+/// per-client-network selectors when one matches. `None` when the primary does
+/// not resolve from the roster, so the caller fails closed to a 503 rather
+/// than pointing at an unreachable target. Pure (no consensus or axum
+/// dependency) so the redirect target is unit-tested in isolation.
 pub(in crate::http) fn primary_redirect_location(
     roster: &ClusterRoster,
     primary_index: u8,
     scheme: &str,
     path_and_query: &str,
+    client_ip: Option<IpAddr>,
 ) -> Option<String> {
-    let authority = primary_advertised_http_authority(roster, primary_index)?;
+    let authority = primary_advertised_http_authority(roster, primary_index, 
client_ip)?;
     Some(format!("{scheme}://{authority}{path_and_query}"))
 }
 
@@ -547,29 +550,33 @@ pub(in crate::http) fn primary_http_socket(
     primary_index: u8,
 ) -> Option<SocketAddr> {
     let (node, http_port) = primary_node(roster, primary_index)?;
-    let ip = node.ip.parse::<IpAddr>().ok()?;
+    let ip = node.config.ip.parse::<IpAddr>().ok()?;
     Some(SocketAddr::new(ip, http_port))
 }
 
-/// Resolve the client-facing HTTP authority (`host:port`) for a redirect. The
-/// advertised address is preferred, with the private roster IP retained as
-/// the compatibility fallback. [`AdvertisedAddress::authority`] brackets IPv6
-/// hosts and passes hostnames through, so the redirect URL stays valid; a
-/// host that is neither a valid IP nor a valid hostname yields `None` so
-/// callers fail closed.
-fn primary_advertised_http_authority(roster: &ClusterRoster, primary_index: 
u8) -> Option<String> {
+/// Resolve the client-facing HTTP authority (`host:port`) for a redirect
+/// through [`ResolvedClusterNode::advertised_for`]: a client-network selector
+/// match first, then the catch-all advertised address, then the private
+/// roster IP as the compatibility fallback. `AdvertisedAddress::authority`
+/// brackets IPv6 hosts and passes hostnames through, so the redirect URL
+/// stays valid; a host that is neither a valid IP nor a valid hostname yields
+/// `None` so callers fail closed.
+fn primary_advertised_http_authority(
+    roster: &ClusterRoster,
+    primary_index: u8,
+    client_ip: Option<IpAddr>,
+) -> Option<String> {
     let (node, http_port) = primary_node(roster, primary_index)?;
-    let host = node.advertised_address.as_deref().unwrap_or(&node.ip);
-    let address = host.parse::<AdvertisedAddress>().ok()?;
+    let address = node.advertised_for(client_ip)?;
     Some(address.authority(http_port))
 }
 
-fn primary_node(roster: &ClusterRoster, primary_index: u8) -> 
Option<(&ClusterNodeConfig, u16)> {
+fn primary_node(roster: &ClusterRoster, primary_index: u8) -> 
Option<(&ResolvedClusterNode, u16)> {
     let node = roster
         .nodes
         .iter()
-        .find(|node| node.replica_id == primary_index)?;
-    let http_port = node.ports.http?;
+        .find(|node| node.config.replica_id == primary_index)?;
+    let http_port = node.config.ports.http?;
     Some((node, http_port))
 }
 
@@ -577,7 +584,7 @@ fn primary_node(roster: &ClusterRoster, primary_index: u8) 
-> Option<(&ClusterNo
 mod tests {
     use super::*;
 
-    use configs::ng_cluster::TransportPorts;
+    use configs::ng_cluster::{ClusterNodeConfig, TransportPorts};
 
     const READ_PATH: &str = "/streams?consistency=linearizable";
     fn node(replica_id: u8, ip: &str, http: Option<u16>) -> ClusterNodeConfig {
@@ -585,6 +592,7 @@ mod tests {
             name: format!("node-{replica_id}"),
             ip: ip.to_owned(),
             advertised_address: None,
+            advertised_addresses: Vec::new(),
             replica_id,
             ports: TransportPorts {
                 tcp: None,
@@ -600,7 +608,7 @@ mod tests {
         ClusterRoster {
             enabled: true,
             name: "test-cluster".to_owned(),
-            nodes,
+            nodes: nodes.into_iter().map(Into::into).collect(),
             self_ip: "127.0.0.1".to_owned(),
             self_ports: TransportPorts::default(),
             metadata_view: 
std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
@@ -616,7 +624,7 @@ mod tests {
             node(1, "10.0.0.2", Some(8090)),
         ]);
         assert_eq!(
-            primary_redirect_location(&roster, 1, "http", READ_PATH),
+            primary_redirect_location(&roster, 1, "http", READ_PATH, None),
             
Some("http://10.0.0.2:8090/streams?consistency=linearizable".to_owned())
         );
     }
@@ -625,7 +633,7 @@ mod tests {
     fn primary_redirect_location_uses_the_listener_scheme() {
         let roster = roster(vec![node(0, "10.0.0.1", Some(8080))]);
         assert_eq!(
-            primary_redirect_location(&roster, 0, "https", READ_PATH),
+            primary_redirect_location(&roster, 0, "https", READ_PATH, None),
             
Some("https://10.0.0.1:8080/streams?consistency=linearizable".to_owned())
         );
     }
@@ -634,7 +642,7 @@ mod tests {
     fn primary_redirect_location_is_none_when_no_node_matches_primary_index() {
         let roster = roster(vec![node(0, "10.0.0.1", Some(8080))]);
         assert_eq!(
-            primary_redirect_location(&roster, 2, "http", READ_PATH),
+            primary_redirect_location(&roster, 2, "http", READ_PATH, None),
             None
         );
     }
@@ -643,7 +651,7 @@ mod tests {
     fn primary_redirect_location_is_none_when_primary_has_no_http_port() {
         let roster = roster(vec![node(0, "10.0.0.1", None)]);
         assert_eq!(
-            primary_redirect_location(&roster, 0, "http", READ_PATH),
+            primary_redirect_location(&roster, 0, "http", READ_PATH, None),
             None
         );
     }
@@ -652,7 +660,7 @@ mod tests {
     fn primary_redirect_location_is_none_for_empty_roster() {
         let roster = roster(Vec::new());
         assert_eq!(
-            primary_redirect_location(&roster, 0, "http", READ_PATH),
+            primary_redirect_location(&roster, 0, "http", READ_PATH, None),
             None
         );
     }
@@ -661,7 +669,7 @@ mod tests {
     fn primary_redirect_location_brackets_ipv6_host() {
         let roster = roster(vec![node(0, "::1", Some(8080))]);
         assert_eq!(
-            primary_redirect_location(&roster, 0, "http", READ_PATH),
+            primary_redirect_location(&roster, 0, "http", READ_PATH, None),
             
Some("http://[::1]:8080/streams?consistency=linearizable".to_owned())
         );
     }
@@ -673,7 +681,7 @@ mod tests {
         let roster = roster(vec![primary]);
 
         assert_eq!(
-            primary_redirect_location(&roster, 0, "https", READ_PATH),
+            primary_redirect_location(&roster, 0, "https", READ_PATH, None),
             
Some("https://[2001:db8::1]:8080/streams?consistency=linearizable".to_owned())
         );
     }
@@ -685,11 +693,45 @@ mod tests {
         let roster = roster(vec![primary]);
 
         assert_eq!(
-            primary_redirect_location(&roster, 0, "https", READ_PATH),
+            primary_redirect_location(&roster, 0, "https", READ_PATH, None),
             
Some("https://broker-1.example.com:8080/streams?consistency=linearizable".to_owned())
         );
     }
 
+    #[test]
+    fn 
primary_redirect_location_uses_the_selector_address_for_a_matching_client() {
+        let mut primary = node(0, "10.0.0.1", Some(8080));
+        primary.advertised_address = Some("203.0.113.1".to_owned());
+        primary.advertised_addresses = 
vec![configs::ng_cluster::AdvertisedAddressSelector {
+            client_cidr: "10.0.0.0/16".to_owned(),
+            address: "10.0.0.1".to_owned(),
+        }];
+        let roster = roster(vec![primary]);
+
+        assert_eq!(
+            primary_redirect_location(
+                &roster,
+                0,
+                "https",
+                READ_PATH,
+                Some("10.0.9.9".parse().unwrap())
+            ),
+            
Some("https://10.0.0.1:8080/streams?consistency=linearizable".to_owned()),
+            "an in-network client must be redirected to the selector address"
+        );
+        assert_eq!(
+            primary_redirect_location(
+                &roster,
+                0,
+                "https",
+                READ_PATH,
+                Some("198.51.100.7".parse().unwrap())
+            ),
+            
Some("https://203.0.113.1:8080/streams?consistency=linearizable".to_owned()),
+            "an out-of-network client must stay on the catch-all address"
+        );
+    }
+
     #[test]
     fn primary_http_socket_uses_private_roster_ip() {
         let mut primary = node(0, "10.0.0.1", Some(8080));
diff --git a/core/server-ng/src/http/extractor.rs 
b/core/server-ng/src/http/extractor.rs
index eb52a8321..c98983ce2 100644
--- a/core/server-ng/src/http/extractor.rs
+++ b/core/server-ng/src/http/extractor.rs
@@ -17,9 +17,10 @@
 
 //! Bearer-credential extractor for protected shard-0 HTTP routes.
 
+use std::net::IpAddr;
 use std::rc::Rc;
 
-use axum::extract::FromRequestParts;
+use axum::extract::{ConnectInfo, FromRequestParts};
 use axum::http::HeaderMap;
 use axum::http::header::AUTHORIZATION;
 use axum::http::request::Parts;
@@ -30,6 +31,7 @@ use super::HttpState;
 use super::error::AuthError;
 use super::session::HttpSession;
 use crate::auth::verify_pat_credentials_with_expiry;
+use crate::http::ClientAddr;
 
 /// Bearer scheme prefix in the `Authorization` header.
 const BEARER: &str = "Bearer ";
@@ -96,6 +98,12 @@ pub struct Identity {
     /// `Location` for its 307 redirect to the primary. Empty only when the URI
     /// carries neither, which a routed read never is.
     pub path_and_query: String,
+    /// Transport-level peer IP, from the [`ClientAddr`] connect info both
+    /// serve paths install. Used only to pick the advertised address a client
+    /// is told about (cluster metadata, the 307 redirect `Location`) - never
+    /// for authorization. `None` (defensive; the extension is always present
+    /// today) degrades to the catch-all advertised address.
+    pub client_ip: Option<IpAddr>,
 }
 
 impl FromRequestParts<HttpState> for Identity {
@@ -121,9 +129,14 @@ impl FromRequestParts<HttpState> for Identity {
             .path_and_query()
             .map(|value| value.as_str().to_owned())
             .unwrap_or_default();
+        let client_ip = parts
+            .extensions
+            .get::<ConnectInfo<ClientAddr>>()
+            .map(|ConnectInfo(address)| address.0.ip());
         Ok(Self {
             user_id,
             path_and_query,
+            client_ip,
         })
     }
 }
diff --git a/core/server-ng/src/http/handlers.rs 
b/core/server-ng/src/http/handlers.rs
index f97f3a53b..c2a90eab9 100644
--- a/core/server-ng/src/http/handlers.rs
+++ b/core/server-ng/src/http/handlers.rs
@@ -599,12 +599,13 @@ pub(in crate::http) async fn get_snapshot(
 /// the per-op authorization gate and the consistency gate, and serves from the
 /// roster captured at listener start plus the sync consensus getters, so it
 /// never touches the metadata STM, consensus, or a VSR session and stays fully
-/// synchronous.
+/// synchronous. The caller's peer IP picks each node's advertised address from
+/// its per-client-network selectors.
 pub(in crate::http) async fn get_cluster_metadata(
     State(state): State<HttpState>,
-    _identity: Identity,
+    identity: Identity,
 ) -> Json<ClusterMetadata> {
-    Json(state.build_cluster_metadata())
+    Json(state.build_cluster_metadata(identity.client_ip))
 }
 
 /// `GET /clients`: list every connected client across all shards as the same
diff --git a/core/server-ng/src/http/reads.rs b/core/server-ng/src/http/reads.rs
index e84cb9944..9246ad610 100644
--- a/core/server-ng/src/http/reads.rs
+++ b/core/server-ng/src/http/reads.rs
@@ -65,7 +65,7 @@ pub(in crate::http) fn authorize_read(
         .authorize(|permissioner| rule(permissioner, identity.user_id))
         .map_err(ReadError::Rejected)?;
     if consistency == Consistency::Linearizable && 
!state.is_metadata_primary() {
-        return Err(state.not_primary_read_error(&identity.path_and_query));
+        return Err(state.not_primary_read_error(&identity.path_and_query, 
identity.client_ip));
     }
     Ok(())
 }
@@ -98,6 +98,7 @@ pub(in crate::http) async fn read_local(
         body,
         Some(identity.user_id),
         &state.roster,
+        identity.client_ip,
     )
     .map_err(ReadError::Rejected)?
     {
diff --git a/core/server-ng/src/http/state.rs b/core/server-ng/src/http/state.rs
index 3971f9aeb..7efb8bcc4 100644
--- a/core/server-ng/src/http/state.rs
+++ b/core/server-ng/src/http/state.rs
@@ -21,6 +21,7 @@
 
 use std::cell::{Cell, RefCell};
 use std::collections::HashMap;
+use std::net::IpAddr;
 use std::rc::Rc;
 use std::sync::Arc;
 
@@ -124,8 +125,15 @@ impl HttpInner {
     /// current VSR primary's HTTP address when it resolves from the roster, 
else
     /// fail closed to the 503. The target is the roster node whose 
`replica_id`
     /// equals `primary_index(view)`; an absent consensus, an unmatched id, or 
a
-    /// port-less node all fall back to [`ReadError::NotPrimary`].
-    pub(in crate::http) fn not_primary_read_error(&self, path_and_query: &str) 
-> ReadError {
+    /// port-less node all fall back to [`ReadError::NotPrimary`]. `client_ip`
+    /// picks the primary's advertised address from its per-client-network
+    /// selectors, so the redirected client lands on the address for its own
+    /// network.
+    pub(in crate::http) fn not_primary_read_error(
+        &self,
+        path_and_query: &str,
+        client_ip: Option<IpAddr>,
+    ) -> ReadError {
         let location = self
             .shard
             .plane
@@ -139,6 +147,7 @@ impl HttpInner {
                     primary_index,
                     self.forward.scheme,
                     path_and_query,
+                    client_ip,
                 )
             });
         location.map_or(ReadError::NotPrimary, ReadError::RedirectToPrimary)
@@ -397,7 +406,12 @@ impl HttpInner {
     /// shared [`ClusterRoster`] assembly. The leader marking comes from this
     /// shard's consensus view; the HTTP listener is shard-0-only, so 
consensus is
     /// always present and every roster read carries real leader/follower 
roles.
-    pub(in crate::http) fn build_cluster_metadata(&self) -> ClusterMetadata {
+    /// `client_ip` picks each node's advertised address from its
+    /// per-client-network selectors.
+    pub(in crate::http) fn build_cluster_metadata(
+        &self,
+        client_ip: Option<IpAddr>,
+    ) -> ClusterMetadata {
         let primary_index = self
             .shard
             .plane
@@ -405,7 +419,7 @@ impl HttpInner {
             .consensus
             .as_ref()
             .map(|consensus| consensus.primary_index(consensus.view()));
-        self.roster.cluster_metadata(primary_index)
+        self.roster.cluster_metadata(primary_index, client_ip)
     }
 }
 
diff --git a/core/server-ng/src/http/tls.rs b/core/server-ng/src/http/tls.rs
index 830e79d22..c987c69dc 100644
--- a/core/server-ng/src/http/tls.rs
+++ b/core/server-ng/src/http/tls.rs
@@ -41,6 +41,7 @@ use std::time::Duration;
 
 use async_channel::{Receiver, Sender};
 use axum::Router;
+use axum::extract::ConnectInfo;
 use compio::net::{TcpListener, TcpStream};
 use compio::runtime::JoinHandle;
 use compio::tls::{TlsAcceptor, TlsStream};
@@ -54,8 +55,10 @@ use message_bus::ShutdownToken;
 use message_bus::transports::tls::{
     TlsServerCredentials, install_default_crypto_provider, load_pem,
 };
+use tower_http::add_extension::AddExtension;
 use tracing::{debug, error};
 
+use crate::http::ClientAddr;
 use crate::server_error::ServerNgError;
 
 /// hyper's auto-builder serves whichever protocol the client selects via
@@ -124,6 +127,9 @@ pub fn spawn_accept_pump(
 /// shutdown the pump drops its sender, this loop ends, and the in-flight
 /// connection tasks drain via their own shutdown clone.
 pub async fn serve(connections: Receiver<Handshaken>, router: Router, 
shutdown: ShutdownToken) {
+    // `with_state(())` finalizes the routes eagerly, once for the whole
+    // listener; per-connection clones share the finalized internals.
+    let router: Router = router.with_state(());
     while let Ok((tls, peer)) = connections.recv().await {
         let router = router.clone();
         let shutdown = shutdown.clone();
@@ -143,8 +149,16 @@ async fn serve_connection(
     let io = HyperStream::new_tls(tls);
     // `Router<()>` already maps the incoming body to axum's `Body` in its own
     // `Service` impl, so it serves hyper's `Request<Incoming>` directly - no
-    // `map_request` shim. `with_state(())` finalizes the routes eagerly.
-    let service = TowerToHyperService::new(router.with_state(()));
+    // `map_request` shim.
+    //
+    // This hand-rolled loop bypasses axum's connect-info make-service (the
+    // plain listener's source of the peer address), so stamp the identical
+    // `ConnectInfo<ClientAddr>` extension on every request of this connection
+    // here - the extractors cannot tell the two paths apart. `AddExtension`
+    // wraps the shared router as one thin per-request insert; `Router::layer`
+    // would rebuild every route's boxed service on each connection.
+    let service =
+        TowerToHyperService::new(AddExtension::new(router, 
ConnectInfo(ClientAddr(peer))));
     let builder = Builder::new(LocalExecutor);
     // `serve_connection_with_upgrades` borrows `builder`, so it must outlive
     // `conn`; keep it bound rather than inlined.
diff --git a/core/server-ng/src/responses.rs b/core/server-ng/src/responses.rs
index 6cc585230..2254b1ce1 100644
--- a/core/server-ng/src/responses.rs
+++ b/core/server-ng/src/responses.rs
@@ -87,6 +87,7 @@ use server_common::send_messages2::{COMMAND_HEADER_SIZE, 
SendMessages2Header};
 use server_common::sharding::IggyNamespace;
 use shard::ConnectedClientInfo;
 use std::cell::RefCell;
+use std::net::IpAddr;
 use std::path::PathBuf;
 use std::rc::Rc;
 use std::sync::{Arc, OnceLock};
@@ -463,13 +464,17 @@ where
 
 /// `user_id` is the authenticated caller, used only by the identity-scoped
 /// reads (currently the PAT list); every other arm ignores it. Authorization
-/// stays with the per-transport gates that run before this builder.
+/// stays with the per-transport gates that run before this builder. 
`client_ip`
+/// is the caller's transport-level peer address, used only by the
+/// cluster-metadata read to pick each node's advertised address; `None`
+/// degrades to the catch-all address.
 pub(crate) fn build_non_replicated_response<B, MJ, S>(
     shard: &Rc<ShellShard<B, MJ, S>>,
     code: u32,
     body: &[u8],
     user_id: Option<u32>,
     roster: &ClusterRoster,
+    client_ip: Option<IpAddr>,
 ) -> Result<NonReplicatedResponse, IggyError>
 where
     B: ShellBus,
@@ -479,7 +484,7 @@ where
 {
     match code {
         GET_CLUSTER_METADATA_CODE => Ok(NonReplicatedResponse::Bytes(
-            build_cluster_metadata_response(roster, shard).to_bytes(),
+            build_cluster_metadata_response(roster, shard, 
client_ip).to_bytes(),
         )),
         GET_STATS_CODE => Ok(NonReplicatedResponse::Bytes(
             build_stats_response(shard)?.to_bytes(),
@@ -597,6 +602,7 @@ where
 fn build_cluster_metadata_response<B, MJ, S>(
     roster: &ClusterRoster,
     shard: &Rc<ShellShard<B, MJ, S>>,
+    client_ip: Option<IpAddr>,
 ) -> ClusterMetadataResponse
 where
     B: ShellBus,
@@ -621,7 +627,7 @@ where
                 .then_some(primary_index)
         })
         .or_else(|| roster.current_primary_index());
-    let metadata = roster.cluster_metadata(primary_index);
+    let metadata = roster.cluster_metadata(primary_index, client_ip);
     ClusterMetadataResponse {
         name: metadata.name,
         nodes: metadata
diff --git a/core/server-ng/src/session_manager.rs 
b/core/server-ng/src/session_manager.rs
index 9033aa98b..b7e63698a 100644
--- a/core/server-ng/src/session_manager.rs
+++ b/core/server-ng/src/session_manager.rs
@@ -291,6 +291,17 @@ impl SessionManager {
         }
     }
 
+    /// The transport-level peer address a connection arrived from, recorded by
+    /// [`Self::ensure_connection`] for every transport. The non-replicated
+    /// read path uses it to pick the advertised address a client is told
+    /// about; `None` (unknown connection) degrades to the catch-all address.
+    #[must_use]
+    pub fn connection_address(&self, connection_id: u128) -> 
Option<SocketAddr> {
+        self.connections
+            .get(&connection_id)
+            .map(|conn| conn.address)
+    }
+
     /// Look up the authenticated user id for a connection.
     #[must_use]
     pub fn get_user_id(&self, connection_id: u128) -> Option<u32> {

Reply via email to