Skip to main content

netget/server/a2s/
wire.rs

1//! Steam server query (A2S) over UDP: request parsing, the challenge exchange, encoding and
2//! decoding of A2S_INFO, A2S_PLAYER and A2S_RULES, and Source split packets.
3use anyhow::{bail, ensure, Context, Result};
4use serde_json::{json, Map, Value};
5use std::time::Duration;
6
7/// Largest datagram either side sends, as Source servers use.
8pub const MAX_PACKET: usize = 1400;
9/// Largest request the server accepts.
10pub const MAX_REQUEST: usize = 1400;
11/// Payload bytes per split packet, and the size field Source servers put in it.
12pub const SPLIT_CHUNK: usize = 1248;
13/// Largest logical response either side builds or reassembles.
14pub const MAX_RESPONSE: usize = 64 * 1024;
15/// Split packets one response may span.
16pub const MAX_SPLIT_PACKETS: usize = 64;
17/// How long a client waits for each reply datagram.
18pub const REPLY_TIMEOUT: Duration = Duration::from_secs(5);
19
20pub const SINGLE: [u8; 4] = [0xFF; 4];
21pub const SPLIT: [u8; 4] = [0xFE, 0xFF, 0xFF, 0xFF];
22pub const INFO_QUERY: &[u8] = b"Source Engine Query\0";
23pub const REQUEST_INFO: u8 = b'T';
24pub const REQUEST_PLAYERS: u8 = b'U';
25pub const REQUEST_RULES: u8 = b'V';
26pub const RESPONSE_CHALLENGE: u8 = b'A';
27pub const RESPONSE_INFO: u8 = b'I';
28pub const RESPONSE_PLAYERS: u8 = b'D';
29pub const RESPONSE_RULES: u8 = b'E';
30/// The challenge a client sends to ask for one.
31pub const NO_CHALLENGE: u32 = 0xFFFF_FFFF;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Kind {
35    Info,
36    Players,
37    Rules,
38}
39
40impl Kind {
41    pub fn name(self) -> &'static str {
42        match self {
43            Kind::Info => "info",
44            Kind::Players => "players",
45            Kind::Rules => "rules",
46        }
47    }
48    pub fn parse(name: &str) -> Result<Self> {
49        Ok(match name {
50            "info" => Kind::Info,
51            "players" => Kind::Players,
52            "rules" => Kind::Rules,
53            other => bail!("query must be info, players or rules, not {other}"),
54        })
55    }
56    pub fn request_byte(self) -> u8 {
57        match self {
58            Kind::Info => REQUEST_INFO,
59            Kind::Players => REQUEST_PLAYERS,
60            Kind::Rules => REQUEST_RULES,
61        }
62    }
63    pub fn response_byte(self) -> u8 {
64        match self {
65            Kind::Info => RESPONSE_INFO,
66            Kind::Players => RESPONSE_PLAYERS,
67            Kind::Rules => RESPONSE_RULES,
68        }
69    }
70}
71
72/// A parsed request: what was asked and the challenge it carried, if any.
73pub fn parse_request(datagram: &[u8]) -> Result<(Kind, Option<u32>)> {
74    ensure!(
75        datagram.len() <= MAX_REQUEST,
76        "A2S request exceeds {MAX_REQUEST} bytes"
77    );
78    ensure!(
79        datagram.len() >= 5 && datagram[..4] == SINGLE,
80        "not a single-packet A2S request"
81    );
82    let challenge = |bytes: &[u8]| -> Option<u32> {
83        bytes
84            .get(..4)
85            .map(|c| u32::from_le_bytes(c.try_into().expect("four bytes")))
86    };
87    match datagram[4] {
88        REQUEST_INFO => {
89            let rest = &datagram[5..];
90            ensure!(
91                rest.starts_with(INFO_QUERY),
92                "A2S_INFO without its query string"
93            );
94            Ok((Kind::Info, challenge(&rest[INFO_QUERY.len()..])))
95        }
96        REQUEST_PLAYERS => Ok((
97            Kind::Players,
98            Some(challenge(&datagram[5..]).context("A2S_PLAYER without a challenge")?),
99        )),
100        REQUEST_RULES => Ok((
101            Kind::Rules,
102            Some(challenge(&datagram[5..]).context("A2S_RULES without a challenge")?),
103        )),
104        other => bail!("unsupported A2S request {other:#04x}"),
105    }
106}
107
108pub fn encode_request(kind: Kind, challenge: Option<u32>) -> Vec<u8> {
109    let mut out = SINGLE.to_vec();
110    out.push(kind.request_byte());
111    if kind == Kind::Info {
112        out.extend_from_slice(INFO_QUERY);
113    }
114    if let Some(challenge) = challenge {
115        out.extend_from_slice(&challenge.to_le_bytes());
116    }
117    out
118}
119
120pub fn encode_challenge(challenge: u32) -> Vec<u8> {
121    let mut out = SINGLE.to_vec();
122    out.push(RESPONSE_CHALLENGE);
123    out.extend_from_slice(&challenge.to_le_bytes());
124    out
125}
126
127fn cstring(out: &mut Vec<u8>, text: &str) {
128    out.extend(text.bytes().filter(|b| *b != 0));
129    out.push(0);
130}
131
132fn field<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
133    v.get(key).filter(|x| !x.is_null())
134}
135
136fn text(v: &Value, key: &str, default: &str, limit: usize) -> Result<String> {
137    let value = match field(v, key) {
138        None => default.to_string(),
139        Some(Value::String(s)) => s.clone(),
140        Some(_) => bail!("{key} must be a string"),
141    };
142    ensure!(value.len() <= limit, "{key} exceeds {limit} bytes");
143    Ok(value)
144}
145
146fn number(v: &Value, key: &str, default: u64, max: u64) -> Result<u64> {
147    match field(v, key) {
148        None => Ok(default),
149        Some(n) => {
150            let n = n
151                .as_u64()
152                .with_context(|| format!("{key} must be a non-negative integer"))?;
153            ensure!(n <= max, "{key} must be at most {max}");
154            Ok(n)
155        }
156    }
157}
158
159fn flag(v: &Value, key: &str) -> Result<bool> {
160    match field(v, key) {
161        None => Ok(false),
162        Some(Value::Bool(b)) => Ok(*b),
163        Some(_) => bail!("{key} must be a boolean"),
164    }
165}
166
167/// A2S_INFO payload from a handler's `a2s_info` answer (Source format, protocol 17).
168pub fn encode_info(v: &Value) -> Result<Vec<u8>> {
169    let mut out = SINGLE.to_vec();
170    out.push(RESPONSE_INFO);
171    out.push(17);
172    cstring(&mut out, &text(v, "name", "", 256)?);
173    cstring(&mut out, &text(v, "map", "", 128)?);
174    cstring(&mut out, &text(v, "folder", "", 128)?);
175    cstring(&mut out, &text(v, "game", "", 128)?);
176    out.extend_from_slice(&(number(v, "app_id", 0, u16::MAX as u64)? as u16).to_le_bytes());
177    out.push(number(v, "players", 0, 255)? as u8);
178    out.push(number(v, "max_players", 0, 255)? as u8);
179    out.push(number(v, "bots", 0, 255)? as u8);
180    out.push(match text(v, "server_type", "dedicated", 16)?.as_str() {
181        "dedicated" => b'd',
182        "listen" => b'l',
183        "proxy" => b'p',
184        other => bail!("server_type must be dedicated, listen or proxy, not {other}"),
185    });
186    out.push(match text(v, "environment", "linux", 16)?.as_str() {
187        "linux" => b'l',
188        "windows" => b'w',
189        "mac" => b'm',
190        other => bail!("environment must be linux, windows or mac, not {other}"),
191    });
192    out.push(flag(v, "private")? as u8);
193    out.push(flag(v, "vac")? as u8);
194    cstring(&mut out, &text(v, "version", "1.0.0.0", 64)?);
195    let port = field(v, "port")
196        .map(|_| number(v, "port", 0, u16::MAX as u64))
197        .transpose()?;
198    let steam_id = field(v, "steam_id")
199        .map(|_| number(v, "steam_id", 0, u64::MAX))
200        .transpose()?;
201    let keywords = field(v, "keywords")
202        .map(|_| text(v, "keywords", "", 512))
203        .transpose()?;
204    let game_id = field(v, "game_id")
205        .map(|_| number(v, "game_id", 0, u64::MAX))
206        .transpose()?;
207    let edf = (if port.is_some() { 0x80 } else { 0 })
208        | (if steam_id.is_some() { 0x10 } else { 0 })
209        | (if keywords.is_some() { 0x20 } else { 0 })
210        | (if game_id.is_some() { 0x01 } else { 0 });
211    if edf != 0 {
212        out.push(edf);
213        if let Some(port) = port {
214            out.extend_from_slice(&(port as u16).to_le_bytes());
215        }
216        if let Some(steam_id) = steam_id {
217            out.extend_from_slice(&steam_id.to_le_bytes());
218        }
219        if let Some(keywords) = keywords {
220            cstring(&mut out, &keywords);
221        }
222        if let Some(game_id) = game_id {
223            out.extend_from_slice(&game_id.to_le_bytes());
224        }
225    }
226    Ok(out)
227}
228
229/// A2S_PLAYER payload: index, name, score (i32) and duration (f32 seconds) per player.
230pub fn encode_players(v: &Value) -> Result<Vec<u8>> {
231    let players = field(v, "players")
232        .map(|p| p.as_array().context("players must be an array"))
233        .transpose()?
234        .cloned()
235        .unwrap_or_default();
236    ensure!(players.len() <= 255, "at most 255 players");
237    let mut out = SINGLE.to_vec();
238    out.push(RESPONSE_PLAYERS);
239    out.push(players.len() as u8);
240    for (index, player) in players.iter().enumerate() {
241        out.push(index as u8);
242        cstring(&mut out, &text(player, "name", "", 128)?);
243        let score = match field(player, "score") {
244            None => 0,
245            Some(s) => s
246                .as_i64()
247                .filter(|s| i32::try_from(*s).is_ok())
248                .context("score must be a 32-bit integer")? as i32,
249        };
250        out.extend_from_slice(&score.to_le_bytes());
251        let duration = match field(player, "duration_secs") {
252            None => 0.0,
253            Some(d) => {
254                d.as_f64()
255                    .filter(|d| d.is_finite() && *d >= 0.0)
256                    .context("duration_secs must be a non-negative number")? as f32
257            }
258        };
259        out.extend_from_slice(&duration.to_le_bytes());
260    }
261    Ok(out)
262}
263
264/// A2S_RULES payload: a count and name/value string pairs, in the handler's order.
265pub fn encode_rules(v: &Value) -> Result<Vec<u8>> {
266    let rules = field(v, "rules")
267        .map(|r| {
268            r.as_object()
269                .context("rules must be an object of name to value")
270        })
271        .transpose()?
272        .cloned()
273        .unwrap_or_default();
274    ensure!(rules.len() <= 4096, "at most 4096 rules");
275    let mut out = SINGLE.to_vec();
276    out.push(RESPONSE_RULES);
277    out.extend_from_slice(&(rules.len() as u16).to_le_bytes());
278    for (name, value) in &rules {
279        cstring(&mut out, name);
280        cstring(
281            &mut out,
282            value
283                .as_str()
284                .with_context(|| format!("rule {name} must be a string"))?,
285        );
286    }
287    Ok(out)
288}
289
290/// Split a logical response into datagrams: one single packet, or Source split packets.
291pub fn packetize(payload: &[u8], id: u32) -> Result<Vec<Vec<u8>>> {
292    ensure!(
293        payload.len() <= MAX_RESPONSE,
294        "A2S response exceeds {MAX_RESPONSE} bytes"
295    );
296    if payload.len() <= MAX_PACKET {
297        return Ok(vec![payload.to_vec()]);
298    }
299    let chunks: Vec<&[u8]> = payload.chunks(SPLIT_CHUNK).collect();
300    ensure!(
301        chunks.len() <= MAX_SPLIT_PACKETS,
302        "A2S response needs more than {MAX_SPLIT_PACKETS} packets"
303    );
304    let id = id & 0x7FFF_FFFF;
305    Ok(chunks
306        .iter()
307        .enumerate()
308        .map(|(number, chunk)| {
309            let mut out = SPLIT.to_vec();
310            out.extend_from_slice(&id.to_le_bytes());
311            out.push(chunks.len() as u8);
312            out.push(number as u8);
313            out.extend_from_slice(&(SPLIT_CHUNK as u16).to_le_bytes());
314            out.extend_from_slice(chunk);
315            out
316        })
317        .collect())
318}
319
320/// Reassembly of split packets on the client side.
321#[derive(Default)]
322pub struct Reassembly {
323    id: Option<u32>,
324    total: usize,
325    parts: Vec<Option<Vec<u8>>>,
326}
327
328impl Reassembly {
329    /// Feed one datagram; `Some(payload)` once the logical response is complete.
330    pub fn feed(&mut self, datagram: &[u8]) -> Result<Option<Vec<u8>>> {
331        ensure!(datagram.len() >= 5, "A2S datagram too short");
332        if datagram[..4] == SINGLE {
333            return Ok(Some(datagram.to_vec()));
334        }
335        ensure!(datagram[..4] == SPLIT, "A2S datagram has an unknown header");
336        ensure!(datagram.len() >= 12, "A2S split packet too short");
337        let id = u32::from_le_bytes(datagram[4..8].try_into()?);
338        ensure!(
339            id & 0x8000_0000 == 0,
340            "compressed A2S responses are not supported"
341        );
342        let total = datagram[8] as usize;
343        let number = datagram[9] as usize;
344        ensure!(
345            (1..=MAX_SPLIT_PACKETS).contains(&total) && number < total,
346            "A2S split packet numbering out of range"
347        );
348        match self.id {
349            None => {
350                self.id = Some(id);
351                self.total = total;
352                self.parts = vec![None; total];
353            }
354            Some(current) => ensure!(
355                current == id && self.total == total,
356                "A2S split packets from two responses"
357            ),
358        }
359        self.parts[number] = Some(datagram[12..].to_vec());
360        if self.parts.iter().all(Option::is_some) {
361            let payload: Vec<u8> = self.parts.iter().flatten().flatten().copied().collect();
362            ensure!(
363                payload.len() <= MAX_RESPONSE,
364                "A2S response exceeds {MAX_RESPONSE} bytes"
365            );
366            ensure!(
367                payload.len() >= 5 && payload[..4] == SINGLE,
368                "reassembled A2S response lacks its header"
369            );
370            return Ok(Some(payload));
371        }
372        Ok(None)
373    }
374}
375
376struct Reader<'a> {
377    data: &'a [u8],
378    at: usize,
379}
380
381impl<'a> Reader<'a> {
382    fn u8(&mut self) -> Result<u8> {
383        let b = *self.data.get(self.at).context("A2S response truncated")?;
384        self.at += 1;
385        Ok(b)
386    }
387    fn bytes<const N: usize>(&mut self) -> Result<[u8; N]> {
388        let slice = self
389            .data
390            .get(self.at..self.at + N)
391            .context("A2S response truncated")?;
392        self.at += N;
393        Ok(slice.try_into()?)
394    }
395    fn cstring(&mut self) -> Result<String> {
396        let rest = self.data.get(self.at..).context("A2S response truncated")?;
397        let end = rest
398            .iter()
399            .position(|b| *b == 0)
400            .context("A2S string not terminated")?;
401        self.at += end + 1;
402        Ok(String::from_utf8_lossy(&rest[..end]).into_owned())
403    }
404    fn more(&self) -> bool {
405        self.at < self.data.len()
406    }
407}
408
409/// Decode a complete response payload (after reassembly) into event data.
410pub fn decode_response(kind: Kind, payload: &[u8]) -> Result<Value> {
411    ensure!(
412        payload.len() >= 5 && payload[..4] == SINGLE,
413        "A2S response lacks its header"
414    );
415    ensure!(
416        payload[4] == kind.response_byte(),
417        "A2S answered {:#04x} to a {} query",
418        payload[4],
419        kind.name()
420    );
421    let mut r = Reader {
422        data: &payload[5..],
423        at: 0,
424    };
425    Ok(match kind {
426        Kind::Info => {
427            let protocol = r.u8()?;
428            let name = r.cstring()?;
429            let map = r.cstring()?;
430            let folder = r.cstring()?;
431            let game = r.cstring()?;
432            let app_id = u16::from_le_bytes(r.bytes()?);
433            let players = r.u8()?;
434            let max_players = r.u8()?;
435            let bots = r.u8()?;
436            let server_type = match r.u8()? {
437                b'd' => "dedicated",
438                b'l' => "listen",
439                b'p' => "proxy",
440                _ => "unknown",
441            };
442            let environment = match r.u8()? {
443                b'l' => "linux",
444                b'w' => "windows",
445                b'm' | b'o' => "mac",
446                _ => "unknown",
447            };
448            let private = r.u8()? != 0;
449            let vac = r.u8()? != 0;
450            let version = r.cstring()?;
451            let mut info = json!({"protocol": protocol, "name": name, "map": map, "folder": folder, "game": game, "app_id": app_id, "players": players, "max_players": max_players, "bots": bots, "server_type": server_type, "environment": environment, "private": private, "vac": vac, "version": version});
452            if r.more() {
453                let edf = r.u8()?;
454                if edf & 0x80 != 0 {
455                    info["port"] = json!(u16::from_le_bytes(r.bytes()?));
456                }
457                if edf & 0x10 != 0 {
458                    info["steam_id"] = json!(u64::from_le_bytes(r.bytes()?));
459                }
460                if edf & 0x40 != 0 {
461                    info["sourcetv_port"] = json!(u16::from_le_bytes(r.bytes()?));
462                    info["sourcetv_name"] = json!(r.cstring()?);
463                }
464                if edf & 0x20 != 0 {
465                    info["keywords"] = json!(r.cstring()?);
466                }
467                if edf & 0x01 != 0 {
468                    info["game_id"] = json!(u64::from_le_bytes(r.bytes()?));
469                }
470            }
471            info
472        }
473        Kind::Players => {
474            let count = r.u8()?;
475            let mut players = Vec::new();
476            for _ in 0..count {
477                let index = r.u8()?;
478                let name = r.cstring()?;
479                let score = i32::from_le_bytes(r.bytes()?);
480                let duration = f32::from_le_bytes(r.bytes()?);
481                players.push(json!({"index": index, "name": name, "score": score, "duration_secs": duration}));
482            }
483            json!(players)
484        }
485        Kind::Rules => {
486            let count = u16::from_le_bytes(r.bytes()?);
487            let mut rules = Map::new();
488            for _ in 0..count {
489                let name = r.cstring()?;
490                let value = r.cstring()?;
491                rules.insert(name, Value::String(value));
492            }
493            Value::Object(rules)
494        }
495    })
496}