diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index ad1a1b2d..c011b9e4 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -80,19 +80,11 @@ pub struct Config { } impl Config { - /// Detect primary host IP (first non-loopback IPv4) + /// Detect primary host IP (default-route interface, not `hostname -I` order) async fn detect_host_ip() -> Result { - let output = tokio::process::Command::new("hostname") - .args(["-I"]) - .output() + Ok(crate::host_ip::primary_host_ipv4() .await - .context("Failed to run hostname -I")?; - let s = String::from_utf8_lossy(&output.stdout); - let ip = s - .split_whitespace() - .find(|s| !s.starts_with("127.") && s.contains('.')) - .unwrap_or("127.0.0.1"); - Ok(ip.to_string()) + .unwrap_or_else(|| "127.0.0.1".to_string())) } pub async fn load() -> Result { diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 374af211..cd9e6592 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -696,22 +696,11 @@ async fn netbird_configured_launch_url() -> Option { PodmanClient::lan_address_for("netbird") } -/// First address from `hostname -I` — the node's primary host IP. Mirrors the -/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the -/// orchestrator renders for `{{HOST_IP}}`. +/// The node's primary host IP. Mirrors the orchestrator's `detect_host_ip` +/// so launch URLs match the cert/config the orchestrator renders for +/// `{{HOST_IP}}`. async fn first_host_ip() -> Option { - let out = tokio::process::Command::new("hostname") - .arg("-I") - .output() - .await - .ok()?; - if !out.status.success() { - return None; - } - String::from_utf8_lossy(&out.stdout) - .split_whitespace() - .next() - .map(ToOwned::to_owned) + crate::host_ip::primary_host_ipv4().await } async fn reachable_lan_address(app_id: &str, candidate: Option) -> Option { diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 6cb14b55..269a38e5 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3087,16 +3087,7 @@ impl ProdContainerOrchestrator { } async fn detect_host_ip() -> Option { - let output = tokio::process::Command::new("hostname") - .arg("-I") - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.split_whitespace().next().map(|s| s.to_string()) + crate::host_ip::primary_host_ipv4().await } async fn detect_host_mdns() -> String { diff --git a/core/archipelago/src/host_ip.rs b/core/archipelago/src/host_ip.rs new file mode 100644 index 00000000..ee1ffdd4 --- /dev/null +++ b/core/archipelago/src/host_ip.rs @@ -0,0 +1,135 @@ +//! Primary host LAN IPv4 detection. +//! +//! `hostname -I` lists addresses in interface-creation order, so once a VPN +//! or bridge interface exists (NetBird's WireGuard tunnel, br-tollgate, …) +//! its address can sort ahead of the real NIC — a fresh-ISO node handed out +//! `https://10.44.0.1:8087` as NetBird's launch URL instead of the LAN IP. +//! The main routing table's default route names the physical uplink even when +//! a VPN is active (NetBird/Tailscale steer traffic via policy-routing rules +//! in separate tables, not by replacing the main-table default), so that is +//! the authoritative source, with `hostname -I` kept only as the last resort +//! for hosts with no default route at all. + +/// The node's primary LAN IPv4, as a string. +/// +/// Resolution order: +/// 1. `src`/`dev` of the main-table default route (`ip -4 route show default`) +/// 2. source address of a connected UDP socket (never transmits) +/// 3. first non-loopback IPv4 from `hostname -I` (legacy behaviour) +pub(crate) async fn primary_host_ipv4() -> Option { + if let Some(ip) = default_route_ip().await { + return Some(ip); + } + if let Some(ip) = udp_route_ip() { + return Some(ip); + } + hostname_i_ip().await +} + +async fn default_route_ip() -> Option { + let out = tokio::process::Command::new("ip") + .args(["-4", "route", "show", "default"]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let route = String::from_utf8_lossy(&out.stdout); + if let Some(ip) = parse_route_src(&route) { + return Some(ip); + } + // No `src` hint on the route — resolve the device's global address. + let dev = parse_route_dev(&route)?; + let out = tokio::process::Command::new("ip") + .args(["-4", "-o", "addr", "show", "dev", &dev, "scope", "global"]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + parse_addr_inet(&String::from_utf8_lossy(&out.stdout)) +} + +fn parse_route_src(route: &str) -> Option { + field_after(route.lines().next()?, "src") +} + +fn parse_route_dev(route: &str) -> Option { + field_after(route.lines().next()?, "dev") +} + +fn field_after(line: &str, key: &str) -> Option { + let mut words = line.split_whitespace(); + while let Some(w) = words.next() { + if w == key { + return words.next().map(ToOwned::to_owned); + } + } + None +} + +fn parse_addr_inet(out: &str) -> Option { + let cidr = field_after(out.lines().next()?, "inet")?; + Some(cidr.split('/').next().unwrap_or(&cidr).to_string()) +} + +/// A connected UDP socket's local address is the source IP the kernel would +/// use to reach the peer; nothing is sent. Can still land on a tunnel IP when +/// a VPN policy-routes all traffic, hence only a fallback. +fn udp_route_ip() -> Option { + let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + match sock.local_addr().ok()?.ip() { + std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_unspecified() => { + Some(v4.to_string()) + } + _ => None, + } +} + +async fn hostname_i_ip() -> Option { + let out = tokio::process::Command::new("hostname") + .arg("-I") + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .find(|s| !s.starts_with("127.") && s.contains('.')) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_src_wins() { + let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600"; + assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116")); + } + + #[test] + fn route_dev_without_src() { + let route = "default via 192.168.1.1 dev enp0s31f6 proto static"; + assert_eq!(parse_route_src(route), None); + assert_eq!(parse_route_dev(route).as_deref(), Some("enp0s31f6")); + } + + #[test] + fn addr_inet_strips_prefix() { + let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec"; + assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65")); + } + + #[test] + fn empty_route_table() { + assert_eq!(parse_route_src(""), None); + assert_eq!(parse_route_dev(""), None); + } +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index a105df47..4d1189ea 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -50,6 +50,7 @@ mod electrs_status; mod federation; mod fips; mod health_monitor; +mod host_ip; mod identity; mod identity_manager; mod marketplace;