diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 760c9595..b37465a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,25 +30,18 @@ jobs: shared-key: ${{ runner.os }} - name: Install nextest - uses: taiki-e/install-action@nextest + run: cargo install cargo-nextest --locked - - name: Build - run: cargo build --verbose - - - name: Run unit tests - run: cargo nextest run --profile ci --lib --verbose - - - name: Run smoke tests - run: cargo nextest run --profile ci --test smoke_test --verbose - - - name: Run script integration tests - run: cargo nextest run --profile ci --test script_integration --verbose - - - name: Run weak mode integration tests + - name: Build httpjail binary run: | - # On macOS, we only support weak mode due to PF limitations - # (PF translation rules cannot match on user/group) - cargo nextest run --profile ci --test weak_integration --verbose + cargo build --bin httpjail --target-dir target + export HTTPJAIL_BIN="$(pwd)/target/debug/httpjail" + echo "Binary built at: ${HTTPJAIL_BIN}" + ls -la "${HTTPJAIL_BIN}" + echo "HTTPJAIL_BIN=${HTTPJAIL_BIN}" >> $GITHUB_ENV + + - name: Run all tests + run: cargo nextest run --profile ci test-linux: name: Linux Tests @@ -92,76 +85,43 @@ jobs: - name: Setup Rust environment and install nextest run: | source ~/.cargo/env + rustup default stable + cargo install cargo-nextest || true - # Install nextest if not already present - if ! command -v cargo-nextest &> /dev/null; then - cargo install cargo-nextest --locked + - name: Fix target directory permissions from previous runs + run: | + if [ -d target ]; then + sudo chown -R ci:ci target || true fi - - name: Build + - name: Build httpjail binary run: | source ~/.cargo/env - # Use incremental compilation for faster builds - export CARGO_INCREMENTAL=1 - cargo build --verbose + cargo build --bin httpjail --target-dir target + export HTTPJAIL_BIN="$(pwd)/target/debug/httpjail" + echo "Binary built at: ${HTTPJAIL_BIN}" + ls -la "${HTTPJAIL_BIN}" + echo "HTTPJAIL_BIN=${HTTPJAIL_BIN}" >> $GITHUB_ENV - - name: Run unit tests + - name: Run all tests (non-root) run: | source ~/.cargo/env - cargo nextest run --profile ci --lib --verbose + cargo nextest run --profile ci --verbose -E 'not (binary(linux_integration) or binary(weak_integration))' - - name: Run smoke tests - run: | - source ~/.cargo/env - cargo nextest run --profile ci --test smoke_test --verbose + - name: Install dependencies for weak mode (curl) + run: sudo apt-get update && sudo apt-get install -y curl - - name: Run script integration tests + - name: Run weak mode integration tests (Linux) run: | source ~/.cargo/env - cargo nextest run --profile ci --test script_integration --verbose + cargo nextest run --profile ci --test weak_integration - - name: Run Linux jail integration tests + - name: Run Linux jail integration tests (sudo) run: | source ~/.cargo/env - # Run all tests without CI workarounds since this is a self-hosted runner + # Run Linux-specific jail tests with sudo to satisfy root requirements sudo -E $(which cargo) nextest run --profile ci --test linux_integration --verbose - - name: Run isolated cleanup tests - run: | - source ~/.cargo/env - # Run only the comprehensive cleanup and sigint tests with the feature flag - # These tests need to run in isolation from other tests - sudo -E $(which cargo) test --test linux_integration --features isolated-cleanup-tests -- test_comprehensive_resource_cleanup test_cleanup_after_sigint - - test-weak: - name: Weak Mode Integration Tests (Linux) - runs-on: ubuntu-latest-8-cores - - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - with: - shared-key: ${{ runner.os }} - - - name: Install nextest - uses: taiki-e/install-action@nextest - - - name: Build - run: cargo build --verbose - - - name: Run script integration tests - run: cargo nextest run --profile ci --test script_integration --verbose - - - name: Run weak mode integration tests - run: cargo nextest run --profile ci --test weak_integration --verbose - clippy: name: Clippy (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/src/jail/linux/mod.rs b/src/jail/linux/mod.rs index 635f90bf..846b8f1a 100644 --- a/src/jail/linux/mod.rs +++ b/src/jail/linux/mod.rs @@ -1,7 +1,8 @@ mod nftables; mod resources; -use super::{Jail, JailConfig}; +use super::Jail; +use super::JailConfig; use crate::sys_resource::ManagedResource; use anyhow::{Context, Result}; use resources::{NFTable, NamespaceConfig, NetworkNamespace, VethPair}; @@ -492,15 +493,16 @@ nameserver 8.8.4.4\n", namespace_name ); - // Create a temporary resolv.conf with public DNS - let temp_resolv = format!("/tmp/httpjail_resolv_{}.conf", &namespace_name); - std::fs::write( - &temp_resolv, - "# Temporary DNS for httpjail namespace\n\ - nameserver 8.8.8.8\n\ - nameserver 8.8.4.4\n\ - nameserver 1.1.1.1\n", - )?; + // Setup DNS for the namespace + // Create a temporary resolv.conf before running the nsenter command + let temp_dir = crate::jail::get_temp_dir(); + std::fs::create_dir_all(&temp_dir).ok(); + let temp_resolv = temp_dir + .join(format!("httpjail_resolv_{}.conf", &namespace_name)) + .to_string_lossy() + .to_string(); + std::fs::write(&temp_resolv, "nameserver 1.1.1.1\nnameserver 8.8.8.8\n") + .with_context(|| format!("Failed to create temp resolv.conf: {}", temp_resolv))?; // First, try to directly write to /etc/resolv.conf in the namespace using echo let write_cmd = Command::new("ip") diff --git a/src/jail/managed.rs b/src/jail/managed.rs index fe125ce0..6d29ab60 100644 --- a/src/jail/managed.rs +++ b/src/jail/managed.rs @@ -9,11 +9,13 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, SystemTime}; use tracing::{debug, error, info, warn}; -/// A jail with lifecycle management (heartbeat and orphan cleanup) +use crate::jail::get_canary_dir; + +/// Manages jail lifecycle and cleanup with automatic cleanup on drop pub struct ManagedJail { jail: J, - // Lifecycle management fields (inlined from JailLifecycleManager) + // Lifecycle management fields canary_dir: PathBuf, canary_path: PathBuf, heartbeat_interval: Duration, @@ -26,9 +28,8 @@ pub struct ManagedJail { } impl ManagedJail { - /// Create a new managed jail pub fn new(jail: J, config: &JailConfig) -> Result { - let canary_dir = PathBuf::from("/tmp/httpjail"); + let canary_dir = get_canary_dir(); let canary_path = canary_dir.join(&config.jail_id); Ok(Self { diff --git a/src/jail/mod.rs b/src/jail/mod.rs index 4360873c..d59b0ff9 100644 --- a/src/jail/mod.rs +++ b/src/jail/mod.rs @@ -1,6 +1,12 @@ use anyhow::Result; use rand::Rng; +pub mod weak; + +#[cfg(target_os = "linux")] +pub mod linux; + +#[cfg(any(target_os = "macos", target_os = "linux"))] pub mod managed; /// Trait for platform-specific jail implementations @@ -43,8 +49,18 @@ pub trait Jail: Send + Sync { Self: Sized; } -/// Configuration for jail setup -#[derive(Debug, Clone)] +/// Get the canary directory for tracking jail lifetimes +pub fn get_canary_dir() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/httpjail") +} + +/// Get the directory for httpjail temporary files (like resolv.conf) +pub fn get_temp_dir() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/httpjail") +} + +/// Jail configuration +#[derive(Clone, Debug)] pub struct JailConfig { /// Port where the HTTP proxy is listening pub http_proxy_port: u16, @@ -102,6 +118,40 @@ impl Default for JailConfig { } } +/// Create a platform-specific jail implementation wrapped with lifecycle management +pub fn create_jail(config: JailConfig, weak_mode: bool) -> Result> { + use self::weak::WeakJail; + + // Always use weak jail on macOS due to PF limitations + // (PF translation rules cannot match on user/group) + #[cfg(target_os = "macos")] + { + let _ = weak_mode; // Suppress unused warning on macOS + // WeakJail doesn't need lifecycle management since it creates no system resources + Ok(Box::new(WeakJail::new(config)?)) + } + + #[cfg(target_os = "linux")] + { + use self::linux::LinuxJail; + if weak_mode { + // WeakJail doesn't need lifecycle management since it creates no system resources + Ok(Box::new(WeakJail::new(config)?)) + } else { + // LinuxJail creates system resources (namespaces, iptables) that need cleanup + Ok(Box::new(self::managed::ManagedJail::new( + LinuxJail::new(config.clone())?, + &config, + )?)) + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + anyhow::bail!("Unsupported platform") + } +} + #[cfg(test)] mod tests { use super::*; @@ -144,50 +194,3 @@ mod tests { assert_eq!(ids.len(), 1000); } } - -// macOS module removed - using weak jail on macOS due to PF limitations -// (PF translation rules cannot match on user/group) - -#[cfg(target_os = "linux")] -pub mod linux; - -mod weak; - -/// Create a platform-specific jail implementation wrapped with lifecycle management -pub fn create_jail(config: JailConfig, weak_mode: bool) -> Result> { - use self::managed::ManagedJail; - use self::weak::WeakJail; - - // Use weak jail if requested or on macOS (since PF cannot match groups with translation rules) - #[cfg(target_os = "macos")] - { - // Always use weak jail on macOS due to PF limitations - // (PF translation rules cannot match on user/group) - let _ = weak_mode; // Suppress unused warning on macOS - Ok(Box::new(ManagedJail::new( - WeakJail::new(config.clone())?, - &config, - )?)) - } - - #[cfg(target_os = "linux")] - { - if weak_mode { - Ok(Box::new(ManagedJail::new( - WeakJail::new(config.clone())?, - &config, - )?)) - } else { - use self::linux::LinuxJail; - Ok(Box::new(ManagedJail::new( - LinuxJail::new(config.clone())?, - &config, - )?)) - } - } - - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - anyhow::bail!("Unsupported platform") - } -} diff --git a/src/main.rs b/src/main.rs index c2affe74..d00bdd6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,11 +140,12 @@ fn setup_logging(verbosity: u8) { fn cleanup_orphans() -> Result<()> { use anyhow::Context; use std::fs; + #[cfg(target_os = "linux")] use std::path::PathBuf; use std::time::{Duration, SystemTime}; use tracing::{debug, info}; - let canary_dir = PathBuf::from("/tmp/httpjail"); + let canary_dir = httpjail::jail::get_canary_dir(); let orphan_timeout = Duration::from_secs(5); // Short timeout to catch recent orphans debug!("Starting direct orphan cleanup scan"); @@ -344,10 +345,8 @@ async fn main() -> Result<()> { let mut parts = s.split_whitespace(); match (parts.next(), parts.next()) { (Some(maybe_method), Some(url_rest)) => { - let method = maybe_method - .parse::() - .or_else(|_| maybe_method.to_ascii_uppercase().parse::()) - .unwrap_or(Method::GET); + let method_str = maybe_method.to_ascii_uppercase(); + let method = method_str.parse::().unwrap_or(Method::GET); (method, url_rest.to_string()) } _ => (Method::GET, s.clone()), @@ -355,10 +354,8 @@ async fn main() -> Result<()> { } else { let maybe_method = &test_vals[0]; let url = &test_vals[1]; - let method = maybe_method - .parse::() - .or_else(|_| maybe_method.to_ascii_uppercase().parse::()) - .unwrap_or(Method::GET); + let method_str = maybe_method.to_ascii_uppercase(); + let method = method_str.parse::().unwrap_or(Method::GET); (method, url.clone()) }; @@ -448,7 +445,8 @@ async fn main() -> Result<()> { } // Create jail canary dir early to reduce race with cleanup - std::fs::create_dir_all("/tmp/httpjail").ok(); + let canary_dir = httpjail::jail::get_canary_dir(); + std::fs::create_dir_all(&canary_dir).ok(); // Configure and execute the target command inside a jail jail_config.http_proxy_port = actual_http_port; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6ba45534..f66b7087 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,22 +1,105 @@ #![allow(dead_code)] // These are utility functions used across different test modules use std::process::Command; +use std::sync::OnceLock; + +static BUILD_RESULT: OnceLock> = OnceLock::new(); /// Build httpjail binary and return the path pub fn build_httpjail() -> Result { - let output = Command::new("cargo") - .args(["build", "--bin", "httpjail"]) - .output() - .map_err(|e| format!("Failed to build httpjail: {}", e))?; - - if !output.status.success() { - return Err(format!( - "Build failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } + BUILD_RESULT + .get_or_init(|| { + // First check if HTTPJAIL_BIN is set (e.g., by CI) + if let Ok(bin_path) = std::env::var("HTTPJAIL_BIN") + && std::path::Path::new(&bin_path).exists() + { + eprintln!("Using httpjail binary from HTTPJAIL_BIN: {}", bin_path); + return Ok(bin_path); + } + + // Determine the target directory + let target_dir = std::env::var("CARGO_TARGET_DIR") + .unwrap_or_else(|_| "target".to_string()); + + // Check if the binary already exists + let binary_path = format!("{}/debug/httpjail", target_dir); + if std::path::Path::new(&binary_path).exists() { + return Ok(binary_path); + } - Ok("target/debug/httpjail".to_string()) + // Also check the default location in case CARGO_TARGET_DIR is not set + let default_path = "target/debug/httpjail"; + if std::path::Path::new(default_path).exists() { + return Ok(default_path.to_string()); + } + + // Binary doesn't exist, try to build it + eprintln!("httpjail binary not found at {} or {}, attempting to build...", binary_path, default_path); + + let output = Command::new("cargo") + .args(["build", "--bin", "httpjail"]) + .output() + .map_err(|e| { + format!( + "Failed to execute 'cargo build --bin httpjail': {}. \n\ + Make sure cargo is in PATH or build the binary manually with 'cargo build --bin httpjail'", + e + ) + }); + + match output { + Ok(output) if output.status.success() => { + // Check both possible locations after build + if std::path::Path::new(&binary_path).exists() { + eprintln!("Successfully built httpjail binary at {}", binary_path); + Ok(binary_path) + } else if std::path::Path::new(default_path).exists() { + eprintln!("Successfully built httpjail binary at {}", default_path); + Ok(default_path.to_string()) + } else { + // If still not found, provide diagnostic information + let target_debug = format!("{}/debug", target_dir); + Err(format!( + "Build command succeeded but binary not found at {} or {}. \n\ + Current directory: {:?}\n\ + CARGO_TARGET_DIR: {:?}\n\ + Contents of {}: {:?}\n\ + Contents of target/debug: {:?}", + binary_path, + default_path, + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("unknown")), + std::env::var("CARGO_TARGET_DIR").ok(), + target_debug, + std::fs::read_dir(&target_debug) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect::>() + }) + .unwrap_or_default(), + std::fs::read_dir("target/debug") + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect::>() + }) + .unwrap_or_default() + )) + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "Failed to build httpjail binary. Build output:\n{}", + stderr + )) + } + Err(e) => Err(e), + } + }) + .clone() } /// Construct httpjail command with standard test settings @@ -127,9 +210,15 @@ impl HttpjailCommand { cmd }; - let output = cmd - .output() - .map_err(|e| format!("Failed to execute httpjail: {}", e))?; + let output = cmd.output().map_err(|e| { + format!( + "Failed to execute httpjail at '{}': {}. \n\ + Current directory: {:?}", + httpjail_path, + e, + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("unknown")) + ) + })?; let exit_code = output.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&output.stdout).to_string();