diff --git a/.claude/settings.json b/.claude/settings.json index aaedab0a..bbb9636a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,6 +7,10 @@ { "type": "command", "command": "cargo fmt" + }, + { + "type": "command", + "command": "cargo clippy --all-targets -- -D warnings" } ] } diff --git a/README.md b/README.md index 472b32d9..583623ab 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ cargo install httpjail ## MVP TODO - [ ] Update README to be more reflective of AI agent restrictions -- [ ] Add a `--server` mode that runs the proxy server but doesn't execute the command +- [x] Add a `--server` mode that runs the proxy server but doesn't execute the command - [ ] Expand test cases to include WebSockets ## Quick Start @@ -43,6 +43,12 @@ httpjail -r "allow-get: api\.github\.com" -r "deny: .*" -- git pull # Use config file for complex rules httpjail --config rules.txt -- python script.py + +# Run as standalone proxy server (no command execution) +httpjail --server -r "allow: .*" +# Server defaults to ports 8080 (HTTP) and 8443 (HTTPS) +# Configure your application: +# HTTP_PROXY=http://localhost:8080 HTTPS_PROXY=http://localhost:8443 ``` ## Architecture Overview @@ -169,8 +175,44 @@ httpjail --config rules.txt -- ./my-application # Verbose logging httpjail -vvv -r "allow: .*" -- curl https://example.com +# Server mode - run as standalone proxy without executing commands +httpjail --server -r "allow: github\.com" -r "deny: .*" +# Server defaults to ports 8080 (HTTP) and 8443 (HTTPS) + +# Server mode with custom ports (format: port or ip:port) +HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server -r "allow: .*" +# Configure applications: HTTP_PROXY=http://localhost:3128 HTTPS_PROXY=http://localhost:3129 + +# Bind to specific interface +HTTPJAIL_HTTP_BIND=192.168.1.100:8080 httpjail --server -r "allow: .*" + +``` + +### Server Mode + +httpjail can run as a standalone proxy server without executing any commands. This is useful when you want to proxy multiple applications through the same httpjail instance. The server binds to localhost (127.0.0.1) only for security. + +```bash +# Start server with default ports (8080 for HTTP, 8443 for HTTPS) on localhost +httpjail --server -r "allow: github\.com" -r "deny: .*" +# Output: Server running on ports 8080 (HTTP) and 8443 (HTTPS). Press Ctrl+C to stop. + +# Start server with custom ports using environment variables +HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server -r "allow: .*" +# Output: Server running on ports 3128 (HTTP) and 3129 (HTTPS). Press Ctrl+C to stop. + +# Bind to all interfaces (use with caution - exposes proxy to network) +HTTPJAIL_HTTP_BIND=0.0.0.0:8080 HTTPJAIL_HTTPS_BIND=0.0.0.0:8443 httpjail --server -r "allow: .*" +# Output: Server running on ports 8080 (HTTP) and 8443 (HTTPS). Press Ctrl+C to stop. + +# Configure your applications to use the proxy: +export HTTP_PROXY=http://localhost:8080 +export HTTPS_PROXY=http://localhost:8443 +curl https://github.com # This request will go through httpjail ``` +**Note**: In server mode, httpjail does not create network isolation. Applications must be configured to use the proxy via environment variables or application-specific proxy settings. + ## TLS Interception httpjail performs HTTPS interception using a locally-generated Certificate Authority (CA). The tool does not modify your system trust store. Instead, it configures the jailed process to trust the httpjail CA via environment variables. diff --git a/src/main.rs b/src/main.rs index 1d58514a..3430c057 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,8 +56,16 @@ struct Args { #[arg(long = "cleanup", hide = true)] cleanup: bool, + /// Run as standalone proxy server (without executing a command) + #[arg( + long = "server", + conflicts_with = "cleanup", + conflicts_with = "timeout" + )] + server: bool, + /// Command and arguments to execute - #[arg(trailing_var_arg = true, required_unless_present = "cleanup")] + #[arg(trailing_var_arg = true, required_unless_present_any = ["cleanup", "server"])] command: Vec, } @@ -298,22 +306,59 @@ async fn main() -> Result<()> { return Ok(()); } + // Handle server mode + if args.server { + info!("Starting httpjail in server mode"); + } + // Build rules from command line arguments let rules = build_rules(&args)?; let rule_engine = RuleEngine::new(rules, args.log_only); - // Get ports from env vars (optional) - let http_port = std::env::var("HTTPJAIL_HTTP_BIND") - .ok() - .and_then(|s| s.parse::().ok()); + // Parse bind configuration from env vars + // Supports both "port" and "ip:port" formats + fn parse_bind_config(env_var: &str) -> (Option, Option) { + if let Ok(val) = std::env::var(env_var) { + if let Some(colon_pos) = val.rfind(':') { + // Try to parse as ip:port + let ip_str = &val[..colon_pos]; + let port_str = &val[colon_pos + 1..]; + + let port = port_str.parse::().ok(); + let ip = ip_str.parse::().ok(); + + if port.is_some() && ip.is_some() { + return (port, ip); + } + } - let https_port = std::env::var("HTTPJAIL_HTTPS_BIND") - .ok() - .and_then(|s| s.parse::().ok()); + // Try to parse as just a port number + if let Ok(port) = val.parse::() { + return (Some(port), None); + } + } + (None, None) + } - // Determine bind address based on platform and mode - let bind_address = if args.weak { - // In weak mode, bind to localhost only + let (http_port_env, http_bind_ip) = parse_bind_config("HTTPJAIL_HTTP_BIND"); + let (https_port_env, https_bind_ip) = parse_bind_config("HTTPJAIL_HTTPS_BIND"); + + // Use env port or default to 8080/8443 in server mode + let http_port = http_port_env.or(if args.server { Some(8080) } else { None }); + let https_port = https_port_env.or(if args.server { Some(8443) } else { None }); + + // Determine bind address based on configuration and mode + let bind_address = if let Some(ip) = http_bind_ip.or(https_bind_ip) { + // If user explicitly specified an IP, use it + match ip { + std::net::IpAddr::V4(ipv4) => Some(ipv4.octets()), + std::net::IpAddr::V6(_) => { + warn!("IPv6 addresses are not currently supported, falling back to IPv4"); + None + } + } + } else if args.weak || args.server { + // In weak mode or server mode, bind to localhost only by default None } else { // For jailed mode on Linux, bind to all interfaces @@ -337,6 +382,31 @@ async fn main() -> Result<()> { actual_http_port, actual_https_port ); + // In server mode, just run the proxy server + if args.server { + // Use tokio::sync::Notify for real-time shutdown signaling + let shutdown_notify = Arc::new(tokio::sync::Notify::new()); + let shutdown_notify_clone = shutdown_notify.clone(); + + ctrlc::set_handler(move || { + info!("Received interrupt signal, shutting down server..."); + shutdown_notify_clone.notify_one(); + }) + .expect("Error setting signal handler"); + + info!( + "Server running on ports {} (HTTP) and {} (HTTPS). Press Ctrl+C to stop.", + actual_http_port, actual_https_port + ); + + // Wait for shutdown signal + shutdown_notify.notified().await; + + info!("Server shutdown complete"); + return Ok(()); + } + + // Normal mode: create jail and execute command // Create jail configuration with actual bound ports let mut jail_config = JailConfig::new(); jail_config.http_proxy_port = actual_http_port; @@ -513,6 +583,7 @@ mod tests { timeout: None, no_jail_cleanup: false, cleanup: false, + server: false, command: vec![], }; diff --git a/src/proxy.rs b/src/proxy.rs index 605abc3d..f70193c5 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -203,9 +203,11 @@ impl ProxyServer { pub async fn start(&mut self) -> Result<(u16, u16)> { // Start HTTP proxy let http_listener = if let Some(port) = self.http_port { + // If port is 0, let OS choose any available port + // Otherwise bind to the specified port TcpListener::bind(SocketAddr::from((self.bind_address, port))).await? } else { - // Find available port in 8000-8999 range + // No port specified, find available port in 8000-8999 range let listener = bind_to_available_port(8000, 8999, self.bind_address).await?; self.http_port = Some(listener.local_addr()?.port()); listener @@ -245,9 +247,11 @@ impl ProxyServer { // Start HTTPS proxy let https_listener = if let Some(port) = self.https_port { + // If port is 0, let OS choose any available port + // Otherwise bind to the specified port TcpListener::bind(SocketAddr::from((self.bind_address, port))).await? } else { - // Find available port in 8000-8999 range + // No port specified, find available port in 8000-8999 range let listener = bind_to_available_port(8000, 8999, self.bind_address).await?; self.https_port = Some(listener.local_addr()?.port()); listener diff --git a/tests/weak_integration.rs b/tests/weak_integration.rs index 1effb363..41cff5b8 100644 --- a/tests/weak_integration.rs +++ b/tests/weak_integration.rs @@ -1,7 +1,10 @@ mod common; -use common::{HttpjailCommand, test_https_allow, test_https_blocking}; +use common::{HttpjailCommand, build_httpjail, test_https_allow, test_https_blocking}; +use std::process::{Command, Stdio}; use std::str::FromStr; +use std::thread; +use std::time::Duration; #[test] fn test_weak_mode_blocks_https_correctly() { @@ -119,3 +122,120 @@ fn test_weak_mode_allows_localhost() { } } } + +// Simple server start function - we know the ports we're setting +fn start_server(http_port: u16, https_port: u16) -> Result { + let httpjail_path = build_httpjail()?; + + let mut cmd = Command::new(&httpjail_path); + cmd.arg("--server") + .arg("-r") + .arg("allow: .*") + .arg("-vv") + .env("HTTPJAIL_HTTP_BIND", http_port.to_string()) + .env("HTTPJAIL_HTTPS_BIND", https_port.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let child = cmd + .spawn() + .map_err(|e| format!("Failed to start server: {}", e))?; + + // Wait for the server to start listening + if !wait_for_server(http_port, Duration::from_secs(5)) { + return Err(format!("Server failed to start on port {}", http_port)); + } + + Ok(child) +} + +fn wait_for_server(port: u16, max_wait: Duration) -> bool { + let start = std::time::Instant::now(); + while start.elapsed() < max_wait { + if std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() { + // Give the server a bit more time to fully initialize + thread::sleep(Duration::from_millis(500)); + return true; + } + thread::sleep(Duration::from_millis(100)); + } + false +} + +fn test_curl_through_proxy(http_port: u16, _https_port: u16) -> Result { + // First, verify the proxy port is actually listening + if !verify_bind_address(http_port, "127.0.0.1") { + return Err(format!("Proxy port {} is not listening", http_port)); + } + + // Use a simple HTTP endpoint that should work in CI + // Try with verbose output for debugging + let output = Command::new("curl") + .arg("-x") + .arg(format!("http://127.0.0.1:{}", http_port)) + .arg("--max-time") + .arg("10") // Increase timeout for CI + .arg("-s") + .arg("-S") // Show errors + .arg("-w") + .arg("\nHTTP_CODE:%{http_code}") + .arg("http://example.com/") + .output() + .map_err(|e| format!("Failed to run curl: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check if curl succeeded (exit code 0) + if !output.status.success() { + // For debugging in CI + eprintln!("Curl failed - stdout: {}", stdout); + eprintln!("Curl failed - stderr: {}", stderr); + return Err(format!( + "Curl failed with status: {}, stderr: {}", + output.status, stderr + )); + } + + // Check if we got a valid HTTP response + if stdout.contains("HTTP_CODE:200") || stdout.contains("Example Domain") { + Ok(stdout.to_string()) + } else if stdout.contains("HTTP_CODE:403") { + // Request was blocked by proxy (which is also fine for testing) + Ok("Blocked by proxy".to_string()) + } else { + Err(format!("Unexpected response: {}", stdout)) + } +} + +fn verify_bind_address(port: u16, expected_ip: &str) -> bool { + // Try to connect to the expected IP + std::net::TcpStream::connect(format!("{}:{}", expected_ip, port)).is_ok() +} + +#[test] +fn test_server_mode() { + // Test server mode with specific ports + let http_port = 19876; + let https_port = 19877; + + let mut server = start_server(http_port, https_port).expect("Failed to start server"); + + // Test HTTP proxy works + match test_curl_through_proxy(http_port, https_port) { + Ok(_response) => { + // Success - proxy is working + } + Err(e) => panic!("Curl test failed: {}", e), + } + + // Verify binds to localhost only + assert!( + verify_bind_address(http_port, "127.0.0.1"), + "Server should bind to localhost" + ); + + // Cleanup + let _ = server.kill(); + let _ = server.wait(); +}