From fc10d8a0818bb87001a64a72552ed28fe60931ee Mon Sep 17 00:00:00 2001 From: auric Date: Sat, 21 Feb 2026 14:59:07 -0600 Subject: Add A2S probing, sd-bus state, tail/broadcast, and bot audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State detection: - Add STATE_STOPPING to ProcessState enum - Replace system("systemctl is-active") with libsystemd sd-bus API for accurate starting/stopping/crashed state reporting; works for any unit type (RCON or STDIN) that declares a service: field - Implement real A2S_INFO UDP queries (src/console/a2s.c) for units with health.type = a2s (Valve games: TF2, GMod); differentiates running / hibernating (0 players) / changing_map (A2S down, RCON up) / unreachable; includes player count and map name in responses - Refactor probe_rcon_state() into probe_unit_state() returning a ProbeResult struct with state, players, max_players, map fields - status and list responses now include players/max_players/map fields New daemon commands: - tail : return ring buffer snapshot as a single response - broadcast : send broadcast_cmd-formatted message to all running units; works for both RCON and STDIN console types New YAML field: - broadcast_cmd: command template (e.g. "say {msg}") — opt-in per unit; units without it are skipped by broadcast CLI (umbrella-cli): - Add tail subcommand (non-interactive output snapshot) - Add broadcast subcommand - status shows Players and Map when available - list adds PLAYERS and MAP columns Bot (umbrella-bot): - Replace !attach / !detach with !tail (shows last 30 lines, no streaming) - Add !broadcast command - Write per-!cmd audit entries to /var/log/umbrella/bot-audit.log - !units and !status responses include player counts when available Co-Authored-By: Claude Sonnet 4.6 --- src/console/a2s.c | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/console/a2s.h | 27 ++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/console/a2s.c create mode 100644 src/console/a2s.h (limited to 'src/console') diff --git a/src/console/a2s.c b/src/console/a2s.c new file mode 100644 index 0000000..96e556b --- /dev/null +++ b/src/console/a2s.c @@ -0,0 +1,200 @@ +#include "a2s.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * A2S_INFO — Valve Source Engine server query protocol (UDP). + * + * Reference: https://developer.valvesoftware.com/wiki/Server_queries + * + * Wire protocol (modern Source, post-Orange Box): + * + * Request: + * FF FF FF FF 54 "Source Engine Query" 00 + * + * Some servers issue a challenge before responding: + * FF FF FF FF 41 <4-byte challenge> + * In that case resend the request with the challenge appended. + * + * Response (type 0x49 = 'I'): + * FF FF FF FF 49 + * protocol + * name + * map ← we want this + * folder + * game + * appid + * players ← we want this + * max_players ← we want this + * ... (remaining fields not parsed) + */ + +/* ── helpers ─────────────────────────────────────────────────────────────── */ + +/* Read a null-terminated string from buf starting at *pos. + * Advances *pos past the null byte. + * Returns 0 on success, -1 if the buffer would be overrun. */ +static int read_string(const uint8_t *buf, int buf_len, + int *pos, char *out, int out_size) +{ + int start = *pos; + while (*pos < buf_len && buf[*pos] != '\0') + (*pos)++; + if (*pos >= buf_len) + return -1; /* no null terminator found */ + + int len = *pos - start; + if (len >= out_size) + len = out_size - 1; + memcpy(out, buf + start, len); + out[len] = '\0'; + + (*pos)++; /* skip null byte */ + return 0; +} + +/* ── a2s_query ───────────────────────────────────────────────────────────── */ + +int a2s_query(const char *host, uint16_t port, int timeout_ms, A2SInfo *out) +{ + memset(out, 0, sizeof(*out)); + out->players = -1; + out->max_players = -1; + + /* Resolve host */ + struct addrinfo hints, *res, *rp; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_DGRAM; + + char port_str[8]; + snprintf(port_str, sizeof(port_str), "%u", port); + + if (getaddrinfo(host, port_str, &hints, &res) != 0) + return -1; + + int fd = -1; + for (rp = res; rp; rp = rp->ai_next) { + fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (fd < 0) continue; + if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) break; + close(fd); + fd = -1; + } + freeaddrinfo(res); + if (fd < 0) return -1; + + /* Set receive timeout */ + struct timeval tv; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + /* Build initial A2S_INFO request */ + static const uint8_t a2s_prefix[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0x54 + }; + static const char a2s_payload[] = "Source Engine Query"; + /* Full request: prefix + payload + null byte */ + uint8_t req[32]; + int req_len = 0; + memcpy(req + req_len, a2s_prefix, sizeof(a2s_prefix)); + req_len += sizeof(a2s_prefix); + memcpy(req + req_len, a2s_payload, sizeof(a2s_payload)); /* includes \0 */ + req_len += sizeof(a2s_payload); + + uint8_t resp[2048]; + int resp_len; + int attempts = 0; + +retry: + if (attempts++ >= 2) { + close(fd); + return -1; + } + + if (send(fd, req, req_len, 0) != req_len) { + close(fd); + return -1; + } + + resp_len = recv(fd, resp, sizeof(resp), 0); + if (resp_len < 5) { + close(fd); + return -1; + } + + /* Check for challenge response: FF FF FF FF 41 <4 bytes> */ + if (resp_len >= 9 && + resp[0] == 0xFF && resp[1] == 0xFF && + resp[2] == 0xFF && resp[3] == 0xFF && + resp[4] == 0x41) + { + /* Append the 4-byte challenge to the request and resend */ + if (req_len + 4 > (int)sizeof(req)) { + close(fd); + return -1; + } + memcpy(req + req_len, resp + 5, 4); + req_len += 4; + goto retry; + } + + /* Expect response type 0x49 ('I') */ + if (resp[0] != 0xFF || resp[1] != 0xFF || + resp[2] != 0xFF || resp[3] != 0xFF || + resp[4] != 0x49) + { + close(fd); + return -1; + } + + close(fd); + + /* Parse response body starting after the 5-byte header */ + int pos = 5; + + /* Skip protocol version (1 byte) */ + if (pos + 1 > resp_len) return -1; + pos++; + + /* Skip name string */ + char scratch[256]; + if (read_string(resp, resp_len, &pos, scratch, sizeof(scratch)) != 0) + return -1; + + /* Map string — this is what we want */ + if (read_string(resp, resp_len, &pos, out->map, sizeof(out->map)) != 0) + return -1; + + /* Skip folder string */ + if (read_string(resp, resp_len, &pos, scratch, sizeof(scratch)) != 0) + return -1; + + /* Skip game string */ + if (read_string(resp, resp_len, &pos, scratch, sizeof(scratch)) != 0) + return -1; + + /* Skip app ID (2 bytes) */ + if (pos + 2 > resp_len) return -1; + pos += 2; + + /* Players (1 byte) */ + if (pos + 1 > resp_len) return -1; + out->players = resp[pos++]; + + /* Max players (1 byte) */ + if (pos + 1 > resp_len) return -1; + out->max_players = resp[pos++]; + + return 0; +} diff --git a/src/console/a2s.h b/src/console/a2s.h new file mode 100644 index 0000000..c96e445 --- /dev/null +++ b/src/console/a2s.h @@ -0,0 +1,27 @@ +#ifndef A2S_H +#define A2S_H + +#include + +/* + * A2S_INFO query (Valve Source engine query protocol). + * Retrieves server information over UDP. + * + * Only used for units with health.type == HEALTH_A2S. + */ + +typedef struct { + int players; /* current player count */ + int max_players; /* server player limit */ + char map[64]; /* current map name */ +} A2SInfo; + +/* + * a2s_query: Send an A2S_INFO request and parse the response. + * + * Returns 0 on success with *out populated. + * Returns -1 on timeout, parse error, or any network failure. + */ +int a2s_query(const char *host, uint16_t port, int timeout_ms, A2SInfo *out); + +#endif /* A2S_H */ -- cgit v1.2.3