Skip to main content

netget/client/rcon/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::rcon::{
8    actions::{action, parameter},
9    wire,
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct RconClientProtocol;
18impl RconClientProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24fn command_action() -> ActionDefinition {
25    action(
26        "rcon_command",
27        "Run a console command on the server; its output arrives as rcon_response.",
28        vec![parameter(
29            "command",
30            "string",
31            "The console command, such as status or list",
32            true,
33        )],
34        json!({"type":"rcon_command","command":"status"}),
35    )
36}
37
38fn disconnect_action() -> ActionDefinition {
39    action(
40        "disconnect",
41        "Close the RCON connection",
42        vec![],
43        json!({"type":"disconnect"}),
44    )
45}
46
47fn actions() -> Vec<ActionDefinition> {
48    vec![command_action(), disconnect_action()]
49}
50
51pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
52    EventType::new(
53        "rcon_connected",
54        "Logged in to the RCON server; run a command.",
55        command_action().example.clone(),
56    )
57    .with_parameters(vec![
58        parameter("remote_addr", "string", "Address of the RCON server", true),
59        parameter(
60            "dialect",
61            "string",
62            "source or minecraft, from the dialect startup parameter",
63            true,
64        ),
65    ])
66    .with_actions(actions())
67});
68
69pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
70    EventType::new(
71        "rcon_response",
72        "The complete output of one command, reassembled from every packet the server sent.",
73        disconnect_action().example.clone(),
74    )
75    .with_parameters(vec![
76        parameter("command", "string", "The command that was run", true),
77        parameter("output", "string", "Its console output", true),
78        parameter(
79            "packets",
80            "number",
81            "How many response packets carried the output",
82            true,
83        ),
84    ])
85    .with_actions(actions())
86});
87
88pub fn validate_command(v: &Value) -> Result<String> {
89    let command = v["command"]
90        .as_str()
91        .ok_or_else(|| anyhow::anyhow!("command must be a string"))?;
92    ensure!(
93        !command.is_empty() && command.len() <= wire::MAX_BODY_OUT,
94        "command must be 1 to {} bytes",
95        wire::MAX_BODY_OUT
96    );
97    ensure!(!command.contains('\0'), "command must not contain NUL");
98    Ok(command.to_string())
99}
100
101impl Protocol for RconClientProtocol {
102    fn protocol_name(&self) -> &'static str {
103        "RCON"
104    }
105    fn stack_name(&self) -> &'static str {
106        "ETH>IP>TCP>RCON"
107    }
108    fn keywords(&self) -> Vec<&'static str> {
109        vec!["rcon", "source rcon", "minecraft rcon", "mcrcon"]
110    }
111    fn description(&self) -> &'static str {
112        "Source/Minecraft RCON client: logs in and runs console commands"
113    }
114    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
115        actions()
116    }
117    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
118        vec![]
119    }
120    fn get_event_types(&self) -> Vec<EventType> {
121        vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
122    }
123    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
124        vec![
125            ParameterDefinition {
126                name: "password".into(),
127                type_hint: "string".into(),
128                description: "RCON password sent at login; the connection fails when the server refuses it".into(),
129                required: true,
130                example: json!("changeme"),
131                default: None,
132            },
133            ParameterDefinition {
134                name: "dialect".into(),
135                type_hint: "string".into(),
136                description: "source (multi-packet output collected with the empty-packet sentinel) or minecraft (one packet per command)".into(),
137                required: false,
138                example: json!("minecraft"),
139                default: Some(json!(crate::server::rcon::DEFAULT_DIALECT)),
140            },
141        ]
142    }
143    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
144        use crate::protocol::metadata::*;
145        ProtocolMetadataV2::builder()
146            .state(DevelopmentState::Experimental)
147            .privilege_requirement(PrivilegeRequirement::None)
148            .well_known_port(27015)
149            .implementation("Tokio TCP with Source RCON framing: login on connect, EXECCOMMAND followed by the empty-packet sentinel to collect multi-packet output")
150            .llm_control("Which console commands to run and what to do with their output")
151            .e2e_testing("tests/client/rcon: scripted fixture and gorcon/rcon's rcontest server as the independent peer")
152            .notes("The password is a startup parameter and is never part of an event. Incoming packets are capped at 4096 bytes and one command's output at 1 MiB; connect, login and reply deadlines are 30 s.")
153            .max_inbound_bytes(wire::MAX_PACKET)
154            .build()
155    }
156    fn example_prompt(&self) -> &'static str {
157        "Connect to RCON at 127.0.0.1:25575 with password changeme and list the players"
158    }
159    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
160        let llm = json!({"type":"open_client","protocol":"rcon","remote_addr":"127.0.0.1:25575","instruction":"List the players","startup_params":{"password":"changeme","dialect":"minecraft"}});
161        let mut static_example = llm.clone();
162        static_example["event_handlers"] = json!([
163            {"event_pattern":"rcon_connected","handler":{"type":"static","actions":[{"type":"rcon_command","command":"list"}]}},
164            {"event_pattern":"rcon_response","handler":{"type":"static","actions":[{"type":"disconnect"}]}}
165        ]);
166        let mut scripted = static_example.clone();
167        scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'rcon_command','command':'list'}]}))"});
168        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
169    }
170    fn group_name(&self) -> &'static str {
171        "Application"
172    }
173}
174
175impl Client for RconClientProtocol {
176    fn connect(
177        &self,
178        ctx: ConnectContext,
179    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
180    {
181        Box::pin(super::connect(ctx))
182    }
183    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
184        match v["type"].as_str() {
185            Some("rcon_command") => {
186                validate_command(&v)?;
187                Ok(ClientActionResult::Custom {
188                    name: "rcon_command".into(),
189                    data: v,
190                })
191            }
192            Some("disconnect") => Ok(ClientActionResult::Disconnect),
193            _ => bail!("Unknown RCON client action"),
194        }
195    }
196}