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}