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,
};
use crate::protocol::{ConnectContext, EventType};
use crate::server::minecraft::{
    actions::{action, parameter},
    wire,
};
use crate::state::app_state::AppState;
use anyhow::{bail, ensure, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;

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

fn status_action() -> ActionDefinition {
    action(
        "minecraft_status",
        "Ping the server for its server-list entry over a fresh connection; the answer arrives as minecraft_status_response.",
        vec![parameter("protocol_version", "number", "Protocol to announce in the handshake, default 767 (1.21.1)", false)],
        json!({"type":"minecraft_status"}),
    )
}

fn legacy_action() -> ActionDefinition {
    action(
        "minecraft_legacy_status",
        "Ping the server the way a 1.6 client does (0xFE 0x01 0xFA MC|PingHost); the answer arrives as minecraft_status_response with legacy true.",
        vec![],
        json!({"type":"minecraft_legacy_status"}),
    )
}

fn login_action() -> ActionDefinition {
    action(
        "minecraft_login",
        "Attempt to join as a player over a fresh connection and report how the server answered (disconnected, encryption required, or accepted). The client never enters the game.",
        vec![
            parameter("username", "string", "Player name, 1 to 16 characters", true),
            parameter("protocol_version", "number", "Protocol to announce, default 767 (1.21.1)", false),
            parameter("uuid", "string", "Profile UUID to send where the version sends one; default all zeros", false),
        ],
        json!({"type":"minecraft_login","username":"alice"}),
    )
}

fn disconnect_action() -> ActionDefinition {
    action(
        "disconnect",
        "Stop this Minecraft client",
        vec![],
        json!({"type":"disconnect"}),
    )
}

fn actions() -> Vec<ActionDefinition> {
    vec![
        status_action(),
        legacy_action(),
        login_action(),
        disconnect_action(),
    ]
}

pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "minecraft_ready",
        "Ready to ping or join the Minecraft server.",
        status_action().example.clone(),
    )
    .with_parameters(vec![parameter(
        "remote_addr",
        "string",
        "Address of the Minecraft server",
        true,
    )])
    .with_actions(actions())
});

pub static STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "minecraft_status_response",
        "The server's server-list entry.",
        login_action().example.clone(),
    )
    .with_parameters(vec![
        parameter(
            "legacy",
            "boolean",
            "True when this came from a legacy ping",
            true,
        ),
        parameter(
            "version_name",
            "string",
            "Version text the server reports",
            false,
        ),
        parameter(
            "protocol",
            "number",
            "Protocol number the server reports",
            false,
        ),
        parameter(
            "online_players",
            "number",
            "Number of players online now",
            false,
        ),
        parameter(
            "max_players",
            "number",
            "Maximum number of player slots",
            false,
        ),
        parameter("motd", "string", "Message of the day as plain text", true),
        parameter("sample", "array", "Players listed: [{name, id}]", false),
        parameter(
            "has_favicon",
            "boolean",
            "Whether the server sent an icon (not included)",
            false,
        ),
        parameter(
            "enforces_secure_chat",
            "boolean",
            "Whether the server enforces signed chat",
            false,
        ),
        parameter(
            "latency_ms",
            "number",
            "Ping round trip, when measured",
            false,
        ),
    ])
    .with_actions(actions())
});

pub static LOGIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "minecraft_login_result",
        "How the server answered a join attempt.",
        status_action().example.clone(),
    )
    .with_parameters(vec![
        parameter(
            "outcome",
            "string",
            "disconnected, encryption_required (an online-mode server) or accepted",
            true,
        ),
        parameter(
            "reason",
            "string",
            "For disconnected: the reason as plain text",
            false,
        ),
        parameter(
            "username",
            "string",
            "For accepted: the name the server assigned",
            false,
        ),
        parameter(
            "uuid",
            "string",
            "For accepted: the UUID the server assigned",
            false,
        ),
        parameter(
            "compression_threshold",
            "number",
            "Set when the server enabled compression first",
            false,
        ),
    ])
    .with_actions(actions())
});

