diff --git a/CLAUDE.md b/CLAUDE.md index 5d0b03d2..aa10d7cd 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. @@ -18,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, @@ -60,12 +81,43 @@ 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 + ```bash -gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail +# SSH into CI-1 instance (interactive or with commands) +./scripts/ci-ssh.sh # Interactive shell +./scripts/ci-ssh.sh "ls /tmp/httpjail-*" # Run command + +# 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 ``` -The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail`. Tests run as the `ci` user, not root. When building manually: +### Manual Testing on CI + ```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 + /home/ci/.cargo/bin/cargo build --profile fast + sudo ./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/README.md b/README.md index 44a21eb7..fc170ff1 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 wget -qO- https://api.github.com ``` ## Architecture Overview 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 new file mode 100755 index 00000000..76f8037e --- /dev/null +++ b/scripts/ci-ssh.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# SSH into the CI-1 instance for debugging + +set -e + +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/src/jail/linux/docker.rs b/src/jail/linux/docker.rs new file mode 100644 index 00000000..a255e4a6 --- /dev/null +++ b/src/jail/linux/docker.rs @@ -0,0 +1,480 @@ +//! 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, +} + +/// Docker routing nftables resource that gets cleaned up on drop +struct DockerRoutingTable { + #[allow(dead_code)] + 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 { + 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>, + /// The Docker routing table resource + docker_routing: 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, + docker_routing: 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 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() + && parent.exists() + { + let parent_str = parent.to_string_lossy(); + cmd.arg("-v") + .arg(format!("{}:{}:ro", parent_str, parent_str)); + } + } + + // 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(&mut 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()?; + + // 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"); + + // 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(()) + } +} + +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<()> { + // Docker network and routing 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, + { + // Clean up Docker-specific resources first + // These will be automatically cleaned up when they go out of scope + let _docker_network = ManagedResource::::for_existing(jail_id); + let _docker_routing = ManagedResource::::for_existing(jail_id); + + // Then delegate to LinuxJail for standard 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, + docker_routing: 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/main.rs b/src/main.rs index d00bdd6c..6e2e8e9e 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, } @@ -453,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)?; diff --git a/tests/linux_integration.rs b/tests/linux_integration.rs index 029f9c6a..ecb4a3b7 100644 --- a/tests/linux_integration.rs +++ b/tests/linux_integration.rs @@ -314,4 +314,200 @@ mod tests { stderr.trim() ); } + + /// Test Docker container execution with --docker-run + #[test] + 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] + 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 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 + ); + } }