diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ec7a4c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: taiki-e/install-action@nextest + + - run: cargo nextest run + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - run: rustup component add clippy + + - run: cargo clippy -- -D warnings + + insanity-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - run: rustup component add clippy rustfmt + + - run: make check diff --git a/.gitignore b/.gitignore index 4c784c6..7577132 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ .bunker/ .* +!.github/ # Documentation .venv-docs/ diff --git a/Cargo.toml b/Cargo.toml index 189707d..c40994a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,10 @@ codegen-units = 1 strip = true panic = "abort" +[lib] +name = "bunkerbox" +path = "src/lib.rs" + [[bin]] name = "bunkerbox" path = "src/main.rs" diff --git a/README.md b/README.md index a220a40..cbb6f41 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ # Bunkerbox +[![CI](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml) +[![Rust](https://img.shields.io/badge/rust-1.97.1-orange)](https://www.rust-lang.org) +[![License](https://img.shields.io/github/license/anomalyco/bunkerbox)](https://github.com/anomalyco/bunkerbox/blob/main/LICENSE) + **Run powerful development agents without handing them your whole machine.** Bunkerbox is an isolation layer for coding agents, CLIs, and other developer tools that need to work inside a project directory. Instead of running those tools directly on the host, Bunkerbox runs them inside a Kata-backed container with a controlled workspace, controlled home directory, and explicit runtime configuration. diff --git a/src/bin/bunkerbox-image.rs b/src/bin/bunkerbox-image.rs index 5cf567f..50d2c07 100644 --- a/src/bin/bunkerbox-image.rs +++ b/src/bin/bunkerbox-image.rs @@ -314,10 +314,9 @@ fn find_vscomm_binary() -> Result { } } - Err(format!( - "musl-static bunkerbox-vscomm not found in target/x86_64-unknown-linux-musl/{{release,debug}}/\n\ + Err("musl-static bunkerbox-vscomm not found in target/x86_64-unknown-linux-musl/{release,debug}/\n\ build it first: make musl-vscomm" - )) + .to_string()) } fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> { diff --git a/src/cfg.rs b/src/cfg.rs index a364054..eb17e20 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -135,11 +135,7 @@ impl RuntimeConfig { pub fn effective_session_cleanup(&self) -> Vec { let user = self.session_cleanup.as_deref().unwrap_or(&[]); - DEFAULT_SESSION_CLEANUP - .iter() - .map(|s| s.to_string()) - .chain(user.iter().cloned()) - .collect() + DEFAULT_SESSION_CLEANUP.iter().map(|s| s.to_string()).chain(user.iter().cloned()).collect() } #[allow(dead_code)] diff --git a/src/cfgsetup.rs b/src/cfgsetup.rs index 0003d8d..aea70d5 100644 --- a/src/cfgsetup.rs +++ b/src/cfgsetup.rs @@ -23,12 +23,14 @@ pub fn run(runtime: Option<&RuntimeConfig>) -> Result<(), String> { let quota = pick_quota()?; let env_mode = pick_env_mode()?; - let detected = if env_mode == EnvMode::Paranoid { buildsys::scan(&repo_root, buildsys::PassthroughMode::Paranoid) } else { detected_relaxed.clone() }; + let detected = + if env_mode == EnvMode::Paranoid { buildsys::scan(&repo_root, buildsys::PassthroughMode::Paranoid) } else { detected_relaxed.clone() }; let passthrough = build_passthrough(detected, env_mode)?; let profiles = pick_profiles(&detected_relaxed)?; let overrides = pick_overrides(runtime)?; - let cfg = ProjectConfig { project: ProjectSection { env: env_mode, quota: Some(quota), exclude: Vec::new(), passthrough }, image: overrides, profiles }; + let cfg = + ProjectConfig { project: ProjectSection { env: env_mode, quota: Some(quota), exclude: Vec::new(), passthrough }, image: overrides, profiles }; let path = repo_root.join(ProjectConfig::PATH); std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| format!("failed to create {}: {e}", path.parent().unwrap().display()))?; diff --git a/src/daemon.rs b/src/daemon.rs index 16f6051..eceb425 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,4 +1,5 @@ use crate::cfg::EnvMode; +use crate::proxy::FilterProxy; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; use crate::vscomm::{ExecRequest, Frame, FrameType, VSOCK_PORT}; use std::path::{Path, PathBuf}; @@ -12,36 +13,28 @@ struct VsockSession { env_mode: EnvMode, workspace: PathBuf, merged_profile: Option>, + has_proxy: bool, } pub struct VsockDaemon { join_handle: tokio::task::JoinHandle<()>, shutdown: tokio::sync::oneshot::Sender<()>, + proxy_handle: Option>, } impl VsockDaemon { pub fn start( - passthrough: Vec, - env_mode: EnvMode, - workspace: PathBuf, - profiles: Vec, - share_dir: PathBuf, + passthrough: Vec, env_mode: EnvMode, workspace: PathBuf, profiles: Vec, share_dir: PathBuf, allow: Vec, ) -> Result { let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let merged_profile = if profiles.is_empty() { None } else { - let loaded: Vec<_> = profiles - .iter() - .map(|p| resolve_profile(p, &share_dir)) - .collect::, _>>()?; + let loaded: Vec<_> = profiles.iter().map(|p| resolve_profile(p, &share_dir)).collect::, _>>()?; let merged = MergedProfile::from_profiles(&loaded); - let check = std::process::Command::new("bwrap") - .arg("--version") - .output() - .map_err(|e| format!("bwrap not found: {e}"))?; + let check = std::process::Command::new("bwrap").arg("--version").output().map_err(|e| format!("bwrap not found: {e}"))?; if !check.status.success() { return Err("bwrap is not functional. Install bubblewrap to use sandbox profiles.".into()); } @@ -49,12 +42,18 @@ impl VsockDaemon { Some(Arc::new(merged)) }; - let session = Arc::new(VsockSession { - passthrough: Arc::new(passthrough), - env_mode, - workspace, - merged_profile, - }); + let has_proxy = !allow.is_empty(); + let proxy_handle = if has_proxy { + let rt = tokio::runtime::Handle::current(); + Some(rt.block_on(async { + let proxy = FilterProxy::new(allow); + proxy.bind().await + })?) + } else { + None + }; + + let session = Arc::new(VsockSession { passthrough: Arc::new(passthrough), env_mode, workspace, merged_profile, has_proxy }); let join_handle = tokio::spawn(async move { let result = daemon_loop(session, shutdown_rx).await; @@ -63,25 +62,26 @@ impl VsockDaemon { } }); - Ok(Self { join_handle, shutdown: shutdown_tx }) + Ok(Self { join_handle, shutdown: shutdown_tx, proxy_handle }) } pub async fn shutdown(self) { let _ = self.shutdown.send(()); let _ = self.join_handle.await; + if let Some(handle) = self.proxy_handle { + handle.abort(); + } } } -async fn daemon_loop( - session: Arc, - mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, -) -> Result<(), String> { +async fn daemon_loop(session: Arc, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>) -> Result<(), String> { use tokio_vsock::VsockListener; let listener = match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, VSOCK_PORT)) { Ok(l) => l, - Err(e) => { - eprintln!("bunkerbox: vsock unavailable (passthrough disabled): {e}"); + Err(_e) => { + // TODO: route to log socket + // eprintln!("bunkerbox: vsock unavailable (passthrough disabled): {e}"); let _ = shutdown_rx.await; return Ok(()); } @@ -94,8 +94,9 @@ async fn daemon_loop( Ok((stream, _peer)) => { let session = session.clone(); tokio::spawn(async move { - if let Err(err) = handle_connection(stream, &session).await { - eprintln!("bunkerbox: vsock session error: {err}"); + if let Err(_err) = handle_connection(stream, &session).await { + // TODO: route to log socket + // eprintln!("bunkerbox: vsock session error: {err}"); } }); } @@ -113,10 +114,7 @@ async fn daemon_loop( Ok(()) } -async fn handle_connection( - stream: tokio_vsock::VsockStream, - session: &VsockSession, -) -> Result<(), String> { +async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSession) -> Result<(), String> { let (mut reader, mut writer) = tokio::io::split(stream); let req = read_exec_request(&mut reader).await?; @@ -130,9 +128,7 @@ async fn handle_connection( let sandbox_cwd = req.cwd.clone(); let host_cwd = if req.cwd.starts_with("/workspace") { - session - .workspace - .join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/')) + session.workspace.join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/')) } else { PathBuf::from(&req.cwd) }; @@ -219,7 +215,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san cmd.arg("--ro-bind").arg(&resolved).arg("/bin/sh"); } - if matches!(merged.network, NetworkMode::None) { + if !session.has_proxy && matches!(merged.network, NetworkMode::None) { cmd.arg("--unshare-net"); } @@ -242,13 +238,30 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san cmd.arg("--setenv").arg("PATH").arg("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"); cmd.arg("--setenv").arg("HOME").arg("/home"); + if session.has_proxy { + let proxy_url = "http://127.0.0.1:20000"; + cmd.arg("--setenv").arg("HTTP_PROXY").arg(proxy_url); + cmd.arg("--setenv").arg("HTTPS_PROXY").arg(proxy_url); + cmd.arg("--setenv").arg("http_proxy").arg(proxy_url); + cmd.arg("--setenv").arg("https_proxy").arg(proxy_url); + } + for (key, val) in &merged.env { cmd.arg("--setenv").arg(key).arg(val); } if session.env_mode == EnvMode::Relaxed { for (key, val) in &req.env { - if key == "PATH" || key == "HOME" || key == "VSOCK_CID" || key.starts_with("BUNKERBOX_") || key.starts_with("XDG_") { + if key == "PATH" + || key == "HOME" + || key == "VSOCK_CID" + || key == "HTTP_PROXY" + || key == "HTTPS_PROXY" + || key == "http_proxy" + || key == "https_proxy" + || key.starts_with("BUNKERBOX_") + || key.starts_with("XDG_") + { continue; } cmd.arg("--setenv").arg(key).arg(val); @@ -269,7 +282,16 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san if session.env_mode == EnvMode::Relaxed { for (key, val) in &req.env { - if key == "PATH" || key == "HOME" || key == "VSOCK_CID" || key.starts_with("BUNKERBOX_") || key.starts_with("XDG_") { + if key == "PATH" + || key == "HOME" + || key == "VSOCK_CID" + || key == "HTTP_PROXY" + || key == "HTTPS_PROXY" + || key == "http_proxy" + || key == "https_proxy" + || key.starts_with("BUNKERBOX_") + || key.starts_with("XDG_") + { continue; } cmd.env(key, val); @@ -309,11 +331,7 @@ fn is_allowed(passthrough: &[String], command: &str, args: &[String]) -> bool { return true; } } else { - let full = if args.is_empty() { - command.to_string() - } else { - format!("{} {}", command, args.join(" ")) - }; + let full = if args.is_empty() { command.to_string() } else { format!("{} {}", command, args.join(" ")) }; if entry == full { return true; } @@ -327,8 +345,7 @@ async fn read_exec_request(reader: &mut R) -> Result(reader: &mut R) -> Result( - mut reader: R, - tx: tokio::sync::mpsc::UnboundedSender>, -) { +async fn pump_to_channel(mut reader: R, tx: tokio::sync::mpsc::UnboundedSender>) { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf).await { diff --git a/src/kata.rs b/src/kata.rs index 357f4f2..bdbd4a0 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -704,11 +704,7 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res let home_size = dir_size(home_path); let max_size = (session_mb as u64) * 1024 * 1024; if home_size > max_size { - eprintln!( - "bunkerbox: warning: home directory is {} MB but session is only {} MB - copy may fail", - home_size / (1024 * 1024), - session_mb - ); + eprintln!("bunkerbox: warning: home directory is {} MB but session is only {} MB - copy may fail", home_size / (1024 * 1024), session_mb); } let cp_status = run_command_quiet("cp", &["-a", &format!("{}/.", home_path.display()), &format!("{}/", session_dir.display())]); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d63a0e9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod cfg; +pub mod cfgsetup; +pub mod clidef; +pub mod cmdrun; +pub mod daemon; +pub mod kata; +pub mod overlay; +pub mod proxy; +pub mod sandbox; +pub mod vscomm; +pub mod workspace; diff --git a/src/main.rs b/src/main.rs index 6547aaa..3065a90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,5 @@ -mod cfg; -mod cfgsetup; -mod clidef; -mod cmdrun; -mod daemon; -mod kata; -mod overlay; -mod sandbox; -mod vscomm; -mod workspace; - -use cfg::{ProjectConfig, WorkspaceMode}; +use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; +use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, workspace}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -170,6 +160,8 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option = config.allow.clone().unwrap_or_default().into_iter().chain(env.image.allow.clone().unwrap_or_default()).collect(); + let daemon = if !env.project.passthrough.is_empty() { Some(daemon::VsockDaemon::start( env.project.passthrough.clone(), @@ -177,6 +169,7 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option, +} + +impl FilterProxy { + pub fn new(allow: Vec) -> Self { + Self { allow } + } + + pub async fn bind(self) -> Result, String> { + let (handle, _) = self.bind_on(PORT).await?; + Ok(handle) + } + + pub async fn bind_on(self, port: u16) -> Result<(tokio::task::JoinHandle<()>, u16), String> { + let addr: SocketAddr = format!("{BIND_ADDR}:{port}").parse().map_err(|e| format!("invalid proxy bind address: {e}"))?; + + let listener = TcpListener::bind(addr).await.map_err(|e| format!("failed to bind proxy on {addr}: {e}"))?; + let bound_port = listener.local_addr().map_err(|e| format!("get local addr: {e}"))?.port(); + + let allow = self.allow; + + let handle = tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _peer)) => { + let allow = allow.clone(); + tokio::spawn(async move { + // TODO: route to log socket + let _ = handle_client(stream, &allow).await; + }); + } + Err(e) => { + eprintln!("bunkerbox-proxy: accept error: {e}"); + } + } + } + }); + + Ok((handle, bound_port)) + } +} + +async fn handle_client(mut client: TcpStream, allow: &[String]) -> Result<(), String> { + let mut buf = [0u8; 8192]; + let n = client.read(&mut buf).await.map_err(|e| format!("read request: {e}"))?; + + if n == 0 { + return Ok(()); + } + + let request = String::from_utf8_lossy(&buf[..n]); + let first_line = request.lines().next().unwrap_or(""); + + let parts: Vec<&str> = first_line.split_whitespace().collect(); + if parts.len() < 2 { + return Err(format!("malformed request: {first_line}")); + } + + let method = parts[0]; + let target = parts[1]; + + let (host, port, is_connect) = if method.eq_ignore_ascii_case("CONNECT") { + let (h, p) = parse_host_port(target)?; + (h, p, true) + } else if target.starts_with("http://") { + let url = target.strip_prefix("http://").ok_or_else(|| format!("malformed URL: {target}"))?; + let (h, p) = url.split_once('/').map_or_else(|| (url, "80"), |(hp, _)| { + hp.split_once(':').unwrap_or((hp, "80")) + }); + (h.to_string(), p.to_string(), false) + } else { + return Err(format!("unsupported request: {first_line}")); + }; + + if !is_allowed(&host, allow) { + let forbidden = b"HTTP/1.1 403 Forbidden\r\n\r\n"; + let _ = client.write_all(forbidden).await; + return Err(format!("blocked: {host}")); + } + + let upstream_addr = format!("{host}:{port}"); + let mut upstream = TcpStream::connect(&upstream_addr) + .await + .map_err(|e| format!("connect to {upstream_addr}: {e}"))?; + + if is_connect { + let established = b"HTTP/1.1 200 Connection Established\r\n\r\n"; + client.write_all(established).await.map_err(|e| format!("write 200: {e}"))?; + } else { + upstream.write_all(&buf[..n]).await.map_err(|e| format!("write upstream: {e}"))?; + } + + let (mut cr, mut cw) = client.into_split(); + let (mut ur, mut uw) = upstream.into_split(); + + let c_to_u = tokio::spawn(async move { tokio::io::copy(&mut cr, &mut uw).await }); + let u_to_c = tokio::spawn(async move { tokio::io::copy(&mut ur, &mut cw).await }); + + let _ = tokio::try_join!(c_to_u, u_to_c); + + Ok(()) +} + +fn parse_host_port(target: &str) -> Result<(String, String), String> { + if let Some((host, port)) = target.rsplit_once(':') { + if port.chars().all(|c| c.is_ascii_digit()) { + return Ok((host.to_string(), port.to_string())); + } + } + Ok((target.to_string(), "443".to_string())) +} + +fn is_allowed(host: &str, allow: &[String]) -> bool { + let host_lower = host.to_lowercase(); + allow.iter().any(|entry| { + let entry_lower = entry.to_lowercase(); + host_lower == entry_lower || host_lower.ends_with(&format!(".{entry_lower}")) + }) +} + +#[cfg(test)] +#[path = "proxy_ut.rs"] +mod proxy_tests; diff --git a/src/proxy_ut.rs b/src/proxy_ut.rs new file mode 100644 index 0000000..3825781 --- /dev/null +++ b/src/proxy_ut.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn test_parse_host_port_with_port() { + let (host, port) = parse_host_port("example.com:443").unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, "443"); +} + +#[test] +fn test_parse_host_port_without_port() { + let (host, port) = parse_host_port("example.com").unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, "443"); +} + +#[test] +fn test_is_allowed_exact_match() { + let allow = vec!["crates.io".to_string()]; + assert!(is_allowed("crates.io", &allow)); +} + +#[test] +fn test_is_allowed_subdomain_match() { + let allow = vec!["crates.io".to_string()]; + assert!(is_allowed("static.crates.io", &allow)); +} + +#[test] +fn test_is_allowed_not_matched() { + let allow = vec!["crates.io".to_string()]; + assert!(!is_allowed("evil.com", &allow)); +} + +#[test] +fn test_is_allowed_case_insensitive() { + let allow = vec!["Crates.IO".to_string()]; + assert!(is_allowed("static.crates.io", &allow)); +} + +#[test] +fn test_is_allowed_partial_no_match() { + let allow = vec!["crates.io".to_string()]; + assert!(!is_allowed("notcrates.io", &allow)); +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 2b2a55a..b8283cc 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -98,15 +98,13 @@ pub fn parse_profile_yaml(yaml: &str) -> Result { pub fn resolve_profile(name_or_path: &str, share_dir: &std::path::Path) -> Result { if name_or_path.starts_with('/') { - let contents = std::fs::read_to_string(name_or_path) - .map_err(|e| format!("failed to read profile {}: {e}", name_or_path))?; + let contents = std::fs::read_to_string(name_or_path).map_err(|e| format!("failed to read profile {}: {e}", name_or_path))?; return parse_profile_yaml(&contents); } let share_path = share_dir.join("profiles").join(format!("{name_or_path}.yaml")); if share_path.exists() { - let contents = std::fs::read_to_string(&share_path) - .map_err(|e| format!("failed to read profile {}: {e}", share_path.display()))?; + let contents = std::fs::read_to_string(&share_path).map_err(|e| format!("failed to read profile {}: {e}", share_path.display()))?; return parse_profile_yaml(&contents); } @@ -131,7 +129,7 @@ mod tests { #[test] fn test_parse_profile() { - let yaml = r#" + let yaml = r#" name: test bin: ls: /usr/bin/ls @@ -144,11 +142,11 @@ env: network: none shell: /bin/sh "#; - let profile = parse_profile_yaml(yaml).unwrap(); - assert_eq!(profile.name, "test"); - assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); - assert_eq!(profile.ro.len(), 1); - assert_eq!(profile.rw.len(), 1); + let profile = parse_profile_yaml(yaml).unwrap(); + assert_eq!(profile.name, "test"); + assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); + assert_eq!(profile.ro.len(), 1); + assert_eq!(profile.rw.len(), 1); assert!(matches!(profile.network, NetworkMode::None)); } @@ -163,7 +161,11 @@ shell: /bin/sh }, ro: vec!["/lib".into()], rw: vec!["/cache".into()], - env: { let mut m = BTreeMap::new(); m.insert("A".into(), "1".into()); m }, + env: { + let mut m = BTreeMap::new(); + m.insert("A".into(), "1".into()); + m + }, network: NetworkMode::None, shell: "/bin/sh".into(), }; @@ -176,7 +178,11 @@ shell: /bin/sh }, ro: vec!["/usr/lib".into()], rw: vec!["/other".into()], - env: { let mut m = BTreeMap::new(); m.insert("B".into(), "2".into()); m }, + env: { + let mut m = BTreeMap::new(); + m.insert("B".into(), "2".into()); + m + }, network: NetworkMode::None, shell: "/bin/dash".into(), }; diff --git a/src/vscomm/buildsys/cargo.rs b/src/vscomm/buildsys/cargo.rs index 0a38092..7d9d957 100644 --- a/src/vscomm/buildsys/cargo.rs +++ b/src/vscomm/buildsys/cargo.rs @@ -14,16 +14,16 @@ impl super::BuildSystem for Cargo { fn passthrough(&self, mode: PassthroughMode, _root: &Path) -> Vec { match mode { PassthroughMode::Relaxed => vec!["cargo *".into()], - PassthroughMode::Paranoid => vec![ - "cargo".into(), - "cargo check".into(), - "cargo build".into(), - "cargo test".into(), - "cargo clippy".into(), - "cargo fmt".into(), - "cargo doc".into(), - "cargo run".into(), - ], + PassthroughMode::Paranoid => vec![ + "cargo".into(), + "cargo check".into(), + "cargo build".into(), + "cargo test".into(), + "cargo clippy".into(), + "cargo fmt".into(), + "cargo doc".into(), + "cargo run".into(), + ], } } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 23a5c67..3f5b11f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,11 +1,7 @@ use std::process::{Command, Output}; pub fn has_bwrap() -> bool { - Command::new("bwrap") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + Command::new("bwrap").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) } pub fn require_bwrap() -> bool { @@ -18,10 +14,7 @@ pub fn require_bwrap() -> bool { } pub fn run_bwrap(args: &[&str]) -> Output { - Command::new("bwrap") - .args(args) - .output() - .expect("spawn bwrap") + Command::new("bwrap").args(args).output().expect("spawn bwrap") } pub fn assert_success(output: &Output) { diff --git a/tests/test_base.rs b/tests/test_base.rs index f53825c..f02ed3f 100644 --- a/tests/test_base.rs +++ b/tests/test_base.rs @@ -3,21 +3,27 @@ use common::*; #[test] fn bwrap_help_works() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = run_bwrap(&["--help"]); assert_success(&output); } #[test] fn bwrap_version_works() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = run_bwrap(&["--version"]); assert_success(&output); } #[test] fn bwrap_missing_command_fails() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = run_bwrap(&[]); assert_failure(&output); } diff --git a/tests/test_proxy.rs b/tests/test_proxy.rs new file mode 100644 index 0000000..7d1c423 --- /dev/null +++ b/tests/test_proxy.rs @@ -0,0 +1,108 @@ +use bunkerbox::proxy::FilterProxy; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +#[tokio::test] +async fn proxy_allows_connect_to_allowed_host() { + let echo = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let echo_port = echo.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut sock, _) = echo.accept().await.unwrap(); + let mut buf = [0u8; 64]; + let n = sock.read(&mut buf).await.unwrap(); + sock.write_all(&buf[..n]).await.unwrap(); + }); + + let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]) + .bind_on(0) + .await + .unwrap(); + + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) + .await + .unwrap(); + + client + .write_all(format!("CONNECT localhost:{echo_port} HTTP/1.1\r\n\r\n").as_bytes()) + .await + .unwrap(); + + let mut response = [0u8; 256]; + let n = client.read(&mut response).await.unwrap(); + let resp = String::from_utf8_lossy(&response[..n]); + assert!(resp.contains("200 Connection Established"), "got: {resp}"); + + client.write_all(b"hello").await.unwrap(); + let mut echo_back = [0u8; 64]; + let n = client.read(&mut echo_back).await.unwrap(); + assert_eq!(&echo_back[..n], b"hello"); + + handle.abort(); +} + +#[tokio::test] +async fn proxy_blocks_connect_to_denied_host() { + let (handle, proxy_port) = FilterProxy::new(vec!["only.this.host".into()]) + .bind_on(0) + .await + .unwrap(); + + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) + .await + .unwrap(); + + client + .write_all(b"CONNECT evil.com:443 HTTP/1.1\r\n\r\n") + .await + .unwrap(); + + let mut response = [0u8; 256]; + let n = client.read(&mut response).await.unwrap(); + let resp = String::from_utf8_lossy(&response[..n]); + assert!(resp.contains("403 Forbidden"), "got: {resp}"); + + handle.abort(); +} + +#[tokio::test] +async fn proxy_forwards_plain_http_to_allowed_host() { + let srv = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let srv_port = srv.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut sock, _) = srv.accept().await.unwrap(); + sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nworld") + .await + .unwrap(); + }); + + let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]) + .bind_on(0) + .await + .unwrap(); + + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) + .await + .unwrap(); + + client + .write_all( + format!("GET http://localhost:{srv_port}/items HTTP/1.1\r\nHost: localhost\r\n\r\n") + .as_bytes(), + ) + .await + .unwrap(); + + let mut response = Vec::new(); + let mut buf = [0u8; 512]; + loop { + match client.read(&mut buf).await { + Ok(0) => break, + Ok(n) => response.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + let resp = String::from_utf8_lossy(&response); + assert!(resp.contains("world"), "got: {resp}"); + + handle.abort(); +} diff --git a/tests/test_sandbox.rs b/tests/test_sandbox.rs index 04cb8b0..cdf44f6 100644 --- a/tests/test_sandbox.rs +++ b/tests/test_sandbox.rs @@ -24,12 +24,24 @@ fn call(extra: &[&str], cmd: &[&str]) -> std::process::Output { #[test] fn bwrap_runs_true() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/true", "/usr/bin/true", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib"], + &[ + "--ro-bind", + "/usr/bin/true", + "/usr/bin/true", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + ], &["/usr/bin/true"], ); assert_success(&output); @@ -37,12 +49,24 @@ fn bwrap_runs_true() { #[test] fn bwrap_exit_code_propagated() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/sh", "/usr/bin/sh", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib"], + &[ + "--ro-bind", + "/usr/bin/sh", + "/usr/bin/sh", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + ], &["/usr/bin/sh", "-c", "exit 42"], ); assert_eq!(output.status.code(), Some(42)); @@ -50,20 +74,34 @@ fn bwrap_exit_code_propagated() { #[test] fn bwrap_blocks_unlisted_binary() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call(&[], &["/usr/bin/env"]); assert_failure(&output); } #[test] fn bwrap_network_blocked() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/ping", "/usr/bin/ping", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib", - "--unshare-net"], + &[ + "--ro-bind", + "/usr/bin/ping", + "/usr/bin/ping", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + "--unshare-net", + ], &["/usr/bin/ping", "-c", "1", "1.1.1.1"], ); assert_failure(&output); @@ -71,13 +109,27 @@ fn bwrap_network_blocked() { #[test] fn bwrap_ro_bind_works() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/sh", "/usr/bin/sh", - "--ro-bind", "/usr/bin/echo", "/usr/bin/echo", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib"], + &[ + "--ro-bind", + "/usr/bin/sh", + "/usr/bin/sh", + "--ro-bind", + "/usr/bin/echo", + "/usr/bin/echo", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + ], &["/usr/bin/sh", "-c", "echo ok"], ); assert_success(&output); @@ -86,14 +138,30 @@ fn bwrap_ro_bind_works() { #[test] fn bwrap_rw_bind_works() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/sh", "/usr/bin/sh", - "--ro-bind", "/usr/bin/touch", "/usr/bin/touch", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib", - "--bind", "/tmp", "/tmp"], + &[ + "--ro-bind", + "/usr/bin/sh", + "/usr/bin/sh", + "--ro-bind", + "/usr/bin/touch", + "/usr/bin/touch", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + "--bind", + "/tmp", + "/tmp", + ], &["/usr/bin/sh", "-c", "touch /tmp/test_bwrap_rw && echo done"], ); assert_success(&output); @@ -102,12 +170,24 @@ fn bwrap_rw_bind_works() { #[test] fn bwrap_command_with_args() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/sh", "/usr/bin/sh", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib"], + &[ + "--ro-bind", + "/usr/bin/sh", + "/usr/bin/sh", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + ], &["/usr/bin/sh", "-c", "echo hello world"], ); assert_success(&output); @@ -116,21 +196,37 @@ fn bwrap_command_with_args() { #[test] fn bwrap_not_found_binary_fails() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call(&[], &["/usr/bin/doesnotexist"]); assert_failure(&output); } #[test] fn bwrap_clearenv_works() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/env", "/usr/bin/env", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib", - "--clearenv", - "--setenv", "FOO", "bar"], + &[ + "--ro-bind", + "/usr/bin/env", + "/usr/bin/env", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + "--clearenv", + "--setenv", + "FOO", + "bar", + ], &["/usr/bin/env"], ); assert_success(&output); @@ -141,12 +237,24 @@ fn bwrap_clearenv_works() { #[test] fn bwrap_only_sees_mounted_paths() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = call( - &["--ro-bind", "/usr/bin/ls", "/usr/bin/ls", - "--ro-bind", "/lib", "/lib", - "--ro-bind", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib"], + &[ + "--ro-bind", + "/usr/bin/ls", + "/usr/bin/ls", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--ro-bind", + "/usr/lib", + "/usr/lib", + ], &["/usr/bin/ls", "/"], ); assert_success(&output); @@ -158,7 +266,9 @@ fn bwrap_only_sees_mounted_paths() { #[test] fn bwrap_missing_args_fails() { - if !require_bwrap() { return; } + if !require_bwrap() { + return; + } let output = run_bwrap(&["--nonexistent"]); assert_failure(&output); }