diff --git a/patchbay/src/netns.rs b/patchbay/src/netns.rs index 93d73fd..c995c47 100644 --- a/patchbay/src/netns.rs +++ b/patchbay/src/netns.rs @@ -75,21 +75,52 @@ fn setup_namespace_thread( crate::ns_tracing::install_namespace_subscriber(log_name, run_dir.map(|p| p.as_path())) } -/// Private mount namespace + optional DNS overlay bind-mounts. +/// Private mount namespace + remount `/proc` + optional DNS overlay bind-mounts. /// Called on every thread that enters a namespace (sync, async, user, blocking pool). +/// +/// We always create a private mount namespace and remount `/proc` so that +/// `/proc/net/route` (and other `/proc/net/*` files) reflect *this* network +/// namespace's state instead of the host's. Without this, libraries that read +/// `/proc/net/route` (e.g. netwatch) get the host's default route interface. fn apply_mount_overlay(overlay: Option<&DnsOverlay>) { - if overlay.is_some() { - if let Err(e) = unshare(CloneFlags::CLONE_NEWNS) { - tracing::warn!( - "unshare(CLONE_NEWNS) failed: {e} — DNS overlay bind-mounts may affect the host" - ); - } + if let Err(e) = unshare(CloneFlags::CLONE_NEWNS) { + tracing::warn!( + "unshare(CLONE_NEWNS) failed: {e} — /proc and DNS overlays may show host data" + ); + } else { + fixup_proc_net(); } if let Some(o) = overlay { o.apply(); } } +/// Bind-mount `/proc/thread-self/net` over `/proc/net` so that +/// `/proc/net/route` (and other `/proc/net/*` files) reflect *this thread's* +/// network namespace instead of the process's original one. +/// +/// On Linux, `/proc/net` is a symlink to `self/net` which resolves to the +/// *thread group leader's* network namespace, not the calling thread's. After +/// `setns(CLONE_NEWNET)`, only `/proc/thread-self/net` reflects the new +/// namespace. This bind-mount makes the standard `/proc/net/route` path work +/// for libraries like `netwatch` that don't know about `thread-self`. +fn fixup_proc_net() { + // First remove the symlink so we can mount over it + let ret = unsafe { + libc::mount( + c"/proc/thread-self/net".as_ptr(), + c"/proc/net".as_ptr(), + std::ptr::null(), + libc::MS_BIND, + std::ptr::null(), + ) + }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + tracing::warn!("bind-mount /proc/thread-self/net -> /proc/net failed: {err}"); + } +} + /// Enters an existing namespace via `setns` and applies mount overlay. fn enter_namespace(fd: &File, overlay: Option<&DnsOverlay>) -> Result<()> { setns(fd, CloneFlags::CLONE_NEWNET).context("setns CLONE_NEWNET")?; @@ -240,9 +271,10 @@ impl Worker { let ns_fd = open_current_thread_netns_fd()?; let mut builder = tokio::runtime::Builder::new_current_thread(); builder.enable_all(); - if let Some(overlay) = thread_opts.dns_overlay.clone() { - builder.on_thread_start(move || apply_mount_overlay(Some(&overlay))); - } + let overlay_for_threads = thread_opts.dns_overlay.clone(); + builder.on_thread_start(move || { + apply_mount_overlay(overlay_for_threads.as_ref()) + }); let rt = builder.build().context("build tokio runtime")?; Ok((ns_fd, rt)) })(); diff --git a/patchbay/src/tests/route.rs b/patchbay/src/tests/route.rs index 1b436dd..c28d5fd 100644 --- a/patchbay/src/tests/route.rs +++ b/patchbay/src/tests/route.rs @@ -214,3 +214,47 @@ async fn replug_iface_reflexive_ip() -> Result<()> { ); Ok(()) } + +/// /proc/net/route inside a device namespace must reflect the namespace's +/// routing table, not the host's. Without a private /proc mount, netwatch +/// reads the host's default route interface (e.g. enp7s0) instead of eth0, +/// causing iroh to bind sockets to a non-existent interface after link flaps. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn proc_net_route_shows_namespace_routes() -> Result<()> { + check_caps()?; + let lab = Lab::new().await?; + let isp = lab.add_router("isp1").build().await?; + let home = lab + .add_router("home1") + .upstream(isp.id()) + .nat(Nat::Home) + .build() + .await?; + let dev = lab + .add_device("dev1") + .iface("eth0", home.id(), None) + .build() + .await?; + + let route_content = dev.run_sync(|| { + std::fs::read_to_string("/proc/net/route").context("read /proc/net/route") + })?; + + // The namespace must contain eth0 with a default route. + assert!( + route_content.contains("eth0"), + "/proc/net/route should contain eth0 but got:\n{route_content}" + ); + + // No host interfaces should leak into the namespace. + for line in route_content.lines().skip(1) { + let iface = line.split_ascii_whitespace().next().unwrap_or(""); + assert!( + iface == "eth0" || iface == "lo" || iface.is_empty(), + "unexpected host interface '{iface}' in namespace /proc/net/route:\n{route_content}" + ); + } + + Ok(()) +}