sentinel_agent_js/
lib.rs

1//! Sentinel JavaScript Agent Library
2//!
3//! A scripting agent for Sentinel reverse proxy that allows custom JavaScript
4//! logic to inspect and modify HTTP requests and responses.
5//!
6//! Uses QuickJS engine for fast, lightweight JavaScript execution.
7
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use rquickjs::{Context as JsContext, Function, Object, Runtime, Value};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::{Arc, RwLock};
15use tracing::{debug, error, info, warn};
16
17use sentinel_agent_protocol::{
18    AgentHandler, AgentResponse, AuditMetadata, ConfigureEvent, HeaderOp, RequestHeadersEvent,
19    ResponseHeadersEvent,
20};
21
22/// Agent configuration from the proxy
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24#[serde(rename_all = "kebab-case")]
25pub struct JsConfigJson {
26    /// Inline script content
27    pub script: Option<String>,
28    /// Whether to fail open on errors
29    #[serde(default)]
30    pub fail_open: bool,
31}
32
33/// Result from JavaScript script execution
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct ScriptResult {
36    /// Decision: "allow", "block", "deny", or "redirect"
37    pub decision: String,
38    /// HTTP status code for block/redirect
39    pub status: Option<u16>,
40    /// Response body for block, or URL for redirect
41    pub body: Option<String>,
42    /// Request headers to add
43    pub add_request_headers: Option<HashMap<String, String>>,
44    /// Request headers to remove
45    pub remove_request_headers: Option<Vec<String>>,
46    /// Response headers to add
47    pub add_response_headers: Option<HashMap<String, String>>,
48    /// Response headers to remove
49    pub remove_response_headers: Option<Vec<String>>,
50    /// Audit tags
51    pub tags: Option<Vec<String>>,
52}
53
54/// Request data exposed to JavaScript
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct JsRequest {
57    pub method: String,
58    pub uri: String,
59    pub client_ip: String,
60    pub correlation_id: String,
61    pub headers: HashMap<String, String>,
62}
63
64/// Response data exposed to JavaScript
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct JsResponse {
67    pub status: u16,
68    pub correlation_id: String,
69    pub headers: HashMap<String, String>,
70}
71
72/// JavaScript scripting agent
73pub struct JsAgent {
74    /// JavaScript runtime
75    runtime: Arc<RwLock<Runtime>>,
76    /// Script content (can be reconfigured)
77    script_content: RwLock<String>,
78    /// Whether to fail open on errors (can be reconfigured)
79    fail_open: RwLock<bool>,
80}
81
82// Safety: We protect the runtime with an RwLock
83unsafe impl Send for JsAgent {}
84unsafe impl Sync for JsAgent {}
85
86impl JsAgent {
87    /// Create a new JavaScript agent with the given script file
88    pub fn new(script_path: PathBuf, fail_open: bool) -> Result<Self> {
89        let script_content = std::fs::read_to_string(&script_path)
90            .with_context(|| format!("Failed to read script file: {:?}", script_path))?;
91
92        Self::from_source(script_content, fail_open)
93    }
94
95    /// Create a new JavaScript agent from script source code
96    pub fn from_source(script_content: String, fail_open: bool) -> Result<Self> {
97        let runtime = Runtime::new().context("Failed to create JavaScript runtime")?;
98
99        info!("JavaScript agent initialized");
100
101        Ok(Self {
102            runtime: Arc::new(RwLock::new(runtime)),
103            script_content: RwLock::new(script_content),
104            fail_open: RwLock::new(fail_open),
105        })
106    }
107
108    /// Reconfigure the agent with new settings
109    ///
110    /// This allows dynamic reconfiguration without restarting the agent.
111    pub fn reconfigure(&self, config: JsConfigJson) -> Result<()> {
112        if let Some(script) = config.script {
113            let mut script_content = self
114                .script_content
115                .write()
116                .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
117            *script_content = script;
118            info!("JavaScript agent script reconfigured");
119        }
120
121        {
122            let mut fail_open = self
123                .fail_open
124                .write()
125                .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
126            *fail_open = config.fail_open;
127        }
128
129        Ok(())
130    }
131
132    /// Convert serde_json::Value to QuickJS Value
133    fn json_to_js<'js>(
134        ctx: &rquickjs::Ctx<'js>,
135        value: &serde_json::Value,
136    ) -> rquickjs::Result<Value<'js>> {
137        match value {
138            serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
139            serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
140            serde_json::Value::Number(n) => {
141                if let Some(i) = n.as_i64() {
142                    Ok(Value::new_int(ctx.clone(), i as i32))
143                } else if let Some(f) = n.as_f64() {
144                    Ok(Value::new_float(ctx.clone(), f))
145                } else {
146                    Ok(Value::new_int(ctx.clone(), 0))
147                }
148            }
149            serde_json::Value::String(s) => {
150                rquickjs::String::from_str(ctx.clone(), s).map(|s| s.into())
151            }
152            serde_json::Value::Array(arr) => {
153                let js_array = rquickjs::Array::new(ctx.clone())?;
154                for (i, item) in arr.iter().enumerate() {
155                    let js_item = Self::json_to_js(ctx, item)?;
156                    js_array.set(i, js_item)?;
157                }
158                Ok(js_array.into())
159            }
160            serde_json::Value::Object(obj) => {
161                let js_obj = Object::new(ctx.clone())?;
162                for (key, val) in obj {
163                    let js_val = Self::json_to_js(ctx, val)?;
164                    js_obj.set(key.as_str(), js_val)?;
165                }
166                Ok(js_obj.into())
167            }
168        }
169    }
170
171    /// Convert QuickJS Value to serde_json::Value
172    fn js_to_json(value: &Value) -> serde_json::Value {
173        if value.is_null() || value.is_undefined() {
174            serde_json::Value::Null
175        } else if let Some(b) = value.as_bool() {
176            serde_json::Value::Bool(b)
177        } else if let Some(i) = value.as_int() {
178            serde_json::json!(i)
179        } else if let Some(f) = value.as_float() {
180            serde_json::json!(f)
181        } else if let Some(s) = value.clone().into_string() {
182            if let Ok(rust_str) = s.to_string() {
183                serde_json::Value::String(rust_str)
184            } else {
185                serde_json::Value::Null
186            }
187        } else if let Some(arr) = value.clone().into_array() {
188            let mut vec = Vec::new();
189            for i in 0..arr.len() {
190                if let Ok(item) = arr.get::<Value>(i) {
191                    vec.push(Self::js_to_json(&item));
192                }
193            }
194            serde_json::Value::Array(vec)
195        } else if let Some(obj) = value.clone().into_object() {
196            let mut map = serde_json::Map::new();
197            for key in obj.keys::<String>().flatten() {
198                if let Ok(val) = obj.get::<_, Value>(&key) {
199                    map.insert(key, Self::js_to_json(&val));
200                }
201            }
202            serde_json::Value::Object(map)
203        } else {
204            serde_json::Value::Null
205        }
206    }
207
208    /// Execute a JavaScript function
209    pub fn call_function(
210        &self,
211        fn_name: &str,
212        arg: serde_json::Value,
213    ) -> Result<Option<ScriptResult>> {
214        let runtime = self
215            .runtime
216            .read()
217            .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
218
219        let script_content = self
220            .script_content
221            .read()
222            .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
223
224        let ctx = JsContext::full(&runtime).context("Failed to create JS context")?;
225
226        ctx.with(|ctx| {
227            // Set up console object
228            let console = Object::new(ctx.clone())?;
229
230            let log_fn = Function::new(ctx.clone(), |args: rquickjs::function::Rest<Value>| {
231                let msg: Vec<String> = args.iter().map(|v| format!("{:?}", v)).collect();
232                info!(target: "js_console", "{}", msg.join(" "));
233            })?;
234            console.set("log", log_fn)?;
235
236            let warn_fn = Function::new(ctx.clone(), |args: rquickjs::function::Rest<Value>| {
237                let msg: Vec<String> = args.iter().map(|v| format!("{:?}", v)).collect();
238                warn!(target: "js_console", "{}", msg.join(" "));
239            })?;
240            console.set("warn", warn_fn)?;
241
242            let error_fn = Function::new(ctx.clone(), |args: rquickjs::function::Rest<Value>| {
243                let msg: Vec<String> = args.iter().map(|v| format!("{:?}", v)).collect();
244                error!(target: "js_console", "{}", msg.join(" "));
245            })?;
246            console.set("error", error_fn)?;
247
248            let globals = ctx.globals();
249            globals.set("console", console)?;
250
251            // Execute the script to define functions
252            ctx.eval::<(), _>(script_content.as_str())?;
253
254            // Check if function exists
255            let func: Option<Function> = globals.get(fn_name).ok();
256
257            let Some(func) = func else {
258                debug!(function = fn_name, "Function not defined in script");
259                return Ok(None);
260            };
261
262            // Convert argument to JS value
263            let js_arg = Self::json_to_js(&ctx, &arg)?;
264
265            // Call the function
266            let result: Value = func.call((js_arg,))?;
267
268            // Convert result to ScriptResult
269            let json_result = Self::js_to_json(&result);
270
271            if json_result.is_null() {
272                return Ok(Some(ScriptResult {
273                    decision: "allow".to_string(),
274                    ..Default::default()
275                }));
276            }
277
278            let script_result: ScriptResult =
279                serde_json::from_value(json_result).map_err(|e| rquickjs::Error::FromJs {
280                    from: "object",
281                    to: "ScriptResult",
282                    message: Some(format!("Failed to parse result: {}", e)),
283                })?;
284
285            Ok(Some(script_result))
286        })
287        .map_err(|e: rquickjs::Error| anyhow::anyhow!("JavaScript error: {}", e))
288    }
289
290    /// Build AgentResponse from ScriptResult
291    pub fn build_response(result: ScriptResult) -> AgentResponse {
292        let decision = result.decision.to_lowercase();
293
294        let mut response = match decision.as_str() {
295            "block" | "deny" => {
296                let status = result.status.unwrap_or(403);
297                AgentResponse::block(status, result.body)
298            }
299            "redirect" => {
300                let status = result.status.unwrap_or(302);
301                let mut resp = AgentResponse::block(status, None);
302                if let Some(url) = result.body {
303                    resp = resp.add_response_header(HeaderOp::Set {
304                        name: "Location".to_string(),
305                        value: url,
306                    });
307                }
308                resp
309            }
310            _ => AgentResponse::default_allow(),
311        };
312
313        // Add request headers
314        if let Some(headers) = result.add_request_headers {
315            for (name, value) in headers {
316                response = response.add_request_header(HeaderOp::Set { name, value });
317            }
318        }
319
320        // Remove request headers
321        if let Some(headers) = result.remove_request_headers {
322            for name in headers {
323                response = response.add_request_header(HeaderOp::Remove { name });
324            }
325        }
326
327        // Add response headers
328        if let Some(headers) = result.add_response_headers {
329            for (name, value) in headers {
330                response = response.add_response_header(HeaderOp::Set { name, value });
331            }
332        }
333
334        // Remove response headers
335        if let Some(headers) = result.remove_response_headers {
336            for name in headers {
337                response = response.add_response_header(HeaderOp::Remove { name });
338            }
339        }
340
341        // Add audit tags
342        if let Some(tags) = result.tags {
343            response = response.with_audit(AuditMetadata {
344                tags,
345                ..Default::default()
346            });
347        }
348
349        response
350    }
351
352    /// Handle script error
353    fn handle_error(&self, error: anyhow::Error, correlation_id: &str) -> AgentResponse {
354        error!(
355            correlation_id = correlation_id,
356            error = %error,
357            "Script execution failed"
358        );
359
360        let fail_open = self.fail_open.read().map(|f| *f).unwrap_or(false);
361
362        if fail_open {
363            AgentResponse::default_allow().with_audit(AuditMetadata {
364                tags: vec!["js-error".to_string(), "fail-open".to_string()],
365                reason_codes: vec![error.to_string()],
366                ..Default::default()
367            })
368        } else {
369            AgentResponse::block(500, Some("Script Error".to_string())).with_audit(AuditMetadata {
370                tags: vec!["js-error".to_string()],
371                reason_codes: vec![error.to_string()],
372                ..Default::default()
373            })
374        }
375    }
376}
377
378#[async_trait]
379impl AgentHandler for JsAgent {
380    async fn on_configure(&self, event: ConfigureEvent) -> AgentResponse {
381        info!(agent_id = %event.agent_id, "Received configuration event");
382
383        // Parse the configuration
384        let config: JsConfigJson = match serde_json::from_value(event.config) {
385            Ok(c) => c,
386            Err(e) => {
387                error!(error = %e, "Failed to parse agent configuration");
388                return AgentResponse::block(
389                    500,
390                    Some(format!("Invalid configuration: {}", e)),
391                );
392            }
393        };
394
395        // Apply the configuration
396        if let Err(e) = self.reconfigure(config) {
397            error!(error = %e, "Failed to apply configuration");
398            return AgentResponse::block(
399                500,
400                Some(format!("Configuration error: {}", e)),
401            );
402        }
403
404        info!("JavaScript agent configured successfully");
405        AgentResponse::default_allow()
406    }
407
408    async fn on_request_headers(&self, event: RequestHeadersEvent) -> AgentResponse {
409        let correlation_id = event.metadata.correlation_id.clone();
410
411        // Build request object for JavaScript
412        let mut headers: HashMap<String, String> = HashMap::new();
413        for (name, values) in &event.headers {
414            headers.insert(name.clone(), values.join(", "));
415        }
416
417        let request = JsRequest {
418            method: event.method.clone(),
419            uri: event.uri.clone(),
420            client_ip: event.metadata.client_ip.clone(),
421            correlation_id: correlation_id.clone(),
422            headers,
423        };
424
425        let request_json = match serde_json::to_value(&request) {
426            Ok(v) => v,
427            Err(e) => return self.handle_error(e.into(), &correlation_id),
428        };
429
430        // Call JavaScript function (blocking - QuickJS is fast)
431        let result = self.call_function("on_request_headers", request_json);
432
433        match result {
434            Ok(Some(script_result)) => {
435                debug!(
436                    correlation_id = correlation_id,
437                    decision = script_result.decision,
438                    "Script returned result"
439                );
440                Self::build_response(script_result)
441            }
442            Ok(None) => {
443                // Function not defined, allow by default
444                AgentResponse::default_allow()
445            }
446            Err(e) => self.handle_error(e, &correlation_id),
447        }
448    }
449
450    async fn on_response_headers(&self, event: ResponseHeadersEvent) -> AgentResponse {
451        let correlation_id = event.correlation_id.clone();
452
453        // Build response object for JavaScript
454        let mut headers: HashMap<String, String> = HashMap::new();
455        for (name, values) in &event.headers {
456            headers.insert(name.clone(), values.join(", "));
457        }
458
459        let response = JsResponse {
460            status: event.status,
461            correlation_id: correlation_id.clone(),
462            headers,
463        };
464
465        let response_json = match serde_json::to_value(&response) {
466            Ok(v) => v,
467            Err(e) => return self.handle_error(e.into(), &correlation_id),
468        };
469
470        // Call JavaScript function
471        let result = self.call_function("on_response_headers", response_json);
472
473        match result {
474            Ok(Some(script_result)) => {
475                debug!(
476                    correlation_id = correlation_id,
477                    decision = script_result.decision,
478                    "Script returned result"
479                );
480                Self::build_response(script_result)
481            }
482            Ok(None) => AgentResponse::default_allow(),
483            Err(e) => self.handle_error(e, &correlation_id),
484        }
485    }
486}