summaryrefslogtreecommitdiff
path: root/AGENTS.md
blob: b7a7ee5be5955c9231633eedb35d8bb81152f7a8 (plain)
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
# 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)

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