From e20456da3d77fa15c2e6b93584142b2808e3b6a4 Mon Sep 17 00:00:00 2001 From: auric Date: Sun, 22 Feb 2026 20:45:04 -0600 Subject: 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 --- AGENTS.md | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 AGENTS.md (limited to 'AGENTS.md') 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) +``` -- cgit v1.2.3