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}