1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
|
# 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 `<pre><code>`
- `nl2br` — converts `\n` to `<br>` so multi-line responses don't
collapse into one paragraph
Never use `reply.replace("\n", "<br>")` 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 <unit>` | `{"cmd":"status","unit":"..."}` | Returns state, players, max_players, map |
| `!tail <unit>` | `{"cmd":"tail","unit":"..."}` | Returns full ring buffer (up to 500 lines); bot trims to last 30 |
| `!cmd <unit> <cmd>` | `{"cmd":"input","unit":"...","data":"...\n"}` | Logged to `/var/log/umbrella/bot-audit.log` |
| `!broadcast <msg>` | `{"cmd":"broadcast","message":"..."}` | Returns sent/failed counts |
| `!restart <unit>` | `{"cmd":"action","unit":"...","action":"restart"}` | |
| `!update <unit>` | `{"cmd":"action","unit":"...","action":"update"}` | |
| `!action <unit> <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)
```
|