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 e757e16a2bbb9d15e7b9f59c7daf0a9fe889c8f3
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       | 932 ++++++++++++++++++++-
 core/configs/src/server_ng_config/defaults.rs      |   1 +
 core/configs/src/server_ng_config/validators.rs    |   1 +
 core/configs_derive/src/config_env.rs              |  22 +-
 .../tests/server/cluster_metadata_vsr.rs           | 229 ++++-
 core/integration/tests/server/http_client.rs       |  19 +-
 core/integration/tests/server/http_tls.rs          | 112 ++-
 core/server-ng/config.toml                         |  27 +
 core/server-ng/src/bootstrap.rs                    |   9 +-
 core/server-ng/src/cluster_meta.rs                 | 125 ++-
 core/server-ng/src/dispatch.rs                     |  24 +-
 core/server-ng/src/http.rs                         |  37 +-
 core/server-ng/src/http/error.rs                   | 102 ++-
 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                     |  15 +-
 core/server-ng/src/responses.rs                    |  12 +-
 core/server-ng/src/session_manager.rs              |  11 +
 23 files changed, 1600 insertions(+), 128 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..2296def6e 100644
--- a/core/configs/src/server_ng_config/cluster.rs
+++ b/core/configs/src/server_ng_config/cluster.rs
@@ -27,8 +27,10 @@ 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::cmp::Reverse;
 use std::fmt;
 use std::net::{IpAddr, Ipv6Addr, SocketAddr};
 use std::str::FromStr;
@@ -367,6 +369,13 @@ 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)]
+    #[config_env(max_elements = 16)]
+    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 +384,144 @@ 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.
+/// 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 and catch-all parsed
+/// once, built wherever a roster is assembled for serving clients
+/// (listener/shard start). Per-request resolution never re-parses config
+/// strings: everything is snapshotted here, so mutating the source config
+/// after conversion has no effect on what clients are told. Entries that do
+/// not parse are dropped at build time; validation already rejects them
+/// whenever the cluster is enabled, and a disabled cluster never consults
+/// the roster.
+#[derive(Debug, Clone)]
+pub struct ResolvedClusterNode {
+    config: ClusterNodeConfig,
+    /// Truncated, canonicalized selector networks with their parsed
+    /// addresses, in declaration order.
+    selectors: Vec<(IpNet, AdvertisedAddress)>,
+    /// Parsed catch-all: [`ClusterNodeConfig::advertised_address`], else the
+    /// roster [`ClusterNodeConfig::ip`]. `None` when the configured value
+    /// does not parse - a set `advertised_address` never falls through to
+    /// the private roster ip.
+    catch_all: Option<AdvertisedAddress>,
+}
+
+impl From<ClusterNodeConfig> for ResolvedClusterNode {
+    fn from(config: ClusterNodeConfig) -> Self {
+        let selectors = config
+            .advertised_addresses
+            .iter()
+            .filter_map(|selector| {
+                let network = selector.client_cidr.parse::<IpNet>().ok()?;
+                let address = 
selector.address.parse::<AdvertisedAddress>().ok()?;
+                Some((canonical_ip_net(network.trunc()), address))
+            })
+            .collect();
+        let catch_all = match config.advertised_address.as_deref() {
+            Some(advertised_address) => advertised_address.parse().ok(),
+            None => config.ip.parse().ok(),
+        };
+        Self {
+            config,
+            selectors,
+            catch_all,
+        }
+    }
+}
+
+impl ResolvedClusterNode {
+    /// The roster entry this node was built from. Read-only: resolution runs
+    /// on the boot-parsed snapshot, never on the config strings.
+    #[must_use]
+    pub fn config(&self) -> &ClusterNodeConfig {
+        &self.config
+    }
+
+    /// The client-facing address for a client connecting from `client_ip`:
+    /// longest-prefix match over the selector networks, then the parsed
+    /// catch-all. `None` when no selector matches and the catch-all did not
+    /// parse; callers choose whether to fail closed (redirect URLs) or to
+    /// publish [`Self::raw_advertised_fallback`] verbatim (cluster metadata).
+    #[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(self.catch_all.as_ref())
+    }
+
+    /// The catch-all ladder ([`ClusterNodeConfig::advertised_address`], else
+    /// the roster [`ClusterNodeConfig::ip`]) as configured, unparsed. Cluster
+    /// metadata publishes this verbatim when [`Self::advertised_for`] finds
+    /// nothing: the roster `ip` is only validated non-empty, and Docker
+    /// service names with underscores exist in the wild.
+    #[must_use]
+    pub fn raw_advertised_fallback(&self) -> &str {
+        self.config
+            .advertised_address
+            .as_deref()
+            .unwrap_or(&self.config.ip)
+    }
+
+    /// Longest-prefix match over the boot-parsed selector networks. 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. `min_by_key` keeps the first of equal-length matches, so
+    /// resolution stays declaration-order deterministic even though a
+    /// validated config cannot produce two matching networks of equal length
+    /// (equal-length distinct networks are disjoint, duplicates are
+    /// rejected).
+    fn selector_address(&self, client_ip: IpAddr) -> 
Option<&AdvertisedAddress> {
+        let client_ip = client_ip.to_canonical();
+        self.selectors
+            .iter()
+            .filter(|(network, _)| network.contains(&client_ip))
+            .min_by_key(|(network, _)| Reverse(network.prefix_len()))
+            .map(|(_, address)| address)
+    }
+}
+
+/// 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 +893,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 +984,104 @@ impl Validatable<ConfigurationError> for ClusterConfig {
                 None => node.ip.parse::<AdvertisedAddress>().ok(),
             };
 
