Skip to content

Commit b651b37

Browse files
committed
Merge branch 'main' into codex/remove-log-only-flag-and-add-request-log-zxb0tf
Resolved conflicts by keeping the dry-run and request-log features from HEAD branch
2 parents 224d1c6 + 1faecaf commit b651b37

8 files changed

Lines changed: 270 additions & 86 deletions

File tree

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
{
88
"type": "command",
99
"command": "cargo fmt"
10+
},
11+
{
12+
"type": "command",
13+
"command": "cargo clippy --all-targets -- -D warnings"
1014
}
1115
]
1216
}

README.md

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ cargo install httpjail
2323
## MVP TODO
2424

2525
- [ ] Update README to be more reflective of AI agent restrictions
26-
- [ ] Add a `--server` mode that runs the proxy server but doesn't execute the command
26+
- [x] Add a `--server` mode that runs the proxy server but doesn't execute the command
2727
- [ ] Expand test cases to include WebSockets
2828

2929
## Quick Start
@@ -44,6 +44,12 @@ httpjail -r "allow-get: api\.github\.com" -r "deny: .*" -- git pull
4444

4545
# Use config file for complex rules
4646
httpjail --config rules.txt -- python script.py
47+
48+
# Run as standalone proxy server (no command execution)
49+
httpjail --server -r "allow: .*"
50+
# Server defaults to ports 8080 (HTTP) and 8443 (HTTPS)
51+
# Configure your application:
52+
# HTTP_PROXY=http://localhost:8080 HTTPS_PROXY=http://localhost:8443
4753
```
4854

4955
## Architecture Overview
@@ -167,14 +173,47 @@ httpjail --config rules.txt -- ./my-application
167173
### Advanced Options
168174

169175
```bash
170-
# Dry run - log what would be blocked without blocking
171-
httpjail --dry-run --config rules.txt -- ./app
172-
173176
# Verbose logging
174177
httpjail -vvv -r "allow: .*" -- curl https://example.com
175178

