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}