# 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). umbrella-bot/umbrella-bot.py Matrix bot. Bridges a Matrix room to umbrella units via the Unix socket. asyncio, matrix-nio with E2E encryption. See "Matrix bot" section. 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"} ``` ## Matrix bot ### Dependencies ``` matrix-nio[e2e] aiofiles markdown ``` ### Response formatting rules — never break these Matrix messages are sent with `format: org.matrix.custom.html`. The `body` field is plain text (client fallback). The `formatted_body` field must be valid HTML — not raw Markdown. Always convert via: ```python md_lib.markdown(reply, extensions=["fenced_code", "nl2br"]) ``` - `fenced_code` — renders triple-backtick code blocks as `
`
- `nl2br` — converts `\n` to `
` so multi-line responses don't collapse into one paragraph Never use `reply.replace("\n", "
")` or pass raw Markdown to `formatted_body` — clients will display literal `**` and backticks. ### Style rules for response strings - **No emojis** — use plain text (`Error:`, `Sent:`, etc.) - **No em dashes** — use a hyphen-minus (`-`) This applies to all strings the bot sends to Matrix: command responses, HELP_TEXT, error messages. ### Power level Commands require power level >= 50 in the configured room. The check runs in `get_user_power_level()` before dispatching any command. Users below 50 get a plain error string and the command is not executed. ### Commands and data flow | Command | Umbrella socket msg | Notes | |---------|--------------------|----| | `!units` | `{"cmd":"list"}` | Returns name, display, state, players, max_players per unit | | `!status ` | `{"cmd":"status","unit":"..."}` | Returns state, players, max_players, map | | `!tail ` | `{"cmd":"tail","unit":"..."}` | Returns full ring buffer (up to 500 lines); bot trims to last 30 | | `!cmd ` | `{"cmd":"input","unit":"...","data":"...\n"}` | Logged to `/var/log/umbrella/bot-audit.log` | | `!broadcast ` | `{"cmd":"broadcast","message":"..."}` | Returns sent/failed counts | | `!restart ` | `{"cmd":"action","unit":"...","action":"restart"}` | | | `!update ` | `{"cmd":"action","unit":"...","action":"update"}` | | | `!action ` | `{"cmd":"action","unit":"...","action":"..."}` | | ### Tail flow (bot side) ``` !tail tf2 -> umbrella.tail(unit) -> {"cmd":"tail","unit":"tf2"} over Unix socket -> daemon responds {"type":"output","data":"line1\nline2\n...","history":true} -> bot splits data into lines, trims to last TAIL_MAX_LINES (30) -> wraps in triple-backtick block -> sends with formatted_body via markdown conversion ``` ### Config `/etc/umbrella/bot.conf` — homeserver, user_id, password, room_id, device_id, store_path, socket_path. `chmod 600` required (contains password). See `clients/umbrella-bot/bot.conf.example`. Encryption keys stored in SQLite at `store_path` (default `/etc/umbrella/bot-store`). First-run: `umbrella-bot.py --setup` to login and obtain device ID. ## 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) Two modes are supported. They are mutually exclusive per unit. ### Fixed-path mode (`logs:`) Point `logs:` at a stable symlink; `log_tail_init` calls `realpath()` to resolve it to the real inode before opening and watching it. 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 so it re-resolves the symlink Source engine: use `sv_log_onefile 1` so the file is stable within a session; only update the symlink on restarts. ### Directory-watch mode (`log_dir` + `log_pattern`) Valve games (TF2, GMod) create a new timestamped log file on every map change (`L0222000.log`, `L0222001.log`, ...). Use `log_dir` to watch the directory and `log_pattern` (fnmatch glob) to filter filenames. Umbrella auto-switches to the newest matching file whenever one is created. ```yaml log_dir: /home/gmod/nnn/gmodds/garrysmod/logs log_pattern: "L???????.log" ``` - `log_pattern` is optional; omitting it defaults to `"*"` (all files) - `log_dir` and `logs:` are mutually exclusive; if both are set, `logs:` is ignored with a warning - Pattern matching uses `fnmatch(3)` — shell-style globs, zero allocation #### How it works - One inotify instance watches the directory for `IN_CREATE`/`IN_MOVED_TO` - On each new matching file, `log_tail_scan_dir` finds the newest one (re-scan handles races where multiple files appear in one event batch) - `log_tail_switch_file` drains the old file, then opens the new one from the beginning so no output is missed - `IN_MOVE_SELF`/`IN_DELETE_SELF` on the current file closes it and scans for a replacement immediately SIGHUP rebuilds everything from scratch via `log_tail_cleanup` + `log_tail_init`. ## 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) ```