netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
use super::record;
use crate::llm::actions::{
    protocol_trait::{ActionResult, Protocol, Server},
    ActionDefinition, Parameter, ParameterDefinition,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::{EventType, SpawnContext};
use crate::state::app_state::AppState;
use anyhow::{bail, ensure, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;

#[derive(Default)]
pub struct FastcgiProtocol;
impl FastcgiProtocol {
    pub fn new() -> Self {
        Self
    }
}

pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
    Parameter {
        name: name.into(),
        type_hint: kind.into(),
        description: description.into(),
        required,
    }
}

pub fn action(
    name: &str,
    description: &str,
    parameters: Vec<Parameter>,
    example: Value,
) -> ActionDefinition {
    ActionDefinition {
        name: name.into(),
        description: description.into(),
        parameters,
        example,
        log_template: Some(LogTemplate::new().with_info(format!("-> FastCGI {name}"))),
    }
}

fn respond_action() -> ActionDefinition {
    action(
        "fastcgi_respond",
        "Answer the request the web server forwarded. Rust writes the CGI response (Status line, headers, body) as STDOUT records, then END_REQUEST.",
        vec![
            parameter("status", "number", "HTTP status the web server should send, 100 to 599; required, so an answer never defaults to success", true),
            parameter("headers", "object", "Response headers as name → value, e.g. {\"Content-Type\": \"application/json\"}; Status is set by Rust", false),
            parameter("body", "string", "Response body as text, or hex when body_encoding is hex (up to 1 MiB)", false),
            parameter("body_encoding", "string", "utf8 (default) or hex for binary bodies", false),
            parameter("stderr", "string", "Optional diagnostic line sent as FCGI_STDERR; web servers write it to their error log", false),
        ],
        json!({"type":"fastcgi_respond","status":200,"headers":{"Content-Type":"text/plain"},"body":"Hello from NetGet"}),
    )
}

pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "fastcgi_request",
        "A complete Responder request from the web server (all PARAMS and STDIN received)",
        respond_action().example.clone(),
    )
    .with_parameters(vec![
        parameter(
            "request_id",
            "number",
            "FastCGI request id on this connection",
            true,
        ),
        parameter("method", "string", "REQUEST_METHOD, e.g. GET or POST", true),
        parameter(
            "request_uri",
            "string",
            "REQUEST_URI as the client sent it",
            false,
        ),
        parameter(
            "script_name",
            "string",
            "SCRIPT_NAME: the script path the web server mapped the request to",
            false,
        ),
        parameter(
            "path_info",
            "string",
            "PATH_INFO: extra path after the script name, when the web server splits one",
            false,
        ),
        parameter(
            "query_string",
            "string",
            "QUERY_STRING: the URL query without the leading ?",
            false,
        ),
        parameter("content_type", "string", "CONTENT_TYPE of the body", false),
        parameter(
            "headers",
            "object",
            "HTTP request headers from the HTTP_* params, lower-case names",
            true,
        ),
        parameter(
            "params",
            "object",
            "Every CGI param the web server sent",
            true,
        ),
        parameter(
            "body",
            "string",
            "Request body (STDIN) as text, or hex when body_encoding is hex",
            true,
        ),
        parameter(
            "body_encoding",
            "string",
            "How body is written: utf8 when the bytes were valid UTF-8 text, hex otherwise",
            true,
        ),
        parameter(
            "keep_conn",
            "boolean",
            "Whether the web server keeps the connection for more requests",
            true,
        ),
    ])
    .with_actions(vec![respond_action()])
});

impl Protocol for FastcgiProtocol {
    fn protocol_name(&self) -> &'static str {
        "FastCGI"
    }
    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>FastCGI"
    }
    fn description(&self) -> &'static str {
        "FastCGI 1.0 application (Responder role) behind a web server such as nginx"
    }
    fn keywords(&self) -> Vec<&'static str> {
        vec!["fastcgi", "fcgi", "php-fpm", "cgi", "responder"]
    }
    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
        vec![]
    }
    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![respond_action()]
    }
    fn get_event_types(&self) -> Vec<EventType> {
        vec![REQUEST_EVENT.clone()]
    }
    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        vec![ParameterDefinition {
            name: "idle_timeout_secs".into(),
            type_hint: "integer".into(),
            description: "Seconds a connection may sit between records before it is closed".into(),
            required: false,
            example: json!(120),
            default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
        }]
    }
    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::*;
        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .privilege_requirement(PrivilegeRequirement::None)
            .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")
            .llm_control("The status, headers and body of every request the web server forwards")
            .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")
            .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.")
            .request_only("FastCGI answers each request the web server forwards; nothing is pushed")
            .answers_on_failure()
            .max_inbound_bytes(record::MAX_BODY_BYTES)
            .build()
    }
    fn example_prompt(&self) -> &'static str {
        "FastCGI app on port 9000 that answers every request with a JSON greeting"
    }
    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        let llm = json!({"type":"open_server","base_stack":"fastcgi","port":9000,"instruction":"Answer every page with a short HTML greeting"});
        let mut static_example = llm.clone();
        static_example["event_handlers"] = json!([
            {"event_pattern":"fastcgi_request","handler":{"type":"static","actions":[respond_action().example]}}
        ]);
        let mut scripted = static_example.clone();
        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']}]}))"});
        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
    }
    fn group_name(&self) -> &'static str {
        "Web & File"
    }
}

impl Server for FastcgiProtocol {
    fn spawn(
        &self,
        ctx: SpawnContext,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
    {
        Box::pin(super::spawn(ctx))
    }
    fn execute_action(&self, v: Value) -> Result<ActionResult> {
        match v["type"].as_str() {
            Some("fastcgi_respond") => {
                check_respond(&v)?;
                Ok(ActionResult::Custom {
                    name: "fastcgi_respond".into(),
                    data: v,
                })
            }
            _ => bail!("Unknown FastCGI server action"),
        }
    }
}

pub fn check_respond(v: &Value) -> Result<()> {
    ensure!(
        v["status"]
            .as_u64()
            .is_some_and(|s| (100..=599).contains(&s)),
        "fastcgi_respond requires an explicit status of 100..=599"
    );
    record::check_headers(v.get("headers"))?;
    record::decode_body(v)?;
    if let Some(e) = v.get("stderr").filter(|e| !e.is_null()) {
        ensure!(
            e.as_str().is_some_and(|e| e.len() <= 4096),
            "stderr must be a string of at most 4096 bytes"
        );
    }
    Ok(())
}