Skip to main content

netget/server/rcon/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct RconProtocol;
14impl RconProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    let log_template = match name {
36        "rcon_auth_decision" => {
37            LogTemplate::new().with_info("-> RCON authentication allowed={allowed}")
38        }
39        "rcon_response" => LogTemplate::new().with_info("-> RCON response {preview(output,80)}"),
40        "rcon_command" => LogTemplate::new().with_info("-> RCON command {preview(command,80)}"),
41        _ => LogTemplate::new().with_info(format!("-> RCON {name}")),
42    };
43    ActionDefinition {
44        name: name.into(),
45        description: description.into(),
46        parameters,
47        example,
48        log_template: Some(log_template),
49    }
50}
51
52fn auth_action() -> ActionDefinition {
53    action(
54        "rcon_auth_decision",
55        "Accept or refuse an RCON login. Used only when no password startup parameter is set; default is refusal.",
56        vec![parameter("allowed", "boolean", "True only if this password should be accepted", true)],
57        json!({"type":"rcon_auth_decision","allowed":false}),
58    )
59}
60
61fn response_action() -> ActionDefinition {
62    action(
63        "rcon_response",
64        "Answer an RCON command with its console output. Rust splits long output across packets.",
65        vec![parameter(
66            "output",
67            "string",
68            "Console text the command printed; empty when it prints nothing",
69            true,
70        )],
71        json!({"type":"rcon_response","output":"There are 0 of a max of 20 players online:"}),
72    )
73}
74
75pub static AUTH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
76    EventType::new(
77        "rcon_auth",
78        "A client sent an RCON login and no password is configured; decide whether it is accepted.",
79        auth_action().example.clone(),
80    )
81    .with_parameters(vec![parameter(
82        "password",
83        "string",
84        "Password the client supplied",
85        true,
86    )])
87    .with_actions(vec![auth_action()])
88});
89
90pub static COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
91    EventType::new(
92        "rcon_command",
93        "An authenticated client ran a console command; answer with its output.",
94        response_action().example.clone(),
95    )
96    .with_parameters(vec![parameter(
97        "command",
98        "string",
99        "The console command, as typed",
100        true,
101    )])
102    .with_actions(vec![response_action()])
103});
104
105impl Protocol for RconProtocol {
106    fn protocol_name(&self) -> &'static str {
107        "RCON"
108    }
109    fn stack_name(&self) -> &'static str {
110        "ETH>IP>TCP>RCON"
111    }
112    fn description(&self) -> &'static str {
113        "Source RCON server (also Minecraft's RCON): password login and console commands answered by the handler"
114    }
115    fn keywords(&self) -> Vec<&'static str> {
116        vec![
117            "rcon",
118            "source rcon",
119            "minecraft rcon",
120            "game server console",
121        ]
122    }
123    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
124        vec![]
125    }
126    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
127        vec![auth_action(), response_action()]
128    }
129    fn get_event_types(&self) -> Vec<EventType> {
130        vec![AUTH_EVENT.clone(), COMMAND_EVENT.clone()]
131    }
132    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
133        vec![
134            ParameterDefinition {
135                name: "password".into(),
136                type_hint: "string".into(),
137                description: "RCON password checked in Rust; when absent, each login is an rcon_auth event".into(),
138                required: false,
139                example: json!("changeme"),
140                default: None,
141            },
142            ParameterDefinition {
143                name: "dialect".into(),
144                type_hint: "string".into(),
145                description: "source (an empty response precedes each auth response, as srcds sends) or minecraft".into(),
146                required: false,
147                example: json!("minecraft"),
148                default: Some(json!(super::DEFAULT_DIALECT)),
149            },
150            ParameterDefinition {
151                name: "idle_timeout_secs".into(),
152                type_hint: "number".into(),
153                description: "Seconds a connection may stay silent before it is closed (1..=86400)".into(),
154                required: false,
155                example: json!(600),
156                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
157            },
158        ]
159    }
160    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
161        use crate::protocol::metadata::*;
162        ProtocolMetadataV2::builder()
163            .state(DevelopmentState::Experimental)
164            .privilege_requirement(PrivilegeRequirement::None)
165            .well_known_port(27015)
166            .implementation("Source RCON framing over Tokio TCP: AUTH with AUTH_RESPONSE (id or -1), EXECCOMMAND answered by RESPONSE_VALUE packets split at 4086 bytes, the empty-packet sentinel mirrored")
167            .llm_control("Console output for each command, and login decisions when no password is configured")
168            .e2e_testing("tests/server/rcon: raw packets and bounds; gorcon/rcon (Go) and the Python rcon package as independent clients")
169            .notes("Plain TCP, as RCON is. A configured password is compared in Rust in constant time and never shown to the handler; without one the handler decides and sees the attempt. Three failed logins close the connection; a command before login closes it. Packets are capped at 4096 bytes and output at 1 MiB. A handler failure on a command closes the connection rather than inventing output.")
170            .request_only("RCON answers each packet the client sends; the server never speaks first")
171            .max_inbound_bytes(super::wire::MAX_PACKET)
172            .build()
173    }
174    fn example_prompt(&self) -> &'static str {
175        "RCON server on port 25575 with password secret that answers like a Minecraft server console"
176    }
177    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
178        let llm = json!({"type":"open_server","base_stack":"rcon","port":25575,"instruction":"Answer like a Minecraft server console","startup_params":{"password":"changeme","dialect":"minecraft"}});
179        let mut scripted = llm.clone();
180        scripted["event_handlers"] = json!([{"event_pattern":"rcon_command","handler":{"type":"script","language":"python","code":"import json,sys\nc=json.load(sys.stdin)['event']['command']\nout='There are 0 of a max of 20 players online:' if c=='list' else 'Unknown command'\nprint(json.dumps({'actions':[{'type':'rcon_response','output':out}]}))"}}]);
181        let mut static_example = llm.clone();
182        static_example["event_handlers"] = json!([{"event_pattern":"rcon_command","handler":{"type":"static","actions":[{"type":"rcon_response","output":"Unknown command"}]}}]);
183        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
184    }
185    fn group_name(&self) -> &'static str {
186        "Application"
187    }
188}
189
190impl Server for RconProtocol {
191    fn spawn(
192        &self,
193        ctx: SpawnContext,
194    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
195    {
196        Box::pin(super::spawn(ctx))
197    }
198    fn execute_action(&self, v: Value) -> Result<ActionResult> {
199        match v["type"].as_str() {
200            Some("rcon_auth_decision") => {
201                ensure!(v["allowed"].is_boolean(), "allowed must be a boolean");
202                Ok(ActionResult::Custom {
203                    name: "rcon_auth_decision".into(),
204                    data: v,
205                })
206            }
207            Some("rcon_response") => {
208                let output = v["output"]
209                    .as_str()
210                    .ok_or_else(|| anyhow::anyhow!("output must be a string"))?;
211                ensure!(
212                    output.len() <= super::wire::MAX_RESPONSE_BYTES,
213                    "output exceeds {} bytes",
214                    super::wire::MAX_RESPONSE_BYTES
215                );
216                Ok(ActionResult::Custom {
217                    name: "rcon_response".into(),
218                    data: v,
219                })
220            }
221            _ => bail!("Unknown RCON server action"),
222        }
223    }
224}