Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions patchbay/src/netns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down Expand Up @@ -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))
})();
Expand Down
44 changes: 44 additions & 0 deletions patchbay/src/tests/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}