Skip to main content

netget/server/fastcgi/
actions.rs

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}