179+
# Server mode - run as standalone proxy without executing commands
180+
httpjail --server -r "allow: github\.com" -r "deny: .*"
181+
# Server defaults to ports 8080 (HTTP) and 8443 (HTTPS)
182+
183+
# Server mode with custom ports (format: port or ip:port)
184+
HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server -r "allow: .*"
185+
# Configure applications: HTTP_PROXY=http://localhost:3128 HTTPS_PROXY=http://localhost:3129
186+
187+
# Bind to specific interface
188+
HTTPJAIL_HTTP_BIND=192.168.1.100:8080 httpjail --server -r "allow: .*"
189+
176190
```
177191

192+
### Server Mode
193+
194+
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.
195+
196+
```bash
197+
# Start server with default ports (8080 for HTTP, 8443 for HTTPS) on localhost
198+
httpjail --server -r "allow: github\.com" -r "deny: .*"
199+
# Output: Server running on ports 8080 (HTTP) and 8443 (HTTPS). Press Ctrl+C to stop.
200+
201+
# Start server with custom ports using environment variables
202+
HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server -r "allow: .*"
203+
# Output: Server running on ports 3128 (HTTP) and 3129 (HTTPS). Press Ctrl+C to stop.
204+
205+
# Bind to all interfaces (use with caution - exposes proxy to network)
206+
HTTPJAIL_HTTP_BIND=0.0.0.0:8080 HTTPJAIL_HTTPS_BIND=0.0.0.0:8443 httpjail --server -r "allow: .*"
207+
# Output: Server running on ports 8080 (HTTP) and 8443 (HTTPS). Press Ctrl+C to stop.
208+
209+
# Configure your applications to use the proxy:
210+
export HTTP_PROXY=http://localhost:8080
211+
export HTTPS_PROXY=http://localhost:8443
212+
curl https://github.com # This request will go through httpjail
213+
```
214+
215+
**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.
216+
178217
## TLS Interception
179218

180219
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.
@@ -201,16 +240,9 @@ How it works:
201240

202241
Notes and limits:
203242

204-
- Tools that ignore the above env vars will fail TLS verification when intercepted. For those, either add tool‑specific flags to point at `ca-cert.pem` or run with `--no-tls-intercept`.
243+
- Tools that ignore the above env vars will fail TLS verification when intercepted. For those, add tool‑specific flags to point at `ca-cert.pem`.
205244
- Long‑lived connections are supported: timeouts are applied only to protocol detection, CONNECT header reads, and TLS handshakes — not to proxied streams (e.g., gRPC/WebSocket).
206245

207-
### Disable TLS Interception
208-
209-
```bash
210-
# Only monitor/block HTTP traffic
211-
httpjail --no-tls-intercept --allow ".*" -- ./app
212-
```
213-
214246
## License
215247

216248
This project is released into the public domain under the CC0 1.0 Universal license. See [LICENSE](LICENSE) for details.

src/jail/mod.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,6 @@ pub struct JailConfig {
5252
/// Port for HTTPS proxy
5353
pub https_proxy_port: u16,
5454

55-
/// Whether to use TLS interception
56-
#[allow(dead_code)]
57-
pub tls_intercept: bool,
58-
5955
/// Unique identifier for this jail instance
6056
pub jail_id: String,
6157

@@ -79,7 +75,6 @@ impl JailConfig {
7975
Self {
8076
http_proxy_port: 8040,
8177
https_proxy_port: 8043,
82-
tls_intercept: true,
8378
jail_id,
8479
enable_heartbeat: true,
8580
heartbeat_interval_secs: 1,

src/main.rs

Lines changed: 95 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@ struct Args {
3737
#[arg(long = "request-log", value_name = "FILE")]
3838
request_log: Option<String>,
3939

40-
/// Disable HTTPS interception
41-
#[arg(long = "no-tls-intercept")]
42-
no_tls_intercept: bool,
43-
4440
/// Interactive approval mode
4541
#[arg(long = "interactive")]
4642
interactive: bool,
@@ -65,8 +61,16 @@ struct Args {
6561
#[arg(long = "cleanup", hide = true)]
6662
cleanup: bool,
6763

64+
/// Run as standalone proxy server (without executing a command)
65+
#[arg(
66+
long = "server",
67+
conflicts_with = "cleanup",
68+
conflicts_with = "timeout"
69+
)]
70+
server: bool,
71+
6872
/// Command and arguments to execute
69-
#[arg(trailing_var_arg = true, required_unless_present = "cleanup")]
73+
#[arg(trailing_var_arg = true, required_unless_present_any = ["cleanup", "server"])]
7074
command: Vec<String>,
7175
}
7276

@@ -307,6 +311,11 @@ async fn main() -> Result<()> {
307311
return Ok(());
308312
}
309313

314+
// Handle server mode
315+
if args.server {
316+
info!("Starting httpjail in server mode");
317+
}
318+
310319
// Build rules from command line arguments
311320
let rules = build_rules(&args)?;
312321
let request_log = if let Some(path) = &args.request_log {
@@ -322,18 +331,50 @@ async fn main() -> Result<()> {
322331
};
323332
let rule_engine = RuleEngine::new(rules, args.dry_run, request_log);
324333

325-
// Get ports from env vars (optional)
326-
let http_port = std::env::var("HTTPJAIL_HTTP_BIND")
327-
.ok()
328-
.and_then(|s| s.parse::<u16>().ok());
334+
// Parse bind configuration from env vars
335+
// Supports both "port" and "ip:port" formats
336+
fn parse_bind_config(env_var: &str) -> (Option<u16>, Option<std::net::IpAddr>) {
337+
if let Ok(val) = std::env::var(env_var) {
338+
if let Some(colon_pos) = val.rfind(':') {
339+
// Try to parse as ip:port
340+
let ip_str = &val[..colon_pos];
341+
let port_str = &val[colon_pos + 1..];
342+
343+
let port = port_str.parse::<u16>().ok();
344+
let ip = ip_str.parse::<std::net::IpAddr>().ok();
345+
346+
if port.is_some() && ip.is_some() {
347+
return (port, ip);
348+
}
349+
}
329350

330-
let https_port = std::env::var("HTTPJAIL_HTTPS_BIND")
331-
.ok()
332-
.and_then(|s| s.parse::<u16>().ok());
351+
// Try to parse as just a port number
352+
if let Ok(port) = val.parse::<u16>() {
353+
return (Some(port), None);
354+
}
355+
}
356+
(None, None)
357+
}
333358

334-
// Determine bind address based on platform and mode
335-
let bind_address = if args.weak {
336-
// In weak mode, bind to localhost only
359+
let (http_port_env, http_bind_ip) = parse_bind_config("HTTPJAIL_HTTP_BIND");
360+
let (https_port_env, https_bind_ip) = parse_bind_config("HTTPJAIL_HTTPS_BIND");
361+
362+
// Use env port or default to 8080/8443 in server mode
363+
let http_port = http_port_env.or(if args.server { Some(8080) } else { None });
364+
let https_port = https_port_env.or(if args.server { Some(8443) } else { None });
365+
366+
// Determine bind address based on configuration and mode
367+
let bind_address = if let Some(ip) = http_bind_ip.or(https_bind_ip) {
368+
// If user explicitly specified an IP, use it
369+
match ip {
370+
std::net::IpAddr::V4(ipv4) => Some(ipv4.octets()),
371+
std::net::IpAddr::V6(_) => {
372+
warn!("IPv6 addresses are not currently supported, falling back to IPv4");
373+
None
374+
}
375+
}
376+
} else if args.weak || args.server {
377+
// In weak mode or server mode, bind to localhost only by default
337378
None
338379
} else {
339380
// For jailed mode on Linux, bind to all interfaces
@@ -357,11 +398,35 @@ async fn main() -> Result<()> {
357398
actual_http_port, actual_https_port
358399
);
359400

401+
// In server mode, just run the proxy server
402+
if args.server {
403+
// Use tokio::sync::Notify for real-time shutdown signaling
404+
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
405+
let shutdown_notify_clone = shutdown_notify.clone();
406+
407+
ctrlc::set_handler(move || {
408+
info!("Received interrupt signal, shutting down server...");
409+
shutdown_notify_clone.notify_one();
410+
})
411+
.expect("Error setting signal handler");
412+
413+
info!(
414+
"Server running on ports {} (HTTP) and {} (HTTPS). Press Ctrl+C to stop.",
415+
actual_http_port, actual_https_port
416+
);
417+
418+
// Wait for shutdown signal
419+
shutdown_notify.notified().await;
420+
421+
info!("Server shutdown complete");
422+
return Ok(());
423+
}
424+
425+
// Normal mode: create jail and execute command
360426
// Create jail configuration with actual bound ports
361427
let mut jail_config = JailConfig::new();
362428
jail_config.http_proxy_port = actual_http_port;
363429
jail_config.https_proxy_port = actual_https_port;
364-
jail_config.tls_intercept = !args.no_tls_intercept;
365430

366431
// Create and setup jail
367432
let mut jail = create_jail(jail_config.clone(), args.weak)?;
@@ -398,21 +463,19 @@ async fn main() -> Result<()> {
398463
// Set up CA certificate environment variables for common tools
399464
let mut extra_env = Vec::new();
400465

401-
if !args.no_tls_intercept {
402-
match httpjail::tls::CertificateManager::get_ca_env_vars() {
403-
Ok(ca_env_vars) => {
404-
debug!(
405-
"Setting {} CA certificate environment variables",
406-
ca_env_vars.len()
407-
);
408-
extra_env = ca_env_vars;
409-
}
410-
Err(e) => {
411-
warn!(
412-
"Failed to set up CA certificate environment variables: {}",
413-
e
414-
);
415-
}
466+
match httpjail::tls::CertificateManager::get_ca_env_vars() {
467+
Ok(ca_env_vars) => {
468+
debug!(
469+
"Setting {} CA certificate environment variables",
470+
ca_env_vars.len()
471+
);
472+
extra_env = ca_env_vars;
473+
}
474+
Err(e) => {
475+
warn!(
476+
"Failed to set up CA certificate environment variables: {}",
477+
e
478+
);
416479
}
417480
}
418481

@@ -531,13 +594,13 @@ mod tests {
531594
config: Some(file.path().to_str().unwrap().to_string()),
532595
dry_run: false,
533596
request_log: None,
534-
no_tls_intercept: false,
535597
interactive: false,
536598
weak: false,
537599
verbose: 0,
538600
timeout: None,
539601
no_jail_cleanup: false,
540602
cleanup: false,
603+
server: false,
541604
command: vec![],
542605
};
543606

src/proxy.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,11 @@ impl ProxyServer {
203203
pub async fn start(&mut self) -> Result<(u16, u16)> {
204204
// Start HTTP proxy
205205
let http_listener = if let Some(port) = self.http_port {
206+
// If port is 0, let OS choose any available port
207+
// Otherwise bind to the specified port
206208
TcpListener::bind(SocketAddr::from((self.bind_address, port))).await?
207209
} else {
208-
// Find available port in 8000-8999 range
210+
// No port specified, find available port in 8000-8999 range
209211
let listener = bind_to_available_port(8000, 8999, self.bind_address).await?;
210212
self.http_port = Some(listener.local_addr()?.port());
211213
listener
@@ -245,9 +247,11 @@ impl ProxyServer {
245247

246248
// Start HTTPS proxy
247249
let https_listener = if let Some(port) = self.https_port {
250+
// If port is 0, let OS choose any available port
251+
// Otherwise bind to the specified port
248252
TcpListener::bind(SocketAddr::from((self.bind_address, port))).await?
249253
} else {
250-
// Find available port in 8000-8999 range
254+
// No port specified, find available port in 8000-8999 range
251255
let listener = bind_to_available_port(8000, 8999, self.bind_address).await?;
252256
self.https_port = Some(listener.local_addr()?.port());
253257
listener

tests/platform_test_macro.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,6 @@ macro_rules! platform_tests {
3232
system_integration::test_jail_request_log::<$platform>();
3333
}
3434

35-
#[test]
36-
#[::serial_test::serial]
37-
fn test_jail_dry_run_mode() {
38-
system_integration::test_jail_dry_run_mode::<$platform>();
39-
}
40-
4135
#[test]
4236
#[::serial_test::serial]
4337
fn test_jail_requires_command() {

tests/system_integration.rs

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -238,34 +238,6 @@ pub fn test_jail_request_log<P: JailTestPlatform>() {
238238
assert!(contents.contains("- GET http://example.com"));
239239
}
240240

241-
/// Test dry-run mode
242-
pub fn test_jail_dry_run_mode<P: JailTestPlatform>() {
243-
P::require_privileges();
244-
245-
let mut cmd = httpjail_cmd();
246-
cmd.arg("--dry-run")
247-
.arg("-r")
248-
.arg("deny: .*") // Deny everything
249-
.arg("--");
250-
curl_http_status_args(&mut cmd, "http://ifconfig.me");
251-
252-
let output = cmd.output().expect("Failed to execute httpjail");
253-
254-
let stdout = String::from_utf8_lossy(&output.stdout);
255-
let stderr = String::from_utf8_lossy(&output.stderr);
256-
if !stderr.is_empty() {
257-
eprintln!("[{}] stderr: {}", P::platform_name(), stderr);
258-
}
259-
260-
// In dry-run mode, even deny rules should not block
261-
assert_eq!(
262-
stdout.trim(),
263-
"200",
264-
"Request should be allowed in dry-run mode"
265-
);
266-
assert!(output.status.success());
267-
}
268-
269241
/// Test that jail requires a command
270242
pub fn test_jail_requires_command<P: JailTestPlatform>() {
271243
// This test doesn't require root

0 commit comments

Comments
 (0)