Skip to main content

netget/server/icap/
actions.rs

1use super::wire;
2use crate::llm::actions::{
3    protocol_trait::{ActionResult, Protocol, Server},
4    ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::log_template::LogTemplate;
7use crate::protocol::{EventType, SpawnContext};
8use crate::state::app_state::AppState;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct IcapProtocol;
15impl IcapProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
22    Parameter {
23        name: name.into(),
24        type_hint: kind.into(),
25        description: description.into(),
26        required,
27    }
28}
29
30pub fn action(
31    name: &str,
32    description: &str,
33    parameters: Vec<Parameter>,
34    example: Value,
35) -> ActionDefinition {
36    let log_template = match name {
37        "icap_response" => LogTemplate::new().with_info("-> ICAP {verdict} {status}"),
38        _ => LogTemplate::new().with_info(format!("-> ICAP {name} {{service}}")),
39    };
40    ActionDefinition {
41        name: name.into(),
42        description: description.into(),
43        parameters,
44        example,
45        log_template: Some(log_template),
46    }
47}
48
49pub const HEADERS_HELP: &str = "[[name, value], ...] (or an object)";
50
51fn response_action() -> ActionDefinition {
52    action(
53        "icap_response",
54        "Decide the pending REQMOD/RESPMOD. Rust frames the ICAP response, ISTag and Encapsulated offsets and chunks the body.",
55        vec![
56            parameter("verdict", "string", "no_modification (204 when the client allows it, else the original echoed), modify, block (an HTTP 403 page), or error (an ICAP error status)", true),
57            parameter("http_request", "object", &format!("modify on REQMOD only: the request to forward instead {{method, uri, version, headers: {HEADERS_HELP}}}"), false),
58            parameter("http_response", "object", &format!("modify: the HTTP response to deliver {{status, reason, headers: {HEADERS_HELP}}} (on REQMOD this answers the client instead of forwarding)"), false),
59            parameter("body_text", "string", "Body for a modified message or a block page (UTF-8 text; Rust sets Content-Length)", false),
60            parameter("status", "number", "error: ICAP status 400, 403, 404, 500 or 503", false),
61        ],
62        json!({"type":"icap_response","verdict":"block","body_text":"Blocked by policy"}),
63    )
64}
65
66pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
67    EventType::new(
68        "icap_request",
69        "A complete REQMOD or RESPMOD (preview already continued, body bounded). Decide whether the HTTP message passes, is changed or is blocked.",
70        json!({"type":"icap_response","verdict":"no_modification"}),
71    )
72    .with_parameters(vec![
73        parameter("method", "string", "REQMOD (an HTTP request on its way to the origin) or RESPMOD (a response on its way to the client)", true),
74        parameter("service", "string", "ICAP service name from the request URI", true),
75        parameter("http_request", "object", "The encapsulated HTTP request {method, uri, version, headers}, when present", false),
76        parameter("http_response", "object", "The encapsulated HTTP response {version, status, reason, headers}, when present", false),
77        parameter("body_text", "string", "The encapsulated body when it is UTF-8", false),
78        parameter("body_binary", "boolean", "true when the body is not UTF-8 (only body_bytes is given)", false),
79        parameter("body_bytes", "number", "Body size in bytes", false),
80        parameter("allow_204", "boolean", "true when the client accepts 204 No Content for an unmodified message", true),
81        parameter("icap_headers", "array", "ICAP request headers as [[name, value]]", true),
82    ])
83    .with_actions(vec![response_action()])
84});
85
86impl Protocol for IcapProtocol {
87    fn protocol_name(&self) -> &'static str {
88        "ICAP"
89    }
90    fn stack_name(&self) -> &'static str {
91        "ETH>IP>TCP>ICAP"
92    }
93    fn description(&self) -> &'static str {
94        "ICAP content adaptation server (RFC 3507): REQMOD/RESPMOD decisions by the handler"
95    }
96    fn keywords(&self) -> Vec<&'static str> {
97        vec![
98            "icap",
99            "rfc3507",
100            "content filtering",
101            "antivirus",
102            "dlp",
103            "proxy adaptation",
104        ]
105    }
106    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
107        vec![]
108    }
109    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
110        vec![response_action()]
111    }
112    fn get_event_types(&self) -> Vec<EventType> {
113        vec![REQUEST_EVENT.clone()]
114    }
115    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
116        vec![
117            ParameterDefinition {
118                name: "services".into(),
119                type_hint: "array".into(),
120                description: "Services this server offers, each {name, methods: [REQMOD, RESPMOD]}; OPTIONS answers come from this list".into(),
121                required: false,
122                example: json!([{"name":"avscan","methods":["RESPMOD"]},{"name":"urlfilter","methods":["REQMOD"]}]),
123                default: Some(json!([{"name": super::DEFAULT_SERVICE, "methods": ["REQMOD", "RESPMOD"]}])),
124            },
125            ParameterDefinition {
126                name: "preview_bytes".into(),
127                type_hint: "number".into(),
128                description: "Preview size advertised in OPTIONS (0..=65536); Rust always continues a preview to the full body before deciding".into(),
129                required: false,
130                example: json!(1024),
131                default: Some(json!(super::DEFAULT_PREVIEW)),
132            },
133            ParameterDefinition {
134                name: "idle_timeout_secs".into(),
135                type_hint: "number".into(),
136                description: "Seconds (1..=86400) a persistent connection may wait between requests".into(),
137                required: false,
138                example: json!(300),
139                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
140            },
141        ]
142    }
143    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
144        use crate::protocol::metadata::*;
145        ProtocolMetadataV2::builder()
146            .state(DevelopmentState::Experimental)
147            .privilege_requirement(PrivilegeRequirement::None)
148            .well_known_port(1344)
149            .implementation("Native RFC 3507 framing over Tokio TCP: Encapsulated offsets, embedded HTTP heads, chunked bodies with preview and 100 Continue, persistent connections; OPTIONS answered from configuration")
150            .llm_control("Whether each REQMOD/RESPMOD passes unmodified, is rewritten, is blocked with a page, or fails with an ICAP error")
151            .e2e_testing("tests/server/icap: c-icap 0.6.5's c-icap-client (independent) sends OPTIONS, REQMOD and RESPMOD with and without preview; framing and bound tests")
152            .notes("No scanning engine: decisions come from the handler. Bodies are bounded at 1 MiB and handed over as text when UTF-8 (binary bodies by size only). 206 partial responses and ICAP over TLS are not implemented. A handler failure is ICAP 500, never a pass.")
153            .request_only("ICAP answers each request on its connection; nothing is sent unprompted")
154            .answers_on_failure()
155            .max_inbound_bytes(wire::MAX_BODY_BYTES)
156            .build()
157    }
158    fn example_prompt(&self) -> &'static str {
159        "ICAP server on port 1344 that blocks responses containing the word EICAR"
160    }
161    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
162        let llm = json!({"type":"open_server","base_stack":"icap","port":1344,"instruction":"Block any response body containing EICAR; pass everything else"});
163        let mut static_example = llm.clone();
164        static_example["event_handlers"] = json!([{"event_pattern":"icap_request","handler":{"type":"static","actions":[{"type":"icap_response","verdict":"no_modification"}]}}]);
165        let mut scripted = llm.clone();
166        scripted["event_handlers"] = json!([{"event_pattern":"icap_request","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nbad='EICAR' in (e.get('body_text') or '')\nprint(json.dumps({'actions':[{'type':'icap_response','verdict':'block' if bad else 'no_modification'}]}))"}}]);
167        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
168    }
169    fn group_name(&self) -> &'static str {
170        "Security"
171    }
172}
173
174impl Server for IcapProtocol {
175    fn spawn(
176        &self,
177        ctx: SpawnContext,
178    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
179    {
180        Box::pin(super::spawn(ctx))
181    }
182    fn execute_action(&self, v: Value) -> Result<ActionResult> {
183        match v["type"].as_str() {
184            Some("icap_response") => {
185                validate(&v)?;
186                Ok(ActionResult::Custom {
187                    name: "icap_response".into(),
188                    data: v,
189                })
190            }
191            _ => bail!("Unknown ICAP server action"),
192        }
193    }
194}
195
196pub fn validate(v: &Value) -> Result<()> {
197    let verdict = v["verdict"].as_str().context("verdict is required")?;
198    match verdict {
199        "no_modification" | "block" => {}
200        "modify" => {
201            ensure!(
202                !v["http_request"].is_null() || !v["http_response"].is_null(),
203                "modify needs http_request or http_response"
204            );
205            if !v["http_request"].is_null() {
206                wire::request_head(&v["http_request"])?;
207            }
208            if !v["http_response"].is_null() {
209                wire::response_head(&v["http_response"])?;
210            }
211        }
212        "error" => {
213            let s = v["status"].as_u64().context("error needs status")?;
214            ensure!(
215                matches!(s, 400 | 403 | 404 | 500 | 503),
216                "status must be 400, 403, 404, 500 or 503"
217            );
218        }
219        other => bail!("unknown verdict '{other}'"),
220    }
221    if let Some(b) = v.get("body_text").filter(|b| !b.is_null()) {
222        ensure!(
223            b.as_str().context("body_text must be a string")?.len() <= wire::MAX_BODY_BYTES,
224            "body_text exceeds the body bound"
225        );
226    }
227    Ok(())
228}