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 +++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 293 insertions(+), 32 deletions(-) (limited to 'src/client.c') 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; -- cgit v1.2.3