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, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct A2sProtocol;
14impl A2sProtocol {
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 ActionDefinition {
36 name: name.into(),
37 description: description.into(),
38 parameters,
39 example,
40 log_template: Some(LogTemplate::new().with_info(format!("-> A2S {name}"))),
41 }
42}
43
44fn info_action() -> ActionDefinition {
45 action(
46 "a2s_info",
47 "Answer an info query. Rust encodes the Source A2S_INFO response.",
48 vec![
49 parameter("name", "string", "Server name shown in the browser", true),
50 parameter(
51 "map",
52 "string",
53 "Map currently loaded, such as de_dust2",
54 true,
55 ),
56 parameter("folder", "string", "Game folder, such as csgo or tf", true),
57 parameter(
58 "game",
59 "string",
60 "Full game name, such as Counter-Strike",
61 true,
62 ),
63 parameter(
64 "app_id",
65 "number",
66 "Steam application id (low 16 bits), such as 730",
67 false,
68 ),
69 parameter(
70 "players",
71 "number",
72 "Players currently connected, 0 to 255",
73 false,
74 ),
75 parameter("max_players", "number", "Player slots, 0 to 255", false),
76 parameter("bots", "number", "Bots among the players, 0 to 255", false),
77 parameter(
78 "server_type",
79 "string",
80 "dedicated (default), listen or proxy",
81 false,
82 ),
83 parameter(
84 "environment",
85 "string",
86 "linux (default), windows or mac",
87 false,
88 ),
89 parameter(
90 "private",
91 "boolean",
92 "True when a password is needed to join",
93 false,
94 ),
95 parameter(
96 "vac",
97 "boolean",
98 "True when Valve Anti-Cheat is enabled",
99 false,
100 ),
101 parameter(
102 "version",
103 "string",
104 "Game version string, default 1.0.0.0",
105 false,
106 ),
107 parameter("port", "number", "Game port, sent in the extra data", false),
108 parameter(
109 "steam_id",
110 "number",
111 "Server SteamID, sent in the extra data",
112 false,
113 ),
114 parameter(
115 "keywords",
116 "string",
117 "Comma-separated server tags, sent in the extra data",
118 false,
119 ),
120 parameter(
121 "game_id",
122 "number",
123 "64-bit game id, sent in the extra data",
124 false,
125 ),
126 ],
127 json!({"type":"a2s_info","name":"NetGet Arena","map":"de_dust2","folder":"csgo","game":"Counter-Strike","app_id":730,"players":3,"max_players":16}),
128 )
129}
130fn players_action() -> ActionDefinition {
131 action(
132 "a2s_players",
133 "Answer a players query with the connected players.",
134 vec![parameter(
135 "players",
136 "array",
137 "[{name, score, duration_secs}] for each connected player",
138 true,
139 )],
140 json!({"type":"a2s_players","players":[{"name":"alice","score":12,"duration_secs":330.5}]}),
141 )
142}
143fn rules_action() -> ActionDefinition {
144 action(
145 "a2s_rules",
146 "Answer a rules query with the server's configuration variables.",
147 vec![parameter(
148 "rules",
149 "object",
150 "Rule names to string values, such as {\"mp_timelimit\": \"30\"}",
151 true,
152 )],
153 json!({"type":"a2s_rules","rules":{"mp_timelimit":"30","sv_gravity":"800"}}),
154 )
155}
156fn refuse_action() -> ActionDefinition {
157 action(
158 "a2s_refuse",
159 "Deliberately leave this query unanswered; logged as a refusal, unlike silence.",
160 vec![parameter(
161 "reason",
162 "string",
163 "Why the query is refused, for the log only",
164 false,
165 )],
166 json!({"type":"a2s_refuse"}),
167 )
168}
169
170pub static QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
171 EventType::new(
172 "a2s_query",
173 "A Steam server-browser query passed the challenge. Answer with a2s_info, a2s_players or a2s_rules to match the query.",
174 info_action().example.clone(),
175 )
176 .with_parameters(vec![
177 parameter("query", "string", "info, players or rules", true),
178 parameter("remote_addr", "string", "Address the query came from", true),
179 ])
180 .with_actions(vec![info_action(), players_action(), rules_action(), refuse_action()])
181});
182
183pub fn answer_name(kind: super::wire::Kind) -> &'static str {
185 match kind {
186 super::wire::Kind::Info => "a2s_info",
187 super::wire::Kind::Players => "a2s_players",
188 super::wire::Kind::Rules => "a2s_rules",
189 }
190}
191
192impl Protocol for A2sProtocol {
193 fn protocol_name(&self) -> &'static str {
194 "A2S"
195 }
196 fn stack_name(&self) -> &'static str {
197 "ETH>IP>UDP>A2S"
198 }
199 fn description(&self) -> &'static str {
200 "Steam/Source server query (A2S_INFO, A2S_PLAYER, A2S_RULES) with challenges and split responses"
201 }
202 fn keywords(&self) -> Vec<&'static str> {
203 vec![
204 "a2s",
205 "steam query",
206 "source query",
207 "server browser",
208 "game server query",
209 ]
210 }
211 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
212 vec![]
213 }
214 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
215 vec![
216 info_action(),
217 players_action(),
218 rules_action(),
219 refuse_action(),
220 ]
221 }
222 fn get_event_types(&self) -> Vec<EventType> {
223 vec![QUERY_EVENT.clone()]
224 }
225 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
226 vec![ParameterDefinition {
227 name: "info_challenge".into(),
228 type_hint: "boolean".into(),
229 description: "Require a challenge for A2S_INFO too, as Source servers have since 2020 (players and rules always need one)".into(),
230 required: false,
231 example: json!(false),
232 default: Some(json!(super::DEFAULT_INFO_CHALLENGE)),
233 }]
234 }
235 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
236 use crate::protocol::metadata::*;
237 ProtocolMetadataV2::builder()
238 .state(DevelopmentState::Experimental)
239 .privilege_requirement(PrivilegeRequirement::None)
240 .well_known_udp_port(27015)
241 .implementation("Source A2S over Tokio UDP: stateless keyed challenges, A2S_INFO with extra data, A2S_PLAYER, A2S_RULES, uncompressed split responses")
242 .llm_control("What the server reports: its info, its players and its rules, per query")
243 .e2e_testing("tests/server/a2s: raw datagrams and bounds; python-a2s and woozymasta/a2s (Go) as independent clients")
244 .notes("Challenges are a keyed hash of the client's address with a per-server random key, so no state is kept per client and a spoofed source cannot complete a query. No GoldSource format, A2A_PING or compressed splits. Requests are capped at 1400 bytes, responses at 64 KiB in at most 64 packets, 64 queries in flight; datagrams beyond that are dropped. A handler failure or refusal leaves the query unanswered.")
245 .deliberately_silent()
247 .connectionless()
249 .max_inbound_bytes(super::wire::MAX_REQUEST)
250 .build()
251 }
252 fn example_prompt(&self) -> &'static str {
253 "A2S server on UDP port 27015 that looks like a busy Counter-Strike server on de_dust2"
254 }
255 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
256 let llm = json!({"type":"open_server","base_stack":"a2s","port":27015,"instruction":"Report a busy Counter-Strike server on de_dust2"});
257 let mut static_example = llm.clone();
258 static_example["event_handlers"] = json!([{"event_pattern":"a2s_query","handler":{"type":"static","actions":[info_action().example]}}]);
259 let mut scripted = llm.clone();
260 scripted["event_handlers"] = json!([{"event_pattern":"a2s_query","handler":{"type":"script","language":"python","code":"import json,sys\nq=json.load(sys.stdin)['event']['query']\na={'info':{'type':'a2s_info','name':'NetGet','map':'cs_office','folder':'csgo','game':'Counter-Strike'},'players':{'type':'a2s_players','players':[]},'rules':{'type':'a2s_rules','rules':{}}}[q]\nprint(json.dumps({'actions':[a]}))"}}]);
261 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
262 }
263 fn group_name(&self) -> &'static str {
264 "Application"
265 }
266}
267
268impl Server for A2sProtocol {
269 fn spawn(
270 &self,
271 ctx: SpawnContext,
272 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
273 {
274 Box::pin(super::spawn(ctx))
275 }
276 fn execute_action(&self, v: Value) -> Result<ActionResult> {
277 let name = v["type"].as_str().unwrap_or_default().to_string();
278 match name.as_str() {
279 "a2s_info" => {
280 super::wire::encode_info(&v)?;
281 }
282 "a2s_players" => {
283 super::wire::encode_players(&v)?;
284 }
285 "a2s_rules" => {
286 super::wire::encode_rules(&v)?;
287 }
288 "a2s_refuse" => {}
289 _ => bail!("Unknown A2S server action"),
290 }
291 Ok(ActionResult::Custom { name, data: v })
292 }
293}