fix(enrollment): filter Docker bridge IPs and add report_interface/report_ip config
- identity.rs: filter 172.16.0.0/12 (Docker bridge) and 169.254.0.0/16 (link-local) from get_ip_addresses() auto-detection - identity.rs: add is_container_bridge(), is_link_local(), get_ip_for_interface(), get_primary_ip() functions - client.rs: add report_interface/report_ip fields to EnrollmentClient, new with_ip_overrides() constructor, register() uses get_primary_ip() - loader.rs: add report_interface/report_ip to EnrollmentConfig - mod.rs: wire config overrides through to EnrollmentClient - config.yaml.example: document new report_interface/report_ip options - Tests: add 18 new bridge filtering/IP override tests, fix Docker container compatibility in existing tests
This commit is contained in:
@ -5,7 +5,7 @@
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::fs;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::process::Command;
|
||||
|
||||
/// Read the D-Bus machine identifier from `/etc/machine-id`.
|
||||
@ -65,6 +65,9 @@ pub fn get_fqdn() -> Result<String> {
|
||||
}
|
||||
|
||||
/// Collect all non-loopback IPv4 addresses from network interfaces.
|
||||
///
|
||||
/// Filters out container bridge subnets (Docker 172.16.0.0/12) and
|
||||
/// link-local addresses (169.254.0.0/16) that are not routable from the manager.
|
||||
pub fn get_ip_addresses() -> Result<Vec<String>> {
|
||||
let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;
|
||||
|
||||
@ -75,7 +78,17 @@ pub fn get_ip_addresses() -> Result<Vec<String>> {
|
||||
return None;
|
||||
}
|
||||
match &iface.ip() {
|
||||
IpAddr::V4(addr) => Some(addr.to_string()),
|
||||
IpAddr::V4(addr) => {
|
||||
// Filter container bridge and link-local subnets
|
||||
if is_container_bridge(addr) || is_link_local(addr) {
|
||||
tracing::debug!(
|
||||
ip = %addr,
|
||||
"Excluding container bridge or link-local IP from enrollment report"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(addr.to_string())
|
||||
}
|
||||
IpAddr::V6(_) => None,
|
||||
}
|
||||
})
|
||||
@ -86,6 +99,112 @@ pub fn get_ip_addresses() -> Result<Vec<String>> {
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
/// Check if an IPv4 address is in a container bridge subnet.
|
||||
///
|
||||
/// Filters the `172.16.0.0/12` range (172.16.0.0 – 172.31.255.255), which is
|
||||
/// Docker's default bridge network allocation.
|
||||
///
|
||||
/// Note: `10.0.0.0/8` is NOT filtered because it is widely used for legitimate
|
||||
/// LAN addressing. If a deployment uses a custom Docker bridge subnet outside
|
||||
/// `172.16.0.0/12`, use `report_interface` or `report_ip` config to override.
|
||||
pub fn is_container_bridge(addr: &Ipv4Addr) -> bool {
|
||||
// 172.16.0.0/12 = 172.16.0.0 – 172.31.255.255
|
||||
// Binary: 10101100.0001xxxx.xxxxxxxx.xxxxxxxx
|
||||
let octets = addr.octets();
|
||||
octets[0] == 172 && (octets[1] & 0xF0) == 0x10
|
||||
}
|
||||
|
||||
/// Check if an IPv4 address is link-local (`169.254.0.0/16`).
|
||||
///
|
||||
/// Link-local addresses are auto-assigned when no DHCP is available and
|
||||
/// are never routable across networks.
|
||||
pub fn is_link_local(addr: &Ipv4Addr) -> bool {
|
||||
let octets = addr.octets();
|
||||
octets[0] == 169 && octets[1] == 254
|
||||
}
|
||||
|
||||
/// Get the IPv4 address of a specific network interface by name.
|
||||
///
|
||||
/// Returns the first non-loopback IPv4 address on the named interface.
|
||||
/// Useful when the admin knows which interface faces the manager network.
|
||||
pub fn get_ip_for_interface(interface_name: &str) -> Result<String> {
|
||||
let ifaces = if_addrs::get_if_addrs()
|
||||
.with_context(|| "Failed to enumerate network interfaces for interface lookup")?;
|
||||
|
||||
for iface in &ifaces {
|
||||
if iface.name != interface_name {
|
||||
continue;
|
||||
}
|
||||
if let IpAddr::V4(addr) = iface.ip() {
|
||||
if !iface.is_loopback() {
|
||||
tracing::info!(
|
||||
interface = interface_name,
|
||||
ip = %addr,
|
||||
"Resolved IP from configured interface"
|
||||
);
|
||||
return Ok(addr.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No non-loopback IPv4 address found on interface '{}'",
|
||||
interface_name
|
||||
))
|
||||
}
|
||||
|
||||
/// Determine the primary IP address to report to the manager.
|
||||
///
|
||||
/// Resolution priority:
|
||||
/// 1. `report_ip` — explicit IP from config (highest priority)
|
||||
/// 2. `report_interface` — IP from a named interface
|
||||
/// 3. Auto-detect — first IP from `get_ip_addresses()` (bridge subnets already filtered)
|
||||
pub fn get_primary_ip(report_interface: Option<&str>, report_ip: Option<&str>) -> Result<String> {
|
||||
// Priority 1: Explicit IP override
|
||||
if let Some(ip) = report_ip {
|
||||
// Validate it parses as IPv4
|
||||
if let Ok(addr) = ip.parse::<Ipv4Addr>() {
|
||||
if !addr.is_loopback() {
|
||||
tracing::info!(
|
||||
ip = ip,
|
||||
"Using explicitly configured report_ip"
|
||||
);
|
||||
return Ok(ip.to_string());
|
||||
}
|
||||
tracing::warn!(
|
||||
ip = ip,
|
||||
"Configured report_ip is a loopback address — ignoring"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
ip = ip,
|
||||
"Configured report_ip is not a valid IPv4 address — falling back to auto-detect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Interface name override
|
||||
if let Some(iface) = report_interface {
|
||||
match get_ip_for_interface(iface) {
|
||||
Ok(ip) => return Ok(ip),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
interface = iface,
|
||||
error = %e,
|
||||
"Configured report_interface lookup failed — falling back to auto-detect"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Auto-detect (bridge subnets already filtered by get_ip_addresses)
|
||||
let addrs = get_ip_addresses()?;
|
||||
addrs
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("No suitable IPv4 address found on any interface"))
|
||||
}
|
||||
|
||||
/// Extract OS distribution details from `/etc/os-release` and kernel version.
|
||||
/// Returns a JSON object with: distro, version, id_like, kernel.
|
||||
pub fn get_os_details() -> Result<serde_json::Value> {
|
||||
@ -178,4 +297,121 @@ mod tests {
|
||||
"OS details must contain kernel version"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Container Bridge & Link-Local Filtering Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_is_container_bridge_docker_default() {
|
||||
// Docker default bridge network: 172.17.0.0/16
|
||||
assert!(is_container_bridge(&"172.17.0.1".parse().unwrap()));
|
||||
assert!(is_container_bridge(&"172.17.255.255".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_container_bridge_full_range() {
|
||||
// 172.16.0.0/12 = 172.16.0.0 – 172.31.255.255
|
||||
assert!(is_container_bridge(&"172.16.0.1".parse().unwrap()));
|
||||
assert!(is_container_bridge(&"172.31.255.255".parse().unwrap()));
|
||||
assert!(is_container_bridge(&"172.20.0.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_container_bridge() {
|
||||
// Outside 172.16.0.0/12
|
||||
assert!(!is_container_bridge(&"192.168.3.36".parse().unwrap()));
|
||||
assert!(!is_container_bridge(&"10.0.0.1".parse().unwrap()));
|
||||
assert!(!is_container_bridge(&"172.32.0.1".parse().unwrap()));
|
||||
assert!(!is_container_bridge(&"172.15.0.1".parse().unwrap()));
|
||||
assert!(!is_container_bridge(&"8.8.8.8".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_link_local() {
|
||||
assert!(is_link_local(&"169.254.0.1".parse().unwrap()));
|
||||
assert!(is_link_local(&"169.254.255.255".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_link_local() {
|
||||
assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
|
||||
assert!(!is_link_local(&"169.253.0.1".parse().unwrap()));
|
||||
assert!(!is_link_local(&"169.255.0.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ip_addresses_excludes_docker_bridge() {
|
||||
// On a system with Docker, the returned IPs should not include 172.16.0.0/12
|
||||
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
|
||||
for addr in &addrs {
|
||||
let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4");
|
||||
assert!(
|
||||
!is_container_bridge(&parsed),
|
||||
"IP '{}' is in Docker bridge range 172.16.0.0/12 — should be excluded",
|
||||
addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ip_addresses_excludes_link_local() {
|
||||
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
|
||||
for addr in &addrs {
|
||||
let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4");
|
||||
assert!(
|
||||
!is_link_local(&parsed),
|
||||
"IP '{}' is link-local 169.254.0.0/16 — should be excluded",
|
||||
addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_primary_ip_auto_detect() {
|
||||
// Without overrides, should return a valid non-bridge IP
|
||||
// In Docker containers, auto-detect may find no routable IPs — that's valid
|
||||
match get_primary_ip(None, None) {
|
||||
Ok(ip) => {
|
||||
assert!(!ip.is_empty(), "Primary IP should not be empty");
|
||||
let parsed: Ipv4Addr = ip.parse().expect("Primary IP should be valid IPv4");
|
||||
assert!(!is_container_bridge(&parsed), "Auto-detected IP should not be Docker bridge");
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("NOTE: No routable IPs found — likely running inside a Docker container");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_primary_ip_explicit_override() {
|
||||
// Explicit IP should be returned as-is
|
||||
let ip = get_primary_ip(None, Some("10.99.99.1"))
|
||||
.expect("Failed with explicit IP");
|
||||
assert_eq!(ip, "10.99.99.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_primary_ip_rejects_loopback_override() {
|
||||
// Loopback in report_ip should fall back to auto-detect
|
||||
// In Docker containers, auto-detect may also fail — that's valid
|
||||
match get_primary_ip(None, Some("127.0.0.1")) {
|
||||
Ok(ip) => assert_ne!(ip, "127.0.0.1"),
|
||||
Err(_) => {
|
||||
eprintln!("NOTE: Loopback rejected but no routable IPs for fallback — Docker container");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_primary_ip_invalid_override_falls_back() {
|
||||
// Invalid IP in report_ip should fall back to auto-detect
|
||||
// In Docker containers, auto-detect may also fail — that's valid
|
||||
match get_primary_ip(None, Some("not-an-ip")) {
|
||||
Ok(ip) => assert!(!ip.is_empty()),
|
||||
Err(_) => {
|
||||
eprintln!("NOTE: Invalid IP rejected but no routable IPs for fallback — Docker container");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user