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/client.c | 325 ++++++++++++++++++++++++++++++++++++++++++++++++------ src/console/a2s.c | 200 +++++++++++++++++++++++++++++++++ src/console/a2s.h | 27 +++++ src/umbrella.h | 2 + src/unit.c | 3 + 5 files changed, 525 insertions(+), 32 deletions(-) create mode 100644 src/console/a2s.c create mode 100644 src/console/a2s.h (limited to 'src') diff --git a/src/client.c b/src/client.c index e1816f2..53b8e53 100644 --- a/src/client.c +++ b/src/client.c @@ -4,6 +4,7 @@ #include "log.h" #include "umbrella.h" #include "console/rcon.h" +#include "console/a2s.h" #include #include @@ -15,6 +16,7 @@ #include #include #include +#include /* ── Socket setup ────────────────────────────────────────────────────────── */ @@ -134,29 +136,174 @@ void client_broadcast_output(const char *unit_name, /* ── Command handlers ────────────────────────────────────────────────────── */ -static const char *probe_rcon_state(Unit *u) { - if (u->console.type != CONSOLE_RCON) - return unit_state_str(u->state); +/* + * systemd_active_state: query the ActiveState of a systemd unit via D-Bus. + * + * Returns a pointer to a static string: "active", "activating", + * "deactivating", "inactive", "failed", or "unknown" on error. + * + * Only called when u->service[0] is set. Works for any unit type + * (RCON or STDIN) that declares a systemd service. + */ +static const char *systemd_active_state(const char *service_name) +{ + sd_bus *bus = NULL; + sd_bus_message *reply = NULL; + sd_bus_error error = SD_BUS_ERROR_NULL; + const char *obj_path = NULL; + char *state_str = NULL; + const char *result = "unknown"; + + if (sd_bus_open_system(&bus) < 0) + goto out; + + /* GetUnit(service_name) → object path */ + if (sd_bus_call_method( + bus, + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "GetUnit", + &error, &reply, + "s", service_name) < 0) + goto out; + + if (sd_bus_message_read(reply, "o", &obj_path) < 0 || !obj_path) + goto out; + + /* Query ActiveState property on the unit object */ + if (sd_bus_get_property_string( + bus, + "org.freedesktop.systemd1", + obj_path, + "org.freedesktop.systemd1.Unit", + "ActiveState", + &error, &state_str) < 0) + goto out; + + /* Map to one of our known strings */ + if (strcmp(state_str, "active") == 0) result = "active"; + else if (strcmp(state_str, "activating") == 0) result = "activating"; + else if (strcmp(state_str, "deactivating") == 0) result = "deactivating"; + else if (strcmp(state_str, "inactive") == 0) result = "inactive"; + else if (strcmp(state_str, "failed") == 0) result = "failed"; + +out: + free(state_str); + sd_bus_message_unref(reply); + sd_bus_error_free(&error); + sd_bus_unref(bus); + return result; +} + +/* + * ProbeResult: returned by probe_unit_state(). + * Carries richer information than a plain state string. + */ +typedef struct { + const char *state; /* pointer to static string */ + int players; /* -1 = not available (non-A2S unit) */ + int max_players; /* -1 = not available */ + char map[64]; /* empty = not available */ +} ProbeResult; + +/* + * probe_unit_state: determine current state of a unit using all available + * probing methods, honouring per-unit configuration. + * + * Decision tree: + * + * STDIN unit: + * → {state = internal process state} (tracked directly by daemon) + * + * Any unit with service[0] set → sd-bus D-Bus: + * "activating" → {state="starting"} + * "deactivating" → {state="stopping"} + * "inactive" → {state="stopped"} + * "failed" → {state="crashed"} + * "active" → continue to application-level probe + * + * RCON unit, health.type == HEALTH_A2S → A2S UDP query: + * success, players == 0 → {state="hibernating", players, max_players, map} + * success, players > 0 → {state="running", players, max_players, map} + * failure → RCON echo probe: + * success → {state="changing_map"} + * failure → {state="unreachable"} + * + * RCON unit, health.type == HEALTH_NONE → RCON echo probe: + * success → {state="running"} + * failure → {state="unreachable"} + */ +static ProbeResult probe_unit_state(Unit *u) +{ + ProbeResult r = { .state = "unknown", .players = -1, .max_players = -1 }; + r.map[0] = '\0'; + + /* STDIN units: state is maintained directly by the process layer */ + if (u->console.type != CONSOLE_RCON) { + r.state = unit_state_str(u->state); + return r; + } - /* Check systemd service state first -- cheap, no network */ + /* For any unit (RCON or STDIN) with a systemd service, check D-Bus first */ if (u->service[0]) { - char cmd[256]; - snprintf(cmd, sizeof(cmd), - "systemctl is-active --quiet %s 2>/dev/null", u->service); - if (system(cmd) != 0) - return "stopped"; + const char *sd = systemd_active_state(u->service); + if (strcmp(sd, "activating") == 0) { r.state = "starting"; return r; } + else if (strcmp(sd, "deactivating") == 0) { r.state = "stopping"; return r; } + else if (strcmp(sd, "inactive") == 0) { r.state = "stopped"; return r; } + else if (strcmp(sd, "failed") == 0) { r.state = "crashed"; return r; } + /* "active" or "unknown" → fall through to application probe */ } - /* Service is active -- probe RCON to confirm reachability */ + /* A2S health probe (Valve games: TF2, GMod, …) */ + if (u->health.type == HEALTH_A2S) { + const char *h_host = u->health.host[0] ? u->health.host : u->console.host; + uint16_t h_port = u->health.port ? u->health.port : u->console.port; + int h_tmo = u->health.timeout_ms > 0 ? u->health.timeout_ms : 5000; + + A2SInfo info; + if (a2s_query(h_host, h_port, h_tmo, &info) == 0) { + r.players = info.players; + r.max_players = info.max_players; + strncpy(r.map, info.map, sizeof(r.map) - 1); + r.state = (r.players == 0) ? "hibernating" : "running"; + return r; + } + + /* A2S failed — try RCON to distinguish changing_map from unreachable */ + char response[64] = {0}; + int ok = rcon_exec(u->console.host, u->console.port, + u->console.password, "echo umbrella_probe", + response, sizeof(response)); + r.state = (ok == 0) ? "changing_map" : "unreachable"; + return r; + } + + /* Default: plain RCON echo probe */ char response[64] = {0}; - int r = rcon_exec(u->console.host, u->console.port, - u->console.password, "echo umbrella_probe", - response, sizeof(response)); - return (r == 0) ? "running" : "unreachable"; + int ok = rcon_exec(u->console.host, u->console.port, + u->console.password, "echo umbrella_probe", + response, sizeof(response)); + r.state = (ok == 0) ? "running" : "unreachable"; + return r; +} + +/* ── JSON helpers ────────────────────────────────────────────────────────── */ + +/* Append probe result's optional fields to an in-progress JSON buffer. + * Call after the last fixed field; this adds players/max_players/map. */ +static int append_probe_fields(char *buf, int pos, int size, + const ProbeResult *r) +{ + pos += snprintf(buf + pos, size - pos, + ",\"players\":%d,\"max_players\":%d,\"map\":\"%s\"", + r->players, r->max_players, r->map); + return pos; } +/* ── cmd_list ─────────────────────────────────────────────────────────────── */ + static void cmd_list(Client *c) { - /* Build a JSON array of unit summaries, using active probing for state */ char buf[PROTO_MAX_MSG]; int pos = 0; @@ -165,31 +312,139 @@ static void cmd_list(Client *c) { for (int i = 0; i < g.unit_count; i++) { Unit *u = &g.units[i]; + ProbeResult r = probe_unit_state(u); + if (i > 0) pos += snprintf(buf + pos, sizeof(buf) - pos, ","); + pos += snprintf(buf + pos, sizeof(buf) - pos, - "{\"name\":\"%s\",\"display\":\"%s\"," - "\"state\":\"%s\"}", - u->name, u->display, probe_rcon_state(u)); + "{\"name\":\"%s\",\"display\":\"%s\",\"state\":\"%s\"", + u->name, u->display, r.state); + pos = append_probe_fields(buf, pos, sizeof(buf), &r); + pos += snprintf(buf + pos, sizeof(buf) - pos, "}"); } snprintf(buf + pos, sizeof(buf) - pos, "]}"); proto_send(c->fd, buf); } +/* ── cmd_status ──────────────────────────────────────────────────────────── */ + static void cmd_status(Client *c, const char *unit_name) { Unit *u = unit_find(unit_name); if (!u) { proto_send_error(c->fd, "unit not found"); return; } - const char *state = probe_rcon_state(u); + ProbeResult r = probe_unit_state(u); char buf[512]; + int pos = 0; + pos += snprintf(buf + pos, sizeof(buf) - pos, + "{\"type\":\"status\",\"unit\":\"%s\",\"display\":\"%s\"," + "\"state\":\"%s\",\"pid\":%d", + u->name, u->display, r.state, (int)u->pid); + pos = append_probe_fields(buf, pos, sizeof(buf), &r); + snprintf(buf + pos, sizeof(buf) - pos, "}"); + proto_send(c->fd, buf); +} + +/* ── cmd_tail ────────────────────────────────────────────────────────────── */ + +static void cmd_tail(Client *c, const char *unit_name) { + Unit *u = unit_find(unit_name); + if (!u) { proto_send_error(c->fd, "unit not found"); return; } + + /* Concatenate all ring buffer lines into a single payload */ + char data[PROTO_MAX_MSG - 256]; + int dpos = 0; + + RingBuffer *rb = u->output; + if (rb && rb->count > 0) { + int start = (rb->head - rb->count + RING_BUF_LINES) % RING_BUF_LINES; + for (int i = 0; i < rb->count && dpos < (int)sizeof(data) - 2; i++) { + int idx = (start + i) % RING_BUF_LINES; + const char *line = rb->lines[idx]; + int len = strlen(line); + if (dpos + len + 1 >= (int)sizeof(data)) + break; + memcpy(data + dpos, line, len); + dpos += len; + /* Ensure each line ends with a newline */ + if (dpos > 0 && data[dpos - 1] != '\n') + data[dpos++] = '\n'; + } + } + data[dpos] = '\0'; + + /* Build response — use proto_send_output with history flag */ + proto_send_output(c->fd, unit_name, data, 1); +} + +/* ── cmd_broadcast ───────────────────────────────────────────────────────── */ + +static void cmd_broadcast(Client *c, const char *message) { + int sent = 0, failed = 0; + + for (int i = 0; i < g.unit_count; i++) { + Unit *u = &g.units[i]; + + /* Skip units without a broadcast command configured */ + if (!u->broadcast_cmd[0]) + continue; + + /* Only broadcast to units the daemon considers running */ + if (u->state != STATE_RUNNING) + continue; + + /* Substitute {msg} with the message in the broadcast command */ + char cmd[256]; + const char *tmpl = u->broadcast_cmd; + const char *placeholder = strstr(tmpl, "{msg}"); + if (placeholder) { + int prefix_len = (int)(placeholder - tmpl); + snprintf(cmd, sizeof(cmd), "%.*s%s%s", + prefix_len, tmpl, + message, + placeholder + 5 /* strlen("{msg}") */); + } else { + /* No placeholder — append message after a space */ + snprintf(cmd, sizeof(cmd), "%s %s", tmpl, message); + } + + int ok = 0; + if (u->console.type == CONSOLE_RCON) { + char response[256] = {0}; + ok = (rcon_exec(u->console.host, u->console.port, + u->console.password, cmd, + response, sizeof(response)) == 0); + } else if (u->console.type == CONSOLE_STDIN) { + if (u->stdin_fd >= 0) { + /* Append newline so the server processes the command */ + size_t clen = strlen(cmd); + if (clen < sizeof(cmd) - 1) { + cmd[clen] = '\n'; + cmd[clen + 1] = '\0'; + ok = (write(u->stdin_fd, cmd, clen + 1) > 0); + } + } + } + + if (ok) { + log_info("broadcast: sent to %s: %s", u->name, cmd); + sent++; + } else { + log_warn("broadcast: failed for %s", u->name); + failed++; + } + } + + char buf[128]; snprintf(buf, sizeof(buf), - "{\"type\":\"status\",\"unit\":\"%s\",\"display\":\"%s\"," - "\"state\":\"%s\",\"pid\":%d}", - u->name, u->display, state, (int)u->pid); + "{\"type\":\"ok\",\"sent\":%d,\"failed\":%d}", sent, failed); proto_send(c->fd, buf); } + +/* ── cmd_attach ──────────────────────────────────────────────────────────── */ + static void cmd_attach(Client *c, const char *unit_name) { Unit *u = unit_find(unit_name); if (!u) { proto_send_error(c->fd, "unit not found"); return; } @@ -301,19 +556,25 @@ int client_handle(int fd) { char action[MAX_NAME] = {0}; char data[RING_BUF_LINE_MAX] = {0}; - json_get_str(buf, "cmd", cmd, sizeof(cmd)); - json_get_str(buf, "unit", unit, sizeof(unit)); - json_get_str(buf, "action", action, sizeof(action)); - json_get_str(buf, "data", data, sizeof(data)); + json_get_str(buf, "cmd", cmd, sizeof(cmd)); + json_get_str(buf, "unit", unit, sizeof(unit)); + json_get_str(buf, "action", action, sizeof(action)); + json_get_str(buf, "data", data, sizeof(data)); + + /* For broadcast, "message" maps to the data field */ + if (strcmp(cmd, "broadcast") == 0) + json_get_str(buf, "message", data, sizeof(data)); log_debug("Client fd=%d cmd=%s unit=%s", fd, cmd, unit); - if (strcmp(cmd, "list") == 0) cmd_list(c); - else if (strcmp(cmd, "status") == 0) cmd_status(c, unit); - else if (strcmp(cmd, "attach") == 0) cmd_attach(c, unit); - else if (strcmp(cmd, "detach") == 0) cmd_detach(c); - else if (strcmp(cmd, "input") == 0) cmd_input(c, unit, data); - else if (strcmp(cmd, "action") == 0) cmd_action(c, unit, action); + if (strcmp(cmd, "list") == 0) cmd_list(c); + else if (strcmp(cmd, "status") == 0) cmd_status(c, unit); + else if (strcmp(cmd, "tail") == 0) cmd_tail(c, unit); + else if (strcmp(cmd, "broadcast") == 0) cmd_broadcast(c, data); + else if (strcmp(cmd, "attach") == 0) cmd_attach(c, unit); + else if (strcmp(cmd, "detach") == 0) cmd_detach(c); + else if (strcmp(cmd, "input") == 0) cmd_input(c, unit, data); + else if (strcmp(cmd, "action") == 0) cmd_action(c, unit, action); else proto_send_error(fd, "unknown command"); return 0; 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 */ diff --git a/src/umbrella.h b/src/umbrella.h index e44d626..2e9d440 100644 --- a/src/umbrella.h +++ b/src/umbrella.h @@ -45,6 +45,7 @@ typedef enum { STATE_STARTING = 1, STATE_RUNNING = 2, STATE_CRASHED = 3, + STATE_STOPPING = 4, } ProcessState; /* ── Action: a named script ──────────────────────────────────────────────── */ @@ -82,6 +83,7 @@ typedef struct { char name[MAX_NAME]; char display[MAX_DISPLAY]; char service[MAX_NAME]; /* systemd unit name, informational */ + char broadcast_cmd[128]; /* command template for !broadcast, e.g. "say {msg}" */ ConsoleConfig console; HealthConfig health; char log_paths[4][MAX_PATH]; diff --git a/src/unit.c b/src/unit.c index 3dd9d26..5baea20 100644 --- a/src/unit.c +++ b/src/unit.c @@ -41,6 +41,7 @@ const char *unit_state_str(ProcessState state) { case STATE_STARTING: return "starting"; case STATE_RUNNING: return "running"; case STATE_CRASHED: return "crashed"; + case STATE_STOPPING: return "stopping"; default: return "unknown"; } } @@ -160,6 +161,8 @@ int unit_load_file(const char *path, Unit *out) { strncpy(out->display, val, MAX_DISPLAY - 1); else if (strcmp(last_key, "service") == 0) strncpy(out->service, val, MAX_NAME - 1); + else if (strcmp(last_key, "broadcast_cmd") == 0) + strncpy(out->broadcast_cmd, val, sizeof(out->broadcast_cmd) - 1); break; case SECTION_CONSOLE: -- cgit v1.2.3