Skip to main content

netget/client/fastcgi/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::fastcgi::actions::{action, parameter};
8use crate::server::fastcgi::record;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct FastcgiClientProtocol;
15impl FastcgiClientProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21fn request_action() -> ActionDefinition {
22    action(
23        "fastcgi_request",
24        "Send one Responder request to the FastCGI application, as a web server would: Rust builds the CGI params (REQUEST_METHOD, SCRIPT_NAME, SCRIPT_FILENAME, QUERY_STRING, CONTENT_*, HTTP_*), the PARAMS and STDIN streams, and waits for END_REQUEST (sending ABORT_REQUEST after the request timeout).",
25        vec![
26            parameter("method", "string", "HTTP method to report, e.g. GET or POST (default GET)", false),
27            parameter("path", "string", "Script path starting with /, e.g. /index.php; SCRIPT_FILENAME is document_root plus this", true),
28            parameter("query", "string", "Query string without the leading ?", false),
29            parameter("headers", "object", "HTTP request headers as name → value; each becomes an HTTP_* param", false),
30            parameter("body", "string", "Request body (STDIN) as text, or hex when body_encoding is hex", false),
31            parameter("body_encoding", "string", "utf8 (default) or hex", false),
32            parameter("params", "object", "Extra CGI params, or overrides of the ones Rust sets, as name → value", false),
33        ],
34        json!({"type":"fastcgi_request","method":"GET","path":"/index.php","query":"page=1"}),
35    )
36}
37fn values_action() -> ActionDefinition {
38    action(
39        "fastcgi_get_values",
40        "Ask the application for its management values (FCGI_GET_VALUES)",
41        vec![parameter(
42            "names",
43            "array",
44            "Variable names to ask for (default FCGI_MAX_CONNS, FCGI_MAX_REQS, FCGI_MPXS_CONNS)",
45            false,
46        )],
47        json!({"type":"fastcgi_get_values"}),
48    )
49}
50fn disconnect_action() -> ActionDefinition {
51    action(
52        "disconnect",
53        "Stop this FastCGI client",
54        vec![],
55        json!({"type":"disconnect"}),
56    )
57}
58fn actions() -> Vec<ActionDefinition> {
59    vec![request_action(), values_action(), disconnect_action()]
60}
61
62pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
63    EventType::new(
64        "fastcgi_connected",
65        "Connected to the FastCGI application",
66        request_action().example.clone(),
67    )
68    .with_parameters(vec![parameter(
69        "remote_addr",
70        "string",
71        "Application address",
72        true,
73    )])
74    .with_actions(actions())
75});
76pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
77    EventType::new(
78        "fastcgi_response",
79        "The application's answer to the last request",
80        disconnect_action().example.clone(),
81    )
82    .with_parameters(vec![
83        parameter("request_id", "number", "FastCGI request id used", true),
84        parameter(
85            "status",
86            "number",
87            "HTTP status from the CGI Status header (200 when absent, 302 with Location)",
88            false,
89        ),
90        parameter(
91            "headers",
92            "object",
93            "CGI response headers, lower-case names",
94            false,
95        ),
96        parameter(
97            "body",
98            "string",
99            "Response body as text, or hex when body_encoding is hex",
100            false,
101        ),
102        parameter(
103            "body_encoding",
104            "string",
105            "How body is written: utf8 when the bytes were valid UTF-8 text, hex otherwise",
106            false,
107        ),
108        parameter(
109            "stderr",
110            "string",
111            "Everything the application wrote to FCGI_STDERR",
112            false,
113        ),
114        parameter(
115            "app_status",
116            "number",
117            "END_REQUEST application status",
118            false,
119        ),
120        parameter(
121            "protocol_status",
122            "string",
123            "request_complete, cant_mpx_conn, overloaded or unknown_role",
124            false,
125        ),
126        parameter(
127            "aborted",
128            "boolean",
129            "True when the client sent ABORT_REQUEST after its timeout",
130            false,
131        ),
132        parameter(
133            "error",
134            "string",
135            "Set when the output was not a CGI response or the connection failed",
136            false,
137        ),
138    ])
139    .with_actions(actions())
140});
141pub static VALUES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
142    EventType::new(
143        "fastcgi_values",
144        "The application's FCGI_GET_VALUES_RESULT",
145        request_action().example.clone(),
146    )
147    .with_parameters(vec![parameter(
148        "values",
149        "object",
150        "Variable name → value as the application reported them",
151        true,
152    )])
153    .with_actions(actions())
154});
155
156impl Protocol for FastcgiClientProtocol {
157    fn protocol_name(&self) -> &'static str {
158        "FastCGI"
159    }
160    fn stack_name(&self) -> &'static str {
161        "ETH>IP>TCP>FastCGI"
162    }
163    fn keywords(&self) -> Vec<&'static str> {
164        vec!["fastcgi", "fcgi", "php-fpm client", "cgi"]
165    }
166    fn description(&self) -> &'static str {
167        "FastCGI 1.0 client: sends Responder requests to an application such as php-fpm, as a web server does"
168    }
169    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
170        actions()
171    }
172    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
173        vec![]
174    }
175    fn get_event_types(&self) -> Vec<EventType> {
176        vec![
177            CONNECTED_EVENT.clone(),
178            RESPONSE_EVENT.clone(),
179            VALUES_EVENT.clone(),
180        ]
181    }
182    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
183        vec![
184            ParameterDefinition {
185                name: "document_root".into(),
186                type_hint: "string".into(),
187                description: "Directory reported as DOCUMENT_ROOT and prefixed to each path for SCRIPT_FILENAME".into(),
188                required: false,
189                example: json!("/var/www/html"),
190                default: Some(json!(super::DEFAULT_DOCUMENT_ROOT)),
191            },
192            ParameterDefinition {
193                name: "request_timeout_secs".into(),
194                type_hint: "integer".into(),
195                description: "Seconds to wait for END_REQUEST before sending ABORT_REQUEST".into(),
196                required: false,
197                example: json!(10),
198                default: Some(json!(super::DEFAULT_REQUEST_TIMEOUT.as_secs())),
199            },
200        ]
201    }
202    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
203        use crate::protocol::metadata::*;
204        ProtocolMetadataV2::builder()
205            .state(DevelopmentState::Experimental)
206            .privilege_requirement(PrivilegeRequirement::None)
207            .implementation("Shared FastCGI 1.0 record codec; Responder requests with KEEP_CONN over one connection (reconnecting when the application closes it), GET_VALUES, ABORT_REQUEST on timeout; CGI response parsed by Rust")
208            .llm_control("Which requests to send, with which method, path, headers and body, and what to do with each response")
209            .e2e_testing("tests/client/fastcgi: flup 1.0.3 (independent WSGI FastCGI server) answers GET, POST, large and stderr-writing requests, GET_VALUES and an aborted slow request")
210            .notes("Responder role only; one request at a time. Response output 1 MiB, stderr 64 KiB.")
211            .max_inbound_bytes(record::MAX_BODY_BYTES)
212            .build()
213    }
214    fn example_prompt(&self) -> &'static str {
215        "Request /index.php from the php-fpm pool at 127.0.0.1:9000"
216    }
217    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
218        let llm = json!({"type":"open_client","protocol":"fastcgi","remote_addr":"127.0.0.1:9000","instruction":"Fetch /index.php and report the status"});
219        let mut static_example = llm.clone();
220        static_example["event_handlers"] = json!([
221            {"event_pattern":"fastcgi_connected","handler":{"type":"static","actions":[request_action().example]}},
222            {"event_pattern":"fastcgi_response","handler":{"type":"static","actions":[]}}
223        ]);
224        let mut scripted = static_example.clone();
225        scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
226        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
227    }
228    fn group_name(&self) -> &'static str {
229        "Web & File"
230    }
231}
232
233impl Client for FastcgiClientProtocol {
234    fn connect(
235        &self,
236        ctx: ConnectContext,
237    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
238    {
239        Box::pin(super::connect(ctx))
240    }
241    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
242        match v["type"].as_str() {
243            Some("fastcgi_request") => {
244                let path = v["path"].as_str().context("path is required")?;
245                ensure!(
246                    path.starts_with('/')
247                        && path.len() <= 2048
248                        && !crate::utils::sanitize::has_controls(&path),
249                    "path must start with / and contain no control characters"
250                );
251                if let Some(m) = v.get("method").filter(|m| !m.is_null()) {
252                    ensure!(
253                        m.as_str().is_some_and(|m| !m.is_empty()
254                            && m.len() <= 32
255                            && m.bytes().all(|b| b.is_ascii_uppercase())),
256                        "method must be an upper-case token"
257                    );
258                }
259                if let Some(q) = v.get("query").filter(|q| !q.is_null()) {
260                    ensure!(
261                        q.as_str().is_some_and(
262                            |q| q.len() <= 8192 && !crate::utils::sanitize::has_controls(&q)
263                        ),
264                        "query must be text without control characters"
265                    );
266                }
267                record::check_headers(v.get("headers"))?;
268                record::check_headers(v.get("params")).context("params")?;
269                record::decode_body(&v)?;
270            }
271            Some("fastcgi_get_values") => {
272                if let Some(n) = v.get("names").filter(|n| !n.is_null()) {
273                    let list = n.as_array().context("names must be an array")?;
274                    ensure!(
275                        list.len() <= 16
276                            && list.iter().all(|x| x.as_str().is_some_and(|s| !s.is_empty()
277                                && s.len() <= 64
278                                && s.bytes().all(|b| b.is_ascii_graphic()))),
279                        "names must be up to 16 printable names"
280                    );
281                }
282            }
283            Some("disconnect") => return Ok(ClientActionResult::Disconnect),
284            _ => bail!("Unknown FastCGI client action"),
285        }
286        Ok(ClientActionResult::Custom {
287            name: v["type"].as_str().unwrap_or_default().into(),
288            data: v,
289        })
290    }
291}