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
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ sudo ./target/release/httpjail --allow "httpbin\.org" -- curl http://httpbin.org
# Test with method-specific rules
sudo ./target/release/httpjail --allow-get ".*" -- curl -X POST http://httpbin.org/post

# Test log-only mode
sudo ./target/release/httpjail --log-only -- curl http://example.com
# Test request logging
sudo ./target/release/httpjail --request-log requests.log -r "allow: .*" -- curl http://example.com
# Log format: "<timestamp> <+/-> <METHOD> <URL>" (+ = allowed, - = blocked)
```

### Test Organization
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ cargo install httpjail
# Allow only requests to github.com
httpjail -r "allow: github\.com" -r "deny: .*" -- claude

# Monitor all requests without blocking
httpjail --log-only -- npm install
# Log requests to a file
httpjail --request-log requests.log -r "allow: .*" -- npm install
# Log format: "<timestamp> <+/-> <METHOD> <URL>" (+ = allowed, - = blocked)

# Block specific domains
httpjail -r "deny: telemetry\..*" -r "allow: .*" -- ./my-app
Expand Down
39 changes: 19 additions & 20 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ use clap::Parser;
use httpjail::jail::{JailConfig, create_jail};
use httpjail::proxy::ProxyServer;
use httpjail::rules::{Action, Rule, RuleEngine};
use std::fs::OpenOptions;
use std::os::unix::process::ExitStatusExt;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};

#[derive(Parser, Debug)]
Expand All @@ -28,9 +29,9 @@ struct Args {
#[arg(short = 'c', long = "config", value_name = "FILE")]
config: Option<String>,

/// Monitor without filtering
#[arg(long = "log-only")]
log_only: bool,
/// Append requests to a log file
#[arg(long = "request-log", value_name = "FILE")]
request_log: Option<String>,

/// Interactive approval mode
#[arg(long = "interactive")]
Expand Down Expand Up @@ -313,7 +314,18 @@ async fn main() -> Result<()> {

// Build rules from command line arguments
let rules = build_rules(&args)?;
let rule_engine = RuleEngine::new(rules, args.log_only);
let request_log = if let Some(path) = &args.request_log {
Some(Arc::new(Mutex::new(
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.context("Failed to open request log file")?,
)))
} else {
None
};
let rule_engine = RuleEngine::new(rules, request_log);

// Parse bind configuration from env vars
// Supports both "port" and "ip:port" formats
Expand Down Expand Up @@ -527,7 +539,7 @@ mod tests {
Rule::new(Action::Deny, r".*").unwrap(),
];

let engine = RuleEngine::new(rules, false);
let engine = RuleEngine::new(rules, None);

// Test allow rule
assert!(matches!(
Expand All @@ -548,19 +560,6 @@ mod tests {
));
}

#[test]
fn test_log_only_mode() {
let rules = vec![Rule::new(Action::Deny, r".*").unwrap()];

let engine = RuleEngine::new(rules, true);

// In log-only mode, everything should be allowed
assert!(matches!(
engine.evaluate(Method::POST, "https://example.com"),
Action::Allow
));
}

#[test]
fn test_build_rules_from_config_file() {
use std::io::Write;
Expand All @@ -576,7 +575,7 @@ mod tests {
let args = Args {
rules: vec![],
config: Some(file.path().to_str().unwrap().to_string()),
log_only: false,
request_log: None,
interactive: false,
weak: false,
verbose: 0,
Expand Down
4 changes: 2 additions & 2 deletions src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ mod tests {
Rule::new(Action::Deny, r".*").unwrap(),
];

let rule_engine = RuleEngine::new(rules, false);
let rule_engine = RuleEngine::new(rules, None);
let proxy = ProxyServer::new(Some(8080), Some(8443), rule_engine, None);

assert_eq!(proxy.http_port, Some(8080));
Expand All @@ -473,7 +473,7 @@ mod tests {
async fn test_proxy_server_auto_port() {
let rules = vec![Rule::new(Action::Allow, r".*").unwrap()];

let rule_engine = RuleEngine::new(rules, false);
let rule_engine = RuleEngine::new(rules, None);
let mut proxy = ProxyServer::new(None, None, rule_engine, None);

let (http_port, https_port) = proxy.start().await.unwrap();
Expand Down
2 changes: 1 addition & 1 deletion src/proxy_tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ mod tests {
Rule::new(Action::Deny, r".*").unwrap(),
]
};
Arc::new(RuleEngine::new(rules, false))
Arc::new(RuleEngine::new(rules, None))
}

/// Create a TLS client config that trusts any certificate (for testing)
Expand Down
86 changes: 65 additions & 21 deletions src/rules.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use anyhow::Result;
use chrono::{SecondsFormat, Utc};
use hyper::Method;
use regex::Regex;
use std::collections::HashSet;
use std::fs::File;
use std::io::Write;
use std::sync::{Arc, Mutex};
use tracing::{info, warn};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -48,22 +52,21 @@ impl Rule {
#[derive(Clone)]
pub struct RuleEngine {
pub rules: Vec<Rule>,
pub log_only: bool,
pub request_log: Option<Arc<Mutex<File>>>,
}

impl RuleEngine {
pub fn new(rules: Vec<Rule>, log_only: bool) -> Self {
RuleEngine { rules, log_only }
pub fn new(rules: Vec<Rule>, request_log: Option<Arc<Mutex<File>>>) -> Self {
RuleEngine { rules, request_log }
}

pub fn evaluate(&self, method: Method, url: &str) -> Action {
if self.log_only {
info!("Request: {} {}", method, url);
return Action::Allow;
}
let mut action = Action::Deny;
let mut matched = false;

for rule in &self.rules {
if rule.matches(method.clone(), url) {
matched = true;
match &rule.action {
Action::Allow => {
info!(
Expand All @@ -72,7 +75,7 @@ impl RuleEngine {
url,
rule.pattern.as_str()
);
return Action::Allow;
action = Action::Allow;
}
Action::Deny => {
warn!(
Expand All @@ -81,21 +84,39 @@ impl RuleEngine {
url,
rule.pattern.as_str()
);
return Action::Deny;
action = Action::Deny;
}
}
break;
}
}

if !matched {
warn!("DENY: {} {} (no matching rules)", method, url);
action = Action::Deny;
}

if let Some(log) = &self.request_log
&& let Ok(mut file) = log.lock()
{
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let status = match &action {
Action::Allow => '+',
Action::Deny => '-',
};
if let Err(e) = writeln!(file, "{} {} {} {}", timestamp, status, method, url) {
warn!("Failed to write to request log: {}", e);
}
}

// Default deny if no rules match
warn!("DENY: {} {} (no matching rules)", method, url);
Action::Deny
action
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};

#[test]
fn test_rule_matching() {
Expand Down Expand Up @@ -125,7 +146,7 @@ mod tests {
Rule::new(Action::Deny, r".*").unwrap(),
];

let engine = RuleEngine::new(rules, false);
let engine = RuleEngine::new(rules, None);

// Test allow rule
assert!(matches!(
Expand Down Expand Up @@ -155,7 +176,7 @@ mod tests {
Rule::new(Action::Deny, r".*").unwrap(),
];

let engine = RuleEngine::new(rules, false);
let engine = RuleEngine::new(rules, None);

// GET should be allowed
assert!(matches!(
Expand All @@ -171,15 +192,38 @@ mod tests {
}

#[test]
fn test_log_only_mode() {
fn test_request_logging() {
use std::fs::OpenOptions;

let rules = vec![Rule::new(Action::Allow, r".*").unwrap()];
let log_file = tempfile::NamedTempFile::new().unwrap();
let file = OpenOptions::new()
.append(true)
.open(log_file.path())
.unwrap();
let engine = RuleEngine::new(rules, Some(Arc::new(Mutex::new(file))));

engine.evaluate(Method::GET, "https://example.com");

let contents = std::fs::read_to_string(log_file.path()).unwrap();
assert!(contents.contains("+ GET https://example.com"));
}

#[test]
fn test_request_logging_denied() {
use std::fs::OpenOptions;

let rules = vec![Rule::new(Action::Deny, r".*").unwrap()];
let log_file = tempfile::NamedTempFile::new().unwrap();
let file = OpenOptions::new()
.append(true)
.open(log_file.path())
.unwrap();
let engine = RuleEngine::new(rules, Some(Arc::new(Mutex::new(file))));

let engine = RuleEngine::new(rules, true);
engine.evaluate(Method::GET, "https://blocked.com");

// In log-only mode, everything should be allowed
assert!(matches!(
engine.evaluate(Method::POST, "https://example.com"),
Action::Allow
));
let contents = std::fs::read_to_string(log_file.path()).unwrap();
assert!(contents.contains("- GET https://blocked.com"));
}
}
4 changes: 2 additions & 2 deletions tests/platform_test_macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ macro_rules! platform_tests {

#[test]
#[::serial_test::serial]
fn test_jail_log_only_mode() {
system_integration::test_jail_log_only_mode::<$platform>();
fn test_jail_request_log() {
system_integration::test_jail_request_log::<$platform>();
}

#[test]
Expand Down
42 changes: 28 additions & 14 deletions tests/system_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,19 @@ pub fn test_jail_method_specific_rules<P: JailTestPlatform>() {
assert_eq!(stdout.trim(), "403", "POST request should be denied");
}

/// Test log-only mode
pub fn test_jail_log_only_mode<P: JailTestPlatform>() {
/// Test request logging
pub fn test_jail_request_log<P: JailTestPlatform>() {
P::require_privileges();

let log_file = tempfile::NamedTempFile::new().expect("Failed to create temp file");
let log_path = log_file.path().to_str().unwrap().to_string();

let mut cmd = httpjail_cmd();
cmd.arg("--log-only").arg("--");
cmd.arg("--request-log")
.arg(&log_path)
.arg("-r")
.arg("allow: .*")
.arg("--");
curl_http_status_args(&mut cmd, "http://example.com");

let output = cmd.output().expect("Failed to execute httpjail");
Expand All @@ -210,18 +217,25 @@ pub fn test_jail_log_only_mode<P: JailTestPlatform>() {
if !stderr.is_empty() {
eprintln!("[{}] stderr: {}", P::platform_name(), stderr);
}
eprintln!("[{}] stdout: {}", P::platform_name(), stdout);

// In log-only mode, all requests should be allowed
// Due to proxy issues, we might get partial responses or timeouts
// Just verify that the request wasn't explicitly blocked (403)
assert!(
!stdout.contains("403") && !stderr.contains("403") && !stdout.contains("Request blocked"),
"Request should not be blocked in log-only mode. Got stdout: '{}', stderr: '{}', exit code: {:?}",
stdout.trim(),
stderr.trim(),
output.status.code()
);
assert_eq!(stdout.trim(), "200", "GET request should be allowed");

// Run a denied request to ensure '-' is logged
let mut cmd = httpjail_cmd();
cmd.arg("--request-log")
.arg(&log_path)
.arg("-r")
.arg("deny: .*")
.arg("--");
curl_http_status_args(&mut cmd, "http://example.com");

let output = cmd.output().expect("Failed to execute httpjail");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "403", "GET request should be denied");

let contents = std::fs::read_to_string(log_file.path()).expect("Failed to read request log");
assert!(contents.contains("+ GET http://example.com"));
assert!(contents.contains("- GET http://example.com"));
}

/// Test that jail requires a command
Expand Down