This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 74614ed05 feat(server): warn when listener binds loopback inside a
container (#4218)
74614ed05 is described below
commit 74614ed050f770c9f3a7b8260b34e3ba930208f6
Author: Kunal Khare <[email protected]>
AuthorDate: Tue Sep 22 13:09:18 2026 +0530
feat(server): warn when listener binds loopback inside a container (#4218)
---
core/configs/src/server_config/validators.rs | 293 +++++++++++++++++++++++++--
1 file changed, 274 insertions(+), 19 deletions(-)
diff --git a/core/configs/src/server_config/validators.rs
b/core/configs/src/server_config/validators.rs
index 1ce11aa90..fef1ebe08 100644
--- a/core/configs/src/server_config/validators.rs
+++ b/core/configs/src/server_config/validators.rs
@@ -26,12 +26,14 @@ use super::COMPONENT;
use super::cluster::STATE_CHUNK_HEADER_LEN;
use super::partition::{CONCURRENT_SERVED_SEGMENTS,
SEGMENT_SIZE_OVERSHOOT_BYTES};
use super::server::ServerConfig;
-use crate::ConfigurationError;
use crate::common::http::HMAC_JWT_ALGORITHMS;
use crate::common::validators::SEGMENT_MAX_SIZE_BYTES;
+use crate::{ConfigEnvMappings, ConfigurationError};
use err_trail::ErrContext;
use iggy_common::{IggyExpiry, MAX_MESSAGE_SIZE_UPPER_BYTES, Validatable};
+use std::ffi::OsStr;
use std::net::SocketAddr;
+use std::path::Path;
/// compio-ws (tungstenite 0.29) `write_buffer_size` default. Used to
/// evaluate the `max_write_buffer_size > write_buffer_size` invariant
@@ -407,31 +409,126 @@ impl ServerConfig {
/// The listener the client-facing address is derived from must not bind a
/// wildcard unless that address is declared outright.
+ ///
+ /// When running inside a container, any client listener binding loopback
+ /// is unreachable from outside that network namespace, which is warned.
fn validate_client_facing_address(&self) -> Result<(), ConfigurationError>
{
- if self.cluster.enabled || self.node.advertised_address.is_some() {
- return Ok(());
+ self.validate_client_facing_address_in_env(is_container())?;
+ Ok(())
+ }
+
+ fn validate_client_facing_address_in_env(
+ &self,
+ is_container: bool,
+ ) -> Result<Vec<String>, ConfigurationError> {
+ if self.cluster.enabled {
+ return Ok(Vec::new());
}
- // No client-facing listener runs, so no client dials this node and
- // there is no address to demand.
- let Some(listener) = self.derived_address_listener() else {
- return Ok(());
- };
- let bind = parse_bind_address(listener.key, listener.address)?;
- if !bind.ip().to_canonical().is_unspecified() {
- return Ok(());
+
+ if let Some(listener) = self.derived_address_listener() {
+ let bind = parse_bind_address(listener.key, listener.address)?;
+ if bind.ip().to_canonical().is_unspecified() &&
self.node.advertised_address.is_none() {
+ eprintln!(
+ "{COMPONENT} - {} binds the wildcard {bind}, which says
which interfaces this node \
+ accepts on rather than where a client reaches it, so
cluster metadata would carry no \
+ address for this node. Set node.advertised_address to the
address clients dial, or \
+ bind a concrete address.",
+ listener.key
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
}
- eprintln!(
- "{COMPONENT} - {} binds the wildcard {bind}, which says which
interfaces this node \
- accepts on rather than where a client reaches it, so cluster
metadata would carry no \
- address for this node. Set node.advertised_address to the address
clients dial, or \
- bind a concrete address.",
- listener.key
- );
- Err(ConfigurationError::InvalidConfigurationValue)
+ let mut warnings = Vec::new();
+ for listener in self.client_listeners() {
+ if !listener.enabled {
+ continue;
+ }
+ let bind = parse_bind_address(listener.key, listener.address)?;
+ let ip = bind.ip().to_canonical();
+
+ if ip.is_loopback() && is_container {
+ let env_var = ServerConfig::find_by_config_path(listener.key)
+ .map_or(listener.key, |m| m.env_name);
+ let port = bind.port();
+ let hint = if self.node.advertised_address.is_none() {
+ format!(
+ " Set {env_var}=0.0.0.0:{port} together with
IGGY_NODE_ADVERTISED_ADDRESS, or bind a concrete address."
+ )
+ } else {
+ format!(" Set {env_var} or bind a concrete address.")
+ };
+ let msg = format!(
+ "{COMPONENT} - {} binds the loopback address {bind} inside
a container; the \
+ server will not be reachable from outside this network
namespace.{hint}",
+ listener.key
+ );
+ eprintln!("{msg}");
+ warnings.push(msg);
+ }
+ }
+
+ Ok(warnings)
}
}
+#[cfg(target_os = "linux")]
+const CONTAINER_CGROUP_MARKERS: &[&str] = &[
+ "/docker/",
+ "/docker-",
+ "/libpod-",
+ "/podman/",
+ "/kubepods/",
+ "/kubepods-",
+ "/containerd/",
+ "/lxc/",
+];
+
+/// Returns true when the process is executing inside a container.
+fn is_container() -> bool {
+ is_container_indicators(
+ Path::new("/.dockerenv"),
+ Path::new("/run/.containerenv"),
+ std::env::var_os("container").as_deref(),
+ std::env::var_os("KUBERNETES_SERVICE_HOST").as_deref(),
+ "/proc/self/cgroup",
+ )
+}
+
+fn is_container_indicators(
+ dockerenv_path: &Path,
+ containerenv_path: &Path,
+ container_env: Option<&OsStr>,
+ k8s_env: Option<&OsStr>,
+ cgroup_path: &str,
+) -> bool {
+ if dockerenv_path.exists() || containerenv_path.exists() {
+ return true;
+ }
+
+ if container_env.is_some() || k8s_env.is_some() {
+ return true;
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ if let Ok(cgroup) = std::fs::read_to_string(cgroup_path)
+ && cgroup.lines().any(|line| {
+ CONTAINER_CGROUP_MARKERS
+ .iter()
+ .any(|marker| line.contains(marker))
+ })
+ {
+ return true;
+ }
+ }
+
+ #[cfg(not(target_os = "linux"))]
+ let _ = cgroup_path;
+
+ false
+}
+
/// A listener's bind address, which is a literal IP and a port and nothing
/// else. `context` names the config key so the operator reads back the one
/// they wrote.
@@ -534,6 +631,164 @@ mod tests {
assert!(config.validate().is_ok());
}
+ #[test]
+ fn given_loopback_bind_in_container_when_validating_should_warn_and_pass()
{
+ let config = config_with_override(
+ "[tcp]\naddress = \"127.0.0.1:8090\"\n[cluster]\nenabled =
false\n",
+ );
+ let warnings = config
+ .validate_client_facing_address_in_env(true)
+ .expect("validation should pass");
+ assert_eq!(warnings.len(), 4);
+ for warning in &warnings {
+ assert!(warning.contains("outside this network namespace"));
+ assert!(warning.contains("together with
IGGY_NODE_ADVERTISED_ADDRESS"));
+ }
+ assert!(warnings[0].contains("IGGY_TCP_ADDRESS=0.0.0.0:8090"));
+ assert!(warnings[1].contains("IGGY_WEBSOCKET_ADDRESS=0.0.0.0:8092"));
+ assert!(warnings[2].contains("IGGY_QUIC_ADDRESS=0.0.0.0:8080"));
+ assert!(warnings[3].contains("IGGY_HTTP_ADDRESS=0.0.0.0:3000"));
+ }
+
+ #[test]
+ fn
given_loopback_bind_outside_container_when_validating_should_pass_without_warning()
{
+ let config = config_with_override(
+ "[tcp]\naddress = \"127.0.0.1:8090\"\n[cluster]\nenabled =
false\n",
+ );
+ let warnings = config
+ .validate_client_facing_address_in_env(false)
+ .expect("validation should pass");
+ assert!(
+ warnings.is_empty(),
+ "loopback outside container must not produce a warning"
+ );
+ }
+
+ #[test]
+ fn
given_loopback_bind_in_container_with_advertised_address_when_validating_should_warn_and_pass()
+ {
+ let config = config_with_override(
+ "[tcp]\naddress = \"0.0.0.0:8090\"\n[cluster]\nenabled = false\n\
+ [node]\nadvertised_address = \"broker-1.example.com\"\n",
+ );
+ let warnings = config
+ .validate_client_facing_address_in_env(true)
+ .expect("validation should pass");
+ assert_eq!(warnings.len(), 3);
+ for warning in &warnings {
+ assert!(warning.contains("outside this network namespace"));
+ assert!(!warning.contains("0.0.0.0"));
+ assert!(!warning.contains("together with
IGGY_NODE_ADVERTISED_ADDRESS"));
+ }
+ assert!(warnings[0].contains("Set IGGY_WEBSOCKET_ADDRESS or bind a
concrete address."));
+ assert!(warnings[1].contains("Set IGGY_QUIC_ADDRESS or bind a concrete
address."));
+ assert!(warnings[2].contains("Set IGGY_HTTP_ADDRESS or bind a concrete
address."));
+ }
+
+ #[test]
+ fn given_dockerenv_file_when_checking_container_should_return_true() {
+ let temp_dir = std::env::temp_dir();
+ let marker = temp_dir.join(format!("test_dockerenv_{}",
std::process::id()));
+ std::fs::write(&marker, "").unwrap();
+ let non_existent = temp_dir.join("non_existent_indicator");
+ let result =
+ is_container_indicators(&marker, &non_existent, None, None,
"/non/existent/cgroup");
+ let _ = std::fs::remove_file(&marker);
+ assert!(result);
+ }
+
+ #[test]
+ fn given_containerenv_file_when_checking_container_should_return_true() {
+ let temp_dir = std::env::temp_dir();
+ let marker = temp_dir.join(format!("test_containerenv_{}",
std::process::id()));
+ std::fs::write(&marker, "").unwrap();
+ let non_existent = temp_dir.join("non_existent_indicator");
+ let result =
+ is_container_indicators(&non_existent, &marker, None, None,
"/non/existent/cgroup");
+ let _ = std::fs::remove_file(&marker);
+ assert!(result);
+ }
+
+ #[test]
+ fn given_container_env_var_when_checking_container_should_return_true() {
+ let non_existent = Path::new("/non/existent/path/to/indicator");
+ assert!(is_container_indicators(
+ non_existent,
+ non_existent,
+ Some(OsStr::new("docker")),
+ None,
+ "/non/existent/cgroup"
+ ));
+ }
+
+ #[test]
+ fn given_kubernetes_env_var_when_checking_container_should_return_true() {
+ let non_existent = Path::new("/non/existent/path/to/indicator");
+ assert!(is_container_indicators(
+ non_existent,
+ non_existent,
+ None,
+ Some(OsStr::new("10.0.0.1")),
+ "/non/existent/cgroup"
+ ));
+ }
+
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn
given_cgroup_with_docker_marker_when_checking_container_should_return_true() {
+ let temp_dir = std::env::temp_dir();
+ let cgroup_file = temp_dir.join(format!("test_docker_cgroup_{}",
std::process::id()));
+ std::fs::write(
+ &cgroup_file,
+
"0::/system.slice/docker-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.scope\n",
+ )
+ .unwrap();
+ let non_existent = temp_dir.join("non_existent_indicator");
+ let result = is_container_indicators(
+ &non_existent,
+ &non_existent,
+ None,
+ None,
+ cgroup_file.to_str().unwrap(),
+ );
+ let _ = std::fs::remove_file(&cgroup_file);
+ assert!(result);
+ }
+
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn
given_host_cgroup_without_markers_when_checking_container_should_return_false()
{
+ let temp_dir = std::env::temp_dir();
+ let cgroup_file = temp_dir.join(format!("test_host_cgroup_{}",
std::process::id()));
+ std::fs::write(
+ &cgroup_file,
+ "0::/user.slice/user-1000.slice/session-1.scope\n",
+ )
+ .unwrap();
+ let non_existent = temp_dir.join("non_existent_indicator");
+ let result = is_container_indicators(
+ &non_existent,
+ &non_existent,
+ None,
+ None,
+ cgroup_file.to_str().unwrap(),
+ );
+ let _ = std::fs::remove_file(&cgroup_file);
+ assert!(!result);
+ }
+
+ #[test]
+ fn
given_missing_container_indicators_when_checking_container_should_return_false()
{
+ let non_existent = Path::new("/non/existent/path/to/indicator");
+ assert!(!is_container_indicators(
+ non_existent,
+ non_existent,
+ None,
+ None,
+ "/non/existent/cgroup"
+ ));
+ }
+
#[test]
fn
given_wildcard_bind_on_a_disabled_listener_when_validating_should_pass() {
let config = config_with_override(