diff --git a/Cargo.lock b/Cargo.lock index cb3ff24..bc2555a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,12 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "assert_cmd" version = "2.0.17" @@ -995,6 +1001,7 @@ name = "httpjail" version = "0.5.1" dependencies = [ "anyhow", + "arc-swap", "assert_cmd", "async-trait", "atty", diff --git a/Cargo.toml b/Cargo.toml index 84bd6fd..11103fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" simple-dns = "0.7" tempfile = "3.8" +arc-swap = "1.7" [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" diff --git a/README.md b/README.md index fc86ca2..619c87e 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,10 @@ Or download a pre-built binary from the [releases page](https://github.com/coder # Allow only requests to github.com (JS) httpjail --js "r.host === 'github.com'" -- your-app -# Load JS from a file +# Load JS from a file (auto-reloads on file changes) echo "/^api\\.example\\.com$/.test(r.host) && r.method === 'GET'" > rules.js httpjail --js-file rules.js -- curl https://api.example.com/health +# File changes are detected and reloaded automatically on each request # Log requests to a file httpjail --request-log requests.log --js "true" -- npm install diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8001779..7338e46 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -16,6 +16,7 @@ httpjail follows a simple configuration hierarchy: Choose how requests are evaluated: - **JavaScript** (`--js` or `--js-file`) - Fast, sandboxed evaluation + - Files specified with `--js-file` are automatically reloaded when changed - **Shell Script** (`--sh`) - System integration, external tools - **Line Processor** (`--proc`) - Stateful, streaming evaluation diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index 63fd4ef..2056fb2 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -80,6 +80,8 @@ isAllowed && r.method !== 'DELETE'; httpjail --js-file rules.js -- npm install ``` +> **Tip:** Rules files are automatically reloaded when they change - perfect for development and debugging! Just edit `rules.js` and the changes take effect on the next request. + ### Request Logging Monitor what requests are being made: diff --git a/docs/guide/rule-engines/javascript.md b/docs/guide/rule-engines/javascript.md index ed31eb8..8c991cc 100644 --- a/docs/guide/rule-engines/javascript.md +++ b/docs/guide/rule-engines/javascript.md @@ -41,6 +41,28 @@ allowedHosts.includes(r.host); httpjail --js-file rules.js -- command ``` +#### Automatic File Reloading + +When using `--js-file`, httpjail automatically detects and reloads the file when it changes. This is especially useful during development and debugging: + +```bash +# Start with initial rules +echo "r.host === 'example.com'" > rules.js +httpjail --js-file rules.js -- your-app + +# In another terminal, update the rules (reloads automatically on next request) +echo "r.host === 'github.com'" > rules.js +``` + +**How it works:** +- File modification time (mtime) is checked on each request +- If the file has changed, it's reloaded and validated +- Invalid JavaScript is rejected and existing rules are kept +- Reload happens atomically without interrupting request processing +- Zero overhead when the file hasn't changed + +**Note:** File watching is only active when using `--js-file`. Inline rules (`--js`) do not reload. + ## Response Format {{#include ../../includes/response-format-table.md}} diff --git a/src/main.rs b/src/main.rs index 669bf05..3fc1fb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -429,7 +429,8 @@ async fn main() -> Result<()> { info!("Using V8 JavaScript rule evaluation from file: {}", js_file); let code = std::fs::read_to_string(js_file) .with_context(|| format!("Failed to read JS file: {}", js_file))?; - let js_engine = match V8JsRuleEngine::new(code) { + let js_file_path = std::path::PathBuf::from(js_file); + let js_engine = match V8JsRuleEngine::new_with_file(code, Some(js_file_path)) { Ok(engine) => Box::new(engine), Err(e) => { eprintln!("Failed to create V8 JavaScript engine: {}", e); diff --git a/src/rules/v8_js.rs b/src/rules/v8_js.rs index 4ed47f2..02f4c9b 100644 --- a/src/rules/v8_js.rs +++ b/src/rules/v8_js.rs @@ -1,20 +1,43 @@ +//! V8 JavaScript rule engine implementation. +//! +//! This module provides a rule engine that evaluates HTTP requests using JavaScript +//! code executed via the V8 engine. It supports automatic file reloading when rules +//! are loaded from a file path. + use crate::rules::common::{RequestInfo, RuleResponse}; use crate::rules::console_log; use crate::rules::{EvaluationResult, RuleEngineTrait}; +use arc_swap::ArcSwap; use async_trait::async_trait; use hyper::Method; +use std::path::PathBuf; use std::sync::Arc; +use std::time::SystemTime; use tokio::sync::Mutex; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; +/// V8-based JavaScript rule engine with automatic file reloading support. +/// +/// The engine uses a lock-free ArcSwap for reading JavaScript code on every request, +/// and employs a singleflight pattern (via Mutex) to prevent concurrent file reloads. pub struct V8JsRuleEngine { - js_code: String, - #[allow(dead_code)] - runtime: Arc>, // Placeholder for V8 runtime management + /// JavaScript code and its last modified time (lock-free atomic updates) + js_code: ArcSwap<(String, Option)>, + /// Optional file path for automatic reloading + js_file_path: Option, + /// Lock to prevent concurrent file reloads (singleflight pattern) + reload_lock: Arc>, } impl V8JsRuleEngine { pub fn new(js_code: String) -> Result> { + Self::new_with_file(js_code, None) + } + + pub fn new_with_file( + js_code: String, + js_file_path: Option, + ) -> Result> { // Initialize V8 platform once and keep it alive for the lifetime of the program use std::sync::OnceLock; static V8_PLATFORM: OnceLock> = OnceLock::new(); @@ -27,27 +50,45 @@ impl V8JsRuleEngine { }); // Compile the JavaScript to check for syntax errors - { - let mut isolate = v8::Isolate::new(v8::CreateParams::default()); - let handle_scope = &mut v8::HandleScope::new(&mut isolate); - let context = v8::Context::new(handle_scope, Default::default()); - let context_scope = &mut v8::ContextScope::new(handle_scope, context); + Self::validate_js_code(&js_code)?; + + // Get initial mtime if file path is provided + let initial_mtime = js_file_path + .as_ref() + .and_then(|path| std::fs::metadata(path).ok().and_then(|m| m.modified().ok())); - let source = - v8::String::new(context_scope, &js_code).ok_or("Failed to create V8 string")?; + let js_code_swap = ArcSwap::from(Arc::new((js_code, initial_mtime))); - v8::Script::compile(context_scope, source, None) - .ok_or("Failed to compile JavaScript expression")?; + if js_file_path.is_some() { + info!("File watching enabled for JS rules - will check for changes on each request"); } info!("V8 JavaScript rule engine initialized"); Ok(Self { - js_code, - runtime: Arc::new(Mutex::new(())), + js_code: js_code_swap, + js_file_path, + reload_lock: Arc::new(Mutex::new(())), }) } - pub fn execute( + /// Validate JavaScript code by compiling it with V8 + fn validate_js_code(js_code: &str) -> Result<(), Box> { + let mut isolate = v8::Isolate::new(v8::CreateParams::default()); + let handle_scope = &mut v8::HandleScope::new(&mut isolate); + let context = v8::Context::new(handle_scope, Default::default()); + let context_scope = &mut v8::ContextScope::new(handle_scope, context); + + let source = v8::String::new(context_scope, js_code).ok_or("Failed to create V8 string")?; + + v8::Script::compile(context_scope, source, None) + .ok_or("Failed to compile JavaScript expression")?; + + Ok(()) + } + + /// Execute JavaScript rules against a request (public API). + /// For internal use, prefer calling `evaluate()` via the RuleEngineTrait. + pub async fn execute( &self, method: &Method, url: &str, @@ -61,7 +102,11 @@ impl V8JsRuleEngine { } }; - match self.create_and_execute(&request_info) { + // Load the current JS code (lock-free) + let code_and_mtime = self.js_code.load(); + let (js_code, _) = &**code_and_mtime; + + match Self::execute_with_code(js_code, &request_info) { Ok(result) => result, Err(e) => { warn!("JavaScript execution failed: {}", e); @@ -196,57 +241,152 @@ impl V8JsRuleEngine { Ok((allowed, message, max_tx_bytes)) } + /// Execute JavaScript code with a given code string (can be called from blocking context) #[allow(clippy::type_complexity)] - fn create_and_execute( - &self, + fn execute_with_code( + js_code: &str, request_info: &RequestInfo, ) -> Result<(bool, Option, Option), Box> { // Create a new isolate for each execution (simpler approach) let mut isolate = v8::Isolate::new(v8::CreateParams::default()); - Self::execute_with_isolate(&mut isolate, &self.js_code, request_info) + Self::execute_with_isolate(&mut isolate, js_code, request_info) } -} -#[async_trait] -impl RuleEngineTrait for V8JsRuleEngine { - async fn evaluate(&self, method: Method, url: &str, requester_ip: &str) -> EvaluationResult { - // Run the JavaScript evaluation in a blocking task to avoid - // issues with V8's single-threaded nature + /// Check if the JS file has changed and reload if necessary. + /// Uses double-check locking pattern to prevent concurrent reloads. + async fn check_and_reload_file(&self) { + let Some(ref path) = self.js_file_path else { + return; + }; + + let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + + // Fast path: check if reload needed (no lock) + let code_and_mtime = self.js_code.load(); + let (_, last_mtime) = &**code_and_mtime; + + if current_mtime != *last_mtime && current_mtime.is_some() { + // Slow path: acquire lock to prevent concurrent reloads (singleflight) + let _guard = self.reload_lock.lock().await; + + // Double-check: file might have been reloaded while waiting for lock + let code_and_mtime = self.js_code.load(); + let (_, last_mtime) = &**code_and_mtime; + + if current_mtime != *last_mtime && current_mtime.is_some() { + info!("Detected change in JS rules file: {:?}", path); + + // Re-read and validate the file + match std::fs::read_to_string(path) { + Ok(new_code) => { + // Validate the new code before reloading + if let Err(e) = Self::validate_js_code(&new_code) { + error!( + "Failed to validate updated JS code: {}. Keeping existing rules.", + e + ); + } else { + // Update the code and mtime atomically (lock-free swap) + self.js_code.store(Arc::new((new_code, current_mtime))); + info!("Successfully reloaded JS rules from file"); + } + } + Err(e) => { + error!( + "Failed to read updated JS file: {}. Keeping existing rules.", + e + ); + } + } + } + } + } + + /// Load the current JS code from the ArcSwap (lock-free operation). + fn load_js_code(&self) -> String { + let code_and_mtime = self.js_code.load(); + let (js_code, _) = &**code_and_mtime; + js_code.clone() + } + + /// Execute JavaScript in a blocking task to handle V8's single-threaded nature. + /// Returns (allowed, context, max_tx_bytes). + async fn execute_js_blocking( + js_code: String, + method: Method, + url: &str, + requester_ip: &str, + ) -> (bool, Option, Option) { let method_clone = method.clone(); let url_clone = url.to_string(); let ip_clone = requester_ip.to_string(); - // Clone self to move into the closure - let self_clone = Self { - js_code: self.js_code.clone(), - runtime: self.runtime.clone(), - }; + tokio::task::spawn_blocking(move || { + let request_info = match RequestInfo::from_request(&method_clone, &url_clone, &ip_clone) + { + Ok(info) => info, + Err(e) => { + warn!("Failed to parse request info: {}", e); + return (false, Some("Invalid request format".to_string()), None); + } + }; - let (allowed, context, max_tx_bytes) = tokio::task::spawn_blocking(move || { - self_clone.execute(&method_clone, &url_clone, &ip_clone) + match Self::execute_with_code(&js_code, &request_info) { + Ok(result) => result, + Err(e) => { + warn!("JavaScript execution failed: {}", e); + (false, Some("JavaScript execution failed".to_string()), None) + } + } }) .await .unwrap_or_else(|e| { warn!("Failed to spawn V8 evaluation task: {}", e); (false, Some("Evaluation failed".to_string()), None) - }); + }) + } + + /// Build an EvaluationResult from the execution outcome. + fn build_evaluation_result( + allowed: bool, + context: Option, + max_tx_bytes: Option, + ) -> EvaluationResult { + let mut result = if allowed { + EvaluationResult::allow() + } else { + EvaluationResult::deny() + }; + + if let Some(ctx) = context { + result = result.with_context(ctx); + } if allowed { - let mut result = EvaluationResult::allow(); - if let Some(ctx) = context { - result = result.with_context(ctx); - } if let Some(bytes) = max_tx_bytes { result = result.with_max_tx_bytes(bytes); } - result - } else { - let mut result = EvaluationResult::deny(); - if let Some(ctx) = context { - result = result.with_context(ctx); - } - result } + + result + } +} + +#[async_trait] +impl RuleEngineTrait for V8JsRuleEngine { + async fn evaluate(&self, method: Method, url: &str, requester_ip: &str) -> EvaluationResult { + // Check if file has changed and reload if necessary + self.check_and_reload_file().await; + + // Load the current JS code (lock-free operation) + let js_code = self.load_js_code(); + + // Execute JavaScript in blocking task + let (allowed, context, max_tx_bytes) = + Self::execute_js_blocking(js_code, method, url, requester_ip).await; + + // Build and return the result + Self::build_evaluation_result(allowed, context, max_tx_bytes) } fn name(&self) -> &str { diff --git a/tests/js_file_reload.rs b/tests/js_file_reload.rs new file mode 100644 index 0000000..8ce3aa1 --- /dev/null +++ b/tests/js_file_reload.rs @@ -0,0 +1,110 @@ +use httpjail::rules::RuleEngineTrait; +use httpjail::rules::v8_js::V8JsRuleEngine; +use hyper::Method; +use std::fs; +use std::path::PathBuf; +use tempfile::NamedTempFile; + +#[tokio::test(flavor = "multi_thread")] +async fn test_js_file_reload() { + // Create a temporary JS file and persist it to avoid early deletion + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + let (file, path) = temp_file.into_parts(); + let file_path = PathBuf::from(&path); + drop(file); // Close the file handle + + // Write initial rule (allow all requests) + fs::write(&file_path, "true").expect("Failed to write initial rule"); + + // Create engine with file watching + let code = fs::read_to_string(&file_path).expect("Failed to read file"); + let engine = V8JsRuleEngine::new_with_file(code, Some(file_path.clone())) + .expect("Failed to create engine"); + + // Test initial rule (should allow) + let result = engine + .evaluate(Method::GET, "https://example.com", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Allow)); + + // Update the JS file (deny all requests) + fs::write(&file_path, "false").expect("Failed to write updated rule"); + + // Reload happens on next evaluate call - no waiting needed + + // Test updated rule (should deny) + let result = engine + .evaluate(Method::GET, "https://example.com", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Deny)); + + // Update again with a more complex rule + fs::write(&file_path, "r.host === 'allowed.com'").expect("Failed to write complex rule"); + + // Reload happens on next evaluate call - no waiting needed + + // Test with allowed host + let result = engine + .evaluate(Method::GET, "https://allowed.com/path", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Allow)); + + // Test with denied host + let result = engine + .evaluate(Method::GET, "https://denied.com/path", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Deny)); + + // Clean up + let _ = fs::remove_file(&file_path); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_js_file_reload_syntax_error() { + // Create a temporary JS file and persist it + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + let (file, path) = temp_file.into_parts(); + let file_path = PathBuf::from(&path); + drop(file); + + // Write initial valid rule + fs::write(&file_path, "true").expect("Failed to write initial rule"); + + // Create engine with file watching + let code = fs::read_to_string(&file_path).expect("Failed to read file"); + let engine = V8JsRuleEngine::new_with_file(code, Some(file_path.clone())) + .expect("Failed to create engine"); + + // Test initial rule (should allow) + let result = engine + .evaluate(Method::GET, "https://example.com", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Allow)); + + // Update the JS file with syntax error + fs::write(&file_path, "this is not valid javascript {{[") + .expect("Failed to write invalid rule"); + + // Reload check happens on next evaluate call - no waiting needed + + // Test that the old rule is still in effect (reload should have been rejected) + let result = engine + .evaluate(Method::GET, "https://example.com", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Allow)); + + // Clean up + let _ = fs::remove_file(&file_path); +} + +#[tokio::test] +async fn test_js_engine_without_file_path() { + // Create engine without file path (no file watching) + let engine = V8JsRuleEngine::new("true".to_string()).expect("Failed to create engine"); + + // Test that it works normally + let result = engine + .evaluate(Method::GET, "https://example.com", "127.0.0.1") + .await; + assert!(matches!(result.action, httpjail::rules::Action::Allow)); +}