-            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)
-                        }
-                    };
-                    if !inserted {
+            // Selector CIDRs and addresses feed clients the same way the
+            // catch-all advertised address does, so they get the same strict
+            // parse. 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.
+            // Parsed before the catch-all enters the conflict pool because
+            // every entry's effective client set depends on the node's full
+            // selector list.
+            let mut selectors = 
Vec::with_capacity(node.advertised_addresses.len());
+            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);
+                    }
+                };
+                selectors.push((client_cidr, address));
+            }
+            let selector_ranges: Vec<ClientAddressRange> = selectors
+                .iter()
+                .map(|(network, _)| ClientAddressRange::from(network))
+                .collect();
+
+            // Endpoint conflicts are checked across every node's selectors
+            // and catch-all on effective client sets - the clients an entry
+            // actually wins after this node's longest-prefix match. Two
+            // nodes may reuse one host:port as long as the winning sets stay
+            // disjoint (that is the feature, and it includes a per-subnet
+            // override shadowing the same node's wider selector); a conflict
+            // means some client wins both entries and would resolve both
+            // nodes to one endpoint. The catch-all is an implicit
+            // match-everything-else selector, so it pools the same way. A
+            // roster ip that fails the strict parse skips the pool: it can
+            // never equal a parsed host, and two raw ips sharing host:port
+            // are already rejected by the bind-endpoint check above.
+            if let Some(address) = &client_address {
+                let catch_all_clients = 
EffectiveClients::for_catch_all(&selector_ranges);
+                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: None,
+                                clients: catch_all_clients.clone(),
+                                host: address.clone(),
+                                port: *port,
+                            },
+                        )?;
+                    }
+                }
+            }
+
+            for (selector_index, (client_cidr, address)) in 
selectors.iter().enumerate() {
+                let sibling_ranges: Vec<ClientAddressRange> = selector_ranges
+                    .iter()
+                    .enumerate()
+                    .filter(|(other_index, _)| *other_index != selector_index)
+                    .map(|(_, range)| *range)
+                    .collect();
+                let clients = EffectiveClients::for_selector(client_cidr, 
&sibling_ranges);
+                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),
+                                clients: clients.clone(),
+                                host: address.clone(),
+                                port: *port,
+                            },
+                        )?;
+                    }
                 }
             }
         }
@@ -933,6 +1157,204 @@ impl Validatable<ConfigurationError> for ClusterConfig {
     }
 }
 
