diff options
| author | auric <auric7@protonmail.com> | 2026-02-22 20:45:04 -0600 |
|---|---|---|
| committer | auric <auric7@protonmail.com> | 2026-02-22 20:45:04 -0600 |
| commit | 71e0f831a5b7b29e635d0b4951f48c2ead47cef2 (patch) | |
| tree | 4773dbeac2aa07a30ca5647ac0ea7157ff395466 | |
| parent | 55d8415e7a367efdce25ce59de91e80a3c7ffb9c (diff) | |
Add README and AGENTS context file
README covers build, install layout, unit YAML format, bundled filters,
log setup, and CLI usage.
AGENTS.md is a compact reference for AI coding assistants: hard design
rules, full file map, key data flows, wire protocol, YAML parser gotcha,
log tail setup pattern, filter contract, and all limits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 190 | ||||
| -rw-r--r-- | README.md | 118 |
2 files changed, 308 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ddfe28 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,190 @@ +# Umbrella — AI Agent Context + +This file is for AI coding assistants. Read it before touching any code. + +## What this is + +A C11 daemon (`umbrella`) and CLI client (`umbrella-cli`) for managing +game server processes. The daemon tails log files, dispatches RCON +commands, and streams output to connected CLI clients over a Unix socket. + +Current targets: TF2, GMod, Minecraft, Terraria. + +## Hard design rules — never violate these + +These are not stylistic. They exist because this runs unattended on +a production game server. + +1. **No dynamic memory allocation** — no `malloc`/`calloc`/`realloc`/`free` + except in `unit.c::ring_init()` (one calloc per unit at load time, + never freed while running). All buffers are statically sized by + constants in `umbrella.h`. + +2. **No recursion** — all loops are bounded by compile-time constants + (`MAX_UNITS`, `MAX_CLIENTS`, `RING_BUF_LINES`, etc.). + +3. **No function pointers** — dispatch is done with explicit `if/switch`. + +4. **Minimum 2 `assert()` calls per function** — at entry, checking + pointer arguments and invariants. + +5. **All return values checked** — `read()`, `write()`, `epoll_ctl()`, + `fcntl()` etc. must have their return values inspected and logged + on error. No silent discards. + +6. **Functions under 60 lines** — split if needed. + +7. **Bounded loops only** — every `for`/`while` has a clear upper bound. + +## File map + +``` +src/ + umbrella.h Central types and limits (Unit, Client, Daemon, RingBuffer). + Edit constants here, not in source files. + main.c Entry point. epoll event loop. Signal handling (SIGTERM, + SIGCHLD, SIGHUP). Process stdout handler. + daemon.c/h Daemonize, PID file, signal fd setup. + unit.c/h YAML parser (libyaml event API), unit loading, ring buffer + ops (ring_init, ring_push, ring_free). + client.c/h Unix socket listener, client lifecycle, JSON command + dispatch (list, status, attach, detach, input, action, + tail, broadcast). systemd D-Bus state queries here. + proto.c/h Length-prefixed JSON wire protocol. 4-byte BE length + JSON. + log.c/h File/stderr logger with timestamps and level filter. + log_tail.c/h inotify-based log file tailing. One inotify_fd per watched + file. Resolves symlinks at init via realpath(). Handles + log rotation (IN_MOVE_SELF / IN_DELETE_SELF). + filter.c/h Persistent filter subprocess per unit. fork/exec with + stdin/stdout pipes. filter_apply() writes chunk, polls + 250ms for output, falls back to pass-through on timeout. + console/ + rcon.c/h Valve RCON TCP protocol (auth + command). + a2s.c/h Valve A2S_INFO UDP health probe with challenge handling. + +clients/ + umbrella-cli/main.c CLI client. JSON over Unix socket. Interactive + attach mode (epoll on socket + stdin). + +filters/ + source.py Source engine filter (TF2, GMod): strips server_cvar, + stuck/path_goal spam, strips timestamp prefix. + minecraft.py Minecraft filter: strips keepAlive, autosave, internal + class logs, strips [HH:MM:SS] [thread] prefix. + terraria.py Terraria filter: strips blank lines and tModLoader + startup noise. +``` + +## Key data flows + +### Log tail → client +``` +srcds writes to log file + → inotify IN_MODIFY fires on epoll + → log_tail_handle() reads new bytes into char buf[1024] + → filter_apply() pipes buf through filter subprocess (if configured) + → ring_push() stores in unit's RingBuffer (500 × 1024) + → client_broadcast_output() sends to all clients with c->attached == unit +``` + +### Client attach → history replay +``` +client sends {"cmd":"attach","unit":"tf2"} + → cmd_attach() finds unit, iterates ring buffer + → sends each stored line as {"type":"output","history":true,...} + → sends {"type":"ok"} + → future output arrives via client_broadcast_output() +``` + +### RCON command +``` +client sends {"cmd":"input","unit":"tf2","data":"status\n"} + → cmd_input() calls rcon_exec() → TCP connect → auth → send → read + → response echoed back as {"type":"output","history":false,...} +``` + +## Wire protocol + +``` +[4 bytes big-endian length][JSON payload] +``` + +Client → daemon commands: +```json +{"cmd":"attach", "unit":"tf2"} +{"cmd":"detach"} +{"cmd":"input", "unit":"tf2", "data":"say hello\n"} +{"cmd":"tail", "unit":"tf2"} +{"cmd":"list"} +{"cmd":"status", "unit":"tf2"} +{"cmd":"action", "unit":"tf2", "name":"update"} +{"cmd":"broadcast", "message":"Server restarting"} +``` + +Daemon → client responses: +```json +{"type":"ok"} +{"type":"error", "message":"unit not found"} +{"type":"output", "unit":"tf2", "data":"...", "history":false} +{"type":"list", "units":[{"name":"tf2","display":"...","state":"running"}]} +{"type":"status", "name":"tf2", "state":"running", "players":4, "max":24, "map":"cp_badlands"} +``` + +## YAML parser gotcha + +`unit.c` uses libyaml's event API with a hand-rolled state machine. +Sequence items (the `logs:` list) arrive as `YAML_SCALAR_EVENT` with +`in_value == 0` — they have no preceding key. The parser handles this +by checking `section == SECTION_LOGS` before the normal key/value path. +If you add another sequence field, you must do the same. + +## Log tail setup (important for operators) + +Most game servers create new timestamped log files per session. The +inotify watch is set up once (at start or SIGHUP) against the resolved +real path. The recommended pattern: + +1. Point unit YAML `logs:` at a stable symlink, e.g. `current.log` +2. Startup script does: `ln -sf /path/to/actual.log /path/to/current.log` +3. SIGHUP umbrella — `log_tail_init` calls `realpath()` to resolve the + symlink to the real file before opening and watching it +4. inotify then watches the actual inode, so `IN_MODIFY` fires correctly + +Source engine: use `sv_log_onefile 1` so the file is stable within a +session; only update the symlink on server restart. + +## Filter contract + +A log filter is any executable that: +- Reads lines from **stdin** +- Writes transformed output to **stdout** +- **Flushes stdout after each write** (critical — Python: `sys.stdout.flush()`) +- Can suppress lines by writing nothing for a given input + +The daemon gives it 250ms to respond per chunk. On timeout, the raw +data passes through unchanged. On write error, the filter is stopped +and that unit runs unfiltered. + +## Limits (all in umbrella.h) + +| Constant | Value | Meaning | +|----------|-------|---------| +| `MAX_UNITS` | 64 | Max loaded unit descriptors | +| `MAX_CLIENTS` | 32 | Max concurrent CLI connections | +| `RING_BUF_LINES` | 500 | Buffered output lines per unit | +| `RING_BUF_LINE_MAX` | 1024 | Max bytes per ring buffer entry | +| `PROTO_MAX_MSG` | 65536 | Max wire message size | +| `MAX_PATH` | 256 | Max filesystem path length | +| `MAX_ACTIONS` | 16 | Named actions per unit | + +## Build + +```bash +# Deps: libyaml-dev libsystemd-dev +make && sudo make install +``` + +Reload after config changes (no restart needed): +```bash +kill -HUP $(cat /run/umbrella/umbrella.pid) +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4c83a6 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# umbrella + +A safety-critical game server admin daemon written in C11. Monitors +multiple server processes via log file tailing, RCON, and A2S probing, +and exposes a Unix socket for CLI clients to attach, send commands, and +receive real-time output. + +## Features + +- Live log tailing via inotify with symlink resolution +- Pluggable per-unit output filters (any stdin→stdout executable) +- RCON command dispatch (Valve protocol) +- A2S health probing (Valve UDP) +- systemd unit state queries over D-Bus +- Ring buffer — 500 lines × 1024 bytes per unit, replayed on attach +- Reload without restart via SIGHUP + +## Build + +```bash +# Dependencies: libyaml-dev, libsystemd-dev +make +sudo make install +``` + +Compiler flags: `-Wall -Wextra -Wpedantic -std=c11 -D_GNU_SOURCE -O2` + +## Install layout + +| Path | Purpose | +|------|---------| +| `/usr/local/sbin/umbrella` | Daemon | +| `/usr/local/bin/umbrella-cli` | CLI client | +| `/etc/umbrella/umbrella.conf` | Daemon config | +| `/etc/umbrella/units/*.yaml` | Unit descriptors | +| `/usr/lib/umbrella/filters/` | Bundled log filters | +| `/run/umbrella/umbrella.sock` | Unix socket | +| `/run/umbrella/umbrella.pid` | PID file | +| `/var/log/umbrella/umbrella.log` | Daemon log | + +## Unit descriptor format + +```yaml +name: tf2 # used in CLI commands +display: "TF2 — novemen" +service: tf2-server.service # systemd unit for state queries + +console: + type: rcon # rcon | stdin + host: 127.0.0.1 + port: 27015 + password_env: TF_RCON_PASSWORD # or: password: plaintext + +health: + type: a2s # a2s | tcp | none + host: 127.0.0.1 + port: 27015 + timeout_ms: 5000 + +logs: + - /path/to/logs/current.log # up to 4 paths; symlinks resolved + +# Optional filter — any executable reading stdin, writing stdout +log_filter: /usr/lib/umbrella/filters/source.py + +broadcast_cmd: "say {msg}" # template for !broadcast + +actions: + update: /path/to/update.sh + restart: /usr/bin/systemctl restart tf2-server.service +``` + +## Bundled filters + +| Filter | Targets | +|--------|---------| +| `source.py` | TF2, GMod, CS2 — strips `server_cvar`/stuck spam, strips timestamp prefix | +| `minecraft.py` | vanilla/Paper/Spigot — strips keepAlive/autosave noise, strips `[HH:MM:SS] [thread]` prefix | +| `terraria.py` | vanilla/tModLoader — strips blank lines and mod-loading spam | + +Custom filters: any executable at any path that reads stdin and flushes +stdout after each write works. Python, shell, compiled binary — anything. + +## Log file setup + +srcds and most dedicated servers create new log files per session. +Point `logs:` at a stable symlink and update it on each server start: + +```bash +# In your startup script / ExecStartPre: +ln -sf /srv/game/logs/L0222000.log /srv/game/logs/current.log +# Then SIGHUP umbrella so it re-resolves the symlink: +kill -HUP $(cat /run/umbrella/umbrella.pid) +``` + +With `sv_log_onefile 1` (Source engine), the log file is fixed for the +server's lifetime, so the symlink only needs updating on restarts. + +## CLI usage + +```bash +umbrella-cli list +umbrella-cli status <unit> +umbrella-cli attach <unit> # live output; Ctrl+D to detach +umbrella-cli tail <unit> # dump ring buffer and exit +umbrella-cli input <unit> <cmd> # send RCON command or stdin line +umbrella-cli action <unit> <name> # run a named action script +umbrella-cli broadcast <message> # send to all units' broadcast_cmd +``` + +## Reload + +```bash +kill -HUP $(cat /run/umbrella/umbrella.pid) +``` + +Reloads unit YAML files, restarts log tails and filter subprocesses. +Running processes are not touched. |
