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 crate::llm::actions::{
    client_trait::{Client, ClientActionResult},
    protocol_trait::Protocol,
    ActionDefinition, ParameterDefinition,
};
use crate::protocol::{ConnectContext, EventType};
use crate::server::fastcgi::actions::{action, parameter};
use crate::server::fastcgi::record;
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;

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

fn request_action() -> ActionDefinition {
    action(
        "fastcgi_request",
        "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).",
        vec![
            parameter("method", "string", "HTTP method to report, e.g. GET or POST (default GET)", false),
            parameter("path", "string", "Script path starting with /, e.g. /index.php; SCRIPT_FILENAME is document_root plus this", true),
            parameter("query", "string", "Query string without the leading ?", false),
            parameter("headers", "object", "HTTP request headers as name → value; each becomes an HTTP_* param", false),
            parameter("body", "string", "Request body (STDIN) as text, or hex when body_encoding is hex", false),
            parameter("body_encoding", "string", "utf8 (default) or hex", false),
            parameter("params", "object", "Extra CGI params, or overrides of the ones Rust sets, as name → value", false),
        ],
        json!({"type":"fastcgi_request","method":"GET","path":"/index.php","query":"page=1"}),
    )
}
fn values_action() -> ActionDefinition {
    action(
        "fastcgi_get_values",
        "Ask the application for its management values (FCGI_GET_VALUES)",
        vec![parameter(
            "names",
            "array",
            "Variable names to ask for (default FCGI_MAX_CONNS, FCGI_MAX_REQS, FCGI_MPXS_CONNS)",
            false,
        )],
        json!({"type":"fastcgi_get_values"}),
    )
}
fn disconnect_action() -> ActionDefinition {
    action(
        "disconnect",
        "Stop this FastCGI client",
        vec![],
        json!({"type":"disconnect"}),
    )
}
fn actions() -> Vec<ActionDefinition> {
    vec![request_action(), values_action(), disconnect_action()]
}

pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "fastcgi_connected",
        "Connected to the FastCGI application",
        request_action().example.clone(),
    )
    .with_parameters(vec![parameter(
        "remote_addr",
        "string",
        "Application address",
        true,
    )])
    .with_actions(actions())
});
pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "fastcgi_response",
        "The application's answer to the last request",
        disconnect_action().example.clone(),
    )
    .with_parameters(vec![
        parameter("request_id", "number", "FastCGI request id used", true),
        parameter(
            "status",
            "number",
            "HTTP status from the CGI Status header (200 when absent, 302 with Location)",
            false,
        ),
        parameter(
            "headers",
            "object",
            "CGI response headers, lower-case names",
            false,
        ),
        parameter(
            "body",
            "string",
            "Response body as text, or hex when body_encoding is hex",
            false,
        ),
        parameter(
            "body_encoding",
            "string",
            "How body is written: utf8 when the bytes were valid UTF-8 text, hex otherwise",
            false,
        ),
        parameter(
            "stderr",
            "string",
            "Everything the application wrote to FCGI_STDERR",
            false,
        ),
        parameter(
            "app_status",
            "number",
            "END_REQUEST application status",
            false,
        ),
        parameter(
            "protocol_status",
            "string",
            "request_complete, cant_mpx_conn, overloaded or unknown_role",
            false,
        ),
        parameter(
            "aborted",
            "boolean",
            "True when the client sent ABORT_REQUEST after its timeout",
            false,
        ),
        parameter(
            "error",
            "string",
            "Set when the output was not a CGI response or the connection failed",
            false,
        ),
    ])
    .with_actions(actions())
});
pub static VALUES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "fastcgi_values",
        "The application's FCGI_GET_VALUES_RESULT",
        request_action().example.clone(),
    )
    .with_parameters(vec![parameter(
        "values",
        "object",
        "Variable name → value as the application reported them",
        true,
    )])
    .with_actions(actions())
});

