From 4d88b9e2e2c8730724e4813db866a8bd2924d005 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 11:38:44 -0500 Subject: [PATCH 01/17] Add --docker-run feature for Docker container network isolation - Add new --docker-run CLI flag to run Docker containers with httpjail - Create docker.rs module with Docker-specific logic - Mount httpjail network namespace for Docker access via /var/run/netns - Inject --network flag to use httpjail's namespace - Pass CA certificate environment variables to container --- src/docker.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 19 ++++- 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/docker.rs diff --git a/src/docker.rs b/src/docker.rs new file mode 100644 index 00000000..59cbf676 --- /dev/null +++ b/src/docker.rs @@ -0,0 +1,189 @@ +//! Docker container execution with httpjail network isolation + +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::{Command, ExitStatus}; +use tracing::{debug, info, warn}; + +/// Execute Docker container with httpjail network isolation +/// +/// This function: +/// 1. Ensures the network namespace created by httpjail is accessible to Docker +/// 2. Modifies the docker run command to use our network namespace +/// 3. Executes the container and returns its exit status +#[cfg(target_os = "linux")] +pub async fn execute_docker_run( + jail_id: &str, + docker_args: &[String], + extra_env: &[(String, String)], +) -> Result { + info!("Setting up Docker container with httpjail network isolation"); + + let namespace_name = format!("httpjail_{}", jail_id); + + // Ensure the namespace is accessible to Docker + ensure_namespace_mounted(&namespace_name)?; + + // Build and execute the docker command + let mut cmd = build_docker_command(&namespace_name, docker_args, extra_env)?; + + info!( + "Executing docker run with network namespace {}", + namespace_name + ); + debug!("Docker command: {:?}", cmd); + + // Execute docker run and wait for it to complete + let status = cmd + .status() + .context("Failed to execute docker run command")?; + + // Clean up the namespace mount point if we created it + cleanup_namespace_mount(&namespace_name); + + Ok(status) +} + +/// Ensure the network namespace is mounted and accessible to Docker +#[cfg(target_os = "linux")] +fn ensure_namespace_mounted(namespace_name: &str) -> Result<()> { + // Ensure /var/run/netns directory exists + let netns_dir = Path::new("/var/run/netns"); + if !netns_dir.exists() { + std::fs::create_dir_all(netns_dir).context("Failed to create /var/run/netns directory")?; + } + + // Check if namespace is already accessible + let output = Command::new("ip") + .args(["netns", "list"]) + .output() + .context("Failed to list network namespaces")?; + + let namespaces = String::from_utf8_lossy(&output.stdout); + if namespaces.contains(namespace_name) { + debug!("Namespace {} already mounted", namespace_name); + return Ok(()); + } + + // Mount the namespace for Docker access + mount_namespace(namespace_name)?; + + Ok(()) +} + +/// Mount the namespace to /var/run/netns for Docker access +#[cfg(target_os = "linux")] +fn mount_namespace(namespace_name: &str) -> Result<()> { + debug!("Mounting namespace {} to /var/run/netns", namespace_name); + + let namespace_path = format!("/var/run/netns/{}", namespace_name); + + // Find a process in the namespace to get its network namespace file descriptor + let mut sleep_cmd = Command::new("ip") + .args(["netns", "exec", namespace_name, "sleep", "1"]) + .spawn() + .context("Failed to spawn process in namespace")?; + + // Get the PID and link the namespace + if let Some(pid) = sleep_cmd.id() { + let proc_ns = format!("/proc/{}/ns/net", pid); + + // Create a bind mount of the namespace + Command::new("touch") + .arg(&namespace_path) + .status() + .context("Failed to create namespace mount point")?; + + Command::new("mount") + .args(["--bind", &proc_ns, &namespace_path]) + .status() + .context("Failed to bind mount namespace")?; + + debug!("Mounted namespace at {}", namespace_path); + } + + // Clean up the sleep process + sleep_cmd.kill().ok(); + sleep_cmd.wait().ok(); + + Ok(()) +} + +/// Build the docker command with network namespace and environment variables +#[cfg(target_os = "linux")] +fn build_docker_command( + namespace_name: &str, + docker_args: &[String], + extra_env: &[(String, String)], +) -> Result { + // Parse docker arguments to check if --network is already specified + let modified_args = filter_network_args(docker_args); + + // Build the docker run command + let mut cmd = Command::new("docker"); + cmd.arg("run"); + + // Add our network namespace + cmd.args([ + "--network", + &format!("ns:/var/run/netns/{}", namespace_name), + ]); + + // Add CA certificate environment variables + for (key, value) in extra_env { + cmd.arg("-e").arg(format!("{}={}", key, value)); + } + + // Add all the user's docker arguments + for arg in &modified_args { + cmd.arg(arg); + } + + Ok(cmd) +} + +/// Filter out any existing --network arguments from docker args +#[cfg(target_os = "linux")] +fn filter_network_args(docker_args: &[String]) -> Vec { + let mut modified_args = Vec::new(); + let mut i = 0; + + while i < docker_args.len() { + if docker_args[i] == "--network" || docker_args[i].starts_with("--network=") { + warn!("Docker --network flag already specified, overriding with httpjail namespace"); + + if docker_args[i] == "--network" { + // Skip the next argument too + i += 2; + continue; + } + } else { + modified_args.push(docker_args[i].clone()); + } + i += 1; + } + + modified_args +} + +/// Clean up the namespace mount point +#[cfg(target_os = "linux")] +fn cleanup_namespace_mount(namespace_name: &str) { + let namespace_path = format!("/var/run/netns/{}", namespace_name); + + if Path::new(&namespace_path).exists() { + Command::new("umount").arg(&namespace_path).status().ok(); + std::fs::remove_file(&namespace_path).ok(); + debug!("Cleaned up namespace mount at {}", namespace_path); + } +} + +/// Stub implementation for non-Linux platforms +#[cfg(not(target_os = "linux"))] +pub async fn execute_docker_run( + _jail_id: &str, + _docker_args: &[String], + _extra_env: &[(String, String)], +) -> Result { + anyhow::bail!("--docker-run is only supported on Linux") +} diff --git a/src/lib.rs b/src/lib.rs index 5fe80b70..86390bc8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod dangerous_verifier; +pub mod docker; pub mod jail; pub mod proxy; pub mod proxy_tls; diff --git a/src/main.rs b/src/main.rs index c2affe74..8c4c4874 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,8 +90,19 @@ struct Args { )] test: Option>, + /// Run a Docker container with httpjail network isolation + /// All arguments after -- are passed to docker run + #[arg( + long = "docker-run", + conflicts_with = "server", + conflicts_with = "cleanup", + conflicts_with = "test", + conflicts_with = "weak" + )] + docker_run: bool, + /// Command and arguments to execute - #[arg(trailing_var_arg = true, required_unless_present_any = ["cleanup", "server", "test"])] + #[arg(trailing_var_arg = true, required_unless_present_any = ["cleanup", "server", "test", "docker_run"])] command: Vec, } @@ -506,7 +517,11 @@ async fn main() -> Result<()> { } // Execute command in jail with extra environment variables - let status = if let Some(timeout_secs) = args.timeout { + let status = if args.docker_run { + // Handle Docker container execution + httpjail::docker::execute_docker_run(&jail_config.jail_id, &args.command, &extra_env) + .await? + } else if let Some(timeout_secs) = args.timeout { info!("Executing command with {}s timeout", timeout_secs); // Use tokio to handle timeout From cab12dd4578f188946344e24eb86c05a6f0e6cde Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 11:42:01 -0500 Subject: [PATCH 02/17] Fix compilation error in docker.rs - Handle Option return type from sleep_cmd.id() properly - Remove unnecessary if-let block indentation --- src/docker.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/docker.rs b/src/docker.rs index 59cbf676..b8228564 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -85,22 +85,23 @@ fn mount_namespace(namespace_name: &str) -> Result<()> { .context("Failed to spawn process in namespace")?; // Get the PID and link the namespace - if let Some(pid) = sleep_cmd.id() { - let proc_ns = format!("/proc/{}/ns/net", pid); - - // Create a bind mount of the namespace - Command::new("touch") - .arg(&namespace_path) - .status() - .context("Failed to create namespace mount point")?; + let pid = sleep_cmd + .id() + .ok_or_else(|| anyhow::anyhow!("Failed to get process ID"))?; + let proc_ns = format!("/proc/{}/ns/net", pid); + + // Create a bind mount of the namespace + Command::new("touch") + .arg(&namespace_path) + .status() + .context("Failed to create namespace mount point")?; - Command::new("mount") - .args(["--bind", &proc_ns, &namespace_path]) - .status() - .context("Failed to bind mount namespace")?; + Command::new("mount") + .args(["--bind", &proc_ns, &namespace_path]) + .status() + .context("Failed to bind mount namespace")?; - debug!("Mounted namespace at {}", namespace_path); - } + debug!("Mounted namespace at {}", namespace_path); // Clean up the sleep process sleep_cmd.kill().ok(); From 8f8487624029ee975e36fb56b2500161db370516 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 13:26:07 -0500 Subject: [PATCH 03/17] Merge main branch and resolve conflicts --- CLAUDE.md | 42 ++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 6 ++++++ src/docker.rs | 4 +--- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d0b03d2..7248475e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,17 @@ protocol), we must establish a timeout for the operation. Timeouts must not preclude long-running connections such as GRPC or WebSocket. +## Building + +For faster builds during development and debugging, use the `fast` profile: + +```bash +cargo build --profile fast +``` + +This profile inherits from release mode but uses lower optimization levels and disables LTO +for significantly faster build times while still providing reasonable performance. + ## Testing When writing tests, prefer pure rust solutions over shell script wrappers. @@ -65,7 +76,34 @@ To debug CI failures on Linux: gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail ``` -The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail`. Tests run as the `ci` user, not root. When building manually: +The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail`. **IMPORTANT: Never modify files in this directory directly as it will interfere with running CI jobs.** + +### Testing Local Changes on CI + +When testing local changes on the CI instance, always work in a fresh directory named after your branch: + ```bash -su - ci -c 'cd /home/ci/actions-runner/_work/httpjail/httpjail && cargo test' +# Set up a fresh workspace for your branch +BRANCH_NAME="your-branch-name" +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " + rm -rf /tmp/httpjail-$BRANCH_NAME + git clone https://github.com/coder/httpjail /tmp/httpjail-$BRANCH_NAME + cd /tmp/httpjail-$BRANCH_NAME + git checkout $BRANCH_NAME +" + +# Sync local changes to the test workspace +gcloud compute scp --recurse src/ root@ci-1:/tmp/httpjail-$BRANCH_NAME/ --zone us-central1-f --project httpjail +gcloud compute scp Cargo.toml root@ci-1:/tmp/httpjail-$BRANCH_NAME/ --zone us-central1-f --project httpjail + +# Build and test in the isolated workspace (using shared cargo cache) +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " + cd /tmp/httpjail-$BRANCH_NAME + export CARGO_HOME=/home/ci/.cargo + export CARGO_TARGET_DIR=/home/ci/.cargo/shared-target + /home/ci/.cargo/bin/cargo build --profile fast + sudo /home/ci/.cargo/shared-target/fast/httpjail --help +" ``` + +This ensures you don't interfere with active CI jobs and provides a clean environment for testing. diff --git a/Cargo.toml b/Cargo.toml index dc950b1c..f966dcfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,9 @@ tempfile = "3.8" assert_cmd = "2.0" predicates = "3.0" serial_test = "3.0" + +[profile.fast] +inherits = "release" +opt-level = 1 +lto = false +codegen-units = 16 diff --git a/src/docker.rs b/src/docker.rs index b8228564..503a18e5 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -85,9 +85,7 @@ fn mount_namespace(namespace_name: &str) -> Result<()> { .context("Failed to spawn process in namespace")?; // Get the PID and link the namespace - let pid = sleep_cmd - .id() - .ok_or_else(|| anyhow::anyhow!("Failed to get process ID"))?; + let pid = sleep_cmd.id(); let proc_ns = format!("/proc/{}/ns/net", pid); // Create a bind mount of the namespace From c00d75bbbe3da4dac70c76d41195b1693edb6a84 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 13:29:57 -0500 Subject: [PATCH 04/17] Add Docker integration tests for --docker-run feature - Test basic Docker container execution - Test network restrictions within containers - Test proper cleanup of namespaces after container exit - Tests gracefully skip if Docker is not available --- tests/linux_integration.rs | 172 +++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/linux_integration.rs b/tests/linux_integration.rs index 029f9c6a..9557be7a 100644 --- a/tests/linux_integration.rs +++ b/tests/linux_integration.rs @@ -314,4 +314,176 @@ mod tests { stderr.trim() ); } + + /// Test Docker container execution with --docker-run + #[test] + #[serial] + fn test_docker_run_basic() { + LinuxPlatform::require_privileges(); + + // Check if Docker is available + let docker_check = std::process::Command::new("docker") + .arg("--version") + .output(); + + if docker_check.is_err() || !docker_check.unwrap().status.success() { + eprintln!("Skipping Docker test: Docker not available"); + return; + } + + // Run a simple Docker container with httpjail + let mut cmd = httpjail_cmd(); + cmd.arg("--js") + .arg("true") // Allow all traffic + .arg("--docker-run") + .arg("--") + .arg("--rm") // Remove container after exit + .arg("alpine:latest") + .arg("echo") + .arg("Hello from Docker"); + + let output = cmd + .output() + .expect("Failed to execute httpjail with docker"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + eprintln!("[Docker test] stdout: {}", stdout); + if !stderr.is_empty() { + eprintln!("[Docker test] stderr: {}", stderr); + } + + assert!( + output.status.success(), + "Docker container should run successfully. Exit code: {:?}", + output.status.code() + ); + + assert!( + stdout.contains("Hello from Docker"), + "Expected output not found. stdout: {}", + stdout + ); + } + + /// Test Docker container with network restrictions + #[test] + #[serial] + fn test_docker_run_with_network_restrictions() { + LinuxPlatform::require_privileges(); + + // Check if Docker is available + let docker_check = std::process::Command::new("docker") + .arg("--version") + .output(); + + if docker_check.is_err() || !docker_check.unwrap().status.success() { + eprintln!("Skipping Docker test: Docker not available"); + return; + } + + // Try to access a blocked domain from within Docker container + let mut cmd = httpjail_cmd(); + cmd.arg("--js") + .arg("host === 'example.com'") // Only allow example.com + .arg("--docker-run") + .arg("--") + .arg("--rm") + .arg("alpine:latest") + .arg("sh") + .arg("-c") + .arg("wget -q -O- --timeout=2 http://httpbin.org/get 2>&1 || echo 'BLOCKED'"); + + let output = cmd + .output() + .expect("Failed to execute httpjail with docker"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + eprintln!("[Docker network restriction test] stdout: {}", stdout); + if !stderr.is_empty() { + eprintln!("[Docker network restriction test] stderr: {}", stderr); + } + + assert!( + stdout.contains("BLOCKED") || stderr.contains("403"), + "Network request should be blocked. stdout: {}, stderr: {}", + stdout, + stderr + ); + } + + /// Test Docker container cleanup after execution + #[test] + #[serial] + fn test_docker_run_cleanup() { + LinuxPlatform::require_privileges(); + + // Check if Docker is available + let docker_check = std::process::Command::new("docker") + .arg("--version") + .output(); + + if docker_check.is_err() || !docker_check.unwrap().status.success() { + eprintln!("Skipping Docker test: Docker not available"); + return; + } + + // Get initial namespace count + let initial_ns = std::process::Command::new("ip") + .args(["netns", "list"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + let initial_httpjail_ns = initial_ns + .lines() + .filter(|l| l.contains("httpjail_")) + .count(); + + // Run Docker container with httpjail + let mut cmd = httpjail_cmd(); + cmd.arg("--js") + .arg("true") + .arg("--docker-run") + .arg("--") + .arg("--rm") + .arg("alpine:latest") + .arg("true"); + + let _output = cmd + .output() + .expect("Failed to execute httpjail with docker"); + + // Check namespace was cleaned up + let final_ns = std::process::Command::new("ip") + .args(["netns", "list"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + let final_httpjail_ns = final_ns.lines().filter(|l| l.contains("httpjail_")).count(); + + assert_eq!( + initial_httpjail_ns, final_httpjail_ns, + "Network namespace not cleaned up after Docker container exit" + ); + + // Also check that no namespace mounts remain in /var/run/netns + if let Ok(entries) = std::fs::read_dir("/var/run/netns") { + let httpjail_mounts: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains("httpjail_")) + .collect(); + + assert!( + httpjail_mounts.is_empty(), + "Found lingering namespace mounts: {:?}", + httpjail_mounts + .iter() + .map(|e| e.file_name()) + .collect::>() + ); + } + } } From 1a7864b794ecdbb04604f7325adf0e0afeca1ff9 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 13:34:44 -0500 Subject: [PATCH 05/17] Add CI helper scripts for easier testing - ci-ssh.sh: SSH into CI-1 instance - ci-sync.sh: Sync local changes to CI without committing - ci-build.sh: Build httpjail on CI with different profiles - ci-test.sh: Run tests on CI with optional filters - ci-run.sh: Execute httpjail directly on CI for quick testing Updated CLAUDE.md with documentation for CI helper scripts and workflows. --- CLAUDE.md | 46 ++++++++++++++++++++++++++++++++------ scripts/ci-build.sh | 39 ++++++++++++++++++++++++++++++++ scripts/ci-run.sh | 44 ++++++++++++++++++++++++++++++++++++ scripts/ci-ssh.sh | 7 ++++++ scripts/ci-sync.sh | 54 +++++++++++++++++++++++++++++++++++++++++++++ scripts/ci-test.sh | 42 +++++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 7 deletions(-) create mode 100755 scripts/ci-build.sh create mode 100755 scripts/ci-run.sh create mode 100755 scripts/ci-ssh.sh create mode 100755 scripts/ci-sync.sh create mode 100755 scripts/ci-test.sh diff --git a/CLAUDE.md b/CLAUDE.md index 7248475e..5f9976be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,16 +71,49 @@ In regular operation of the CLI-only jail (non-server mode), info and warn logs The Linux CI tests run on a self-hosted runner (`ci-1`) in GCP. Only Coder employees can directly SSH into this instance for debugging. -To debug CI failures on Linux: +The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail`. **IMPORTANT: Never modify files in this directory directly as it will interfere with running CI jobs.** + +### CI Helper Scripts + +Helper scripts are provided in `./scripts/` to simplify CI operations: + ```bash -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail +# SSH into CI-1 instance +./scripts/ci-ssh.sh + +# Sync local changes to CI (without committing) +./scripts/ci-sync.sh [branch-name] + +# Build on CI +./scripts/ci-build.sh [branch-name] [profile] # profile: debug, release, or fast + +# Run tests on CI +./scripts/ci-test.sh [branch-name] [test-filter] + +# Run httpjail directly on CI +./scripts/ci-run.sh [branch-name] [httpjail-args...] ``` -The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail`. **IMPORTANT: Never modify files in this directory directly as it will interfere with running CI jobs.** +#### Example Workflow + +```bash +# Sync and build your changes +./scripts/ci-sync.sh +./scripts/ci-build.sh + +# Run specific tests +./scripts/ci-test.sh docker-run docker_run + +# Quick test with httpjail +./scripts/ci-run.sh docker-run --js 'true' -- echo hello + +# Interactive debugging +./scripts/ci-ssh.sh +``` -### Testing Local Changes on CI +### Manual Testing on CI -When testing local changes on the CI instance, always work in a fresh directory named after your branch: +If you prefer manual commands or need more control: ```bash # Set up a fresh workspace for your branch @@ -100,9 +133,8 @@ gcloud compute scp Cargo.toml root@ci-1:/tmp/httpjail-$BRANCH_NAME/ --zone us-ce gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " cd /tmp/httpjail-$BRANCH_NAME export CARGO_HOME=/home/ci/.cargo - export CARGO_TARGET_DIR=/home/ci/.cargo/shared-target /home/ci/.cargo/bin/cargo build --profile fast - sudo /home/ci/.cargo/shared-target/fast/httpjail --help + sudo ./target/fast/httpjail --help " ``` diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh new file mode 100755 index 00000000..27b8c21b --- /dev/null +++ b/scripts/ci-build.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Build httpjail on CI-1 + +set -e + +BRANCH_NAME="${1:-$(git branch --show-current)}" +PROFILE="${2:-release}" + +if [ -z "$BRANCH_NAME" ]; then + echo "Error: Could not determine branch name" + echo "Usage: $0 [branch-name] [profile]" + echo " branch-name: Name of the branch/workspace (default: current branch)" + echo " profile: Build profile - debug, release, or fast (default: release)" + exit 1 +fi + +echo "Building httpjail on CI-1..." +echo " Branch: $BRANCH_NAME" +echo " Profile: $PROFILE" +echo "" + +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " + cd /tmp/httpjail-$BRANCH_NAME + export CARGO_HOME=/home/ci/.cargo + + if [ '$PROFILE' = 'debug' ]; then + echo 'Building debug profile...' + /home/ci/.cargo/bin/cargo build + echo 'Binary at: /tmp/httpjail-$BRANCH_NAME/target/debug/httpjail' + elif [ '$PROFILE' = 'fast' ]; then + echo 'Building fast profile...' + /home/ci/.cargo/bin/cargo build --profile fast + echo 'Binary at: /tmp/httpjail-$BRANCH_NAME/target/fast/httpjail' + else + echo 'Building release profile...' + /home/ci/.cargo/bin/cargo build --release + echo 'Binary at: /tmp/httpjail-$BRANCH_NAME/target/release/httpjail' + fi +" \ No newline at end of file diff --git a/scripts/ci-run.sh b/scripts/ci-run.sh new file mode 100755 index 00000000..0b543aae --- /dev/null +++ b/scripts/ci-run.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Run httpjail binary on CI-1 for quick testing + +set -e + +BRANCH_NAME="${1:-$(git branch --show-current)}" +shift 2>/dev/null || true + +if [ -z "$BRANCH_NAME" ]; then + echo "Error: Could not determine branch name" + echo "Usage: $0 [branch-name] [httpjail-args...]" + echo " branch-name: Name of the branch/workspace (default: current branch)" + echo " httpjail-args: Arguments to pass to httpjail" + echo "" + echo "Example:" + echo " $0 docker-run --js 'true' --docker-run -- alpine:latest echo hello" + exit 1 +fi + +echo "Running httpjail on CI-1..." +echo " Branch: $BRANCH_NAME" +echo " Args: $@" +echo "" + +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " + cd /tmp/httpjail-$BRANCH_NAME + + # Find the httpjail binary (prefer release, then fast, then debug) + if [ -f target/release/httpjail ]; then + HTTPJAIL=target/release/httpjail + elif [ -f target/fast/httpjail ]; then + HTTPJAIL=target/fast/httpjail + elif [ -f target/debug/httpjail ]; then + HTTPJAIL=target/debug/httpjail + else + echo 'Error: httpjail binary not found. Run ci-build.sh first.' + exit 1 + fi + + echo \"Using binary: \$HTTPJAIL\" + echo '' + + sudo \$HTTPJAIL $* +" \ No newline at end of file diff --git a/scripts/ci-ssh.sh b/scripts/ci-ssh.sh new file mode 100755 index 00000000..bdf39c0d --- /dev/null +++ b/scripts/ci-ssh.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# SSH into the CI-1 instance for debugging + +set -e + +echo "Connecting to CI-1 instance..." +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail "$@" \ No newline at end of file diff --git a/scripts/ci-sync.sh b/scripts/ci-sync.sh new file mode 100755 index 00000000..86954af0 --- /dev/null +++ b/scripts/ci-sync.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Sync local changes to CI-1 for testing without committing + +set -e + +BRANCH_NAME="${1:-$(git branch --show-current)}" + +if [ -z "$BRANCH_NAME" ]; then + echo "Error: Could not determine branch name" + echo "Usage: $0 [branch-name]" + exit 1 +fi + +echo "Syncing branch '$BRANCH_NAME' to CI-1..." + +# Ensure test directory exists with fresh clone +echo "Setting up test workspace..." +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " + rm -rf /tmp/httpjail-$BRANCH_NAME + git clone https://github.com/coder/httpjail /tmp/httpjail-$BRANCH_NAME + cd /tmp/httpjail-$BRANCH_NAME + git checkout $BRANCH_NAME || git checkout -b $BRANCH_NAME +" 2>/dev/null || true + +# Sync source files +echo "Syncing source files..." +gcloud compute scp --recurse --quiet \ + src/ \ + root@ci-1:/tmp/httpjail-$BRANCH_NAME/ \ + --zone us-central1-f --project httpjail + +# Sync Cargo files +echo "Syncing Cargo files..." +gcloud compute scp --quiet \ + Cargo.toml Cargo.lock \ + root@ci-1:/tmp/httpjail-$BRANCH_NAME/ \ + --zone us-central1-f --project httpjail 2>/dev/null || true + +# Sync test files if they exist +if [ -d "tests" ]; then + echo "Syncing test files..." + gcloud compute scp --recurse --quiet \ + tests/ \ + root@ci-1:/tmp/httpjail-$BRANCH_NAME/ \ + --zone us-central1-f --project httpjail +fi + +echo "Sync complete! Test workspace: /tmp/httpjail-$BRANCH_NAME" +echo "" +echo "To build:" +echo " ./scripts/ci-build.sh $BRANCH_NAME" +echo "" +echo "To run tests:" +echo " ./scripts/ci-test.sh $BRANCH_NAME" \ No newline at end of file diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh new file mode 100755 index 00000000..a83f734e --- /dev/null +++ b/scripts/ci-test.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Run tests on CI-1 + +set -e + +BRANCH_NAME="${1:-$(git branch --show-current)}" +TEST_FILTER="${2:-}" + +if [ -z "$BRANCH_NAME" ]; then + echo "Error: Could not determine branch name" + echo "Usage: $0 [branch-name] [test-filter]" + echo " branch-name: Name of the branch/workspace (default: current branch)" + echo " test-filter: Optional test name filter (e.g., 'docker_run')" + exit 1 +fi + +echo "Running tests on CI-1..." +echo " Branch: $BRANCH_NAME" +if [ -n "$TEST_FILTER" ]; then + echo " Filter: $TEST_FILTER" +fi +echo "" + +# Build command with optional filter +TEST_CMD="/home/ci/.cargo/bin/cargo test --release --test linux_integration" +if [ -n "$TEST_FILTER" ]; then + TEST_CMD="$TEST_CMD $TEST_FILTER" +fi + +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " + cd /tmp/httpjail-$BRANCH_NAME + export CARGO_HOME=/home/ci/.cargo + export PATH=/home/ci/.cargo/bin:\$PATH + export RUST_BACKTRACE=1 + + echo 'Running Linux integration tests...' + sudo -E $TEST_CMD 2>&1 | tee test-output.log + + echo '' + echo 'Test summary:' + grep -E '(test result:|running [0-9]+ test)' test-output.log || true +" \ No newline at end of file From 513e1e36250cc35384903925affcd59672efce00 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 13:55:25 -0500 Subject: [PATCH 06/17] Improve mount_namespace to avoid sleep, add CI helper scripts - Rework mount_namespace to use existing namespace processes instead of spawning sleep - Remove obtuse CI scripts, keep only ci-ssh.sh and ci-scp.sh - Update ci-ssh.sh to accept remote commands - Add ci-scp.sh for easy file transfers - Remove #[serial] from Docker tests for better performance - Remove redundant Docker cleanup test (reuses existing jail cleanup) - Update CLAUDE.md with simplified CI documentation --- CLAUDE.md | 40 +++----------------- scripts/ci-build.sh | 39 ------------------- scripts/ci-run.sh | 44 ---------------------- scripts/ci-scp.sh | 40 ++++++++++++++++++++ scripts/ci-ssh.sh | 9 ++++- scripts/ci-sync.sh | 54 --------------------------- scripts/ci-test.sh | 42 --------------------- src/docker.rs | 61 ++++++++++++++++++++---------- tests/linux_integration.rs | 76 -------------------------------------- 9 files changed, 94 insertions(+), 311 deletions(-) delete mode 100755 scripts/ci-build.sh delete mode 100755 scripts/ci-run.sh create mode 100755 scripts/ci-scp.sh delete mode 100755 scripts/ci-sync.sh delete mode 100755 scripts/ci-test.sh diff --git a/CLAUDE.md b/CLAUDE.md index 5f9976be..5ff837f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,46 +75,18 @@ The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail` ### CI Helper Scripts -Helper scripts are provided in `./scripts/` to simplify CI operations: - -```bash -# SSH into CI-1 instance -./scripts/ci-ssh.sh - -# Sync local changes to CI (without committing) -./scripts/ci-sync.sh [branch-name] - -# Build on CI -./scripts/ci-build.sh [branch-name] [profile] # profile: debug, release, or fast - -# Run tests on CI -./scripts/ci-test.sh [branch-name] [test-filter] - -# Run httpjail directly on CI -./scripts/ci-run.sh [branch-name] [httpjail-args...] -``` - -#### Example Workflow - ```bash -# Sync and build your changes -./scripts/ci-sync.sh -./scripts/ci-build.sh +# SSH into CI-1 instance (interactive or with commands) +./scripts/ci-ssh.sh # Interactive shell +./scripts/ci-ssh.sh "ls /tmp/httpjail-*" # Run command -# Run specific tests -./scripts/ci-test.sh docker-run docker_run - -# Quick test with httpjail -./scripts/ci-run.sh docker-run --js 'true' -- echo hello - -# Interactive debugging -./scripts/ci-ssh.sh +# SCP files to/from CI-1 +./scripts/ci-scp.sh src/ /tmp/httpjail-docker-run/ # Upload +./scripts/ci-scp.sh root@ci-1:/path/to/file ./ # Download ``` ### Manual Testing on CI -If you prefer manual commands or need more control: - ```bash # Set up a fresh workspace for your branch BRANCH_NAME="your-branch-name" diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh deleted file mode 100755 index 27b8c21b..00000000 --- a/scripts/ci-build.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# Build httpjail on CI-1 - -set -e - -BRANCH_NAME="${1:-$(git branch --show-current)}" -PROFILE="${2:-release}" - -if [ -z "$BRANCH_NAME" ]; then - echo "Error: Could not determine branch name" - echo "Usage: $0 [branch-name] [profile]" - echo " branch-name: Name of the branch/workspace (default: current branch)" - echo " profile: Build profile - debug, release, or fast (default: release)" - exit 1 -fi - -echo "Building httpjail on CI-1..." -echo " Branch: $BRANCH_NAME" -echo " Profile: $PROFILE" -echo "" - -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " - cd /tmp/httpjail-$BRANCH_NAME - export CARGO_HOME=/home/ci/.cargo - - if [ '$PROFILE' = 'debug' ]; then - echo 'Building debug profile...' - /home/ci/.cargo/bin/cargo build - echo 'Binary at: /tmp/httpjail-$BRANCH_NAME/target/debug/httpjail' - elif [ '$PROFILE' = 'fast' ]; then - echo 'Building fast profile...' - /home/ci/.cargo/bin/cargo build --profile fast - echo 'Binary at: /tmp/httpjail-$BRANCH_NAME/target/fast/httpjail' - else - echo 'Building release profile...' - /home/ci/.cargo/bin/cargo build --release - echo 'Binary at: /tmp/httpjail-$BRANCH_NAME/target/release/httpjail' - fi -" \ No newline at end of file diff --git a/scripts/ci-run.sh b/scripts/ci-run.sh deleted file mode 100755 index 0b543aae..00000000 --- a/scripts/ci-run.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -# Run httpjail binary on CI-1 for quick testing - -set -e - -BRANCH_NAME="${1:-$(git branch --show-current)}" -shift 2>/dev/null || true - -if [ -z "$BRANCH_NAME" ]; then - echo "Error: Could not determine branch name" - echo "Usage: $0 [branch-name] [httpjail-args...]" - echo " branch-name: Name of the branch/workspace (default: current branch)" - echo " httpjail-args: Arguments to pass to httpjail" - echo "" - echo "Example:" - echo " $0 docker-run --js 'true' --docker-run -- alpine:latest echo hello" - exit 1 -fi - -echo "Running httpjail on CI-1..." -echo " Branch: $BRANCH_NAME" -echo " Args: $@" -echo "" - -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " - cd /tmp/httpjail-$BRANCH_NAME - - # Find the httpjail binary (prefer release, then fast, then debug) - if [ -f target/release/httpjail ]; then - HTTPJAIL=target/release/httpjail - elif [ -f target/fast/httpjail ]; then - HTTPJAIL=target/fast/httpjail - elif [ -f target/debug/httpjail ]; then - HTTPJAIL=target/debug/httpjail - else - echo 'Error: httpjail binary not found. Run ci-build.sh first.' - exit 1 - fi - - echo \"Using binary: \$HTTPJAIL\" - echo '' - - sudo \$HTTPJAIL $* -" \ No newline at end of file diff --git a/scripts/ci-scp.sh b/scripts/ci-scp.sh new file mode 100755 index 00000000..2d9db6d1 --- /dev/null +++ b/scripts/ci-scp.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# SCP files to/from the CI-1 instance + +set -e + +BRANCH_NAME="${BRANCH_NAME:-$(git branch --show-current)}" + +if [ $# -lt 1 ]; then + echo "Usage: $0 [destination]" + echo " Copy files to CI-1: $0 src/ /tmp/httpjail-\$BRANCH_NAME/" + echo " Copy from CI-1: $0 root@ci-1:/path/to/file local/" + echo "" + echo "Environment:" + echo " BRANCH_NAME: Target branch directory (default: current git branch)" + exit 1 +fi + +SOURCE="$1" +DEST="${2:-/tmp/httpjail-$BRANCH_NAME/}" + +# Check if source is remote (contains ci-1:) +if [[ "$SOURCE" == *"ci-1:"* ]]; then + # Downloading from CI-1 + SOURCE_PATH="${SOURCE#*:}" + gcloud compute scp --quiet --recurse \ + "root@ci-1:$SOURCE_PATH" \ + "$DEST" \ + --zone us-central1-f --project httpjail +else + # Uploading to CI-1 + # If destination doesn't start with root@ci-1:, prepend it + if [[ "$DEST" != "root@ci-1:"* ]]; then + DEST="root@ci-1:$DEST" + fi + + gcloud compute scp --quiet --recurse \ + "$SOURCE" \ + "$DEST" \ + --zone us-central1-f --project httpjail +fi \ No newline at end of file diff --git a/scripts/ci-ssh.sh b/scripts/ci-ssh.sh index bdf39c0d..76f8037e 100755 --- a/scripts/ci-ssh.sh +++ b/scripts/ci-ssh.sh @@ -3,5 +3,10 @@ set -e -echo "Connecting to CI-1 instance..." -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail "$@" \ No newline at end of file +if [ $# -eq 0 ]; then + echo "Connecting to CI-1 instance (interactive)..." + gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail +else + # Execute command remotely + gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- "$@" +fi \ No newline at end of file diff --git a/scripts/ci-sync.sh b/scripts/ci-sync.sh deleted file mode 100755 index 86954af0..00000000 --- a/scripts/ci-sync.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -# Sync local changes to CI-1 for testing without committing - -set -e - -BRANCH_NAME="${1:-$(git branch --show-current)}" - -if [ -z "$BRANCH_NAME" ]; then - echo "Error: Could not determine branch name" - echo "Usage: $0 [branch-name]" - exit 1 -fi - -echo "Syncing branch '$BRANCH_NAME' to CI-1..." - -# Ensure test directory exists with fresh clone -echo "Setting up test workspace..." -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " - rm -rf /tmp/httpjail-$BRANCH_NAME - git clone https://github.com/coder/httpjail /tmp/httpjail-$BRANCH_NAME - cd /tmp/httpjail-$BRANCH_NAME - git checkout $BRANCH_NAME || git checkout -b $BRANCH_NAME -" 2>/dev/null || true - -# Sync source files -echo "Syncing source files..." -gcloud compute scp --recurse --quiet \ - src/ \ - root@ci-1:/tmp/httpjail-$BRANCH_NAME/ \ - --zone us-central1-f --project httpjail - -# Sync Cargo files -echo "Syncing Cargo files..." -gcloud compute scp --quiet \ - Cargo.toml Cargo.lock \ - root@ci-1:/tmp/httpjail-$BRANCH_NAME/ \ - --zone us-central1-f --project httpjail 2>/dev/null || true - -# Sync test files if they exist -if [ -d "tests" ]; then - echo "Syncing test files..." - gcloud compute scp --recurse --quiet \ - tests/ \ - root@ci-1:/tmp/httpjail-$BRANCH_NAME/ \ - --zone us-central1-f --project httpjail -fi - -echo "Sync complete! Test workspace: /tmp/httpjail-$BRANCH_NAME" -echo "" -echo "To build:" -echo " ./scripts/ci-build.sh $BRANCH_NAME" -echo "" -echo "To run tests:" -echo " ./scripts/ci-test.sh $BRANCH_NAME" \ No newline at end of file diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh deleted file mode 100755 index a83f734e..00000000 --- a/scripts/ci-test.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -# Run tests on CI-1 - -set -e - -BRANCH_NAME="${1:-$(git branch --show-current)}" -TEST_FILTER="${2:-}" - -if [ -z "$BRANCH_NAME" ]; then - echo "Error: Could not determine branch name" - echo "Usage: $0 [branch-name] [test-filter]" - echo " branch-name: Name of the branch/workspace (default: current branch)" - echo " test-filter: Optional test name filter (e.g., 'docker_run')" - exit 1 -fi - -echo "Running tests on CI-1..." -echo " Branch: $BRANCH_NAME" -if [ -n "$TEST_FILTER" ]; then - echo " Filter: $TEST_FILTER" -fi -echo "" - -# Build command with optional filter -TEST_CMD="/home/ci/.cargo/bin/cargo test --release --test linux_integration" -if [ -n "$TEST_FILTER" ]; then - TEST_CMD="$TEST_CMD $TEST_FILTER" -fi - -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail -- " - cd /tmp/httpjail-$BRANCH_NAME - export CARGO_HOME=/home/ci/.cargo - export PATH=/home/ci/.cargo/bin:\$PATH - export RUST_BACKTRACE=1 - - echo 'Running Linux integration tests...' - sudo -E $TEST_CMD 2>&1 | tee test-output.log - - echo '' - echo 'Test summary:' - grep -E '(test result:|running [0-9]+ test)' test-output.log || true -" \ No newline at end of file diff --git a/src/docker.rs b/src/docker.rs index 503a18e5..db28c105 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -78,32 +78,53 @@ fn mount_namespace(namespace_name: &str) -> Result<()> { let namespace_path = format!("/var/run/netns/{}", namespace_name); - // Find a process in the namespace to get its network namespace file descriptor - let mut sleep_cmd = Command::new("ip") - .args(["netns", "exec", namespace_name, "sleep", "1"]) - .spawn() - .context("Failed to spawn process in namespace")?; - - // Get the PID and link the namespace - let pid = sleep_cmd.id(); - let proc_ns = format!("/proc/{}/ns/net", pid); - - // Create a bind mount of the namespace - Command::new("touch") - .arg(&namespace_path) - .status() - .context("Failed to create namespace mount point")?; + // Create the mount point file + std::fs::File::create(&namespace_path).context("Failed to create namespace mount point")?; + + // Find the PID of any process already running in the namespace + // httpjail keeps a process alive in the namespace, so we can use that + let output = Command::new("ip") + .args(["netns", "pids", namespace_name]) + .output() + .context("Failed to get PIDs in namespace")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to get PIDs from namespace: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let pids_str = String::from_utf8_lossy(&output.stdout); + let first_pid = pids_str + .lines() + .next() + .and_then(|line| line.trim().parse::().ok()) + .context("No process found in namespace")?; - Command::new("mount") + let proc_ns = format!("/proc/{}/ns/net", first_pid); + + // Verify the namespace file exists + if !Path::new(&proc_ns).exists() { + anyhow::bail!("Namespace file {} does not exist", proc_ns); + } + + // Bind mount the namespace + let mount_status = Command::new("mount") .args(["--bind", &proc_ns, &namespace_path]) .status() .context("Failed to bind mount namespace")?; - debug!("Mounted namespace at {}", namespace_path); + if !mount_status.success() { + // Clean up the mount point if mount failed + std::fs::remove_file(&namespace_path).ok(); + anyhow::bail!("Failed to bind mount namespace"); + } - // Clean up the sleep process - sleep_cmd.kill().ok(); - sleep_cmd.wait().ok(); + debug!( + "Mounted namespace at {} using PID {}", + namespace_path, first_pid + ); Ok(()) } diff --git a/tests/linux_integration.rs b/tests/linux_integration.rs index 9557be7a..4c047d0b 100644 --- a/tests/linux_integration.rs +++ b/tests/linux_integration.rs @@ -317,7 +317,6 @@ mod tests { /// Test Docker container execution with --docker-run #[test] - #[serial] fn test_docker_run_basic() { LinuxPlatform::require_privileges(); @@ -368,7 +367,6 @@ mod tests { /// Test Docker container with network restrictions #[test] - #[serial] fn test_docker_run_with_network_restrictions() { LinuxPlatform::require_privileges(); @@ -412,78 +410,4 @@ mod tests { stderr ); } - - /// Test Docker container cleanup after execution - #[test] - #[serial] - fn test_docker_run_cleanup() { - LinuxPlatform::require_privileges(); - - // Check if Docker is available - let docker_check = std::process::Command::new("docker") - .arg("--version") - .output(); - - if docker_check.is_err() || !docker_check.unwrap().status.success() { - eprintln!("Skipping Docker test: Docker not available"); - return; - } - - // Get initial namespace count - let initial_ns = std::process::Command::new("ip") - .args(["netns", "list"]) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default(); - - let initial_httpjail_ns = initial_ns - .lines() - .filter(|l| l.contains("httpjail_")) - .count(); - - // Run Docker container with httpjail - let mut cmd = httpjail_cmd(); - cmd.arg("--js") - .arg("true") - .arg("--docker-run") - .arg("--") - .arg("--rm") - .arg("alpine:latest") - .arg("true"); - - let _output = cmd - .output() - .expect("Failed to execute httpjail with docker"); - - // Check namespace was cleaned up - let final_ns = std::process::Command::new("ip") - .args(["netns", "list"]) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default(); - - let final_httpjail_ns = final_ns.lines().filter(|l| l.contains("httpjail_")).count(); - - assert_eq!( - initial_httpjail_ns, final_httpjail_ns, - "Network namespace not cleaned up after Docker container exit" - ); - - // Also check that no namespace mounts remain in /var/run/netns - if let Ok(entries) = std::fs::read_dir("/var/run/netns") { - let httpjail_mounts: Vec<_> = entries - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().contains("httpjail_")) - .collect(); - - assert!( - httpjail_mounts.is_empty(), - "Found lingering namespace mounts: {:?}", - httpjail_mounts - .iter() - .map(|e| e.file_name()) - .collect::>() - ); - } - } } From 1f4f7064b88d218d8b0641c1268a36051cefa87b Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 14:03:19 -0500 Subject: [PATCH 07/17] Simplify Docker namespace handling with RAII holder - Add NamespaceHolder struct that cleans up process on drop - Remove unnecessary namespace mounting logic (jail already handles it) - Just ensure a process exists in namespace for Docker to use - Remove redundant cleanup_namespace_mount function --- src/docker.rs | 151 +++++++++++++++++++------------------------------- 1 file changed, 57 insertions(+), 94 deletions(-) diff --git a/src/docker.rs b/src/docker.rs index db28c105..3efcd13c 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -2,9 +2,32 @@ use anyhow::{Context, Result}; use std::path::Path; -use std::process::{Command, ExitStatus}; +use std::process::{Child, Command, ExitStatus}; use tracing::{debug, info, warn}; +/// A wrapper around Child that kills the process on drop +struct NamespaceHolder { + child: Child, + namespace_name: String, +} + +impl NamespaceHolder { + fn new(child: Child, namespace_name: String) -> Self { + Self { + child, + namespace_name, + } + } +} + +impl Drop for NamespaceHolder { + fn drop(&mut self) { + debug!("Cleaning up namespace holder for {}", self.namespace_name); + self.child.kill().ok(); + self.child.wait().ok(); + } +} + /// Execute Docker container with httpjail network isolation /// /// This function: @@ -22,7 +45,8 @@ pub async fn execute_docker_run( let namespace_name = format!("httpjail_{}", jail_id); // Ensure the namespace is accessible to Docker - ensure_namespace_mounted(&namespace_name)?; + // The holder will automatically clean up when dropped + let _namespace_holder = ensure_namespace_mounted(&namespace_name)?; // Build and execute the docker command let mut cmd = build_docker_command(&namespace_name, docker_args, extra_env)?; @@ -38,95 +62,46 @@ pub async fn execute_docker_run( .status() .context("Failed to execute docker run command")?; - // Clean up the namespace mount point if we created it - cleanup_namespace_mount(&namespace_name); + // The namespace holder will be automatically cleaned up on drop Ok(status) } -/// Ensure the network namespace is mounted and accessible to Docker -#[cfg(target_os = "linux")] -fn ensure_namespace_mounted(namespace_name: &str) -> Result<()> { - // Ensure /var/run/netns directory exists - let netns_dir = Path::new("/var/run/netns"); - if !netns_dir.exists() { - std::fs::create_dir_all(netns_dir).context("Failed to create /var/run/netns directory")?; - } - - // Check if namespace is already accessible - let output = Command::new("ip") - .args(["netns", "list"]) - .output() - .context("Failed to list network namespaces")?; - - let namespaces = String::from_utf8_lossy(&output.stdout); - if namespaces.contains(namespace_name) { - debug!("Namespace {} already mounted", namespace_name); - return Ok(()); - } - - // Mount the namespace for Docker access - mount_namespace(namespace_name)?; - - Ok(()) -} - -/// Mount the namespace to /var/run/netns for Docker access +/// Ensure there's a process in the namespace so Docker can use it +/// Returns a holder that keeps the process alive #[cfg(target_os = "linux")] -fn mount_namespace(namespace_name: &str) -> Result<()> { - debug!("Mounting namespace {} to /var/run/netns", namespace_name); - - let namespace_path = format!("/var/run/netns/{}", namespace_name); - - // Create the mount point file - std::fs::File::create(&namespace_path).context("Failed to create namespace mount point")?; - - // Find the PID of any process already running in the namespace - // httpjail keeps a process alive in the namespace, so we can use that - let output = Command::new("ip") - .args(["netns", "pids", namespace_name]) - .output() - .context("Failed to get PIDs in namespace")?; - - if !output.status.success() { - anyhow::bail!( - "Failed to get PIDs from namespace: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let pids_str = String::from_utf8_lossy(&output.stdout); - let first_pid = pids_str - .lines() - .next() - .and_then(|line| line.trim().parse::().ok()) - .context("No process found in namespace")?; - - let proc_ns = format!("/proc/{}/ns/net", first_pid); - - // Verify the namespace file exists - if !Path::new(&proc_ns).exists() { - anyhow::bail!("Namespace file {} does not exist", proc_ns); - } - - // Bind mount the namespace - let mount_status = Command::new("mount") - .args(["--bind", &proc_ns, &namespace_path]) - .status() - .context("Failed to bind mount namespace")?; - - if !mount_status.success() { - // Clean up the mount point if mount failed - std::fs::remove_file(&namespace_path).ok(); - anyhow::bail!("Failed to bind mount namespace"); - } +fn ensure_namespace_mounted(namespace_name: &str) -> Result> { + // The namespace should already exist from the strong jail setup + // We just need to ensure there's a process in it for Docker + debug!( + "Starting holder process in namespace {} for Docker", + namespace_name + ); + // Start a minimal process in the namespace that will keep it alive + // We use sleep infinity which uses minimal resources + let namespace_holder = Command::new("ip") + .args([ + "netns", + "exec", + namespace_name, + "sh", + "-c", + "exec sleep infinity", + ]) + .spawn() + .context("Failed to spawn holder process in namespace")?; + + let holder_pid = namespace_holder.id(); debug!( - "Mounted namespace at {} using PID {}", - namespace_path, first_pid + "Started holder process {} in namespace {}", + holder_pid, namespace_name ); - Ok(()) + Ok(Some(NamespaceHolder::new( + namespace_holder, + namespace_name.to_string(), + ))) } /// Build the docker command with network namespace and environment variables @@ -186,18 +161,6 @@ fn filter_network_args(docker_args: &[String]) -> Vec { modified_args } -/// Clean up the namespace mount point -#[cfg(target_os = "linux")] -fn cleanup_namespace_mount(namespace_name: &str) { - let namespace_path = format!("/var/run/netns/{}", namespace_name); - - if Path::new(&namespace_path).exists() { - Command::new("umount").arg(&namespace_path).status().ok(); - std::fs::remove_file(&namespace_path).ok(); - debug!("Cleaned up namespace mount at {}", namespace_path); - } -} - /// Stub implementation for non-Linux platforms #[cfg(not(target_os = "linux"))] pub async fn execute_docker_run( From 586d59b29e56a2bf82a99e42b2631b401c1f04a8 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 14:20:22 -0500 Subject: [PATCH 08/17] Fix Docker namespace visibility and update documentation - Add code to make namespaces visible in Docker's netns directory - Add NamespaceHolder cleanup for Docker mounts - Update CLAUDE.md with HTTPJAIL_BIN test environment variable - Fix ci-ssh.sh to accept remote commands - Add ci-scp.sh helper script Note: Docker integration may not work on all systems due to Docker's namespace isolation. The daemon may not be able to access externally created network namespaces depending on the Docker version and configuration. --- CLAUDE.md | 10 +++++++++ src/docker.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ff837f3..aa10d7cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,16 @@ When testing behavior outside of the strong jailing, use `--weak` for an environ invocation of the tool. `--weak` works by setting the `HTTP_PROXY` and `HTTPS_PROXY` environment variables to the proxy address. +### Integration Tests + +The integration tests use the `HTTPJAIL_BIN` environment variable to determine which binary to test. +Always set this to the most up-to-date binary before running tests: + +```bash +export HTTPJAIL_BIN=/path/to/httpjail +cargo test --test linux_integration +``` + ## Cargo Cache Occasionally you will encounter permissions issues due to running the tests under sudo. In these cases, diff --git a/src/docker.rs b/src/docker.rs index 3efcd13c..bc15e977 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -25,6 +25,17 @@ impl Drop for NamespaceHolder { debug!("Cleaning up namespace holder for {}", self.namespace_name); self.child.kill().ok(); self.child.wait().ok(); + + // Clean up the Docker namespace mount if it exists + let docker_ns_path = format!("/var/run/docker/netns/{}", self.namespace_name); + if Path::new(&docker_ns_path).exists() { + Command::new("umount").arg(&docker_ns_path).status().ok(); + std::fs::remove_file(&docker_ns_path).ok(); + debug!( + "Cleaned up Docker namespace mount for {}", + self.namespace_name + ); + } } } @@ -67,16 +78,13 @@ pub async fn execute_docker_run( Ok(status) } -/// Ensure there's a process in the namespace so Docker can use it +/// Ensure there's a process in the namespace and it's visible to Docker /// Returns a holder that keeps the process alive #[cfg(target_os = "linux")] fn ensure_namespace_mounted(namespace_name: &str) -> Result> { // The namespace should already exist from the strong jail setup - // We just need to ensure there's a process in it for Docker - debug!( - "Starting holder process in namespace {} for Docker", - namespace_name - ); + // We need to ensure there's a process in it and make it visible to Docker + debug!("Setting up namespace {} for Docker", namespace_name); // Start a minimal process in the namespace that will keep it alive // We use sleep infinity which uses minimal resources @@ -98,6 +106,41 @@ fn ensure_namespace_mounted(namespace_name: &str) -> Result Date: Fri, 12 Sep 2025 14:50:51 -0500 Subject: [PATCH 09/17] feat: Implement DockerLinux jail for container network isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create DockerLinux jail that wraps LinuxJail with Docker-specific functionality - Use isolated Docker networks with no default connectivity - Route traffic from Docker network to jail proxy via host-side nftables - Containers run with normal permissions, no elevated access required - All network manipulation done on host side, no reliance on container commands - Reuse existing LinuxJail infrastructure for network namespaces and proxy - Add Docker network as SystemResource for automatic cleanup - Integration tests pass with proper network filtering 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/docker.rs | 215 -------------------- src/jail/linux/docker.rs | 421 +++++++++++++++++++++++++++++++++++++++ src/jail/linux/mod.rs | 3 + src/jail/mod.rs | 15 +- src/lib.rs | 1 - src/main.rs | 8 +- 6 files changed, 440 insertions(+), 223 deletions(-) delete mode 100644 src/docker.rs create mode 100644 src/jail/linux/docker.rs diff --git a/src/docker.rs b/src/docker.rs deleted file mode 100644 index bc15e977..00000000 --- a/src/docker.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Docker container execution with httpjail network isolation - -use anyhow::{Context, Result}; -use std::path::Path; -use std::process::{Child, Command, ExitStatus}; -use tracing::{debug, info, warn}; - -/// A wrapper around Child that kills the process on drop -struct NamespaceHolder { - child: Child, - namespace_name: String, -} - -impl NamespaceHolder { - fn new(child: Child, namespace_name: String) -> Self { - Self { - child, - namespace_name, - } - } -} - -impl Drop for NamespaceHolder { - fn drop(&mut self) { - debug!("Cleaning up namespace holder for {}", self.namespace_name); - self.child.kill().ok(); - self.child.wait().ok(); - - // Clean up the Docker namespace mount if it exists - let docker_ns_path = format!("/var/run/docker/netns/{}", self.namespace_name); - if Path::new(&docker_ns_path).exists() { - Command::new("umount").arg(&docker_ns_path).status().ok(); - std::fs::remove_file(&docker_ns_path).ok(); - debug!( - "Cleaned up Docker namespace mount for {}", - self.namespace_name - ); - } - } -} - -/// Execute Docker container with httpjail network isolation -/// -/// This function: -/// 1. Ensures the network namespace created by httpjail is accessible to Docker -/// 2. Modifies the docker run command to use our network namespace -/// 3. Executes the container and returns its exit status -#[cfg(target_os = "linux")] -pub async fn execute_docker_run( - jail_id: &str, - docker_args: &[String], - extra_env: &[(String, String)], -) -> Result { - info!("Setting up Docker container with httpjail network isolation"); - - let namespace_name = format!("httpjail_{}", jail_id); - - // Ensure the namespace is accessible to Docker - // The holder will automatically clean up when dropped - let _namespace_holder = ensure_namespace_mounted(&namespace_name)?; - - // Build and execute the docker command - let mut cmd = build_docker_command(&namespace_name, docker_args, extra_env)?; - - info!( - "Executing docker run with network namespace {}", - namespace_name - ); - debug!("Docker command: {:?}", cmd); - - // Execute docker run and wait for it to complete - let status = cmd - .status() - .context("Failed to execute docker run command")?; - - // The namespace holder will be automatically cleaned up on drop - - Ok(status) -} - -/// Ensure there's a process in the namespace and it's visible to Docker -/// Returns a holder that keeps the process alive -#[cfg(target_os = "linux")] -fn ensure_namespace_mounted(namespace_name: &str) -> Result> { - // The namespace should already exist from the strong jail setup - // We need to ensure there's a process in it and make it visible to Docker - debug!("Setting up namespace {} for Docker", namespace_name); - - // Start a minimal process in the namespace that will keep it alive - // We use sleep infinity which uses minimal resources - let namespace_holder = Command::new("ip") - .args([ - "netns", - "exec", - namespace_name, - "sh", - "-c", - "exec sleep infinity", - ]) - .spawn() - .context("Failed to spawn holder process in namespace")?; - - let holder_pid = namespace_holder.id(); - debug!( - "Started holder process {} in namespace {}", - holder_pid, namespace_name - ); - - // Docker looks for namespaces in /var/run/docker/netns, not /var/run/netns - // We need to make our namespace visible there - let docker_netns_dir = Path::new("/var/run/docker/netns"); - if !docker_netns_dir.exists() { - std::fs::create_dir_all(docker_netns_dir) - .context("Failed to create Docker netns directory")?; - } - - let docker_ns_path = format!("/var/run/docker/netns/{}", namespace_name); - let system_ns_path = format!("/var/run/netns/{}", namespace_name); - - // Create a bind mount from the system namespace to Docker's directory - if !Path::new(&docker_ns_path).exists() { - // Create the target file - std::fs::File::create(&docker_ns_path) - .context("Failed to create Docker namespace mount point")?; - - // Bind mount the existing namespace to Docker's directory - let mount_status = Command::new("mount") - .args(["--bind", &system_ns_path, &docker_ns_path]) - .status() - .context("Failed to bind mount namespace to Docker directory")?; - - if !mount_status.success() { - // Clean up on failure - std::fs::remove_file(&docker_ns_path).ok(); - anyhow::bail!("Failed to make namespace visible to Docker"); - } - - debug!( - "Made namespace {} visible to Docker at {}", - namespace_name, docker_ns_path - ); - } - - Ok(Some(NamespaceHolder::new( - namespace_holder, - namespace_name.to_string(), - ))) -} - -/// Build the docker command with network namespace and environment variables -#[cfg(target_os = "linux")] -fn build_docker_command( - namespace_name: &str, - docker_args: &[String], - extra_env: &[(String, String)], -) -> Result { - // Parse docker arguments to check if --network is already specified - let modified_args = filter_network_args(docker_args); - - // Build the docker run command - let mut cmd = Command::new("docker"); - cmd.arg("run"); - - // Add our network namespace (using Docker's netns directory) - cmd.args([ - "--network", - &format!("ns:/var/run/docker/netns/{}", namespace_name), - ]); - - // Add CA certificate environment variables - for (key, value) in extra_env { - cmd.arg("-e").arg(format!("{}={}", key, value)); - } - - // Add all the user's docker arguments - for arg in &modified_args { - cmd.arg(arg); - } - - Ok(cmd) -} - -/// Filter out any existing --network arguments from docker args -#[cfg(target_os = "linux")] -fn filter_network_args(docker_args: &[String]) -> Vec { - let mut modified_args = Vec::new(); - let mut i = 0; - - while i < docker_args.len() { - if docker_args[i] == "--network" || docker_args[i].starts_with("--network=") { - warn!("Docker --network flag already specified, overriding with httpjail namespace"); - - if docker_args[i] == "--network" { - // Skip the next argument too - i += 2; - continue; - } - } else { - modified_args.push(docker_args[i].clone()); - } - i += 1; - } - - modified_args -} - -/// Stub implementation for non-Linux platforms -#[cfg(not(target_os = "linux"))] -pub async fn execute_docker_run( - _jail_id: &str, - _docker_args: &[String], - _extra_env: &[(String, String)], -) -> Result { - anyhow::bail!("--docker-run is only supported on Linux") -} diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs new file mode 100644 index 00000000..7afc9bcd --- /dev/null +++ b/src/jail/linux/docker.rs @@ -0,0 +1,421 @@ +//! Docker container execution wrapped in Linux jail network isolation + +use super::LinuxJail; +use crate::jail::{Jail, JailConfig}; +use crate::sys_resource::{ManagedResource, SystemResource}; +use anyhow::{Context, Result}; +use std::process::{Command, ExitStatus}; +use tracing::{debug, info, warn}; + +/// Docker network resource that gets cleaned up on drop +struct DockerNetwork { + network_name: String, +} + +impl DockerNetwork { + fn new(jail_id: &str) -> Result { + let network_name = format!("httpjail_{}", jail_id); + Ok(Self { network_name }) + } +} + +impl SystemResource for DockerNetwork { + fn create(jail_id: &str) -> Result { + let network_name = format!("httpjail_{}", jail_id); + + // Create Docker network with no default gateway (isolated) + // Using a /24 subnet in the 172.20.x.x range + let subnet = Self::compute_docker_subnet(jail_id); + + let output = Command::new("docker") + .args([ + "network", + "create", + "--driver", + "bridge", + "--subnet", + &subnet, + "--opt", + "com.docker.network.bridge.enable_ip_masquerade=false", + &network_name, + ]) + .output() + .context("Failed to create Docker network")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("already exists") { + info!("Docker network {} already exists", network_name); + } else { + anyhow::bail!("Failed to create Docker network: {}", stderr); + } + } else { + info!( + "Created Docker network {} with subnet {}", + network_name, subnet + ); + } + + Ok(Self { network_name }) + } + + fn cleanup(&mut self) -> Result<()> { + debug!("Cleaning up Docker network: {}", self.network_name); + + let output = Command::new("docker") + .args(["network", "rm", &self.network_name]) + .output() + .context("Failed to remove Docker network")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not found") { + debug!("Docker network {} already removed", self.network_name); + } else { + warn!("Failed to remove Docker network: {}", stderr); + } + } else { + info!("Removed Docker network {}", self.network_name); + } + + Ok(()) + } + + fn for_existing(jail_id: &str) -> Self { + let network_name = format!("httpjail_{}", jail_id); + Self { network_name } + } +} + +impl DockerNetwork { + /// Compute a unique Docker subnet for this jail (172.20.x.0/24) + fn compute_docker_subnet(jail_id: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + jail_id.hash(&mut hasher); + let h = hasher.finish(); + let third_octet = ((h % 256) as u8).max(1); // 1-255 + format!("172.20.{}.0/24", third_octet) + } + + /// Get the Docker bridge interface name for this network + fn get_bridge_name(&self) -> Result { + let output = Command::new("docker") + .args(["network", "inspect", &self.network_name, "-f", "{{.Id}}"]) + .output() + .context("Failed to inspect Docker network")?; + + if !output.status.success() { + anyhow::bail!("Failed to get Docker network ID"); + } + + let network_id = String::from_utf8_lossy(&output.stdout) + .trim() + .chars() + .take(12) + .collect::(); + + Ok(format!("br-{}", network_id)) + } +} + +/// DockerLinux jail implementation that combines Docker containers with Linux jail isolation +/// +/// This jail wraps the standard LinuxJail to provide network isolation for Docker containers. +/// Unlike the previous approach, this implementation: +/// +/// 1. Creates a complete Linux jail with network namespace +/// 2. Creates an isolated Docker network with no default connectivity +/// 3. Uses nftables on the host to route traffic from Docker network to jail +/// 4. Runs containers in the isolated Docker network +/// +/// The implementation reuses all LinuxJail networking, nftables, and resource management +/// while adding Docker-specific network creation and routing. +pub struct DockerLinux { + /// The underlying Linux jail that provides network isolation + inner_jail: LinuxJail, + /// Configuration for the jail + config: JailConfig, + /// The Docker network resource + docker_network: Option>, +} + +impl DockerLinux { + /// Create a new DockerLinux jail + pub fn new(config: JailConfig) -> Result { + let inner_jail = LinuxJail::new(config.clone())?; + Ok(Self { + inner_jail, + config, + docker_network: None, + }) + } + + /// Build the docker command with isolated network + fn build_docker_command( + &self, + docker_args: &[String], + extra_env: &[(String, String)], + ) -> Result { + let network_name = format!("httpjail_{}", self.config.jail_id); + // Parse docker arguments to filter out conflicting options and find the image + let modified_args = Self::filter_network_args(docker_args); + + // Find where the image name is in the args + let mut image_idx = None; + let mut skip_next = false; + + for (i, arg) in modified_args.iter().enumerate() { + if skip_next { + skip_next = false; + continue; + } + + // Skip known flags that take values + if arg == "-e" + || arg == "-v" + || arg == "-p" + || arg == "--name" + || arg == "--entrypoint" + || arg == "-w" + || arg == "--user" + { + skip_next = true; + continue; + } + + // If it doesn't start with -, it's likely the image + if !arg.starts_with('-') { + image_idx = Some(i); + break; + } + } + + let image_idx = image_idx.context("Could not find Docker image in arguments")?; + + // Split args into: docker options, image, and command + let docker_opts = &modified_args[..image_idx]; + let image = &modified_args[image_idx]; + let user_command = if modified_args.len() > image_idx + 1 { + &modified_args[image_idx + 1..] + } else { + &[] + }; + + // Build the docker run command + let mut cmd = Command::new("docker"); + cmd.arg("run"); + + // Use our isolated Docker network + cmd.args(["--network", &network_name]); + + // Add CA certificate environment variables + for (key, value) in extra_env { + cmd.arg("-e").arg(format!("{}={}", key, value)); + } + + // Add user's docker options + for opt in docker_opts { + cmd.arg(opt); + } + + // Add the image + cmd.arg(image); + + // Add user command if provided + for arg in user_command { + cmd.arg(arg); + } + + Ok(cmd) + } + + /// Filter out any existing --network arguments from docker args + fn filter_network_args(docker_args: &[String]) -> Vec { + let mut modified_args = Vec::new(); + let mut i = 0; + + while i < docker_args.len() { + if docker_args[i] == "--network" || docker_args[i].starts_with("--network=") { + info!("Overriding Docker --network flag with httpjail namespace"); + + if docker_args[i] == "--network" { + // Skip the next argument too + i += 2; + continue; + } + } else { + modified_args.push(docker_args[i].clone()); + } + i += 1; + } + + modified_args + } + + /// Setup nftables rules to route Docker network traffic to jail + fn setup_docker_routing(&self) -> Result<()> { + let docker_network = self + .docker_network + .as_ref() + .context("Docker network not created")?; + + if let Some(network) = docker_network.inner() { + let bridge_name = network.get_bridge_name()?; + let subnet = DockerNetwork::compute_docker_subnet(&self.config.jail_id); + + // Get the jail's veth host IP + let host_ip = LinuxJail::compute_host_ip_for_jail_id(&self.config.jail_id); + let host_ip_str = super::format_ip(host_ip); + + info!( + "Setting up routing from Docker bridge {} to jail at {}", + bridge_name, host_ip_str + ); + + // Add nftables rules to: + // 1. Allow traffic from Docker network to jail's proxy ports + // 2. DNAT HTTP/HTTPS traffic to the proxy + let table_name = format!("httpjail_docker_{}", self.config.jail_id); + + // Create nftables rules + let nft_rules = format!( + "table ip {} {{ + chain prerouting {{ + type nat hook prerouting priority -100; + iifname \"{}\" tcp dport 80 dnat to {}:{}; + iifname \"{}\" tcp dport 443 dnat to {}:{}; + }} + + chain forward {{ + type filter hook forward priority 0; + iifname \"{}\" oifname \"vh_{}\" accept; + iifname \"vh_{}\" oifname \"{}\" ct state established,related accept; + }} + }}", + table_name, + bridge_name, + host_ip_str, + self.config.http_proxy_port, + bridge_name, + host_ip_str, + self.config.https_proxy_port, + bridge_name, + self.config.jail_id, + self.config.jail_id, + bridge_name + ); + + // Apply the rules + let mut nft_cmd = Command::new("nft"); + nft_cmd.arg("-f").arg("-"); + nft_cmd.stdin(std::process::Stdio::piped()); + + let mut child = nft_cmd.spawn().context("Failed to spawn nft command")?; + + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + stdin + .write_all(nft_rules.as_bytes()) + .context("Failed to write nftables rules")?; + } + + let status = child.wait().context("Failed to wait for nft command")?; + + if !status.success() { + anyhow::bail!("Failed to apply nftables rules for Docker routing"); + } + + info!("Docker routing rules applied successfully"); + } + + Ok(()) + } + + /// Cleanup Docker routing rules + fn cleanup_docker_routing(&self) -> Result<()> { + let table_name = format!("httpjail_docker_{}", self.config.jail_id); + + let output = Command::new("nft") + .args(["delete", "table", "ip", &table_name]) + .output() + .context("Failed to delete Docker routing table")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("No such file or directory") { + warn!("Failed to delete Docker routing table: {}", stderr); + } + } else { + debug!("Cleaned up Docker routing table {}", table_name); + } + + Ok(()) + } +} + +impl Jail for DockerLinux { + fn setup(&mut self, proxy_port: u16) -> Result<()> { + // First setup the inner Linux jail + self.inner_jail.setup(proxy_port)?; + + // Create the Docker network + self.docker_network = Some(ManagedResource::::create( + &self.config.jail_id, + )?); + + // Setup routing from Docker network to jail + self.setup_docker_routing()?; + + info!("DockerLinux jail setup complete with Docker network isolation"); + Ok(()) + } + + fn execute(&self, command: &[String], extra_env: &[(String, String)]) -> Result { + info!("Executing Docker container in isolated network"); + + // Build and execute the docker command + let mut cmd = self.build_docker_command(command, extra_env)?; + + debug!("Docker command: {:?}", cmd); + + // Execute docker run and wait for it to complete + let status = cmd + .status() + .context("Failed to execute docker run command")?; + + Ok(status) + } + + fn cleanup(&self) -> Result<()> { + // Cleanup Docker routing first + self.cleanup_docker_routing().ok(); + + // Docker network will be cleaned up automatically via ManagedResource drop + + // Delegate to inner jail for cleanup + self.inner_jail.cleanup() + } + + fn jail_id(&self) -> &str { + self.inner_jail.jail_id() + } + + fn cleanup_orphaned(jail_id: &str) -> Result<()> + where + Self: Sized, + { + // Delegate to LinuxJail for orphan cleanup + LinuxJail::cleanup_orphaned(jail_id) + } +} + +impl Clone for DockerLinux { + fn clone(&self) -> Self { + Self { + inner_jail: self.inner_jail.clone(), + config: self.config.clone(), + docker_network: None, + } + } +} diff --git a/src/jail/linux/mod.rs b/src/jail/linux/mod.rs index 846b8f1a..582105a1 100644 --- a/src/jail/linux/mod.rs +++ b/src/jail/linux/mod.rs @@ -1,6 +1,9 @@ mod nftables; mod resources; +#[cfg(target_os = "linux")] +pub mod docker; + use super::Jail; use super::JailConfig; use crate::sys_resource::ManagedResource; diff --git a/src/jail/mod.rs b/src/jail/mod.rs index d59b0ff9..a7a9269b 100644 --- a/src/jail/mod.rs +++ b/src/jail/mod.rs @@ -119,7 +119,11 @@ impl Default for JailConfig { } /// Create a platform-specific jail implementation wrapped with lifecycle management -pub fn create_jail(config: JailConfig, weak_mode: bool) -> Result> { +pub fn create_jail( + config: JailConfig, + weak_mode: bool, + docker_mode: bool, +) -> Result> { use self::weak::WeakJail; // Always use weak jail on macOS due to PF limitations @@ -127,6 +131,7 @@ pub fn create_jail(config: JailConfig, weak_mode: bool) -> Result> #[cfg(target_os = "macos")] { let _ = weak_mode; // Suppress unused warning on macOS + let _ = docker_mode; // Docker mode not supported on macOS // WeakJail doesn't need lifecycle management since it creates no system resources Ok(Box::new(WeakJail::new(config)?)) } @@ -134,9 +139,17 @@ pub fn create_jail(config: JailConfig, weak_mode: bool) -> Result> #[cfg(target_os = "linux")] { use self::linux::LinuxJail; + use self::linux::docker::DockerLinux; + if weak_mode { // WeakJail doesn't need lifecycle management since it creates no system resources Ok(Box::new(WeakJail::new(config)?)) + } else if docker_mode { + // DockerLinux wraps LinuxJail with Docker-specific execution + Ok(Box::new(self::managed::ManagedJail::new( + DockerLinux::new(config.clone())?, + &config, + )?)) } else { // LinuxJail creates system resources (namespaces, iptables) that need cleanup Ok(Box::new(self::managed::ManagedJail::new( diff --git a/src/lib.rs b/src/lib.rs index 86390bc8..5fe80b70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub mod dangerous_verifier; -pub mod docker; pub mod jail; pub mod proxy; pub mod proxy_tls; diff --git a/src/main.rs b/src/main.rs index 59ef4a15..6e2e8e9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -464,7 +464,7 @@ async fn main() -> Result<()> { jail_config.https_proxy_port = actual_https_port; // Create and setup jail - let mut jail = create_jail(jail_config.clone(), args.weak)?; + let mut jail = create_jail(jail_config.clone(), args.weak, args.docker_run)?; // Setup jail (pass 0 as the port parameter is ignored) jail.setup(0)?; @@ -515,11 +515,7 @@ async fn main() -> Result<()> { } // Execute command in jail with extra environment variables - let status = if args.docker_run { - // Handle Docker container execution - httpjail::docker::execute_docker_run(&jail_config.jail_id, &args.command, &extra_env) - .await? - } else if let Some(timeout_secs) = args.timeout { + let status = if let Some(timeout_secs) = args.timeout { info!("Executing command with {}s timeout", timeout_secs); // Use tokio to handle timeout From 84eef4d3b3de60e52ed8fde5005094da6fe8cb8a Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 14:54:25 -0500 Subject: [PATCH 10/17] fix: Remove unused variable and mark dead code in DockerLinux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused subnet variable in setup_docker_routing - Mark DockerNetwork::new as dead_code (used for API consistency) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/jail/linux/docker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index 7afc9bcd..678eddd9 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -13,6 +13,7 @@ struct DockerNetwork { } impl DockerNetwork { + #[allow(dead_code)] fn new(jail_id: &str) -> Result { let network_name = format!("httpjail_{}", jail_id); Ok(Self { network_name }) @@ -262,7 +263,6 @@ impl DockerLinux { if let Some(network) = docker_network.inner() { let bridge_name = network.get_bridge_name()?; - let subnet = DockerNetwork::compute_docker_subnet(&self.config.jail_id); // Get the jail's veth host IP let host_ip = LinuxJail::compute_host_ip_for_jail_id(&self.config.jail_id); From 661c0f95243dd07f53dfc1f108fc309af414d8da Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 14:57:18 -0500 Subject: [PATCH 11/17] fix: Ensure proper orphan cleanup for DockerLinux jail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cleanup for orphaned Docker networks via ManagedResource - Add cleanup for orphaned Docker routing nftables - Properly chain cleanup with LinuxJail orphan cleanup This ensures DockerLinux follows the same resource cleanup pattern as LinuxJail for handling orphaned resources from crashed processes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/jail/linux/docker.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index 678eddd9..999d91eb 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -405,7 +405,28 @@ impl Jail for DockerLinux { where Self: Sized, { - // Delegate to LinuxJail for orphan cleanup + // Clean up Docker-specific resources first + + // Clean up orphaned Docker network + let _docker_network = ManagedResource::::for_existing(jail_id); + + // Clean up orphaned Docker routing table + let table_name = format!("httpjail_docker_{}", jail_id); + let output = Command::new("nft") + .args(["delete", "table", "ip", &table_name]) + .output() + .context("Failed to delete Docker routing table")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") { + warn!("Failed to delete orphaned Docker routing table: {}", stderr); + } + } else { + debug!("Cleaned up orphaned Docker routing table {}", table_name); + } + + // Then delegate to LinuxJail for standard orphan cleanup LinuxJail::cleanup_orphaned(jail_id) } } From 3a672264d13f4fa5d7fa1b6b8477a405343550f7 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 14:59:12 -0500 Subject: [PATCH 12/17] refactor: Make Docker routing table a proper SystemResource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create DockerRoutingTable implementing SystemResource trait - Use ManagedResource for automatic cleanup of routing tables - Simplify cleanup logic by leveraging RAII pattern - Ensure consistent resource management across all Docker resources This follows the same pattern as other system resources in httpjail, ensuring proper cleanup even when processes crash. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/jail/linux/docker.rs | 102 ++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index 999d91eb..a27e7c30 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -12,6 +12,52 @@ struct DockerNetwork { network_name: String, } +/// Docker routing nftables resource that gets cleaned up on drop +struct DockerRoutingTable { + jail_id: String, + table_name: String, +} + +impl SystemResource for DockerRoutingTable { + fn create(jail_id: &str) -> Result { + let table_name = format!("httpjail_docker_{}", jail_id); + Ok(Self { + jail_id: jail_id.to_string(), + table_name, + }) + } + + fn cleanup(&mut self) -> Result<()> { + debug!("Cleaning up Docker routing table: {}", self.table_name); + + let output = Command::new("nft") + .args(["delete", "table", "ip", &self.table_name]) + .output() + .context("Failed to delete Docker routing table")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") { + warn!("Failed to delete Docker routing table: {}", stderr); + } else { + debug!("Docker routing table {} already removed", self.table_name); + } + } else { + info!("Removed Docker routing table {}", self.table_name); + } + + Ok(()) + } + + fn for_existing(jail_id: &str) -> Self { + let table_name = format!("httpjail_docker_{}", jail_id); + Self { + jail_id: jail_id.to_string(), + table_name, + } + } +} + impl DockerNetwork { #[allow(dead_code)] fn new(jail_id: &str) -> Result { @@ -139,6 +185,8 @@ pub struct DockerLinux { config: JailConfig, /// The Docker network resource docker_network: Option>, + /// The Docker routing table resource + docker_routing: Option>, } impl DockerLinux { @@ -149,6 +197,7 @@ impl DockerLinux { inner_jail, config, docker_network: None, + docker_routing: None, }) } @@ -255,7 +304,7 @@ impl DockerLinux { } /// Setup nftables rules to route Docker network traffic to jail - fn setup_docker_routing(&self) -> Result<()> { + fn setup_docker_routing(&mut self) -> Result<()> { let docker_network = self .docker_network .as_ref() @@ -327,27 +376,12 @@ impl DockerLinux { } info!("Docker routing rules applied successfully"); - } - - Ok(()) - } - /// Cleanup Docker routing rules - fn cleanup_docker_routing(&self) -> Result<()> { - let table_name = format!("httpjail_docker_{}", self.config.jail_id); - - let output = Command::new("nft") - .args(["delete", "table", "ip", &table_name]) - .output() - .context("Failed to delete Docker routing table")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("No such file or directory") { - warn!("Failed to delete Docker routing table: {}", stderr); - } - } else { - debug!("Cleaned up Docker routing table {}", table_name); + // Store the routing table as a managed resource for cleanup + // Note: We create the resource AFTER applying the rules + self.docker_routing = Some(ManagedResource::::create( + &self.config.jail_id, + )?); } Ok(()) @@ -388,10 +422,7 @@ impl Jail for DockerLinux { } fn cleanup(&self) -> Result<()> { - // Cleanup Docker routing first - self.cleanup_docker_routing().ok(); - - // Docker network will be cleaned up automatically via ManagedResource drop + // Docker network and routing will be cleaned up automatically via ManagedResource drop // Delegate to inner jail for cleanup self.inner_jail.cleanup() @@ -406,25 +437,9 @@ impl Jail for DockerLinux { Self: Sized, { // Clean up Docker-specific resources first - - // Clean up orphaned Docker network + // These will be automatically cleaned up when they go out of scope let _docker_network = ManagedResource::::for_existing(jail_id); - - // Clean up orphaned Docker routing table - let table_name = format!("httpjail_docker_{}", jail_id); - let output = Command::new("nft") - .args(["delete", "table", "ip", &table_name]) - .output() - .context("Failed to delete Docker routing table")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") { - warn!("Failed to delete orphaned Docker routing table: {}", stderr); - } - } else { - debug!("Cleaned up orphaned Docker routing table {}", table_name); - } + let _docker_routing = ManagedResource::::for_existing(jail_id); // Then delegate to LinuxJail for standard orphan cleanup LinuxJail::cleanup_orphaned(jail_id) @@ -437,6 +452,7 @@ impl Clone for DockerLinux { inner_jail: self.inner_jail.clone(), config: self.config.clone(), docker_network: None, + docker_routing: None, } } } From 4cd56e3109c24cfd614d4c77b4756c00911326a3 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 15:03:58 -0500 Subject: [PATCH 13/17] fix: Mark unused jail_id field as dead_code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jail_id field is kept for consistency and potential future use, but currently not read. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/jail/linux/docker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index a27e7c30..bdd2a311 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -14,6 +14,7 @@ struct DockerNetwork { /// Docker routing nftables resource that gets cleaned up on drop struct DockerRoutingTable { + #[allow(dead_code)] jail_id: String, table_name: String, } From 82f85f81ec007374eadfd67a273220a8a5bb6b28 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 15:19:55 -0500 Subject: [PATCH 14/17] feat: Add Docker TLS support with CA certificate bind mounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bind mount CA certificate into Docker containers for TLS interception - Mount both the certificate file and parent directory (read-only) - Add integration tests for Docker HTTPS/TLS functionality - Test both allowed and blocked HTTPS traffic scenarios - Use ifconfig.me/ip endpoint for reliable IP address retrieval This ensures TLS interception works correctly in Docker containers by making the httpjail CA certificate accessible inside the container. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/jail/linux/docker.rs | 23 ++++++++- tests/linux_integration.rs | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index bdd2a311..9156b210 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -260,9 +260,30 @@ impl DockerLinux { // Use our isolated Docker network cmd.args(["--network", &network_name]); - // Add CA certificate environment variables + // Add CA certificate environment variables and bind mount the CA certificate + let mut ca_cert_path = None; for (key, value) in extra_env { cmd.arg("-e").arg(format!("{}={}", key, value)); + + // Track the CA certificate path for bind mounting + if key == "SSL_CERT_FILE" && ca_cert_path.is_none() { + ca_cert_path = Some(value.clone()); + } + } + + // Bind mount the CA certificate if we have one + if let Some(cert_path) = ca_cert_path { + // Mount the CA certificate to the same path in the container (read-only) + cmd.arg("-v").arg(format!("{}:{}:ro", cert_path, cert_path)); + + // Also mount the parent directory if it exists (for SSL_CERT_DIR) + if let Some(parent) = std::path::Path::new(&cert_path).parent() { + if parent.exists() { + let parent_str = parent.to_string_lossy(); + cmd.arg("-v") + .arg(format!("{}:{}:ro", parent_str, parent_str)); + } + } } // Add user's docker options diff --git a/tests/linux_integration.rs b/tests/linux_integration.rs index 4c047d0b..ecb4a3b7 100644 --- a/tests/linux_integration.rs +++ b/tests/linux_integration.rs @@ -410,4 +410,104 @@ mod tests { stderr ); } + + /// Test Docker container with HTTPS/TLS interception + #[test] + fn test_docker_run_with_tls() { + LinuxPlatform::require_privileges(); + + // Check if Docker is available + let docker_check = std::process::Command::new("docker") + .arg("--version") + .output(); + + if docker_check.is_err() || !docker_check.unwrap().status.success() { + eprintln!("Skipping Docker test: Docker not available"); + return; + } + + // Test HTTPS access to ifconfig.me + let mut cmd = httpjail_cmd(); + cmd.arg("--js") + .arg("true") // Allow all traffic + .arg("--docker-run") + .arg("--") + .arg("--rm") + .arg("alpine:latest") + .arg("sh") + .arg("-c") + .arg("wget -q -O- --timeout=5 https://ifconfig.me/ip 2>&1 | head -1"); + + let output = cmd + .output() + .expect("Failed to execute httpjail with docker"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + eprintln!("[Docker TLS test] stdout: {}", stdout); + if !stderr.is_empty() { + eprintln!("[Docker TLS test] stderr: {}", stderr); + } + + // Should get an IP address back (the Docker container's external IP) + assert!( + output.status.success(), + "HTTPS request should succeed. Exit code: {:?}", + output.status.code() + ); + + // Basic check that we got an IP-like response + let response = stdout.trim(); + assert!( + !response.is_empty() && response.chars().any(|c| c == '.' || c.is_numeric()), + "Should receive IP address from ifconfig.me, got: {}", + response + ); + } + + /// Test Docker container with HTTPS/TLS blocking + #[test] + fn test_docker_run_with_tls_blocked() { + LinuxPlatform::require_privileges(); + + // Check if Docker is available + let docker_check = std::process::Command::new("docker") + .arg("--version") + .output(); + + if docker_check.is_err() || !docker_check.unwrap().status.success() { + eprintln!("Skipping Docker test: Docker not available"); + return; + } + + // Test HTTPS blocking + let mut cmd = httpjail_cmd(); + cmd.arg("--js") + .arg("false") // Block all traffic + .arg("--docker-run") + .arg("--") + .arg("--rm") + .arg("alpine:latest") + .arg("sh") + .arg("-c") + .arg("wget -q -O- --timeout=3 https://ifconfig.me/ip 2>&1 || echo 'BLOCKED'"); + + let output = cmd + .output() + .expect("Failed to execute httpjail with docker"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + eprintln!("[Docker TLS blocked test] stdout: {}", stdout); + if !stderr.is_empty() { + eprintln!("[Docker TLS blocked test] stderr: {}", stderr); + } + + assert!( + stdout.contains("BLOCKED") || stderr.contains("403") || stderr.contains("certificate"), + "HTTPS request should be blocked. stdout: {}, stderr: {}", + stdout, + stderr + ); + } } From f9a404505c27178130e9502c6c9bcadfbeb875a9 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 15:28:56 -0500 Subject: [PATCH 15/17] docs: Add Docker example to README quickstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a one-liner example showing how to use --docker-run to isolate Docker containers with httpjail network policies. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 44a21eb7..edf6c37a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ httpjail --server --js "true" # Server defaults to ports 8080 (HTTP) and 8443 (HTTPS) # Configure your application: # HTTP_PROXY=http://localhost:8080 HTTPS_PROXY=http://localhost:8443 + +# Run Docker containers with network isolation (Linux only) +httpjail --js "r.host === 'api.github.com'" --docker-run -- --rm alpine:latest sh -c "apk add curl && curl https://api.github.com" ``` ## Architecture Overview From 4f66380bab5581908b17fe864c34cda99e75232c Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 15:30:22 -0500 Subject: [PATCH 16/17] docs: Simplify Docker example using wget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use wget which is included in Alpine by default instead of installing curl, making the example cleaner and faster. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edf6c37a..fc170ff1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ httpjail --server --js "true" # HTTP_PROXY=http://localhost:8080 HTTPS_PROXY=http://localhost:8443 # Run Docker containers with network isolation (Linux only) -httpjail --js "r.host === 'api.github.com'" --docker-run -- --rm alpine:latest sh -c "apk add curl && curl https://api.github.com" +httpjail --js "r.host === 'api.github.com'" --docker-run -- --rm alpine:latest wget -qO- https://api.github.com ``` ## Architecture Overview From e3562cf97d0d0f5a95bb036b5a9922c5c89bdaee Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 12 Sep 2025 15:41:50 -0500 Subject: [PATCH 17/17] fix: Collapse nested if statements per clippy suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use if-let with && to combine conditions as recommended by clippy. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/jail/linux/docker.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/jail/linux/docker.rs b/src/jail/linux/docker.rs index 9156b210..a255e4a6 100644 --- a/src/jail/linux/docker.rs +++ b/src/jail/linux/docker.rs @@ -277,12 +277,12 @@ impl DockerLinux { cmd.arg("-v").arg(format!("{}:{}:ro", cert_path, cert_path)); // Also mount the parent directory if it exists (for SSL_CERT_DIR) - if let Some(parent) = std::path::Path::new(&cert_path).parent() { - if parent.exists() { - let parent_str = parent.to_string_lossy(); - cmd.arg("-v") - .arg(format!("{}:{}:ro", parent_str, parent_str)); - } + if let Some(parent) = std::path::Path::new(&cert_path).parent() + && parent.exists() + { + let parent_str = parent.to_string_lossy(); + cmd.arg("-v") + .arg(format!("{}:{}:ro", parent_str, parent_str)); } }