+/// One advertised client endpoint and the clients it wins, pooled by
+/// [`ClusterConfig::validate`] so selectors and catch-all conflict-check
+/// against each other. `network: None` is the catch-all (`advertised_address`,
+/// or the roster `ip` as fallback); `clients` is the entry's effective set
+/// after its node's longest-prefix shadowing.
+struct AdvertisedEndpoint<'roster> {
+    node_name: &'roster str,
+    transport: &'static str,
+    network: Option<IpNet>,
+    clients: EffectiveClients,
+    host: AdvertisedAddress,
+    port: u16,
+}
+
+impl AdvertisedEndpoint<'_> {
+    /// True when some client would resolve both entries to one host:port on
+    /// two different nodes. Effective client sets already encode each node's
+    /// longest-prefix shadowing, so nested networks conflict only where the
+    /// wider entry still wins some client that the other node's entry also
+    /// wins. Entries of one node never conflict: their effective sets are
+    /// disjoint by construction.
+    fn conflicts_with(&self, other: &Self) -> bool {
+        self.node_name != other.node_name
+            && self.port == other.port
+            && self.host == other.host
+            && self.clients.overlaps(&other.clients)
+    }
+
+    fn authority(&self) -> String {
+        self.host.authority(self.port)
+    }
+
+    fn network_description(&self) -> String {
+        match self.network {
+            Some(network) => format!("client_cidr {network}"),
+            None => "every client network (catch-all)".to_owned(),
+        }
+    }
+}
+
+/// The clients an advertised entry actually wins under its node's
+/// longest-prefix match, built by [`ClusterConfig::validate`] for the
+/// cross-node conflict scan.
+#[derive(Clone)]
+struct EffectiveClients {
+    /// Sorted disjoint ranges of winning client addresses.
+    ranges: Vec<ClientAddressRange>,
+    /// The catch-all also wins clients whose peer address the transport
+    /// could not produce ([`ResolvedClusterNode::advertised_for`] with no
+    /// client IP), so two catch-alls overlap even when selectors cover both
+    /// address families.
+    serves_unknown_peers: bool,
+}
+
+impl EffectiveClients {
+    /// A selector wins its network minus the sibling networks nested inside
+    /// it (longer prefixes take the node's LPM). `sibling_ranges` must
+    /// exclude the selector's own network.
+    fn for_selector(network: &IpNet, sibling_ranges: &[ClientAddressRange]) -> 
Self {
+        Self {
+            ranges: 
ClientAddressRange::from(network).subtract_nested(sibling_ranges),
+            serves_unknown_peers: false,
+        }
+    }
+
+    /// The catch-all wins every client no selector matches, in both address
+    /// families, plus unknown-peer clients.
+    fn for_catch_all(selector_ranges: &[ClientAddressRange]) -> Self {
+        let mut ranges = 
ClientAddressRange::FULL_IPV4.subtract_nested(selector_ranges);
+        
ranges.extend(ClientAddressRange::FULL_IPV6.subtract_nested(selector_ranges));
+        Self {
+            ranges,
+            serves_unknown_peers: true,
+        }
+    }
+
+    fn overlaps(&self, other: &Self) -> bool {
+        if self.serves_unknown_peers && other.serves_unknown_peers {
+            return true;
+        }
+        self.ranges.iter().any(|range| {
+            other.ranges.iter().any(|other_range| {
+                range.is_ipv4 == other_range.is_ipv4
+                    && range.first <= other_range.last
+                    && other_range.first <= range.last
+            })
+        })
+    }
+}
+
+/// Inclusive range of client addresses within one family. Client IPs
+/// canonicalize to v4 before matching, so v4 and v6 networks match disjoint
+/// client populations and a range never spans families.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+struct ClientAddressRange {
+    is_ipv4: bool,
+    first: u128,
+    last: u128,
+}
+
+impl ClientAddressRange {
+    const FULL_IPV4: Self = Self {
+        is_ipv4: true,
+        first: 0,
+        last: u32::MAX as u128,
+    };
+    const FULL_IPV6: Self = Self {
+        is_ipv4: false,
+        first: 0,
+        last: u128::MAX,
+    };
+
+    /// `self` minus every range nested inside it, as sorted disjoint
+    /// leftovers. CIDR networks are nested or disjoint, never partially
+    /// overlapping, so a range outside `self` is either disjoint from it
+    /// (subtracts nothing) or contains it (a shorter prefix, which loses
+    /// LPM and also subtracts nothing).
+    fn subtract_nested(self, ranges: &[Self]) -> Vec<Self> {
+        let mut nested: Vec<Self> = ranges
+            .iter()
+            .filter(|range| {
+                range.is_ipv4 == self.is_ipv4
+                    && range.first >= self.first
+                    && range.last <= self.last
+            })
+            .copied()
+            .collect();
+        nested.sort_unstable();
+        let mut remaining = Vec::new();
+        let mut cursor = Some(self.first);
+        for nested_range in nested {
+            let Some(next_free) = cursor else { break };
+            if nested_range.first > next_free {
+                remaining.push(Self {
+                    is_ipv4: self.is_ipv4,
+                    first: next_free,
+                    last: nested_range.first - 1,
+                });
+            }
+            cursor = nested_range
+                .last
+                .checked_add(1)
+                .map(|after| after.max(next_free));
+        }
+        if let Some(next_free) = cursor
+            && next_free <= self.last
+        {
+            remaining.push(Self {
+                is_ipv4: self.is_ipv4,
+                first: next_free,
+                last: self.last,
+            });
+        }
+        remaining
+    }
+}
+
+impl From<&IpNet> for ClientAddressRange {
+    fn from(network: &IpNet) -> Self {
+        match network {
+            IpNet::V4(network) => Self {
+                is_ipv4: true,
+                first: u128::from(u32::from(network.network())),
+                last: u128::from(u32::from(network.broadcast())),
+            },
+            IpNet::V6(network) => Self {
+                is_ipv4: false,
+                first: u128::from(network.network()),
+                last: u128::from(network.broadcast()),
+            },
+        }
+    }
+}
+
+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 {}); their effective client sets overlap after longest-prefix 
shadowing, 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::*;
@@ -996,6 +1418,26 @@ mod tests {
             "unexpected deserialization error: {error}"
         );
     }