impl Protocol for FastcgiClientProtocol {
    fn protocol_name(&self) -> &'static str {
        "FastCGI"
    }
    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>FastCGI"
    }
    fn keywords(&self) -> Vec<&'static str> {
        vec!["fastcgi", "fcgi", "php-fpm client", "cgi"]
    }
    fn description(&self) -> &'static str {
        "FastCGI 1.0 client: sends Responder requests to an application such as php-fpm, as a web server does"
    }
    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
        actions()
    }
    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![]
    }
    fn get_event_types(&self) -> Vec<EventType> {
        vec![
            CONNECTED_EVENT.clone(),
            RESPONSE_EVENT.clone(),
            VALUES_EVENT.clone(),
        ]
    }
    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        vec![
            ParameterDefinition {
                name: "document_root".into(),
                type_hint: "string".into(),
                description: "Directory reported as DOCUMENT_ROOT and prefixed to each path for SCRIPT_FILENAME".into(),
                required: false,
                example: json!("/var/www/html"),
                default: Some(json!(super::DEFAULT_DOCUMENT_ROOT)),
            },
            ParameterDefinition {
                name: "request_timeout_secs".into(),
                type_hint: "integer".into(),
                description: "Seconds to wait for END_REQUEST before sending ABORT_REQUEST".into(),
                required: false,
                example: json!(10),
                default: Some(json!(super::DEFAULT_REQUEST_TIMEOUT.as_secs())),
            },
        ]
    }
    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::*;
        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .privilege_requirement(PrivilegeRequirement::None)
            .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")
            .llm_control("Which requests to send, with which method, path, headers and body, and what to do with each response")
            .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")
            .notes("Responder role only; one request at a time. Response output 1 MiB, stderr 64 KiB.")
            .max_inbound_bytes(record::MAX_BODY_BYTES)
            .build()
    }
    fn example_prompt(&self) -> &'static str {
        "Request /index.php from the php-fpm pool at 127.0.0.1:9000"
    }
    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        let llm = json!({"type":"open_client","protocol":"fastcgi","remote_addr":"127.0.0.1:9000","instruction":"Fetch /index.php and report the status"});
        let mut static_example = llm.clone();
        static_example["event_handlers"] = json!([
            {"event_pattern":"fastcgi_connected","handler":{"type":"static","actions":[request_action().example]}},
            {"event_pattern":"fastcgi_response","handler":{"type":"static","actions":[]}}
        ]);
        let mut scripted = static_example.clone();
        scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
    }
    fn group_name(&self) -> &'static str {
        "Web & File"
    }
}

impl Client for FastcgiClientProtocol {
    fn connect(
        &self,
        ctx: ConnectContext,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
    {
        Box::pin(super::connect(ctx))
    }
    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
        match v["type"].as_str() {
            Some("fastcgi_request") => {
                let path = v["path"].as_str().context("path is required")?;
                ensure!(
                    path.starts_with('/')
                        && path.len() <= 2048
                        && !crate::utils::sanitize::has_controls(&path),
                    "path must start with / and contain no control characters"
                );
                if let Some(m) = v.get("method").filter(|m| !m.is_null()) {
                    ensure!(
                        m.as_str().is_some_and(|m| !m.is_empty()
                            && m.len() <= 32
                            && m.bytes().all(|b| b.is_ascii_uppercase())),
                        "method must be an upper-case token"
                    );
                }
                if let Some(q) = v.get("query").filter(|q| !q.is_null()) {
                    ensure!(
                        q.as_str().is_some_and(
                            |q| q.len() <= 8192 && !crate::utils::sanitize::has_controls(&q)
                        ),
                        "query must be text without control characters"
                    );
                }
                record::check_headers(v.get("headers"))?;
                record::check_headers(v.get("params")).context("params")?;
                record::decode_body(&v)?;
            }
            Some("fastcgi_get_values") => {
                if let Some(n) = v.get("names").filter(|n| !n.is_null()) {
                    let list = n.as_array().context("names must be an array")?;
                    ensure!(
                        list.len() <= 16
                            && list.iter().all(|x| x.as_str().is_some_and(|s| !s.is_empty()
                                && s.len() <= 64
                                && s.bytes().all(|b| b.is_ascii_graphic()))),
                        "names must be up to 16 printable names"
                    );
                }
            }
            Some("disconnect") => return Ok(ClientActionResult::Disconnect),
            _ => bail!("Unknown FastCGI client action"),
        }
        Ok(ClientActionResult::Custom {
            name: v["type"].as_str().unwrap_or_default().into(),
            data: v,
        })
    }
}