diff --git a/Cargo.lock b/Cargo.lock index b4adcd42..c7c707ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,6 +270,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -801,6 +807,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -822,6 +839,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1007,16 +1025,20 @@ dependencies = [ "ctrlc", "dirs", "filetime", + "futures", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "libc", "lru", + "netlink-packet-route", + "nix 0.29.0", "pprof", "predicates", "rand", "rcgen", + "rtnetlink", "rustls", "serde", "serde_json", @@ -1461,6 +1483,70 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c171cd77b4ee8c7708da746ce392440cb7bcf618d122ec9ecc607b12938bf4" +dependencies = [ + "anyhow", + "byteorder", + "libc", + "log", + "netlink-packet-core", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "netlink-proto" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72452e012c2f8d612410d89eea01e2d9b56205274abb35d53f60200b2ec41d60" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.16", +] + +[[package]] +name = "netlink-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +dependencies = [ + "bytes", + "futures", + "libc", + "log", + "tokio", +] + [[package]] name = "nix" version = "0.26.4" @@ -1472,6 +1558,29 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.9.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -2002,6 +2111,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rtnetlink" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b684475344d8df1859ddb2d395dd3dac4f8f3422a1aa0725993cb375fc5caba5" +dependencies = [ + "futures", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-packet-utils", + "netlink-proto", + "netlink-sys", + "nix 0.27.1", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc-demangle" version = "0.1.26" diff --git a/Cargo.toml b/Cargo.toml index 33cc4db7..8171cd87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,10 @@ atty = "0.2" [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" socket2 = "0.5" +rtnetlink = "0.14" +netlink-packet-route = "0.19" +futures = "0.3" +nix = { version = "0.29", features = ["mount", "sched"] } [dev-dependencies] tempfile = "3.8" diff --git a/docs/guide/platform-support.md b/docs/guide/platform-support.md index 85cfe24f..ebc03067 100644 --- a/docs/guide/platform-support.md +++ b/docs/guide/platform-support.md @@ -42,6 +42,9 @@ Full network isolation using namespaces and nftables. - nftables (`nft` command) - libssl-dev (for TLS) - sudo access (for namespace creation) +- CAP_SYS_ADMIN and CAP_NET_ADMIN capabilities (automatically available with sudo, or in privileged containers) + +**Note:** httpjail no longer requires the `ip` command from `iproute2`. It uses direct syscalls and netlink for all network namespace operations. This allows it to work in minimal container images (like Alpine) or container runtimes like sysbox that provide the necessary capabilities but don't include the `iproute2` package. ### How It Works @@ -60,6 +63,39 @@ sudo httpjail --js "r.host === 'github.com'" -- curl https://api.github.com httpjail --weak --js "r.host === 'github.com'" -- curl https://api.github.com ``` +### Running Inside Containers + +httpjail works inside container environments (Docker, sysbox-runc, etc.) with proper capabilities: + +```bash +# Docker with privileged mode (full capabilities) +docker run --privileged --rm -it alpine:latest sh -c ' + wget https://github.com/coder/httpjail/releases/latest/download/httpjail-linux-amd64 -O /usr/local/bin/httpjail + chmod +x /usr/local/bin/httpjail + apk add --no-cache nftables + httpjail --js "r.host === \"example.com\"" -- wget -qO- https://example.com +' + +# sysbox-runc (provides CAP_SYS_ADMIN automatically) +docker run --runtime=sysbox-runc --rm -it alpine:latest sh -c ' + wget https://github.com/coder/httpjail/releases/latest/download/httpjail-linux-amd64 -O /usr/local/bin/httpjail + chmod +x /usr/local/bin/httpjail + apk add --no-cache nftables + httpjail --js "r.host === \"example.com\"" -- wget -qO- https://example.com +' + +# Or use weak mode if you don't have the necessary capabilities +httpjail --weak --js "r.host === \"example.com\"" -- wget -qO- https://example.com +``` + +**Requirements for strong mode in containers:** +- CAP_SYS_ADMIN capability (for network namespace operations) +- CAP_NET_ADMIN capability (for network configuration) +- `nft` binary available (nftables) +- NO need for `iproute2` package + +**Note:** Weak mode (`--weak`) works in any container but only sets HTTP_PROXY/HTTPS_PROXY environment variables, so applications must respect proxy settings. + ## macOS ``` @@ -140,3 +176,24 @@ httpjail sets these variables for the child process to trust the CA certificate: - `NODE_EXTRA_CA_CERTS` - Node.js - `CARGO_HTTP_CAINFO` - Cargo - `GIT_SSL_CAINFO` - Git + +## Weak Mode + +Weak mode is available on all platforms and uses environment variables only: + +```bash +httpjail --weak --js "r.host === 'allowed.com'" -- your-app +``` + +**Characteristics:** +- ✅ No root/sudo required +- ✅ Works on all platforms +- ❌ Apps must respect HTTP_PROXY/HTTPS_PROXY +- ❌ Cannot enforce policy on non-compliant apps +- ⚠️ Lower security than strong mode + +**Use weak mode when:** +- You don't have root access +- Testing on macOS (default behavior) +- Working with proxy-aware applications +- Running in containers without CAP_SYS_ADMIN diff --git a/src/jail/linux/mod.rs b/src/jail/linux/mod.rs index e09826ce..55fbde42 100644 --- a/src/jail/linux/mod.rs +++ b/src/jail/linux/mod.rs @@ -1,4 +1,5 @@ mod dns; +mod netlink; mod nftables; mod resources; @@ -11,10 +12,25 @@ use crate::sys_resource::ManagedResource; use anyhow::{Context, Result}; use dns::DummyDnsServer; use resources::{NFTable, NamespaceConfig, NetworkNamespace, VethPair}; +use std::future::Future; use std::process::{Command, ExitStatus}; use std::sync::{Arc, Mutex}; use tracing::{debug, info, warn}; +/// Run async code, using the ambient tokio runtime when available +fn block_on(future: F) -> F::Output +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)), + Err(_) => tokio::runtime::Runtime::new() + .expect("Failed to create tokio runtime") + .block_on(future), + } +} + // Linux namespace network configuration constants were previously fixed; the // implementation now computes unique per‑jail subnets dynamically. @@ -183,25 +199,12 @@ impl LinuxJail { self.veth_ns() ); - // Move veth_ns end into the namespace - let output = Command::new("ip") - .args([ - "link", - "set", - &self.veth_ns(), - "netns", - &self.namespace_name(), - ]) - .output() + // Move veth_ns end into the namespace using netlink + let veth_ns = self.veth_ns(); + let ns_name = self.namespace_name(); + block_on(async move { netlink::move_link_to_netns(&veth_ns, &ns_name).await }) .context("Failed to move veth to namespace")?; - if !output.status.success() { - anyhow::bail!( - "Failed to move veth to namespace: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - Ok(()) } @@ -214,58 +217,37 @@ impl LinuxJail { // This is a fallback in case the bind mount didn't work self.ensure_namespace_dns()?; - // Format the host IP once - let host_ip = format_ip(self.host_ip); - - // Commands to run inside the namespace - let commands = vec![ + // Get handle inside namespace + let ns_clone = namespace_name.clone(); + let handle = block_on(async move { netlink::get_handle_in_netns(&ns_clone).await })?; + + // Parse guest CIDR to get IP and prefix + let guest_parts: Vec<&str> = self.guest_cidr.split('/').collect(); + let guest_ip: std::net::Ipv4Addr = + guest_parts[0].parse().context("Failed to parse guest IP")?; + let prefix_len: u8 = guest_parts[1] + .parse() + .context("Failed to parse prefix length")?; + + // Parse host IP for gateway + let host_ip: std::net::Ipv4Addr = format_ip(self.host_ip) + .parse() + .context("Failed to parse host IP")?; + + // Configure networking inside namespace + block_on(async move { // Bring up loopback - vec!["ip", "link", "set", "lo", "up"], - // Configure veth interface with IP - vec!["ip", "addr", "add", &self.guest_cidr, "dev", &veth_ns], - vec!["ip", "link", "set", &veth_ns, "up"], - // Add default route pointing to host - vec!["ip", "route", "add", "default", "via", &host_ip], - ]; + netlink::set_link_up(&handle, "lo", true).await?; - for cmd_args in commands { - let mut cmd = Command::new("ip"); - cmd.args(["netns", "exec", &namespace_name]); - cmd.args(&cmd_args); - - debug!("Executing in namespace: {:?}", cmd); - let output = cmd.output().context(format!( - "Failed to execute: ip netns exec {} {:?}", - namespace_name, cmd_args - ))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!( - "Failed to configure namespace networking ({}): {}", - cmd_args.join(" "), - stderr - ); - } - } + // Configure veth interface with IP + netlink::add_addr(&handle, &veth_ns, guest_ip, prefix_len).await?; + netlink::set_link_up(&handle, &veth_ns, true).await?; - // Verify routes were added - let mut verify_cmd = Command::new("ip"); - verify_cmd.args(["netns", "exec", &namespace_name, "ip", "route", "show"]); - if let Ok(output) = verify_cmd.output() { - let routes = String::from_utf8_lossy(&output.stdout); - info!( - "Routes in namespace {} after configuration:\n{}", - namespace_name, routes - ); + // Add default route pointing to host + netlink::add_default_route(&handle, host_ip).await?; - if !routes.contains(&host_ip) && !routes.contains("default") { - warn!( - "WARNING: No route to host {} found in namespace. Network may not work properly.", - host_ip - ); - } - } + Ok::<(), anyhow::Error>(()) + })?; debug!("Configured networking inside namespace {}", namespace_name); Ok(()) @@ -275,30 +257,27 @@ impl LinuxJail { fn configure_host_networking(&self) -> Result<()> { let veth_host = self.veth_host(); - // Configure host side of veth - let commands = vec![ - vec!["addr", "add", &self.host_cidr, "dev", &veth_host], - vec!["link", "set", &veth_host, "up"], - ]; + // Parse host CIDR to get IP and prefix + let host_parts: Vec<&str> = self.host_cidr.split('/').collect(); + let host_ip: std::net::Ipv4Addr = + host_parts[0].parse().context("Failed to parse host IP")?; + let prefix_len: u8 = host_parts[1] + .parse() + .context("Failed to parse prefix length")?; - for cmd_args in commands { - let output = Command::new("ip") - .args(&cmd_args) - .output() - .context(format!("Failed to execute: ip {:?}", cmd_args))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Ignore "File exists" errors for IP addresses (might be from previous run) - if !stderr.contains("File exists") { - anyhow::bail!( - "Failed to configure host networking (ip {}): {}", - cmd_args.join(" "), - stderr - ); - } - } - } + // Clone for debug after move + let veth_host_debug = veth_host.clone(); + + // Configure host side + block_on(async move { + let (connection, handle, _) = rtnetlink::new_connection()?; + tokio::spawn(connection); + + netlink::add_addr(&handle, &veth_host, host_ip, prefix_len).await?; + netlink::set_link_up(&handle, &veth_host, true).await?; + + Ok::<(), anyhow::Error>(()) + })?; // Enable IP forwarding for this interface let output = Command::new("sysctl") @@ -313,7 +292,7 @@ impl LinuxJail { ); } - debug!("Configured host side networking for {}", veth_host); + debug!("Configured host side networking for {}", veth_host_debug); Ok(()) } @@ -463,126 +442,46 @@ nameserver {}\n", Ok(()) } - /// Ensure DNS works in the namespace by copying resolv.conf if needed - #[allow(clippy::collapsible_if)] + /// Ensure DNS works in the namespace by bind-mounting resolv.conf fn ensure_namespace_dns(&self) -> Result<()> { let namespace_name = self.namespace_name(); + let source_resolv = format!("/etc/netns/{}/resolv.conf", namespace_name); - // Check if DNS is already working by testing /etc/resolv.conf in namespace - let check_cmd = Command::new("ip") - .args(["netns", "exec", &namespace_name, "cat", "/etc/resolv.conf"]) - .output(); - - let needs_fix = if let Ok(output) = check_cmd { - if !output.status.success() { - info!("Cannot read /etc/resolv.conf in namespace, will fix DNS"); - true - } else { - let content = String::from_utf8_lossy(&output.stdout); - // Check if it's pointing to systemd-resolved or is empty - if content.is_empty() || content.contains("127.0.0.53") { - info!("DNS points to systemd-resolved or is empty in namespace, will fix"); - true - } else if content.contains("nameserver") { - info!("DNS already configured in namespace {}", namespace_name); - false - } else { - info!("No nameserver found in namespace resolv.conf, will fix"); - true - } - } - } else { - info!("Failed to check DNS in namespace, will attempt fix"); - true - }; - - if !needs_fix { - return Ok(()); - } - - // DNS not working, try to fix it by copying a working resolv.conf - info!( - "Fixing DNS in namespace {} by copying resolv.conf", - namespace_name + debug!( + "Bind-mounting {} to /etc/resolv.conf in namespace {}", + source_resolv, namespace_name ); - // Setup DNS for the namespace - // Create a temporary resolv.conf before running the nsenter command - let temp_dir = crate::jail::get_temp_dir(); - std::fs::create_dir_all(&temp_dir).ok(); - let temp_resolv = temp_dir - .join(format!("httpjail_resolv_{}.conf", &namespace_name)) - .to_string_lossy() - .to_string(); - // Use the host veth IP where our dummy DNS server listens - let host_ip = format_ip(self.host_ip); - let dns_content = format!("nameserver {}\n", host_ip); - std::fs::write(&temp_resolv, &dns_content) - .with_context(|| format!("Failed to create temp resolv.conf: {}", temp_resolv))?; - - // First, try to directly write to /etc/resolv.conf in the namespace using echo - let write_cmd = Command::new("ip") - .args([ - "netns", - "exec", - &namespace_name, - "sh", - "-c", - &format!("echo 'nameserver {}' > /etc/resolv.conf", host_ip), - ]) - .output(); - - if let Ok(output) = write_cmd { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - warn!("Failed to write resolv.conf into namespace: {}", stderr); - - // Try another approach - mount bind - let mount_cmd = Command::new("ip") - .args([ - "netns", - "exec", - &namespace_name, - "mount", - "--bind", - &temp_resolv, - "/etc/resolv.conf", - ]) - .output(); - - if let Ok(mount_output) = mount_cmd { - if mount_output.status.success() { - info!("Successfully bind-mounted resolv.conf in namespace"); - } else { - let mount_stderr = String::from_utf8_lossy(&mount_output.stderr); - warn!("Failed to bind mount resolv.conf: {}", mount_stderr); - - // Last resort - try copying the file content - let cp_cmd = Command::new("cp") - .args([ - &temp_resolv, - &format!( - "/proc/self/root/etc/netns/{}/resolv.conf", - namespace_name - ), - ]) - .output(); - - if let Ok(cp_output) = cp_cmd - && cp_output.status.success() - { - info!("Successfully copied resolv.conf via /proc"); - } - } - } - } else { - info!("Successfully wrote resolv.conf into namespace"); + // Use mount --bind to override /etc/resolv.conf inside the namespace + // This works even if /etc/resolv.conf is a symlink + match netlink::execute_in_netns( + &namespace_name, + &[ + "mount".to_string(), + "--bind".to_string(), + source_resolv.clone(), + "/etc/resolv.conf".to_string(), + ], + &[], + None, + ) { + Ok(status) if status.success() => { + debug!("Successfully bind-mounted resolv.conf in namespace"); + } + Ok(status) => { + warn!( + "Failed to bind-mount resolv.conf in namespace (exit: {}), DNS may not work", + status.code().unwrap_or(-1) + ); + } + Err(e) => { + warn!( + "Error bind-mounting resolv.conf in namespace: {}. DNS may not work.", + e + ); } } - // Clean up temp file - let _ = std::fs::remove_file(&temp_resolv); - Ok(()) } @@ -679,12 +578,17 @@ impl Jail for LinuxJail { let drop_privs = if current_uid == 0 { // Running as root - check for SUDO_UID/SUDO_GID to drop privileges to original user match (std::env::var("SUDO_UID"), std::env::var("SUDO_GID")) { - (Ok(uid), Ok(gid)) => { - debug!( - "Will drop privileges to uid={} gid={} after entering namespace", - uid, gid - ); - Some((uid, gid)) + (Ok(uid_str), Ok(gid_str)) => { + if let (Ok(uid), Ok(gid)) = (uid_str.parse::(), gid_str.parse::()) { + debug!( + "Will drop privileges to uid={} gid={} after entering namespace", + uid, gid + ); + Some((uid, gid)) + } else { + debug!("Failed to parse SUDO_UID/SUDO_GID, continuing as root"); + None + } } _ => { debug!("Running as root but no SUDO_UID/SUDO_GID found, continuing as root"); @@ -696,56 +600,16 @@ impl Jail for LinuxJail { None }; - // Build command: ip netns exec - // If we need to drop privileges, we wrap with setpriv - let mut cmd = Command::new("ip"); - cmd.args(["netns", "exec", &self.namespace_name()]); - - // Handle privilege dropping and command execution - if let Some((uid, gid)) = drop_privs { - // Use setpriv to drop privileges to the original user - // setpriv is lighter than runuser - no PAM, direct execve() - cmd.arg("setpriv"); - cmd.arg(format!("--reuid={}", uid)); // Set real and effective UID - cmd.arg(format!("--regid={}", gid)); // Set real and effective GID - cmd.arg("--init-groups"); // Initialize supplementary groups - cmd.arg("--"); // End of options - for arg in command { - cmd.arg(arg); - } - } else { - // No privilege dropping, execute directly - cmd.arg(&command[0]); - for arg in &command[1..] { - cmd.arg(arg); - } - } - - // Set environment variables - for (key, value) in extra_env { - cmd.env(key, value); - } - - // Preserve SUDO environment variables for consistency with macOS - if let Ok(sudo_user) = std::env::var("SUDO_USER") { - cmd.env("SUDO_USER", sudo_user); - } - if let Ok(sudo_uid) = std::env::var("SUDO_UID") { - cmd.env("SUDO_UID", sudo_uid); - } - if let Ok(sudo_gid) = std::env::var("SUDO_GID") { - cmd.env("SUDO_GID", sudo_gid); - } - - debug!("Executing command: {:?}", cmd); + // Execute command in namespace using netlink (no dependency on `ip` binary) + debug!("Executing command in namespace: {:?}", command); // Note: We do NOT set HTTP_PROXY/HTTPS_PROXY environment variables here. // The jail uses nftables rules to transparently redirect traffic to the proxy, // making it work with applications that don't respect proxy environment variables. - let status = cmd - .status() - .context("Failed to execute command in namespace")?; + let status = + netlink::execute_in_netns(&self.namespace_name(), command, extra_env, drop_privs) + .context("Failed to execute command in namespace")?; Ok(status) } diff --git a/src/jail/linux/netlink.rs b/src/jail/linux/netlink.rs new file mode 100644 index 00000000..00ae8fe0 --- /dev/null +++ b/src/jail/linux/netlink.rs @@ -0,0 +1,350 @@ +//! Network namespace operations using netlink instead of `ip` CLI +//! +//! This module provides direct syscall/netlink based alternatives to the `ip` command, +//! allowing httpjail to work in container environments (like sysbox) that have +//! CAP_SYS_ADMIN but don't include the iproute2 package. + +use anyhow::{Context, Result}; +use futures::stream::TryStreamExt; +use nix::mount::umount; +use nix::sched::{CloneFlags, setns}; +use rtnetlink::{Handle, new_connection}; +use std::fs; +use std::net::Ipv4Addr; +use std::os::unix::io::AsRawFd; +use std::os::unix::process::ExitStatusExt; +use std::path::PathBuf; +use tracing::debug; + +const NETNS_RUN_DIR: &str = "/var/run/netns"; + +/// Create a named network namespace +/// +/// This mimics `ip netns add ` by: +/// 1. Creating a new network namespace +/// 2. Bind-mounting it to /var/run/netns/ for persistence +pub fn create_netns(name: &str) -> Result<()> { + let netns_path = PathBuf::from(NETNS_RUN_DIR).join(name); + + // Ensure /var/run/netns exists + fs::create_dir_all(NETNS_RUN_DIR) + .with_context(|| format!("Failed to create directory {}", NETNS_RUN_DIR))?; + + // Create an empty file to use as bind mount target + fs::File::create(&netns_path) + .with_context(|| format!("Failed to create namespace file {:?}", netns_path))?; + + // Fork to create the namespace, then bind mount it + unsafe { + match libc::fork() { + -1 => anyhow::bail!("fork() failed: {}", std::io::Error::last_os_error()), + 0 => { + // Child process + // Create new network namespace + if libc::unshare(libc::CLONE_NEWNET) != 0 { + libc::_exit(1); + } + + // Bind mount our namespace to the file + let source = b"/proc/self/ns/net\0"; + let target = format!("{}\0", netns_path.display()); + let result = libc::mount( + source.as_ptr() as *const libc::c_char, + target.as_ptr() as *const libc::c_char, + std::ptr::null(), + libc::MS_BIND, + std::ptr::null(), + ); + + if result != 0 { + libc::_exit(1); + } + + libc::_exit(0); + } + child_pid => { + // Parent process - wait for child + let mut status: libc::c_int = 0; + if libc::waitpid(child_pid, &mut status, 0) == -1 { + let _ = fs::remove_file(&netns_path); + anyhow::bail!("waitpid() failed: {}", std::io::Error::last_os_error()); + } + + if !libc::WIFEXITED(status) || libc::WEXITSTATUS(status) != 0 { + let _ = fs::remove_file(&netns_path); + anyhow::bail!("Failed to create network namespace"); + } + } + } + } + + debug!("Created network namespace: {}", name); + Ok(()) +} + +/// Delete a named network namespace +/// +/// This mimics `ip netns del ` by unmounting and removing the namespace file +pub fn delete_netns(name: &str) -> Result<()> { + let netns_path = PathBuf::from(NETNS_RUN_DIR).join(name); + + if !netns_path.exists() { + debug!("Namespace {} does not exist, nothing to delete", name); + return Ok(()); + } + + // Unmount the namespace + if let Err(e) = umount(&netns_path) { + // If already unmounted, that's fine + debug!("umount failed (may already be unmounted): {}", e); + } + + // Remove the file + fs::remove_file(&netns_path) + .with_context(|| format!("Failed to remove namespace file {:?}", netns_path))?; + + debug!("Deleted network namespace: {}", name); + Ok(()) +} + +/// Create a veth pair (mimics `ip link add type veth peer name `) +pub async fn create_veth_pair(name1: &str, name2: &str) -> Result<()> { + let (connection, handle, _) = new_connection()?; + tokio::spawn(connection); + handle + .link() + .add() + .veth(name1.to_string(), name2.to_string()) + .execute() + .await + .with_context(|| format!("Failed to create veth pair {} <-> {}", name1, name2))?; + debug!("Created veth pair: {} <-> {}", name1, name2); + Ok(()) +} + +/// Move a network interface into a namespace (mimics `ip link set netns `) +pub async fn move_link_to_netns(interface: &str, netns_name: &str) -> Result<()> { + let netns_path = PathBuf::from(NETNS_RUN_DIR).join(netns_name); + let netns_fd = fs::File::open(&netns_path) + .with_context(|| format!("Failed to open namespace {:?}", netns_path))?; + + let (connection, handle, _) = new_connection()?; + tokio::spawn(connection); + let idx = get_link_index(&handle, interface).await?; + handle + .link() + .set(idx) + .setns_by_fd(netns_fd.as_raw_fd()) + .execute() + .await + .with_context(|| format!("Failed to move {} to namespace {}", interface, netns_name))?; + debug!("Moved interface {} to namespace {}", interface, netns_name); + Ok(()) +} + +/// Get link index by name +async fn get_link_index(handle: &Handle, interface: &str) -> Result { + let mut links = handle + .link() + .get() + .match_name(interface.to_string()) + .execute(); + links + .try_next() + .await? + .map(|link| link.header.index) + .ok_or_else(|| anyhow::anyhow!("Interface {} not found", interface)) +} + +/// Set an interface up or down (mimics `ip link set up/down`) +pub async fn set_link_up(handle: &Handle, interface: &str, up: bool) -> Result<()> { + let idx = get_link_index(handle, interface).await?; + let req = handle.link().set(idx); + if up { + req.up().execute().await?; + debug!("Set interface {} up", interface); + } else { + req.down().execute().await?; + debug!("Set interface {} down", interface); + } + Ok(()) +} + +/// Add an IP address to an interface (mimics `ip addr add / dev `) +pub async fn add_addr( + handle: &Handle, + interface: &str, + addr: Ipv4Addr, + prefix_len: u8, +) -> Result<()> { + let idx = get_link_index(handle, interface).await?; + handle + .address() + .add(idx, addr.into(), prefix_len) + .execute() + .await + .with_context(|| { + format!( + "Failed to add address {}/{} to {}", + addr, prefix_len, interface + ) + })?; + debug!("Added address {}/{} to {}", addr, prefix_len, interface); + Ok(()) +} + +/// Add a default route (mimics `ip route add default via `) +pub async fn add_default_route(handle: &Handle, gateway: Ipv4Addr) -> Result<()> { + handle + .route() + .add() + .v4() + .gateway(gateway) + .execute() + .await + .context("Failed to add default route")?; + debug!("Added default route via {}", gateway); + Ok(()) +} + +/// Delete a link (mimics `ip link del `) +pub async fn delete_link(interface: &str) -> Result<()> { + let (connection, handle, _) = new_connection()?; + tokio::spawn(connection); + match get_link_index(&handle, interface).await { + Ok(idx) => { + handle + .link() + .del(idx) + .execute() + .await + .with_context(|| format!("Failed to delete interface {}", interface))?; + debug!("Deleted interface {}", interface); + } + Err(_) => { + debug!("Interface {} not found, nothing to delete", interface); + } + } + Ok(()) +} + +/// Get a netlink handle connected to a specific namespace +pub async fn get_handle_in_netns(name: &str) -> Result { + let netns_path = PathBuf::from(NETNS_RUN_DIR).join(name); + let netns_fd = fs::File::open(&netns_path) + .with_context(|| format!("Failed to open namespace {:?}", netns_path))?; + + // Open current namespace to restore later + let current_ns = + fs::File::open("/proc/self/ns/net").context("Failed to open current network namespace")?; + + // Enter the target namespace + setns(&netns_fd, CloneFlags::CLONE_NEWNET).context("Failed to enter namespace")?; + + // Create connection in this namespace + let (connection, handle, _) = new_connection()?; + tokio::spawn(connection); + + // Return to original namespace + let _ = setns(¤t_ns, CloneFlags::CLONE_NEWNET); + + Ok(handle) +} + +/// Execute a command in a namespace (equivalent to `ip netns exec`) +/// +/// This uses setns to enter the namespace and then fork/exec the command. +pub fn execute_in_netns( + namespace_name: &str, + command: &[String], + extra_env: &[(String, String)], + drop_privs: Option<(u32, u32)>, // (uid, gid) +) -> Result { + use std::os::unix::process::CommandExt; + use std::process::Command; + + if command.is_empty() { + anyhow::bail!("No command specified"); + } + + let netns_path = PathBuf::from(NETNS_RUN_DIR).join(namespace_name); + let netns_fd = std::fs::File::open(&netns_path) + .with_context(|| format!("Failed to open namespace {:?}", netns_path))?; + let netns_raw_fd = netns_fd.as_raw_fd(); + + // Fork and exec in the namespace + unsafe { + match libc::fork() { + -1 => anyhow::bail!("fork() failed: {}", std::io::Error::last_os_error()), + 0 => { + // Child process + // Enter the network namespace using raw libc call + if libc::setns(netns_raw_fd, libc::CLONE_NEWNET) != 0 { + libc::_exit(127); + } + + // Drop privileges if requested + if let Some((uid, gid)) = drop_privs { + // Set GID first (must be done before dropping UID) + if libc::setgid(gid) != 0 { + libc::_exit(126); + } + // Initialize supplementary groups + if libc::setgroups(0, std::ptr::null()) != 0 { + libc::_exit(126); + } + // Set UID + if libc::setuid(uid) != 0 { + libc::_exit(126); + } + } + + // Build command + let mut cmd = Command::new(&command[0]); + for arg in &command[1..] { + cmd.arg(arg); + } + + // Set environment variables + for (key, value) in extra_env { + cmd.env(key, value); + } + + // Preserve SUDO environment variables + if let Ok(sudo_user) = std::env::var("SUDO_USER") { + cmd.env("SUDO_USER", sudo_user); + } + if let Ok(sudo_uid) = std::env::var("SUDO_UID") { + cmd.env("SUDO_UID", sudo_uid); + } + if let Ok(sudo_gid) = std::env::var("SUDO_GID") { + cmd.env("SUDO_GID", sudo_gid); + } + + // Execute (this replaces the current process) + let err = cmd.exec(); + // If we get here, exec failed + eprintln!("Failed to exec: {}", err); + libc::_exit(127); + } + child_pid => { + // Parent process - wait for child + let mut status: libc::c_int = 0; + if libc::waitpid(child_pid, &mut status, 0) == -1 { + anyhow::bail!("waitpid() failed: {}", std::io::Error::last_os_error()); + } + + // Convert status to ExitStatus + if libc::WIFEXITED(status) { + let code = libc::WEXITSTATUS(status); + Ok(std::process::ExitStatus::from_raw(code << 8)) + } else if libc::WIFSIGNALED(status) { + let signal = libc::WTERMSIG(status); + Ok(std::process::ExitStatus::from_raw(signal)) + } else { + Ok(std::process::ExitStatus::from_raw(status)) + } + } + } + } +} diff --git a/src/jail/linux/resources.rs b/src/jail/linux/resources.rs index ccc2584f..38ae1798 100644 --- a/src/jail/linux/resources.rs +++ b/src/jail/linux/resources.rs @@ -1,7 +1,21 @@ use crate::sys_resource::SystemResource; use anyhow::{Context, Result}; -use std::process::Command; -use tracing::{debug, info}; +use std::future::Future; +use tracing::debug; + +/// Run async code, using the ambient tokio runtime when available +fn block_on(future: F) -> F::Output +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)), + Err(_) => tokio::runtime::Runtime::new() + .expect("Failed to create tokio runtime") + .block_on(future), + } +} /// Network namespace resource pub struct NetworkNamespace { @@ -20,24 +34,14 @@ impl SystemResource for NetworkNamespace { fn create(jail_id: &str) -> Result { let name = format!("httpjail_{}", jail_id); - let output = Command::new("ip") - .args(["netns", "add", &name]) - .output() - .context("Failed to execute ip netns add")?; - - if output.status.success() { - info!("Created network namespace: {}", name); - Ok(Self { - name, - created: true, - }) - } else { - anyhow::bail!( - "Failed to create namespace {}: {}", - name, - String::from_utf8_lossy(&output.stderr) - ) - } + // Use netlink-based implementation instead of `ip` command + super::netlink::create_netns(&name) + .context("Failed to create network namespace via netlink")?; + + Ok(Self { + name, + created: true, + }) } fn cleanup(&mut self) -> Result<()> { @@ -45,25 +49,10 @@ impl SystemResource for NetworkNamespace { return Ok(()); } - let output = Command::new("ip") - .args(["netns", "del", &self.name]) - .output() - .context("Failed to execute ip netns del")?; - - if output.status.success() { - debug!("Deleted network namespace: {}", self.name); - self.created = false; - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("No such file") || stderr.contains("Cannot find") { - // Already deleted - self.created = false; - Ok(()) - } else { - Err(anyhow::anyhow!("Failed to delete namespace: {}", stderr)) - } - } + super::netlink::delete_netns(&self.name).context("Failed to delete network namespace")?; + + self.created = false; + Ok(()) } fn for_existing(jail_id: &str) -> Self { @@ -100,26 +89,18 @@ impl SystemResource for VethPair { let host_name = format!("vh_{}", jail_id); let ns_name = format!("vn_{}", jail_id); - let output = Command::new("ip") - .args([ - "link", "add", &host_name, "type", "veth", "peer", "name", &ns_name, - ]) - .output() - .context("Failed to create veth pair")?; - - if output.status.success() { - debug!("Created veth pair: {} <-> {}", host_name, ns_name); - Ok(Self { - host_name, - ns_name, - created: true, - }) - } else { - anyhow::bail!( - "Failed to create veth pair: {}", - String::from_utf8_lossy(&output.stderr) - ) - } + // Use netlink-based implementation + let host_clone = host_name.clone(); + let ns_clone = ns_name.clone(); + block_on(async move { super::netlink::create_veth_pair(&host_clone, &ns_clone).await }) + .context("Failed to create veth pair via netlink")?; + + debug!("Created veth pair: {} <-> {}", host_name, ns_name); + Ok(Self { + host_name, + ns_name, + created: true, + }) } fn cleanup(&mut self) -> Result<()> { @@ -128,9 +109,8 @@ impl SystemResource for VethPair { } // Deleting the host side will automatically delete both ends - let _ = Command::new("ip") - .args(["link", "del", &self.host_name]) - .output(); + let host_name = self.host_name.clone(); + let _ = block_on(async move { super::netlink::delete_link(&host_name).await }); self.created = false; Ok(()) diff --git a/src/main.rs b/src/main.rs index dc7cb423..bda70b4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -554,6 +554,11 @@ async fn main() -> Result<()> { jail_config.http_proxy_port = actual_http_port; jail_config.https_proxy_port = actual_https_port; + // Ensure a command was provided before doing any privileged setup + if args.run_args.exec_command.is_empty() { + anyhow::bail!("No command specified"); + } + // Create and setup jail let mut jail = create_jail( jail_config.clone(),