# 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)
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)
```