+
+    #[test]
+    fn advertised_addresses_env_expansion_is_capped() {
+        // The selectors Vec nests inside the nodes Vec, so the derive's
+        // index ceilings multiply; without the field's max_elements cap the
+        // default of 256x256 adds ~131k leaked mappings to every boot.
+        let mappings = <ClusterConfig as 
configs::ConfigEnvMappings>::env_mappings();
+        assert!(
+            mappings
+                .iter()
+                .any(|mapping| 
mapping.env_name.contains("ADVERTISED_ADDRESSES_15_")),
+            "selector index 15 must stay reachable by env override"
+        );
+        assert!(
+            !mappings
+                .iter()
+                .any(|mapping| 
mapping.env_name.contains("ADVERTISED_ADDRESSES_16_")),
+            "selector env expansion must stop at max_elements = 16"
+        );
+    }
 }
 
 #[cfg(test)]
@@ -1075,6 +1517,167 @@ 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 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 equal_prefix_matches_resolve_deterministically_to_first_declared() {
+        // No validated config reaches this state: these networks truncate to
+        // one /16, which validation rejects as a duplicate. Pinned anyway so
+        // a future relaxation of that rule cannot make resolution
+        // order-dependent.
+        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()
+            ))
+        );
+    }
+}
+
 #[cfg(test)]
 mod cluster_validate_tests {
     use super::*;
@@ -1084,11 +1687,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 +2117,291 @@ 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: n1 has no longer prefix of
+        // its own shadowing the /16 overlap, so a 10.0.0.0/16 client wins
+        // n1's /8 and n2's /16, resolving both 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/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_nested_cidr_reuse_shadowed_by_same_node_longer_prefix() {
+        // n1's /16 selector shadows its /8 within 10.0.0.0/16, so n1's /8
+        // entry wins only 10.0.0.0/8 minus 10.0.0.0/16 - disjoint from n2's
+        // /16. No client resolves 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/8", "192.0.2.10"),
+            selector("10.0.0.0/16", "192.0.2.20"),
+        ];
+        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_ok());
+    }
+
+    #[test]
+    fn validate_rejects_partially_shadowed_nested_cidr_reuse() {
+        // n1's /24 shadow carves only part of the /16 overlap: a client in
+        // 10.0.0.0/16 outside 10.0.0.0/24 still wins n1's /8 entry and n2's
+        // /16 entry, resolving 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/8", "192.0.2.10"),
+            selector("10.0.0.0/24", "192.0.2.20"),
+        ];
+        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_catch_all_reuse_shadowed_by_same_node_selector() {
+        // n1's /16 selector shadows its catch-all within 10.0.0.0/16, so
+        // the catch-all never wins a client inside n2's /24. Without the
+        // shadow the same pair conflicts (see
+        // 
validate_rejects_selector_endpoint_clashing_with_another_nodes_catch_all).
+        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.20")];
+        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/24", "192.0.2.10")];
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_ok());
+    }
+
+    #[test]
+    fn validate_accepts_selector_reusing_a_fully_shadowed_catch_all_address() {
+        // n1's selectors cover both address families, so its catch-all wins
+        // known peers nowhere; only unknown-peer clients reach it, and they
+        // never match n2's selector.
+        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("0.0.0.0/0", "192.0.2.20"),
+            selector("::/0", "192.0.2.30"),
+        ];
+        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_ok());
+    }
+
+    #[test]
+    fn 
validate_accepts_catch_all_spelling_another_nodes_selector_address_when_self_shadowed()
 {
+        // Split-network NAT roster: n2's catch-all spells n1's 10/8 selector
+        // address, but n2's own 10/8 selector shadows its catch-all inside
+        // 10/8 (outside it n1 serves its own catch-all), so no client
+        // resolves 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/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_address = Some("192.0.2.10".to_owned());
+        n2.advertised_addresses = vec![selector("10.0.0.0/8", "192.0.2.20")];
+        n2.ports.tcp = Some(8090);
+
+        assert!(cfg(vec![n1, n2]).validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_duplicate_catch_all_even_when_fully_shadowed() {
+        // A client whose peer address the transport cannot produce always
+        // falls to the catch-all, so duplicate catch-alls conflict even when
+        // selectors cover every known network.
+        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("0.0.0.0/0", "192.0.2.20"),
+            selector("::/0", "192.0.2.30"),
+        ];
+        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.advertised_addresses = vec![
+            selector("0.0.0.0/0", "192.0.2.40"),
+            selector("::/0", "192.0.2.50"),
+        ];
+        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/configs_derive/src/config_env.rs 
b/core/configs_derive/src/config_env.rs
index 33f15e9f6..69dbc0c85 100644
--- a/core/configs_derive/src/config_env.rs
+++ b/core/configs_derive/src/config_env.rs
@@ -20,16 +20,18 @@ use proc_macro2::TokenStream as TokenStream2;
 use quote::{format_ident, quote};
 use syn::{DeriveInput, Generics, Ident, Type};
 
-/// Maximum number of array elements to generate env var mappings for.
+/// Default number of array elements to generate env var mappings for.
 ///
 /// For `Vec<T>` fields, mappings are generated for indices 0 through 255
 /// (e.g., `FIELD_0_NAME`, ..., `FIELD_255_NAME`). Environment variables for
 /// indices beyond this limit are silently ignored.
 ///
-/// The ceiling mirrors `cluster.nodes` (the only known Vec<nested>
-/// configuration surface), which the validator allows up to 255 replicas.
-/// Keeping the two in sync means every node index reachable by the validator
-/// is also reachable by env overrides.
+/// The ceiling mirrors `cluster.nodes`, which the validator allows up to 255
+/// replicas. Keeping the two in sync means every node index reachable by the
+/// validator is also reachable by env overrides. Vecs nested inside another
+/// Vec multiply their ceilings (256 x 256 entries, each `Box::leak`ed at
+/// first use), so nested-vec fields like `cluster.nodes.advertised_addresses`
+/// must lower theirs with `#[config_env(max_elements = N)]`.
 const MAX_ARRAY_ELEMENTS: usize = 256;
 
 /// Container-level attributes for `#[config_env(...)]`
@@ -88,6 +90,11 @@ struct FieldOpts {
     /// Treat this field as a leaf value (not a nested config struct)
     #[darling(default)]
     leaf: bool,
+
+    /// Cap the array-index expansion of a `Vec` field below
+    /// [`MAX_ARRAY_ELEMENTS`]. Ignored on non-Vec fields.
+    #[darling(default)]
+    max_elements: Option<usize>,
 }
 
 /// Represents a single environment variable mapping (leaf field)
@@ -111,6 +118,8 @@ struct NestedFieldInfo {
     field_env_segment: String,
     /// The field name for config path construction
     field_name: String,
+    /// Array-index expansion ceiling for Vec fields
+    max_elements: usize,
 }
 
 pub fn generate_impl(input: &DeriveInput) -> TokenStream2 {
@@ -261,7 +270,7 @@ fn generate_struct_impl(
 
             if info.is_vec {
                 // For Vec fields, expand with array indices
-                let max_elements = MAX_ARRAY_ELEMENTS;
+                let max_elements = info.max_elements;
                 quote! {
                     let nested_mappings = <#ty as 
configs::ConfigEnvMappings>::env_mappings();
                     for i in 0..#max_elements {
@@ -533,6 +542,7 @@ fn collect_mappings(fields: &[FieldOpts]) -> 
(Vec<EnvMapping>, Vec<NestedFieldIn
                 is_vec,
                 field_env_segment,
                 field_name,
+                max_elements: field.max_elements.unwrap_or(MAX_ARRAY_ELEMENTS),
             });
         }
     }
diff --git a/core/integration/tests/server/cluster_metadata_vsr.rs 
b/core/integration/tests/server/cluster_metadata_vsr.rs
index f15ad4416..2011f6f1d 100644
--- a/core/integration/tests/server/cluster_metadata_vsr.rs
+++ b/core/integration/tests/server/cluster_metadata_vsr.rs
@@ -17,10 +17,29 @@
 
 //! 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` - never the decoy selector on a
+//! foreign network. Every selector here advertises `localhost`: it is the
+//! one hostname the leader-aware SDK client's address comparison rewrites to
+//! `127.0.0.1`, so any other value makes that client redial the advertised
+//! host on every fresh connect - including the harness readiness probe,
+//! which would wedge startup on an unresolvable name. A non-`localhost`
+//! hostname selector therefore cannot run end to end until the SDK-side
+//! comparison is fixed; that flow is pinned at unit level instead
+//! (`cluster_meta.rs`, `cluster.rs`).
+
+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 +93,211 @@ 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. Note `localhost` is also the one
+/// hostname that client's `is_same_address` check rewrites to `127.0.0.1`,
+/// so binary-transport tests must use it; the raw-hostname flow is pinned by
+/// the HTTP-only [`HOSTNAME_SELECTOR_ADDRESS`] test instead.
+const SELECTOR_ADDRESS: &str = "localhost";
+
+/// Decoy selector on a network no test client connects from: its address in
+/// metadata would mean CIDR matching is broken (matching everything, or
+/// matching the server's own bind address instead of the peer), not that
+/// selector precedence works.
+const DECOY_CIDR: &str = "10.0.0.0/8";
+const DECOY_ADDRESS: &str = "203.0.113.99";
+
+/// A 2-node cluster whose roster gives every node a loopback-CIDR selector
+/// plus the decoy selector that must never match. 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 {
+            for (suffix, value) in [
+                ("0_CLIENT_CIDR", LOOPBACK_CIDR),
+                ("0_ADDRESS", SELECTOR_ADDRESS),
+                ("1_CLIENT_CIDR", DECOY_CIDR),
+                ("1_ADDRESS", DECOY_ADDRESS),
+            ] {
+                harness.node_mut(node).add_env(
+                    
format!("IGGY_CLUSTER_NODES_{roster_entry}_ADVERTISED_ADDRESSES_{suffix}"),
+                    value,
+                );
+            }
+        }
+    }
+    harness
+}
+
+/// Assert every roster node reports `expected_address` to this loopback
+/// client; without the selector the same roster publishes `127.0.0.1`, and
+/// broken CIDR matching would surface the decoy instead.
+fn assert_advertised_addresses(
+    nodes: impl IntoIterator<Item = (String, String)>,
+    expected_address: &str,
+) {
+    let mut seen = 0;
+    for (name, ip) in nodes {
+        assert_eq!(
+            ip, expected_address,
+            "node '{name}' must advertise its loopback selector address to a 
loopback client"
+        );
+        seen += 1;
+    }
+    assert_eq!(seen, SELECTOR_CLUSTER_NODES, "full roster must be reported");
+}
+
+/// Extract `(name, ip)` per node from a `/cluster/metadata` HTTP reply.
+async fn http_metadata_nodes(http: &HttpClient) -> Vec<(String, String)> {
+    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");
+    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()
+}
+
+#[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_advertised_addresses(
+        metadata
+            .nodes
+            .iter()
+            .map(|node| (node.name.clone(), node.ip.clone())),
+        SELECTOR_ADDRESS,
+    );
+}
+
+#[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;
+    assert_advertised_addresses(http_metadata_nodes(&http).await, 
SELECTOR_ADDRESS);
+}
+
+/// 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 -
+    // once, before the settle loop: every login commits a Register through
+    // metadata consensus, so logging in per round would race the election the
+    // loop waits out, and login retries would eat the shared settle budget.
+    let mut sessions = Vec::with_capacity(SELECTOR_CLUSTER_NODES);
+    for node in 0..SELECTOR_CLUSTER_NODES {
+        let addr = harness.node(node).http_addr().expect("node http address");
+        
sessions.push(HttpClient::login_root_no_redirect(format!("http://{addr}";)).await);
+    }
+
+    let deadline = Instant::now() + LOGIN_TIMEOUT;
+    let mut verdicts: Vec<(usize, StatusCode, Option<String>)>;
+    loop {
+        verdicts = Vec::new();
+        for (node, session) in sessions.iter().enumerate() {
+            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/integration/tests/server/http_tls.rs 
b/core/integration/tests/server/http_tls.rs
index 80bd58910..f36e4a058 100644
--- a/core/integration/tests/server/http_tls.rs
+++ b/core/integration/tests/server/http_tls.rs
@@ -21,7 +21,11 @@
 //! client over the wire and proves the response was actually served over
 //! HTTP/2, negotiated via ALPN. A failure here (h1 fallback, handshake
 //! rejection, or a plaintext bind) is a real defect in that path, not a test
-//! artifact.
+//! artifact. The `/cluster/metadata` step additionally proves the TLS serve
+//! loop's hand-stamped `ConnectInfo<ClientAddr>` reaches the extractors: the
+//! plain listener gets that extension from axum's connect-info make-service,
+//! the TLS loop stamps it itself, and only a client-IP-dependent response
+//! can tell a missing stamp from a working one.
 
 use std::path::PathBuf;
 use std::time::{Duration, Instant};
@@ -39,6 +43,26 @@ const READY_TIMEOUT: Duration = Duration::from_secs(15);
 const READY_RETRY_INTERVAL: Duration = Duration::from_millis(50);
 const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
 
+/// Selector marker distinct from the roster ip (`127.0.0.1`): it appears in
+/// metadata only when the request carried the client's peer address, so it
+/// distinguishes a working `ConnectInfo` stamp from the catch-all fallback a
+/// missing stamp silently degrades to. `localhost` specifically, because the
+/// cluster readiness probe logs in over the leader-aware binary client,
+/// which redials any other advertised hostname and would wedge startup on an
+/// unresolvable name.
+const TLS_SELECTOR_ADDRESS: &str = "localhost";
+
+/// Selectors covering the loopback client in both address families
+/// (`localhost` may resolve to either).
+const TLS_SELECTOR_ENVS: [(&str, &str); 4] = [
+    ("0_CLIENT_CIDR", "127.0.0.0/8"),
+    ("0_ADDRESS", TLS_SELECTOR_ADDRESS),
+    ("1_CLIENT_CIDR", "::1/128"),
+    ("1_ADDRESS", TLS_SELECTOR_ADDRESS),
+];
+
+const TLS_CLUSTER_NODES: usize = 2;
+
 /// Absolute path to a repo loopback cert asset. The spawned server's CWD is a
 /// temp dir, so relative paths break; `CARGO_MANIFEST_DIR` is the integration
 /// crate, whose sibling `../certs` holds the checked-in loopback material.
@@ -47,35 +71,49 @@ fn cert_asset(file: &str) -> PathBuf {
         .unwrap_or_else(|error| panic!("canonicalize repo cert asset {file}: 
{error}"))
 }
 
-/// Boot iggy-server-ng with `[http.tls]` enabled against the repo loopback
-/// cert, then prove a real HTTPS request is served over HTTP/2. Single node:
-/// the HTTP listener is shard-0-only and cluster formation is irrelevant to
-/// what we are proving, so a standalone node keeps the signal clean.
+/// Boot an iggy-server-ng cluster with `[http.tls]` enabled against the repo
+/// loopback cert, then prove a real HTTPS request is served over HTTP/2. Two
+/// nodes rather than one because the `/cluster/metadata` assertion below
+/// needs an enabled cluster roster (a single-node harness runs with the
+/// cluster disabled, where metadata synthesizes a self node and selectors
+/// are inert); the HTTPS requests themselves only ever talk to node 0.
 #[tokio::test]
 #[serial_test::parallel]
 async fn given_http_tls_enabled_when_pinging_should_serve_https_over_http2() {
     let mut harness = TestHarness::builder()
         .default_server()
-        .cluster_nodes(1)
+        .cluster_nodes(TLS_CLUSTER_NODES)
         .build()
         .expect("build TLS harness");
 
     let cert = cert_asset("iggy_cert.pem");
     let key = cert_asset("iggy_key.pem");
-    harness
-        .server_mut()
-        .add_env("IGGY_HTTP_TLS_ENABLED", "true");
-    harness
-        .server_mut()
-        .add_env("IGGY_HTTP_TLS_CERT_FILE", cert.display().to_string());
-    harness
-        .server_mut()
-        .add_env("IGGY_HTTP_TLS_KEY_FILE", key.display().to_string());
+    for node in 0..TLS_CLUSTER_NODES {
+        harness
+            .node_mut(node)
+            .add_env("IGGY_HTTP_TLS_ENABLED", "true");
+        harness
+            .node_mut(node)
+            .add_env("IGGY_HTTP_TLS_CERT_FILE", cert.display().to_string());
+        harness
+            .node_mut(node)
+            .add_env("IGGY_HTTP_TLS_KEY_FILE", key.display().to_string());
+        // The roster (selectors included) must be identical on every node;
+        // the selectors back the ConnectInfo-stamp assertion below.
+        for roster_entry in 0..TLS_CLUSTER_NODES {
+            for (suffix, value) in TLS_SELECTOR_ENVS {
+                harness.node_mut(node).add_env(
+                    
format!("IGGY_CLUSTER_NODES_{roster_entry}_ADVERTISED_ADDRESSES_{suffix}"),
+                    value,
+                );
+            }
+        }
+    }
 
     harness
         .start()
         .await
-        .expect("start server-ng with HTTPS enabled");
+        .expect("start server-ng cluster with HTTPS enabled");
 
     let addr = harness
         .server()
@@ -149,10 +187,46 @@ async fn 
given_http_tls_enabled_when_pinging_should_serve_https_over_http2() {
         "login response must also be HTTP/2"
     );
     let identity: IdentityInfo = login.json().await.expect("decode 
IdentityInfo over HTTPS");
+    let token = identity
+        .access_token
+        .expect("login over HTTPS must return an access token");
     assert!(
-        identity
-            .access_token
-            .is_some_and(|token| !token.token.is_empty()),
+        !token.token.is_empty(),
         "login over HTTPS must return a non-empty access token"
     );
+
+    // ConnectInfo-stamp proof: the TLS serve loop stamps the peer address by
+    // hand (`http::tls::serve_connection`), and `Identity.client_ip` silently
+    // degrades to the catch-all when the extension is missing. Only the
+    // selector address in the metadata reply proves the stamp reached the
+    // handler; the catch-all here is the roster ip `127.0.0.1`.
+    let metadata = client
+        .get(format!("{base_url}/cluster/metadata"))
+        .bearer_auth(&token.token)
+        .send()
+        .await
+        .expect("cluster metadata request over HTTPS");
+    assert_eq!(
+        metadata.status(),
+        StatusCode::OK,
+        "cluster metadata must answer 200 over HTTPS"
+    );
+    let metadata: serde_json::Value = metadata.json().await.expect("decode 
cluster metadata");
+    let nodes = metadata["nodes"]
+        .as_array()
+        .expect("metadata must carry a nodes array");
+    assert_eq!(
+        nodes.len(),
+        TLS_CLUSTER_NODES,
+        "full roster must be reported"
+    );
+    for node in nodes {
+        assert_eq!(
+            node["ip"].as_str(),
+            Some(TLS_SELECTOR_ADDRESS),
+            "HTTPS metadata must serve the loopback selector address for node 
{}, \
+             proving the TLS path stamped the client peer address",
+            node["name"]
+        );
+    }
 }
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index af6d3c9ce..52cc0c124 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -739,6 +739,33 @@ 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; clients matching no selector
+# fall back to 'advertised_address', then 'ip'. Matching is per address
+# family: '0.0.0.0/0' matches no IPv6 client and '::/0' matches no IPv4
+# client, so covering both families takes one selector per family (or the
+# catch-all). IPv4-mapped IPv6 CIDRs ('::ffff:10.0.0.0/104') match like
+# their IPv4 form only at prefix length 96 or longer; shorter ones match
+# native IPv6 clients only. 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..10d17b83d 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,18 @@ 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`]).
+/// Metadata deliberately does NOT fail closed like the redirect path: a host
+/// that parses as neither IP nor hostname (the roster `ip` is only validated
+/// non-empty - Docker service names with underscores exist in the wild)
+/// publishes verbatim via [`ResolvedClusterNode::raw_advertised_fallback`].
+fn client_host(node: &ResolvedClusterNode, client_ip: Option<IpAddr>) -> 
String {
+    node.advertised_for(client_ip).map_or_else(
+        || node.raw_advertised_fallback().to_owned(),
+        ToString::to_string,
+    )
 }
 
 const fn role_for(primary_index: Option<u8>, replica_id: u8) -> 
ClusterNodeRole {
@@ -167,40 +181,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 +234,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 +246,47 @@ 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");
+    }
+
+    // Unit-level pin for the hostname-selector flow: it cannot run end to
+    // end because the leader-aware SDK client redials any advertised
+    // hostname other than `localhost` on every fresh connect, wedging the
+    // integration harness readiness probe on an unresolvable name (see
+    // cluster_metadata_vsr.rs).
+    #[test]
+    fn cluster_metadata_serves_a_hostname_selector_normalized_to_lowercase() {
+        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: "Broker.Internal.Test".to_owned(),
+        }];
+
+        let metadata = roster_of(node).cluster_metadata(Some(0), 
Some("10.0.9.9".parse().unwrap()));
+
+        assert_eq!(metadata.nodes[0].ip, "broker.internal.test");
+    }
 }
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..82b3fd997 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).
@@ -327,7 +349,12 @@ fn router(
         None => router,
     };
 
-    merge_web_ui(router, web_ui)
+    // `with_state(())` finalizes every route eagerly, once for the whole
+    // listener - including the web-ui routes merged after the stateful
+    // `with_state` above, which would otherwise stay boxed handlers that
+    // axum rebuilds per request. Identity on already-finalized routes, so
+    // both the plain and the TLS serve paths share the finalized form.
+    merge_web_ui(router, web_ui).with_state(())
 }
 
 /// The control-plane route table: every write here commits through the
diff --git a/core/server-ng/src/http/error.rs b/core/server-ng/src/http/error.rs
index 28f5f56c2..df52466c5 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,35 @@ 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. This is the fail-closed caller: a host that is neither a
+/// valid IP nor a valid hostname yields `None` and the redirect becomes a
+/// 503 rather than a `Location` pointing at an unparsable target (cluster
+/// metadata makes the opposite choice and publishes such a host verbatim).
+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 +586,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 +594,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 +610,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 +626,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 +635,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 +644,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 +653,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 +662,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 +671,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 +683,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 +695,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..b4e0137a5 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
@@ -143,8 +146,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