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}