pub fn protocol_version(v: &Value) -> Result<i32> {
    match &v["protocol_version"] {
        Value::Null => Ok(wire::DEFAULT_PROTOCOL),
        p => {
            let n = p
                .as_i64()
                .ok_or_else(|| anyhow::anyhow!("protocol_version must be an integer"))?;
            i32::try_from(n).map_err(|_| anyhow::anyhow!("protocol_version is out of range"))
        }
    }
}

pub fn check(v: &Value) -> Result<()> {
    match v["type"].as_str() {
        Some("minecraft_status") => {
            protocol_version(v)?;
        }
        Some("minecraft_legacy_status") => {}
        Some("minecraft_login") => {
            protocol_version(v)?;
            let name = v["username"]
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("username must be a string"))?;
            ensure!(
                (1..=wire::MAX_USERNAME_CHARS).contains(&name.chars().count()),
                "username must be 1 to {} characters",
                wire::MAX_USERNAME_CHARS
            );
            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
                wire::parse_uuid(uuid)?;
            }
        }
        _ => bail!("Unknown Minecraft client action"),
    }
    Ok(())
}

impl Protocol for MinecraftClientProtocol {
    fn protocol_name(&self) -> &'static str {
        "Minecraft"
    }
    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>Minecraft"
    }
    fn keywords(&self) -> Vec<&'static str> {
        vec!["minecraft", "server list ping", "minecraft status", "slp"]
    }
    fn description(&self) -> &'static str {
        "Minecraft Java Edition client: server-list ping (modern and legacy) and login probing"
    }
    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
        actions()
    }
    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![]
    }
    fn get_event_types(&self) -> Vec<EventType> {
        vec![
            READY_EVENT.clone(),
            STATUS_EVENT.clone(),
            LOGIN_EVENT.clone(),
        ]
    }
    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::*;
        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .privilege_requirement(PrivilegeRequirement::None)
            .well_known_port(25565)
            .implementation("Tokio TCP, one connection per action: handshake, status and ping/pong; the 1.6 legacy ping; handshake and Login Start read through Set Compression, plugin and cookie requests to Disconnect, Encryption Request or Login Success")
            .llm_control("Which servers to ping or join, as whom, and what to do with each answer")
            .e2e_testing("tests/client/minecraft: NetGet's own server for every path; node-minecraft-protocol's createServer as the independent peer for status, a login it accepts and one it kicks")
            .notes("Offline probing only: no Mojang authentication or encryption, so an online-mode server is reported as encryption_required, and an accepted login is reported and closed before play. Status responses are bounded to 32767 characters and decompressed packets to the same size; each read has a 30 s deadline.")
            .max_inbound_bytes(wire::MAX_CLIENTBOUND_PACKET)
            .build()
    }
    fn example_prompt(&self) -> &'static str {
        "Ping the Minecraft server at 127.0.0.1:25565 and report its MOTD and player count"
    }
    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        let llm = json!({"type":"open_client","protocol":"minecraft","remote_addr":"127.0.0.1:25565","instruction":"Report the server's MOTD and player count"});
        let mut static_example = llm.clone();
        static_example["event_handlers"] = json!([
            {"event_pattern":"minecraft_ready","handler":{"type":"static","actions":[{"type":"minecraft_status"}]}},
            {"event_pattern":"minecraft_status_response","handler":{"type":"static","actions":[{"type":"disconnect"}]}}
        ]);
        let mut scripted = static_example.clone();
        scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'minecraft_login','username':'alice'}]}))"});
        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
    }
    fn group_name(&self) -> &'static str {
        "Application"
    }
}

impl Client for MinecraftClientProtocol {
    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> {
        if v["type"] == "disconnect" {
            return Ok(ClientActionResult::Disconnect);
        }
        check(&v)?;
        Ok(ClientActionResult::Custom {
            name: v["type"].as_str().unwrap_or_default().to_string(),
            data: v,
        })
    }
}