Skip to main content

netget/server/a2s/
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, 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
183/// The action that answers each query kind.
184pub 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            // Every A2S reply asserts something about the server; there is no error reply.
246            .deliberately_silent()
247            // Each query stands alone: the challenge is recomputed from the address, never stored.
248            .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}