1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::icap::actions::{action, parameter, HEADERS_HELP};
8use crate::server::icap::wire;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct IcapClientProtocol;
15impl IcapClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21fn request_action() -> ActionDefinition {
22 action(
23 "icap_request",
24 "Send one ICAP request on the persistent connection. Rust frames the head, Encapsulated offsets and chunked body, sends a preview when asked and continues it on 100 Continue.",
25 vec![
26 parameter("method", "string", "OPTIONS, REQMOD or RESPMOD", true),
27 parameter("service", "string", "ICAP service name, e.g. avscan", true),
28 parameter("http_request", "object", &format!("REQMOD (required) / RESPMOD (optional): {{method, uri, version, headers: {HEADERS_HELP}}}"), false),
29 parameter("http_response", "object", &format!("RESPMOD: {{status, reason, version, headers: {HEADERS_HELP}}}"), false),
30 parameter("body_text", "string", "Encapsulated body as UTF-8 text", false),
31 parameter("preview", "number", "Send only the first N body bytes first (Preview header); Rust sends the rest if the server answers 100 Continue", false),
32 parameter("allow_204", "boolean", "Offer Allow: 204 (default true)", false),
33 ],
34 json!({"type":"icap_request","method":"RESPMOD","service":"avscan","http_request":{"method":"GET","uri":"http://example.com/"},"http_response":{"status":200,"reason":"OK","headers":[["Content-Type","text/plain"]]},"body_text":"hello"}),
35 )
36}
37fn disconnect_action() -> ActionDefinition {
38 action(
39 "disconnect",
40 "Close the ICAP connection",
41 vec![],
42 json!({"type":"disconnect"}),
43 )
44}
45fn actions() -> Vec<ActionDefinition> {
46 vec![request_action(), disconnect_action()]
47}
48
49pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
50 EventType::new(
51 "icap_connected",
52 "Connected to the ICAP server",
53 json!({"type":"icap_request","method":"OPTIONS","service":"avscan"}),
54 )
55 .with_parameters(vec![parameter(
56 "remote_addr",
57 "string",
58 "ICAP server address",
59 true,
60 )])
61 .with_actions(actions())
62});
63pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
64 EventType::new(
65 "icap_response",
66 "The ICAP server's answer to the last request",
67 disconnect_action().example.clone(),
68 )
69 .with_parameters(vec![
70 parameter("method", "string", "The request's method", true),
71 parameter("service", "string", "The request's service", true),
72 parameter(
73 "status",
74 "number",
75 "ICAP status: 200 adapted, 204 unmodified, 4xx/5xx errors",
76 true,
77 ),
78 parameter("reason", "string", "ICAP reason phrase", true),
79 parameter(
80 "icap_headers",
81 "array",
82 "ICAP response headers as [[name, value]] (OPTIONS: Methods, Preview, ISTag, ...)",
83 true,
84 ),
85 parameter(
86 "http_request",
87 "object",
88 "Adapted HTTP request, when returned",
89 false,
90 ),
91 parameter(
92 "http_response",
93 "object",
94 "Adapted or substituted HTTP response, when returned",
95 false,
96 ),
97 parameter(
98 "body_text",
99 "string",
100 "Returned body when it is UTF-8",
101 false,
102 ),
103 parameter("body_bytes", "number", "Returned body size", false),
104 parameter(
105 "continued",
106 "boolean",
107 "true when the server asked for the rest of a preview",
108 false,
109 ),
110 ])
111 .with_actions(actions())
112});
113
114impl Protocol for IcapClientProtocol {
115 fn protocol_name(&self) -> &'static str {
116 "ICAP"
117 }
118 fn stack_name(&self) -> &'static str {
119 "ETH>IP>TCP>ICAP"
120 }
121 fn keywords(&self) -> Vec<&'static str> {
122 vec!["icap", "rfc3507", "content adaptation", "antivirus client"]
123 }
124 fn description(&self) -> &'static str {
125 "ICAP client sending OPTIONS, REQMOD and RESPMOD with preview over a persistent connection"
126 }
127 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
128 actions()
129 }
130 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
131 vec![]
132 }
133 fn get_event_types(&self) -> Vec<EventType> {
134 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
135 }
136 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
137 use crate::protocol::metadata::*;
138 ProtocolMetadataV2::builder()
139 .state(DevelopmentState::Experimental)
140 .privilege_requirement(PrivilegeRequirement::None)
141 .well_known_port(1344)
142 .implementation("Shared RFC 3507 framing (src/server/icap/wire.rs); preview with 100 Continue; one request at a time on a persistent connection")
143 .llm_control("Which requests to send and how to act on each adaptation verdict")
144 .e2e_testing("tests/client/icap: c-icap 0.6.5's echo service (independent server) answers OPTIONS, REQMOD and RESPMOD with preview; NetGet pair and malformed-server refusals")
145 .notes("Bodies are text (UTF-8) in actions and events, bounded at 1 MiB; binary response bodies are reported by size. No ICAP over TLS, no 206.")
146 .max_inbound_bytes(wire::MAX_BODY_BYTES)
147 .build()
148 }
149 fn example_prompt(&self) -> &'static str {
150 "Ask the ICAP server at 127.0.0.1:1344 for its OPTIONS and scan a test response"
151 }
152 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
153 let llm = json!({"type":"open_client","protocol":"icap","remote_addr":"127.0.0.1:1344","instruction":"Scan a sample response with the avscan service"});
154 let mut static_example = llm.clone();
155 static_example["event_handlers"] = json!([
156 {"event_pattern":"icap_connected","handler":{"type":"static","actions":[{"type":"icap_request","method":"OPTIONS","service":"avscan"}]}},
157 {"event_pattern":"icap_response","handler":{"type":"static","actions":[]}}
158 ]);
159 let mut scripted = static_example.clone();
160 scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'icap_request','method':'OPTIONS','service':'avscan'}]}))"});
161 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
162 }
163 fn group_name(&self) -> &'static str {
164 "Security"
165 }
166}
167
168impl Client for IcapClientProtocol {
169 fn connect(
170 &self,
171 ctx: ConnectContext,
172 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
173 {
174 Box::pin(super::connect(ctx))
175 }
176 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
177 match v["type"].as_str() {
178 Some("icap_request") => {
179 let method = v["method"].as_str().context("method is required")?;
180 ensure!(
181 matches!(method, "OPTIONS" | "REQMOD" | "RESPMOD"),
182 "method must be OPTIONS, REQMOD or RESPMOD"
183 );
184 let service = v["service"].as_str().context("service is required")?;
185 ensure!(
186 !service.is_empty()
187 && service.len() <= 64
188 && service
189 .bytes()
190 .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)),
191 "service must be letters, digits, '-', '_' or '.'"
192 );
193 if method == "REQMOD" {
194 wire::request_head(&v["http_request"])?;
195 }
196 if method == "RESPMOD" {
197 wire::response_head(&v["http_response"])?;
198 if !v["http_request"].is_null() {
199 wire::request_head(&v["http_request"])?;
200 }
201 }
202 if let Some(b) = v["body_text"].as_str() {
203 ensure!(b.len() <= wire::MAX_BODY_BYTES, "body_text exceeds 1 MiB");
204 ensure!(method != "OPTIONS", "OPTIONS carries no body");
205 }
206 Ok(ClientActionResult::Custom {
207 name: "icap_request".into(),
208 data: v,
209 })
210 }
211 Some("disconnect") => Ok(ClientActionResult::Disconnect),
212 _ => bail!("Unknown ICAP client action"),
213 }
214 }
215}