1use super::record;
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, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct FastcgiProtocol;
15impl FastcgiProtocol {
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 ActionDefinition {
37 name: name.into(),
38 description: description.into(),
39 parameters,
40 example,
41 log_template: Some(LogTemplate::new().with_info(format!("-> FastCGI {name}"))),
42 }
43}
44
45fn respond_action() -> ActionDefinition {
46 action(
47 "fastcgi_respond",
48 "Answer the request the web server forwarded. Rust writes the CGI response (Status line, headers, body) as STDOUT records, then END_REQUEST.",
49 vec![
50 parameter("status", "number", "HTTP status the web server should send, 100 to 599; required, so an answer never defaults to success", true),
51 parameter("headers", "object", "Response headers as name → value, e.g. {\"Content-Type\": \"application/json\"}; Status is set by Rust", false),
52 parameter("body", "string", "Response body as text, or hex when body_encoding is hex (up to 1 MiB)", false),
53 parameter("body_encoding", "string", "utf8 (default) or hex for binary bodies", false),
54 parameter("stderr", "string", "Optional diagnostic line sent as FCGI_STDERR; web servers write it to their error log", false),
55 ],
56 json!({"type":"fastcgi_respond","status":200,"headers":{"Content-Type":"text/plain"},"body":"Hello from NetGet"}),
57 )
58}
59
60pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
61 EventType::new(
62 "fastcgi_request",
63 "A complete Responder request from the web server (all PARAMS and STDIN received)",
64 respond_action().example.clone(),
65 )
66 .with_parameters(vec![
67 parameter(
68 "request_id",
69 "number",
70 "FastCGI request id on this connection",
71 true,
72 ),
73 parameter("method", "string", "REQUEST_METHOD, e.g. GET or POST", true),
74 parameter(
75 "request_uri",
76 "string",
77 "REQUEST_URI as the client sent it",
78 false,
79 ),
80 parameter(
81 "script_name",
82 "string",
83 "SCRIPT_NAME: the script path the web server mapped the request to",
84 false,
85 ),
86 parameter(
87 "path_info",
88 "string",
89 "PATH_INFO: extra path after the script name, when the web server splits one",
90 false,
91 ),
92 parameter(
93 "query_string",
94 "string",
95 "QUERY_STRING: the URL query without the leading ?",
96 false,
97 ),
98 parameter("content_type", "string", "CONTENT_TYPE of the body", false),
99 parameter(
100 "headers",
101 "object",
102 "HTTP request headers from the HTTP_* params, lower-case names",
103 true,
104 ),
105 parameter(
106 "params",
107 "object",
108 "Every CGI param the web server sent",
109 true,
110 ),
111 parameter(
112 "body",
113 "string",
114 "Request body (STDIN) as text, or hex when body_encoding is hex",
115 true,
116 ),
117 parameter(
118 "body_encoding",
119 "string",
120 "How body is written: utf8 when the bytes were valid UTF-8 text, hex otherwise",
121 true,
122 ),
123 parameter(
124 "keep_conn",
125 "boolean",
126 "Whether the web server keeps the connection for more requests",
127 true,
128 ),
129 ])
130 .with_actions(vec![respond_action()])
131});
132
133impl Protocol for FastcgiProtocol {
134 fn protocol_name(&self) -> &'static str {
135 "FastCGI"
136 }
137 fn stack_name(&self) -> &'static str {
138 "ETH>IP>TCP>FastCGI"
139 }
140 fn description(&self) -> &'static str {
141 "FastCGI 1.0 application (Responder role) behind a web server such as nginx"
142 }
143 fn keywords(&self) -> Vec<&'static str> {
144 vec!["fastcgi", "fcgi", "php-fpm", "cgi", "responder"]
145 }
146 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
147 vec![]
148 }
149 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
150 vec![respond_action()]
151 }
152 fn get_event_types(&self) -> Vec<EventType> {
153 vec![REQUEST_EVENT.clone()]
154 }
155 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
156 vec![ParameterDefinition {
157 name: "idle_timeout_secs".into(),
158 type_hint: "integer".into(),
159 description: "Seconds a connection may sit between records before it is closed".into(),
160 required: false,
161 example: json!(120),
162 default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
163 }]
164 }
165 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
166 use crate::protocol::metadata::*;
167 ProtocolMetadataV2::builder()
168 .state(DevelopmentState::Experimental)
169 .privilege_requirement(PrivilegeRequirement::None)
170 .implementation("Hand-written FastCGI 1.0 record codec (BEGIN/ABORT/END_REQUEST, PARAMS, STDIN, STDOUT, STDERR, GET_VALUES, UNKNOWN_TYPE); Responder role; CGI response built by Rust")
171 .llm_control("The status, headers and body of every request the web server forwards")
172 .e2e_testing("tests/server/fastcgi: nginx (independent FastCGI client) forwards GET, POST bodies, large responses and keep-alive requests; raw records for management, abort, multiplexing and bounds")
173 .notes("Responder role only (Authorizer and Filter answer UNKNOWN_ROLE). One request at a time per connection (FCGI_MPXS_CONNS 0; a concurrent BEGIN gets CANT_MPX_CONN). PARAMS 64 KiB, STDIN 1 MiB. No storage: the handler supplies every response.")
174 .request_only("FastCGI answers each request the web server forwards; nothing is pushed")
175 .answers_on_failure()
176 .max_inbound_bytes(record::MAX_BODY_BYTES)
177 .build()
178 }
179 fn example_prompt(&self) -> &'static str {
180 "FastCGI app on port 9000 that answers every request with a JSON greeting"
181 }
182 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
183 let llm = json!({"type":"open_server","base_stack":"fastcgi","port":9000,"instruction":"Answer every page with a short HTML greeting"});
184 let mut static_example = llm.clone();
185 static_example["event_handlers"] = json!([
186 {"event_pattern":"fastcgi_request","handler":{"type":"static","actions":[respond_action().example]}}
187 ]);
188 let mut scripted = static_example.clone();
189 scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'fastcgi_respond','status':200,'body':'you asked for '+e['request_uri']}]}))"});
190 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
191 }
192 fn group_name(&self) -> &'static str {
193 "Web & File"
194 }
195}
196
197impl Server for FastcgiProtocol {
198 fn spawn(
199 &self,
200 ctx: SpawnContext,
201 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
202 {
203 Box::pin(super::spawn(ctx))
204 }
205 fn execute_action(&self, v: Value) -> Result<ActionResult> {
206 match v["type"].as_str() {
207 Some("fastcgi_respond") => {
208 check_respond(&v)?;
209 Ok(ActionResult::Custom {
210 name: "fastcgi_respond".into(),
211 data: v,
212 })
213 }
214 _ => bail!("Unknown FastCGI server action"),
215 }
216 }
217}
218
219pub fn check_respond(v: &Value) -> Result<()> {
220 ensure!(
221 v["status"]
222 .as_u64()
223 .is_some_and(|s| (100..=599).contains(&s)),
224 "fastcgi_respond requires an explicit status of 100..=599"
225 );
226 record::check_headers(v.get("headers"))?;
227 record::decode_body(v)?;
228 if let Some(e) = v.get("stderr").filter(|e| !e.is_null()) {
229 ensure!(
230 e.as_str().is_some_and(|e| e.len() <= 4096),
231 "stderr must be a string of at most 4096 bytes"
232 );
233 }
234 Ok(())
235}