diff --git a/.gitignore b/.gitignore index af48cc8d..fe892c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__/ *.xcuserstate editable.marker *.DS_Store +/.vscode/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2b317d60..5c26589e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2049,6 +2049,7 @@ dependencies = [ "hickory-server", "image", "internet-packet", + "libc", "log", "lru_time_cache", "nix 0.31.3", diff --git a/Cargo.toml b/Cargo.toml index df54cb34..f2cc82d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,7 +100,9 @@ sysinfo = "0.39.6" [target.'cfg(target_os = "linux")'.dependencies] tun = { workspace = true, features = ["async"] } tempfile = "3.20.0" + sysinfo = "0.39.6" +libc = "0.2" [dev-dependencies] env_logger = "0.11" @@ -119,3 +121,6 @@ opt-level = 3 [features] tracing = ["console-subscriber"] +# Enables tests that require running as root (uid 0) and eBPF-capable kernel. +# Usage: CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo -E" cargo test --features root-tests +root-tests = [] diff --git a/mitmproxy-linux/src/main2.rs b/mitmproxy-linux/src/main2.rs index 9f6227a1..aff8c03e 100644 --- a/mitmproxy-linux/src/main2.rs +++ b/mitmproxy-linux/src/main2.rs @@ -313,4 +313,85 @@ mod tests { async fn bpf_load() { load_bpf(0).unwrap(); } + + /// Regression test for: mitmproxy started as root (e.g. privileged + /// Kubernetes container) must NOT require `sudo`. + /// + /// The test simulates a container image that has no `sudo` binary by + /// running with a PATH that excludes any directory containing `sudo`. + /// It then invokes the redirector binary directly (as root would) and + /// verifies it starts — proving that the fix in `start_redirector` works: + /// when `uid == 0`, the binary is launched directly without `sudo`. + /// + /// Requires: feature `root-tests` + runner must be root (uid 0). + /// In CI this is achieved with: + /// CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo -E" cargo test --features root-tests + #[cfg_attr(not(feature = "root-tests"), ignore)] + #[tokio::test] + async fn root_starts_redirector_without_sudo() { + // Gate: this test only makes sense when running as root. + let uid = unsafe { libc::getuid() }; + assert_eq!(uid, 0, "this test must run as root (uid=0), got uid={uid}"); + + // Simulate a container image without sudo: remove every directory + // that contains a `sudo` binary from PATH. + let path_without_sudo: String = std::env::var("PATH") + .unwrap_or_default() + .split(':') + .filter(|dir| !std::path::Path::new(dir).join("sudo").exists()) + .collect::>() + .join(":"); + // Safety: single-threaded at this point in the test, env mutation is OK. + unsafe { std::env::set_var("PATH", &path_without_sudo) }; + + // Sanity-check: sudo must not be reachable anymore. + let sudo_found = std::process::Command::new("which") + .arg("sudo") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + assert!( + !sudo_found, + "sudo is still reachable via PATH={path_without_sudo:?}; pre-condition not met" + ); + + // The redirector binary is produced by `cargo build` in the same + // workspace. CARGO_BIN_EXE_mitmproxy-linux-redirector is injected by + // cargo when compiling tests that live in the same workspace as the + // binary target. + let redirector_exe = std::path::PathBuf::from( + env!("CARGO_BIN_EXE_mitmproxy-linux-redirector"), + ); + assert!( + redirector_exe.exists(), + "redirector binary not found at {}; run `cargo build` first", + redirector_exe.display() + ); + + // Spawn the redirector directly (as root, no sudo). + // We pass an intentionally non-existent pipe-dir so the process exits + // quickly with an error, but the important thing is that it was + // LAUNCHED — meaning no "Failed to run sudo" error occurred. + let output = std::process::Command::new(&redirector_exe) + .arg("/tmp/mitmproxy-root-test-nonexistent") + .env("PATH", &path_without_sudo) + .output() + .expect("failed to spawn the redirector binary — did it fail because sudo was missing?"); + + // The process may exit with an error (missing pipe dir / no eBPF), + // but must NOT produce the "No such file or directory" error that + // comes from trying to execute a missing `sudo`. + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("No such file or directory"), + "redirector stderr contains 'No such file or directory', \ + which suggests sudo was invoked even though we are root.\n\ + stderr: {stderr}" + ); + assert!( + !stderr.contains("Failed to run sudo"), + "redirector stderr contains 'Failed to run sudo' — the fix is not active.\n\ + stderr: {stderr}" + ); + } } diff --git a/rust_out b/rust_out new file mode 100755 index 00000000..49a4d860 Binary files /dev/null and b/rust_out differ diff --git a/src/packet_sources/linux.rs b/src/packet_sources/linux.rs index fff263ae..338ce6e1 100755 --- a/src/packet_sources/linux.rs +++ b/src/packet_sources/linux.rs @@ -21,31 +21,68 @@ use tokio::net::UnixDatagram; use tokio::process::Command; use tokio::time::timeout; +/// Returns `true` if the current process is running as root (uid 0). +#[cfg(unix)] +fn is_root() -> bool { + // SAFETY: getuid() is always safe to call. + unsafe { libc::getuid() == 0 } +} + +/// Builds the [`std::process::Command`] used to launch the redirector binary. +/// +/// When `already_root` is `true`, the redirector is invoked directly so that +/// environments without `sudo` (e.g. a privileged Kubernetes container) work +/// out of the box. Otherwise `sudo --non-interactive --preserve-env` is +/// prepended to perform privilege escalation. +/// +/// Extracted as a pure helper so it can be unit-tested without spawning +/// real processes. +fn build_redirector_command( + executable: &Path, + listener_addr: &Path, + already_root: bool, +) -> Command { + if already_root { + let mut cmd = Command::new(executable); + cmd.arg(listener_addr); + cmd + } else { + let mut cmd = Command::new("sudo"); + cmd.arg("--non-interactive") + .arg("--preserve-env") + .arg(executable) + .arg(listener_addr); + cmd + } +} + async fn start_redirector( executable: &Path, listener_addr: &Path, shutdown: shutdown::Receiver, ) -> Result { - debug!("Elevating privileges..."); - // Try to elevate privileges using a dummy sudo invocation. - // The idea here is to block execution and give the user time to enter their password. - // For now, we naively assume that all systems 1) have sudo and 2) timestamp_timeout > 0. - let mut sudo = Command::new("sudo") - .arg("echo") - .arg("-n") - .spawn() - .context("Failed to run sudo.")?; - sudo.stdin.take(); - if !sudo.wait().await.is_ok_and(|x| x.success()) { - bail!("Failed to elevate privileges"); + let already_root = is_root(); + + if already_root { + debug!("Already running as root, skipping privilege elevation."); + } else { + debug!("Elevating privileges..."); + // Try to elevate privileges using a dummy sudo invocation. + // The idea here is to block execution and give the user time to enter their password. + // For now, we naively assume that all systems 1) have sudo and 2) timestamp_timeout > 0. + let mut sudo = Command::new("sudo") + .arg("echo") + .arg("-n") + .spawn() + .context("Failed to run sudo.")?; + sudo.stdin.take(); + if !sudo.wait().await.is_ok_and(|x| x.success()) { + bail!("Failed to elevate privileges"); + } } debug!("Starting mitmproxy-linux-redirector..."); - let mut redirector_process = Command::new("sudo") - .arg("--non-interactive") - .arg("--preserve-env") - .arg(executable) - .arg(listener_addr) + let mut redirector_process = build_redirector_command(executable, listener_addr, already_root) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -215,3 +252,136 @@ impl PacketSourceTask for LinuxTask { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::path::Path; + + // ----------------------------------------------------------------------- + // is_root() + // ----------------------------------------------------------------------- + + /// `is_root()` must agree with the raw `getuid()` syscall. + #[test] + fn is_root_matches_getuid() { + let uid = unsafe { libc::getuid() }; + assert_eq!(is_root(), uid == 0); + } + + /// Running `cargo test` without privileges means we are NOT root. + /// This guards against accidentally shipping a build where `is_root()` + /// is hardcoded to `true`. + #[test] + fn is_root_is_false_when_unprivileged() { + if unsafe { libc::getuid() } == 0 { + // Explicitly skip when the test runner itself is root (e.g. CI + // root-tests run). Use the `root_*` tests below instead. + return; + } + assert!(!is_root(), "expected is_root() == false for non-root user"); + } + + /// When tests are explicitly run as root (feature `root-tests`), confirm + /// that `is_root()` returns `true`. + #[cfg(feature = "root-tests")] + #[test] + fn is_root_is_true_when_privileged() { + assert!(is_root(), "expected is_root() == true when running as root"); + } + + // ----------------------------------------------------------------------- + // build_redirector_command() + // ----------------------------------------------------------------------- + + /// Helper: extract the program name from a `tokio::process::Command`. + fn program_of(cmd: &Command) -> String { + // as_std() gives std::process::Command whose Debug format is: + // "program" "arg1" "arg2" ... + let dbg = format!("{:?}", cmd.as_std()); + dbg.trim_start_matches('"') + .split('"') + .next() + .unwrap_or("") + .to_string() + } + + /// Helper: collect all arguments from a `tokio::process::Command`. + fn args_of(cmd: &Command) -> Vec { + // std::process::Command Debug format: `"prog" "a" "b" ...` + let dbg = format!("{:?}", cmd.as_std()); + let mut tokens = dbg.split('"').filter(|s| !s.trim().is_empty()); + tokens.next(); // skip program + tokens.map(|s| s.to_string()).collect() + } + + /// When already root, the command must start with the redirector executable + /// itself — *not* with `sudo`. + #[test] + fn command_as_root_runs_executable_directly() { + let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector"); + let addr = Path::new("/tmp/mitmproxy-test"); + let cmd = build_redirector_command(exe, addr, /* already_root = */ true); + let prog = program_of(&cmd); + assert!( + prog.ends_with("mitmproxy-linux-redirector"), + "expected executable as first token, got: {prog:?}" + ); + assert!( + !prog.contains("sudo"), + "sudo must NOT appear as the program when already root, got: {prog:?}" + ); + } + + /// When NOT root, the command must start with `sudo`. + #[test] + fn command_without_root_uses_sudo() { + let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector"); + let addr = Path::new("/tmp/mitmproxy-test"); + let cmd = build_redirector_command(exe, addr, /* already_root = */ false); + let prog = program_of(&cmd); + assert!( + prog.ends_with("sudo"), + "expected 'sudo' as first token, got: {prog:?}" + ); + } + + /// When NOT root, the sudo invocation must pass `--non-interactive` and + /// `--preserve-env` so that the redirector inherits the user's environment + /// variables without prompting for a password. + #[test] + fn sudo_command_has_required_flags() { + let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector"); + let addr = Path::new("/tmp/mitmproxy-test"); + let cmd = build_redirector_command(exe, addr, false); + let args = args_of(&cmd); + assert!( + args.iter().any(|a| a == "--non-interactive"), + "--non-interactive flag missing from sudo invocation; args={args:?}" + ); + assert!( + args.iter().any(|a| a == "--preserve-env"), + "--preserve-env flag missing from sudo invocation; args={args:?}" + ); + } + + /// The listener address must be the last argument in both the root and + /// non-root command variants. + #[test] + fn listener_addr_is_last_argument() { + let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector"); + let addr = Path::new("/tmp/mitmproxy-9999"); + + for already_root in [true, false] { + let cmd = build_redirector_command(exe, addr, already_root); + let args = args_of(&cmd); + let last = args.last().cloned().unwrap_or_default(); + assert_eq!( + OsStr::new(&last), + addr.as_os_str(), + "listener_addr must be the last argument (already_root={already_root}); args={args:?}" + ); + } + } +}