Skip to main content

netget/client/icap/
actions.rs

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}