summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorauric <auric7@protonmail.com>2026-02-22 20:45:04 -0600
committerauric <auric7@protonmail.com>2026-02-22 20:45:04 -0600
commit71e0f831a5b7b29e635d0b4951f48c2ead47cef2 (patch)
tree4773dbeac2aa07a30ca5647ac0ea7157ff395466
parent55d8415e7a367efdce25ce59de91e80a3c7ffb9c (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.md190
-rw-r--r--README.md118
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.