Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
{
"type": "command",
"command": "cargo fmt"
},
{
"type": "command",
"command": "cargo clippy --all-targets -- -D warnings"
}
]
}
Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 82 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

Expand Down Expand Up @@ -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::<u16>().ok());
// Parse bind configuration from env vars
// Supports both "port" and "ip:port" formats
fn parse_bind_config(env_var: &str) -> (Option<u16>, Option<std::net::IpAddr>) {
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::<u16>().ok();
let ip = ip_str.parse::<std::net::IpAddr>().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::<u16>().ok());
// Try to parse as just a port number
if let Ok(port) = val.parse::<u16>() {
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
Expand All @@ -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;
Expand Down Expand Up @@ -513,6 +583,7 @@ mod tests {
timeout: None,
no_jail_cleanup: false,
cleanup: false,
server: false,
command: vec![],
};

Expand Down
8 changes: 6 additions & 2 deletions src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
122 changes: 121 additions & 1 deletion tests/weak_integration.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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<std::process::Child, String> {
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<String, String> {
// 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();
}