Skip to main content

netget/server/proxy/
actions.rs

1//! HTTP Proxy protocol actions implementation
2//!
3//! This module provides actions for:
4//! - Handling intercepted requests (pass/block/modify)
5//! - Handling intercepted responses (pass/block/modify)
6//! - Handling HTTPS connections in pass-through mode (allow/block)
7//!
8//! Certificate mode and filter modes are configured through startup parameters,
9//! not actions: the configuration is read once when the server spawns.
10
11use super::filter;
12use super::filter::{HttpsConnectionAction, RequestAction, ResponseAction};
13use crate::llm::actions::{
14    protocol_trait::{ActionResult, Protocol, Server},
15    ActionDefinition, Parameter, ParameterDefinition,
16};
17use crate::protocol::log_template::LogTemplate;
18use crate::protocol::EventType;
19use crate::state::app_state::AppState;
20use anyhow::{Context, Result};
21use serde_json::json;
22use std::sync::LazyLock;
23
24/// Read a model-supplied HTTP status, refusing anything outside 100-599.
25///
26/// `as u16` on a `u64` wraps in silence: `status: 65736` is `200`, so a *block* — the only
27/// way the model can refuse a request — reached the client as the success it was refusing.
28/// Clamping would be a different lie, so an out-of-range value is an error the repair loop
29/// can see and correct.
30fn parse_status(action: &serde_json::Value, default: u16) -> Result<u16> {
31    let Some(value) = action.get("status") else {
32        return Ok(default);
33    };
34    if value.is_null() {
35        return Ok(default);
36    }
37    let raw = value
38        .as_u64()
39        .with_context(|| format!("'status' must be an HTTP status code, got {value}"))?;
40    if !(100..=599).contains(&raw) {
41        return Err(anyhow::anyhow!(
42            "'status' {raw} is not an HTTP status code; use 100-599 \
43             (e.g. 403 to block a request, 502 to block a response)"
44        ));
45    }
46    Ok(raw as u16)
47}
48
49/// HTTP Proxy protocol action handler
50pub struct ProxyProtocol;
51
52impl ProxyProtocol {
53    pub fn new() -> Self {
54        Self
55    }
56}
57
58// Implement Protocol trait (common functionality)
59impl Protocol for ProxyProtocol {
60    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
61        vec![
62                ParameterDefinition {
63                    name: "certificate_mode".to_string(),
64                    type_hint: "string".to_string(),
65                    description: "Certificate mode: 'generate' (MITM - decrypt HTTPS using a CA generated fresh at startup, requires clients to trust it) or 'none' (pass-through, no decryption, allow/block only). Default: 'none'. 'load_from_file' is not implemented and is rejected at startup.".to_string(),
66                    required: false,
67                    example: json!("generate"),
68                    default: None,
69                },
70                // cert_path / key_path are read by ProxyServer::spawn_with_llm_actions in the
71                // 'load_from_file' branch. They must be declared even though that mode is
72                // rejected: StartupParams validates every key against this list *before*
73                // add_server, so an undeclared read produced "Attempted to access undeclared
74                // startup parameter 'cert_path'" and the caller never saw the deliberate
75                // "not implemented" message written below it.
76                ParameterDefinition {
77                    name: "cert_path".to_string(),
78                    type_hint: "string".to_string(),
79                    description: "Path to a CA certificate in PEM format. Only read when certificate_mode is 'load_from_file', which is NOT implemented and is rejected at startup - so setting this cannot make HTTPS interception use your own CA. Use certificate_mode 'generate' with ca_export_path instead.".to_string(),
80                    required: false,
81                    example: json!("./ca.crt"),
82                    default: None,
83                },
84                ParameterDefinition {
85                    name: "key_path".to_string(),
86                    type_hint: "string".to_string(),
87                    description: "Path to the CA private key in PEM format. Same restriction as cert_path: only read by the unimplemented 'load_from_file' certificate mode. The key is never read by any working code path.".to_string(),
88                    required: false,
89                    example: json!("./ca.key"),
90                    default: None,
91                },
92                ParameterDefinition {
93                    name: "ca_export_path".to_string(),
94                    type_hint: "string".to_string(),
95                    description: "Write the generated MITM CA certificate (public certificate only, never the private key) to this path. Clients must be configured to trust this certificate or HTTPS interception will fail with an unknown-issuer error. The CA is regenerated on every server start, so a previously exported file stops working once the server restarts.".to_string(),
96                    required: false,
97                    example: json!("./netget-ca.crt"),
98                    default: None,
99                },
100                ParameterDefinition {
101                    name: "request_filter_mode".to_string(),
102                    type_hint: "string".to_string(),
103                    description: "Request filter mode: 'all' (intercept everything), 'match_only' (only if filters match), 'none' (pass through)".to_string(),
104                    required: false,
105                    example: json!("match_only"),
106                    default: None,
107                },
108                ParameterDefinition {
109                    name: "response_filter_mode".to_string(),
110                    type_hint: "string".to_string(),
111                    description: "Response filter mode: 'all', 'match_only', or 'none'".to_string(),
112                    required: false,
113                    example: json!("all"),
114                    default: None,
115                },
116                ParameterDefinition {
117                    name: "https_connection_filter_mode".to_string(),
118                    type_hint: "string".to_string(),
119                    description: "HTTPS connection filter mode (pass-through only): 'all', 'match_only', or 'none'".to_string(),
120                    required: false,
121                    example: json!("match_only"),
122                    default: None,
123                },
124            ]
125    }
126    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
127        // Deliberately empty. Six configuration actions used to be advertised here
128        // (configure_certificate, configure_request_filters,
129        // configure_response_filters, configure_https_connection_filters,
130        // set_filter_mode, export_ca_certificate). Every one of them only
131        // serialised its arguments into ActionResult::Output, which nothing ever
132        // read back: the filter config is snapshotted at spawn time and cloned per
133        // connection, and no code path called set_proxy_filter_config afterwards.
134        // So they reported success while changing nothing, and worse, an Output
135        // emitted during a request event was mis-parsed as a RequestAction and
136        // aborted the decision for that request.
137        //
138        // Certificate mode, the three filter modes and the CA export path are all
139        // configured through startup parameters instead (see
140        // get_startup_parameters), which do take effect.
141        Vec::new()
142    }
143    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
144        vec![
145            // Request handling actions (sync - in response to intercepted request)
146            handle_request_pass_action(),
147            handle_request_block_action(),
148            handle_request_modify_action(),
149            // Response handling actions (sync - in response to intercepted response)
150            handle_response_pass_action(),
151            handle_response_block_action(),
152            handle_response_modify_action(),
153            // HTTPS connection handling (sync - in response to HTTPS CONNECT in pass-through mode)
154            handle_https_connection_allow_action(),
155            handle_https_connection_block_action(),
156        ]
157    }
158    fn protocol_name(&self) -> &'static str {
159        "Proxy"
160    }
161    fn get_event_types(&self) -> Vec<EventType> {
162        get_proxy_event_types()
163    }
164    fn stack_name(&self) -> &'static str {
165        "ETH>IP>TCP>HTTP>PROXY"
166    }
167    fn keywords(&self) -> Vec<&'static str> {
168        vec!["proxy", "mitm"]
169    }
170    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
171        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
172
173        ProtocolMetadataV2::builder()
174            .state(DevelopmentState::Experimental)
175            .well_known_port(8080)
176            .implementation("Manual HTTP/1.1 with rcgen v0.14 + rustls")
177            .llm_control("Request pass/block/modify, response pass/block/modify (MITM only), HTTPS allow/block")
178            .e2e_testing("reqwest configured as a real proxy client against local target servers (tests/server/proxy/test.rs), plus mocked-LLM startup and MITM scenarios (tests/server/proxy/e2e_test.rs). No curl and no browser has been run against it.")
179            .notes(
180                "OPEN RELAY BY DESIGN, AND UNRESTRICTED: the destination of every request and \
181                 every CONNECT is chosen by the peer (from the request-target or the Host \
182                 header), and there is no allow-list, deny-list or network restriction of any \
183                 kind. Loopback, link-local (including 169.254.169.254, the cloud \
184                 instance-metadata endpoint) and every RFC 1918 range are reachable, so anyone \
185                 who can reach this port can reach whatever this host can -- an SSRF pivot into \
186                 the operator's private network, and a relay someone else's traffic can be \
187                 laundered through. The only gate is the model, and `request_filter_mode` / \
188                 `https_connection_filter_mode` decide whether it is consulted at all: at \
189                 `none`, or at `match_only` with filters that miss, traffic is forwarded with no \
190                 consultation whatsoever. The `*_filters` lists select what the model is ASKED \
191                 about; they do not restrict anything on their own. Do not expose this to an \
192                 untrusted network. \
193                 In MITM mode the generated CA's private key never leaves memory and is never \
194                 written to disk -- `ca_export_path` writes the public certificate only -- but a \
195                 client that trusts that CA has its TLS broken for every host, so the exported \
196                 file must be treated as the sensitive artefact it is. \
197                 HTTP/1.1 only; the MITM CA is generated per run and clients must trust it; \
198                 certificate_mode 'load_from_file' is not implemented and is rejected at startup \
199                 (its cert_path/key_path parameters are declared only so that rejection is the \
200                 error the caller sees); responses on plain HTTP are forwarded without LLM \
201                 consultation; only the first exchange of a keep-alive HTTPS connection is \
202                 inspected; the request head is capped at 64 KiB with a 30s deadline and an \
203                 upstream response at 8 MiB, but the number of concurrent connections is \
204                 unbounded.",
205            )
206            .max_inbound_bytes(crate::server::proxy::MAX_REQUEST_BYTES)
207            .build()
208    }
209    fn description(&self) -> &'static str {
210        "HTTP/HTTPS proxy server"
211    }
212    fn example_prompt(&self) -> &'static str {
213        "Start an HTTP proxy on port 8080"
214    }
215    fn group_name(&self) -> &'static str {
216        "Proxy & Network"
217    }
218
219    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
220        use crate::llm::actions::StartupExamples;
221
222        StartupExamples::new(
223            // LLM mode
224            json!({
225                "type": "open_server",
226                "port": 8080,
227                "base_stack": "proxy",
228                "instruction": "HTTP/HTTPS proxy server. Pass all HTTP requests through. For HTTPS CONNECT requests, allow all connections in pass-through mode."
229            }),
230            // Script mode
231            json!({
232                "type": "open_server",
233                "port": 8080,
234                "base_stack": "proxy",
235                "event_handlers": [{
236                    "event_pattern": "proxy_http_request",
237                    "handler": {
238                        "type": "script",
239                        "language": "python",
240                        "code": "return [{\"type\": \"handle_request_pass\"}]"
241                    }
242                }, {
243                    "event_pattern": "proxy_https_connect",
244                    "handler": {
245                        "type": "script",
246                        "language": "python",
247                        "code": "return [{\"type\": \"handle_https_connection_allow\"}]"
248                    }
249                }]
250            }),
251            // Static mode
252            json!({
253                "type": "open_server",
254                "port": 8080,
255                "base_stack": "proxy",
256                "event_handlers": [{
257                    "event_pattern": "proxy_http_request",
258                    "handler": {
259                        "type": "static",
260                        "actions": [{
261                            "type": "handle_request_pass"
262                        }]
263                    }
264                }, {
265                    "event_pattern": "proxy_https_connect",
266                    "handler": {
267                        "type": "static",
268                        "actions": [{
269                            "type": "handle_https_connection_allow"
270                        }]
271                    }
272                }]
273            }),
274        )
275    }
276}
277
278// Implement Server trait (server-specific functionality)
279impl Server for ProxyProtocol {
280    fn spawn(
281        &self,
282        ctx: crate::protocol::SpawnContext,
283    ) -> std::pin::Pin<
284        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
285    > {
286        Box::pin(async move {
287            use crate::server::proxy::ProxyServer;
288            ProxyServer::spawn_with_llm_actions(
289                ctx.legacy_listen_addr(),
290                ctx.llm_client,
291                ctx.state,
292                ctx.status_tx,
293                ctx.server_id,
294                ctx.startup_params,
295            )
296            .await
297        })
298    }
299    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
300        let action_type = action
301            .get("type")
302            .and_then(|v| v.as_str())
303            .context("Missing 'type' field in action")?;
304
305        match action_type {
306            // Request handling
307            "handle_request_pass" => self.execute_handle_request_pass(action),
308            "handle_request_block" => self.execute_handle_request_block(action),
309            "handle_request_modify" => self.execute_handle_request_modify(action),
310
311            // Response handling
312            "handle_response_pass" => self.execute_handle_response_pass(action),
313            "handle_response_block" => self.execute_handle_response_block(action),
314            "handle_response_modify" => self.execute_handle_response_modify(action),
315
316            // HTTPS connection handling
317            "handle_https_connection_allow" => self.execute_handle_https_connection_allow(action),
318            "handle_https_connection_block" => self.execute_handle_https_connection_block(action),
319
320            _ => Err(anyhow::anyhow!("Unknown Proxy action: {}", action_type)),
321        }
322    }
323}
324
325impl ProxyProtocol {
326    // ========================================================================
327    // Request Handling Actions
328    // ========================================================================
329
330    /// Pass request through unchanged
331    fn execute_handle_request_pass(&self, _action: serde_json::Value) -> Result<ActionResult> {
332        let result = RequestAction::Pass;
333        Ok(ActionResult::Output(
334            serde_json::to_vec(&result).context("Failed to serialize request action")?,
335        ))
336    }
337
338    /// Block request and return error response
339    fn execute_handle_request_block(&self, action: serde_json::Value) -> Result<ActionResult> {
340        let status = parse_status(&action, 403)?;
341
342        let body = action
343            .get("body")
344            .and_then(|v| v.as_str())
345            .unwrap_or("Request blocked by proxy")
346            .to_string();
347
348        let result = RequestAction::Block { status, body };
349        Ok(ActionResult::Output(
350            serde_json::to_vec(&result).context("Failed to serialize request action")?,
351        ))
352    }
353
354    /// Modify request before forwarding
355    fn execute_handle_request_modify(&self, action: serde_json::Value) -> Result<ActionResult> {
356        let headers: Option<std::collections::HashMap<String, String>> = action
357            .get("headers")
358            .and_then(|v| serde_json::from_value(v.clone()).ok());
359        if let Some(headers) = &headers {
360            filter::check_modify_headers(headers)?;
361        }
362
363        let remove_headers = action
364            .get("remove_headers")
365            .and_then(|v| serde_json::from_value(v.clone()).ok());
366
367        let new_path = action
368            .get("new_path")
369            .and_then(|v| v.as_str())
370            .map(|s| s.to_string());
371        if let Some(path) = &new_path {
372            filter::check_request_target_part("new_path", path)?;
373        }
374
375        let query_params: Option<std::collections::HashMap<String, String>> = action
376            .get("query_params")
377            .and_then(|v| serde_json::from_value(v.clone()).ok());
378        if let Some(params) = &query_params {
379            filter::check_query_params(params)?;
380        }
381
382        let new_body = action
383            .get("new_body")
384            .and_then(|v| v.as_str())
385            .map(|s| s.to_string());
386
387        let body_replacements = action
388            .get("body_replacements")
389            .and_then(|v| serde_json::from_value(v.clone()).ok());
390
391        let result = RequestAction::Modify {
392            headers,
393            remove_headers,
394            new_path,
395            query_params,
396            new_body,
397            body_replacements,
398        };
399
400        Ok(ActionResult::Output(
401            serde_json::to_vec(&result).context("Failed to serialize request action")?,
402        ))
403    }
404
405    // ========================================================================
406    // Response Handling Actions
407    // ========================================================================
408
409    /// Pass response through unchanged
410    fn execute_handle_response_pass(&self, _action: serde_json::Value) -> Result<ActionResult> {
411        let result = ResponseAction::Pass;
412        Ok(ActionResult::Output(
413            serde_json::to_vec(&result).context("Failed to serialize response action")?,
414        ))
415    }
416
417    /// Block response and return different one
418    fn execute_handle_response_block(&self, action: serde_json::Value) -> Result<ActionResult> {
419        let status = parse_status(&action, 502)?;
420
421        let body = action
422            .get("body")
423            .and_then(|v| v.as_str())
424            .unwrap_or("Response blocked by proxy")
425            .to_string();
426
427        let result = ResponseAction::Block { status, body };
428        Ok(ActionResult::Output(
429            serde_json::to_vec(&result).context("Failed to serialize response action")?,
430        ))
431    }
432
433    /// Modify response before returning to client
434    fn execute_handle_response_modify(&self, action: serde_json::Value) -> Result<ActionResult> {
435        // A modify that leaves the status alone is legitimate, so `None` stays `None` — but a
436        // status that *is* named has to be a real one.
437        let status = match action.get("status") {
438            None => None,
439            Some(v) if v.is_null() => None,
440            Some(_) => Some(parse_status(&action, 0)?),
441        };
442
443        let headers: Option<std::collections::HashMap<String, String>> = action
444            .get("headers")
445            .and_then(|v| serde_json::from_value(v.clone()).ok());
446        if let Some(headers) = &headers {
447            filter::check_modify_headers(headers)?;
448        }
449
450        let remove_headers = action
451            .get("remove_headers")
452            .and_then(|v| serde_json::from_value(v.clone()).ok());
453
454        let new_body = action
455            .get("new_body")
456            .and_then(|v| v.as_str())
457            .map(|s| s.to_string());
458
459        let body_replacements = action
460            .get("body_replacements")
461            .and_then(|v| serde_json::from_value(v.clone()).ok());
462
463        let result = ResponseAction::Modify {
464            status,
465            headers,
466            remove_headers,
467            new_body,
468            body_replacements,
469        };
470
471        Ok(ActionResult::Output(
472            serde_json::to_vec(&result).context("Failed to serialize response action")?,
473        ))
474    }
475
476    // ========================================================================
477    // HTTPS Connection Handling (Pass-Through Mode)
478    // ========================================================================
479
480    /// Allow HTTPS connection to proceed
481    fn execute_handle_https_connection_allow(
482        &self,
483        _action: serde_json::Value,
484    ) -> Result<ActionResult> {
485        let result = HttpsConnectionAction::Allow;
486        Ok(ActionResult::Output(
487            serde_json::to_vec(&result).context("Failed to serialize HTTPS connection action")?,
488        ))
489    }
490
491    /// Block HTTPS connection
492    fn execute_handle_https_connection_block(
493        &self,
494        action: serde_json::Value,
495    ) -> Result<ActionResult> {
496        let reason = action
497            .get("reason")
498            .and_then(|v| v.as_str())
499            .map(|s| s.to_string());
500
501        let result = HttpsConnectionAction::Block { reason };
502        Ok(ActionResult::Output(
503            serde_json::to_vec(&result).context("Failed to serialize HTTPS connection action")?,
504        ))
505    }
506}
507
508// ============================================================================
509// Action Definitions
510// ============================================================================
511
512// Request Handling Actions
513
514fn handle_request_pass_action() -> ActionDefinition {
515    ActionDefinition {
516        name: "handle_request_pass".to_string(),
517        description: "Pass the intercepted request through unchanged to its destination"
518            .to_string(),
519        parameters: vec![],
520        example: json!({
521            "type": "handle_request_pass"
522        }),
523        log_template: Some(
524            LogTemplate::new()
525                .with_info("-> Proxy PASS request")
526                .with_debug("Proxy handle_request_pass: forwarding unchanged"),
527        ),
528    }
529}
530
531fn handle_request_block_action() -> ActionDefinition {
532    ActionDefinition {
533        name: "handle_request_block".to_string(),
534        description: "Block the intercepted request and return an error response to the client"
535            .to_string(),
536        parameters: vec![
537            Parameter {
538                name: "status".to_string(),
539                type_hint: "number".to_string(),
540                description: "HTTP status code (default: 403)".to_string(),
541                required: false,
542            },
543            Parameter {
544                name: "body".to_string(),
545                type_hint: "string".to_string(),
546                description: "Response body explaining why request was blocked".to_string(),
547                required: false,
548            },
549        ],
550        example: json!({
551            "type": "handle_request_block",
552            "status": 403,
553            "body": "Access denied by security policy"
554        }),
555        log_template: Some(
556            LogTemplate::new()
557                .with_info("-> Proxy BLOCK request {status}")
558                .with_debug("Proxy handle_request_block: status={status}"),
559        ),
560    }
561}
562
563fn handle_request_modify_action() -> ActionDefinition {
564    ActionDefinition {
565        name: "handle_request_modify".to_string(),
566        description: "Modify the intercepted request before forwarding to destination".to_string(),
567        parameters: vec![
568            Parameter {
569                name: "headers".to_string(),
570                type_hint: "object".to_string(),
571                description: "Headers to add or modify (key-value pairs)".to_string(),
572                required: false,
573            },
574            Parameter {
575                name: "remove_headers".to_string(),
576                type_hint: "array".to_string(),
577                description: "Header names to remove".to_string(),
578                required: false,
579            },
580            Parameter {
581                name: "new_path".to_string(),
582                type_hint: "string".to_string(),
583                description: "New URL path (replaces entire path)".to_string(),
584                required: false,
585            },
586            Parameter {
587                name: "query_params".to_string(),
588                type_hint: "object".to_string(),
589                description: "Query parameters to add/modify".to_string(),
590                required: false,
591            },
592            Parameter {
593                name: "new_body".to_string(),
594                type_hint: "string".to_string(),
595                description: "Complete body replacement".to_string(),
596                required: false,
597            },
598            Parameter {
599                name: "body_replacements".to_string(),
600                type_hint: "array".to_string(),
601                description:
602                    "Array of regex replacements: [{pattern: 'regex', replacement: 'text'}]"
603                        .to_string(),
604                required: false,
605            },
606        ],
607        example: json!({
608            "type": "handle_request_modify",
609            "headers": {
610                "X-Proxy-Modified": "true",
611                "User-Agent": "CustomBot/1.0"
612            },
613            "remove_headers": ["Cookie"],
614            "body_replacements": [
615                {
616                    "pattern": "password",
617                    "replacement": "****REDACTED****"
618                }
619            ]
620        }),
621        log_template: Some(
622            LogTemplate::new()
623                .with_info("-> Proxy MODIFY request")
624                .with_debug("Proxy handle_request_modify: path={new_path}"),
625        ),
626    }
627}
628
629// Response Handling Actions
630
631fn handle_response_pass_action() -> ActionDefinition {
632    ActionDefinition {
633        name: "handle_response_pass".to_string(),
634        description: "Pass the intercepted response through unchanged to the client".to_string(),
635        parameters: vec![],
636        example: json!({
637            "type": "handle_response_pass"
638        }),
639        log_template: Some(
640            LogTemplate::new()
641                .with_info("-> Proxy PASS response")
642                .with_debug("Proxy handle_response_pass: forwarding unchanged"),
643        ),
644    }
645}
646
647fn handle_response_block_action() -> ActionDefinition {
648    ActionDefinition {
649        name: "handle_response_block".to_string(),
650        description: "Block the intercepted response and return a different response to the client"
651            .to_string(),
652        parameters: vec![
653            Parameter {
654                name: "status".to_string(),
655                type_hint: "number".to_string(),
656                description: "HTTP status code (default: 502)".to_string(),
657                required: false,
658            },
659            Parameter {
660                name: "body".to_string(),
661                type_hint: "string".to_string(),
662                description: "Response body".to_string(),
663                required: false,
664            },
665        ],
666        example: json!({
667            "type": "handle_response_block",
668            "status": 502,
669            "body": "Response blocked by content policy"
670        }),
671        log_template: Some(
672            LogTemplate::new()
673                .with_info("-> Proxy BLOCK response {status}")
674                .with_debug("Proxy handle_response_block: status={status}"),
675        ),
676    }
677}
678
679fn handle_response_modify_action() -> ActionDefinition {
680    ActionDefinition {
681        name: "handle_response_modify".to_string(),
682        description: "Modify the intercepted response before returning to client".to_string(),
683        parameters: vec![
684            Parameter {
685                name: "status".to_string(),
686                type_hint: "number".to_string(),
687                description: "New HTTP status code".to_string(),
688                required: false,
689            },
690            Parameter {
691                name: "headers".to_string(),
692                type_hint: "object".to_string(),
693                description: "Headers to add or modify (key-value pairs)".to_string(),
694                required: false,
695            },
696            Parameter {
697                name: "remove_headers".to_string(),
698                type_hint: "array".to_string(),
699                description: "Header names to remove".to_string(),
700                required: false,
701            },
702            Parameter {
703                name: "new_body".to_string(),
704                type_hint: "string".to_string(),
705                description: "Complete body replacement".to_string(),
706                required: false,
707            },
708            Parameter {
709                name: "body_replacements".to_string(),
710                type_hint: "array".to_string(),
711                description:
712                    "Array of regex replacements: [{pattern: 'regex', replacement: 'text'}]"
713                        .to_string(),
714                required: false,
715            },
716        ],
717        example: json!({
718            "type": "handle_response_modify",
719            "headers": {
720                "X-Content-Filtered": "true"
721            },
722            "body_replacements": [
723                {
724                    "pattern": "secret-api-key-\\w+",
725                    "replacement": "****REDACTED****"
726                }
727            ]
728        }),
729        log_template: Some(
730            LogTemplate::new()
731                .with_info("-> Proxy MODIFY response")
732                .with_debug("Proxy handle_response_modify: status={status}"),
733        ),
734    }
735}
736
737// HTTPS Connection Handling Actions
738
739fn handle_https_connection_allow_action() -> ActionDefinition {
740    ActionDefinition {
741        name: "handle_https_connection_allow".to_string(),
742        description: "Allow HTTPS connection to proceed (pass-through mode only, no MITM)"
743            .to_string(),
744        parameters: vec![],
745        example: json!({
746            "type": "handle_https_connection_allow"
747        }),
748        log_template: Some(
749            LogTemplate::new()
750                .with_info("-> Proxy ALLOW HTTPS")
751                .with_debug("Proxy handle_https_connection_allow: tunnel established"),
752        ),
753    }
754}
755
756fn handle_https_connection_block_action() -> ActionDefinition {
757    ActionDefinition {
758        name: "handle_https_connection_block".to_string(),
759        description: "Block HTTPS connection (pass-through mode only, no MITM)".to_string(),
760        parameters: vec![Parameter {
761            name: "reason".to_string(),
762            type_hint: "string".to_string(),
763            description: "Optional reason for blocking".to_string(),
764            required: false,
765        }],
766        example: json!({
767            "type": "handle_https_connection_block",
768            "reason": "Destination blocked by security policy"
769        }),
770        log_template: Some(
771            LogTemplate::new()
772                .with_info("-> Proxy BLOCK HTTPS")
773                .with_debug("Proxy handle_https_connection_block: reason={reason}"),
774        ),
775    }
776}
777
778// ============================================================================
779// Proxy Event Type Constants
780// ============================================================================
781
782/// HTTP request event - triggered when proxy receives HTTP request
783pub static PROXY_HTTP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
784    EventType::new(
785        "proxy_http_request",
786        "HTTP request intercepted by proxy",
787        json!({"type": "placeholder", "event_id": "proxy_http_request"}),
788    )
789    .with_parameters(vec![
790        Parameter {
791            name: "method".to_string(),
792            type_hint: "string".to_string(),
793            description: "HTTP method (GET, POST, etc.)".to_string(),
794            required: true,
795        },
796        Parameter {
797            name: "url".to_string(),
798            type_hint: "string".to_string(),
799            description: "Full request URL".to_string(),
800            required: true,
801        },
802        Parameter {
803            name: "host".to_string(),
804            type_hint: "string".to_string(),
805            description: "Host header value".to_string(),
806            required: true,
807        },
808        Parameter {
809            name: "path".to_string(),
810            type_hint: "string".to_string(),
811            description: "Request path".to_string(),
812            required: true,
813        },
814    ])
815    .with_actions(vec![
816        handle_request_pass_action(),
817        handle_request_block_action(),
818        handle_request_modify_action(),
819    ])
820    .with_log_template(
821        LogTemplate::new()
822            .with_info("Proxy {client_ip} {method} {host}")
823            .with_debug("HTTP proxy {method} to {host} from {client_ip}:{client_port}")
824            .with_trace("Proxy: {json_pretty(.)}"),
825    )
826});
827
828/// HTTP response event - triggered when proxy receives HTTP response from upstream server
829pub static PROXY_HTTP_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
830    EventType::new(
831        "proxy_http_response",
832        "HTTP response received from upstream server",
833        json!({"type": "placeholder", "event_id": "proxy_http_response"}),
834    )
835    .with_parameters(vec![
836        Parameter {
837            name: "status_code".to_string(),
838            type_hint: "number".to_string(),
839            description: "HTTP status code (200, 404, etc.)".to_string(),
840            required: true,
841        },
842        Parameter {
843            name: "url".to_string(),
844            type_hint: "string".to_string(),
845            description: "Original request URL".to_string(),
846            required: true,
847        },
848        Parameter {
849            name: "headers".to_string(),
850            type_hint: "object".to_string(),
851            description: "Response headers as key-value pairs".to_string(),
852            required: true,
853        },
854        Parameter {
855            name: "body".to_string(),
856            type_hint: "string".to_string(),
857            description: "Response body (may be truncated for large responses)".to_string(),
858            required: false,
859        },
860    ])
861    .with_actions(vec![
862        handle_response_pass_action(),
863        handle_response_block_action(),
864        handle_response_modify_action(),
865    ])
866    .with_log_template(
867        LogTemplate::new()
868            .with_info("Proxy {client_ip} response {status_code}")
869            .with_debug("HTTP proxy response {status_code} from {url}")
870            .with_trace("Proxy: {json_pretty(.)}"),
871    )
872});
873
874/// HTTPS connection event - triggered when proxy receives CONNECT request
875pub static PROXY_HTTPS_CONNECT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
876    EventType::new(
877        "proxy_https_connect",
878        "HTTPS CONNECT request intercepted by proxy (pass-through mode)",
879        json!({"type": "handle_https_connection_allow"}),
880    )
881    .with_parameters(vec![
882        Parameter {
883            name: "destination_host".to_string(),
884            type_hint: "string".to_string(),
885            description: "Destination hostname".to_string(),
886            required: true,
887        },
888        Parameter {
889            name: "destination_port".to_string(),
890            type_hint: "number".to_string(),
891            description: "Destination port".to_string(),
892            required: true,
893        },
894        Parameter {
895            name: "sni".to_string(),
896            type_hint: "string".to_string(),
897            description: "SNI (Server Name Indication) from TLS handshake".to_string(),
898            required: false,
899        },
900    ])
901    .with_actions(vec![
902        handle_https_connection_allow_action(),
903        handle_https_connection_block_action(),
904    ])
905    .with_log_template(
906        LogTemplate::new()
907            .with_info("Proxy {client_ip} CONNECT {destination_host}:{destination_port}")
908            .with_debug("HTTPS CONNECT to {destination_host}:{destination_port} from {client_ip}:{client_port}")
909            .with_trace("Proxy: {json_pretty(.)}"),
910    )
911});
912
913/// Get Proxy event types
914pub fn get_proxy_event_types() -> Vec<EventType> {
915    vec![
916        PROXY_HTTP_REQUEST_EVENT.clone(),
917        PROXY_HTTP_RESPONSE_EVENT.clone(),
918        PROXY_HTTPS_CONNECT_EVENT.clone(),
919    ]
920}