feat(monitor): admin-only latest.log stream at monitor.
Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft server's latest.log into an in-memory ring buffer and serves it to an embedded single-page UI (autoscroll, substring/regex filter, level chips). Read-only by construction — /data is mounted :ro, there is no mutating route, and the container holds no credentials. It publishes NO port: caddy's basic_auth vhost is the only way in, which is the point, since the service has no auth of its own and the log carries player IPs, chat and admin commands. Two non-obvious choices, both to avoid prod-only failures: - Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers proxied responses by default, so an EventSource would stall on cochi (whose vhost is an untracked per-host file) and not reproduce locally. Delay was accepted at design time, so polling is strictly better. - The cursor is a sequence number, not a byte offset, and the tailer reopens on inode change or size shrink. itzg rotates latest.log on every server restart, i.e. every deploy — a naive tailer goes silent afterwards while still answering HTTP 200. Verified against rotate, truncate, delete/recreate, partial-line and ring-overflow cases. MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE. MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose interpolates $-sequences in .env values, so a bare hash is silently mangled ($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why. Confirmed empirically, and it is the opposite of filestash, which needs $6$. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8507ffd020
commit
fca6747bcc
13 files changed
+1241
-4
No files matched your search
@@ -0,0 +1,303 @@
|
||||
# 23 — Monitor (`monitor.${BASE_DOMAIN}`)
|
||||
|
||||
> Admin-only, read-only monitoring page: **realtime `latest.log` stream** with
|
||||
> autoscroll + filter, plus a small dashboard of live server facts. One new Go
|
||||
> service (`docker/monitor/`), one caddy vhost behind HTTP basic auth, **no
|
||||
> published port**, **no actions of any kind in the UI**.
|
||||
|
||||
## Scope, stated as constraints
|
||||
|
||||
These were settled by design interview; they are the constraints, not preferences.
|
||||
|
||||
1. **Admin-only.** Nothing here is player-facing. Player-facing status already
|
||||
has two homes: the landing's ServerCard (`15-mc-status.md`) and Uptime Kuma
|
||||
(`10-uptime-kuma.md`). Because the audience is one person, log lines are
|
||||
never sanitized — they carry IPs, chat and admin commands verbatim.
|
||||
2. **Read-only. No controls.** No console, no start/stop, no command input, no
|
||||
kick/ban. The UI issues `GET`s and nothing else. This is a design rule, not a
|
||||
v1 limitation — see *RCON* for how it survives contact with TPS.
|
||||
3. **`latest.log` only.** Not whole-stack container logs. This is what kills the
|
||||
docker-socket requirement outright (see *Rejected*).
|
||||
4. **Delay is acceptable.** Explicitly accepted at design time, and it is what
|
||||
makes polling viable over SSE.
|
||||
5. **Unreachable except through the subdomain.** No host port, no LAN access,
|
||||
no direct container access from outside `mcnet`.
|
||||
|
||||
## The one fact that drives every decision
|
||||
|
||||
**The monitor never needs to reach outside `mcnet`, and nothing outside `mcnet`
|
||||
needs to reach it except caddy.** So it publishes no port at all — same as
|
||||
`drasl`, `nmsr`, `filestash` and `mc-status`. Constraint 5 then costs *zero*
|
||||
firewall work: there is no host-level listener to firewall. `nmap` against the
|
||||
host still shows only 25565, 24454/udp, 80 and 443.
|
||||
|
||||
Everything else follows: no `DOCKER-USER` iptables chain, no bind-address env
|
||||
var, no ufw rule that Docker would silently bypass anyway.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser ── https://monitor.${BASE_DOMAIN}
|
||||
└► host nginx (TLS, own LE cert)
|
||||
└► caddy 80-monitor.caddy ── basic_auth (bcrypt) ──► monitor:8080
|
||||
│
|
||||
monitor container: │
|
||||
mc_data:/data:ro ← tail /data/logs/latest.log ────────┤
|
||||
./backups:/backups:ro ← newest archive stat (phase 2) │
|
||||
RCON → minecraft:25575 ← allowlisted queries only (phase 3) │
|
||||
HTTP → mc-status:8080 ← status + roster reuse (phase 2) │
|
||||
//go:embed ui/ ← single index.html, no build step ───┘
|
||||
```
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Service source | `docker/monitor/` (`main.go`, `go.mod`, `Dockerfile`, `ui/index.html`) |
|
||||
| Image | `ulicraft/monitor:local`, two-stage → scratch (same shape as mc-status) |
|
||||
| Compose service | `monitor`, `networks: [mcnet]`, **no `ports:`** |
|
||||
| Ingress | `caddy/conf.d/80-monitor.caddy` |
|
||||
| TLS / vhost | `nginx/ulicraft-caddy.conf.tmpl` + `LE_SUBDOMAINS` |
|
||||
| Per-host toggle | `MONITOR_ENABLED` (mirrors the `FILES_BROWSE` on/off pattern) |
|
||||
|
||||
## Log pipeline
|
||||
|
||||
### Transport: polling, not SSE
|
||||
|
||||
`GET /api/log?since=<seq>` → `{lines: [...], seq: N, dropped: M}`, client polls
|
||||
every ~2s.
|
||||
|
||||
**Why not SSE.** The request chain is host-nginx → caddy → app. nginx buffers
|
||||
proxied responses by default, so an `EventSource` stalls until the buffer fills
|
||||
unless the vhost carries `proxy_buffering off;`. That vhost is the per-host
|
||||
untracked file on cochi (see `cochi host-local divergence`), so the failure
|
||||
would appear **only on prod** and not reproduce locally. Constraint 4 means
|
||||
polling costs nothing, and it deletes the whole bug class. WebSocket was
|
||||
rejected for the same reason plus an upgrade-header hop it doesn't need.
|
||||
|
||||
### Cursor is a sequence number, NOT a byte offset
|
||||
|
||||
The server keeps an in-memory ring buffer (5000 lines) and numbers lines
|
||||
monotonically. `since` is that number.
|
||||
|
||||
- A **byte offset stops meaning anything the moment the file rotates**, and
|
||||
itzg rotates `latest.log` → `YYYY-MM-DD-N.log.gz` on *every server restart* —
|
||||
i.e. on every deploy. Offsets are wrong routinely, not rarely.
|
||||
- `dropped` > 0 tells the client it fell behind the ring and must re-request the
|
||||
full buffer rather than silently skip lines. Without this the UI lies by
|
||||
omission after any burst (mod crash spam, world load).
|
||||
|
||||
### Rotation handling
|
||||
|
||||
The tailer stats the path each tick and reopens on **inode change or size
|
||||
shrink**. Both happen on every deploy. A tailer that only follows an open fd
|
||||
goes permanently silent after the first restart while still returning HTTP 200 —
|
||||
the classic version of "the monitor says everything is fine."
|
||||
|
||||
### Cold start
|
||||
|
||||
On boot, read the tail of `/data/logs/latest.log` to prime the ring. That yields
|
||||
5000 lines of history immediately with **no database, no persistence, no state
|
||||
volume**. A monitor restart costs nothing that isn't still on disk. Session
|
||||
history (joins/leaves) is derived from the same buffer, not stored.
|
||||
|
||||
## Dashboard tiles
|
||||
|
||||
Everything below is available with **no new privileges**. Uptime Kuma already
|
||||
owns up/down and uptime *history* — do not rebuild that here.
|
||||
|
||||
| Tile | Source | Phase |
|
||||
|---|---|---|
|
||||
| Server online + MOTD + icon | `mc-status:8080/v2/status/java/…` over mcnet | 2 |
|
||||
| Players online (names + NMSR heads) | same, `players.list[]` | 2 |
|
||||
| Registered players | `mc-status:8080/players` (Drasl-backed) | 2 |
|
||||
| Disk free on the world volume | `statfs` on `/data` | 2 |
|
||||
| Backup freshness | newest file in `./backups` (`:ro` mount) | 2 |
|
||||
| Join/leave session history | parsed from the buffer already tailed | 2 |
|
||||
| TPS / MSPT | RCON, see below | 3 |
|
||||
|
||||
**Deliberately excluded:** service reachability probes (Kuma's job), world-size
|
||||
walk (expensive, low value, cache-or-nothing), container health (needs the
|
||||
docker socket).
|
||||
|
||||
## RCON — the one credential, and why it doesn't break constraint 2
|
||||
|
||||
TPS is the number you actually want when someone says "the server is lagging",
|
||||
and **the only source without adding a 165th mod is an RCON query**. So the
|
||||
monitor holds `RCON_PASSWORD` and dials `minecraft:25575`.
|
||||
|
||||
Constraint 2 survives because the UI never sends a command: the poller runs a
|
||||
**compile-time allowlist** (`neoforge tps`, `list`) on a 30s timer. There is no
|
||||
code path from an HTTP request to an RCON string. Hardcode the allowlist so a
|
||||
future refactor can't quietly turn this into a web console.
|
||||
|
||||
- **The command is `/neoforge tps` on NeoForge 1.21.1**, not `/forge tps`.
|
||||
Verify against the running server before wiring it, and treat an
|
||||
unknown-command reply as "TPS unavailable" rather than letting it kill the
|
||||
poll loop.
|
||||
- Alternative considered: the `spark` mod. Better data (real profiling), but a
|
||||
165th jar into a pack already over its stated ceiling, plus its own web
|
||||
endpoint to secure. Rejected for now; still the upgrade path if RCON TPS
|
||||
proves too coarse.
|
||||
|
||||
## Auth + ingress
|
||||
|
||||
Basic auth lives in **caddy**, not host nginx: `caddy/conf.d/*.caddy` is in git,
|
||||
the nginx vhosts are per-host untracked drift on cochi.
|
||||
|
||||
```caddy
|
||||
# Monitor — admin-only log stream + dashboard (monitor service). Read-only.
|
||||
http://monitor.{$BASE_DOMAIN} {
|
||||
basic_auth {
|
||||
{$MONITOR_USER} {$MONITOR_PASSWORD_HASH}
|
||||
}
|
||||
reverse_proxy monitor:8080
|
||||
}
|
||||
```
|
||||
|
||||
- **caddy `basic_auth` needs a bcrypt hash** (`caddy hash-password`). Note this
|
||||
is the *opposite* of the filestash trap in `20-files.md`, where bcrypt is the
|
||||
hash that silently fails and `$6$` is required. The two services take
|
||||
different formats; don't copy a hash between them.
|
||||
- One user, one password (`MONITOR_USER` / `MONITOR_PASSWORD_HASH` in `.env`).
|
||||
- **Uptime Kuma does not need an auth exemption.** Kuma is on `mcnet`, so it
|
||||
probes `http://monitor:8080/healthz` directly and never traverses caddy. The
|
||||
whole vhost stays gated with no public path carve-out.
|
||||
|
||||
### Rejected ingress alternatives
|
||||
|
||||
- **Apex path `/monitor/*`** (the mc-status precedent — no DNS, no cert). Serving
|
||||
a JS app from a subpath is exactly the failure class `20-files.md` documents
|
||||
for filestash. Fixable with relative asset paths, but it's a permanent
|
||||
footgun tax to save one DNS record.
|
||||
- **NetBird / VPN-only, no public listener.** Considered and dropped. Worth
|
||||
recording *why it was attractive*: zero auth surface, logs never touch the
|
||||
internet. And why it was dropped: NetBird gates *reachability*, not
|
||||
*identity* — any peer the ACL admits gets the full log stream with no login,
|
||||
so the security model silently degrades the moment a second device or person
|
||||
joins that network.
|
||||
- **SSH tunnel to `127.0.0.1:9090`.** Most secure option available. Dropped
|
||||
because it defeats checking the server from a phone.
|
||||
|
||||
## Rejected: whole-stack logs via the docker socket
|
||||
|
||||
Following caddy/drasl/filestash/mc-backup logs is genuinely where a lot of the
|
||||
debugging value lives (deploy failures surface in caddy and drasl, not in
|
||||
`latest.log`). It was dropped because constraint 3 limits scope to `latest.log`,
|
||||
and that removes the need for the socket entirely.
|
||||
|
||||
Recorded so the tradeoff isn't relitigated blind:
|
||||
|
||||
> Mounting `/var/run/docker.sock` into a container is equivalent to granting
|
||||
> that container root on the host, and the `:ro` flag does **not** prevent this
|
||||
> — it makes the socket *file* read-only while the Docker API behind it still
|
||||
> accepts container-create calls. The mitigation, had we needed it, is
|
||||
> `tecnativa/docker-socket-proxy` restricted to GET containers+logs.
|
||||
|
||||
As designed, the monitor's only mount is `mc_data:/data:ro` (plus `./backups:ro`
|
||||
in phase 2), and its only secret is `RCON_PASSWORD` in phase 3.
|
||||
|
||||
## UI
|
||||
|
||||
Single `index.html`, `//go:embed`-ed into the binary. Vanilla JS + CSS, **no
|
||||
build step, no pnpm in the deploy path**, image stays a two-stage scratch build.
|
||||
Copy the landing's CSS custom properties so it doesn't look alien.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Autoscroll sticks to bottom only while already at the bottom.** Scroll up
|
||||
and it stops fighting you; a "jump to live" button returns.
|
||||
- **Filter** is plain substring, case-insensitive, client-side over the loaded
|
||||
buffer. `/re/` syntax opts into regex.
|
||||
- **Level chips** (ERROR / WARN / INFO) as quick toggles — log4j lines are
|
||||
`[HH:mm:ss] [thread/LEVEL]`, so this is a cheap parse.
|
||||
- ERROR red, WARN amber, everything else default.
|
||||
|
||||
Rejected: an Astro page (reuses landing components but adds a second pnpm build
|
||||
to `update-all.sh`), and caddy-served static assets (UI and API then version-skew
|
||||
independently).
|
||||
|
||||
## Deploy surface
|
||||
|
||||
New/changed files:
|
||||
|
||||
- `docker/monitor/{main.go,go.mod,Dockerfile,ui/index.html}`
|
||||
- `docker-compose.yml` — `monitor` service (no `ports:`)
|
||||
- `caddy/conf.d/80-monitor.caddy`
|
||||
- `nginx/ulicraft-caddy.conf.tmpl` — one `server` block + the `:80` redirect name
|
||||
- `.env.example` — `MONITOR_USER`, `MONITOR_PASSWORD_HASH`, `MONITOR_ENABLED`,
|
||||
`LE_SUBDOMAINS += monitor`
|
||||
- `plan/00-overview.md`, `plan/19-routes.md` — index + route table
|
||||
|
||||
Gotchas:
|
||||
|
||||
- **`update-all.sh` rolling mode cannot see caddy conf changes.** It has no
|
||||
pre-pull baseline for `caddy/conf.d/*.caddy`, so the first deploy needs
|
||||
`--hard` — a full `down`/`up`, i.e. **Minecraft world downtime**. Later
|
||||
monitor-only changes are rolling (`up -d --build monitor`).
|
||||
- **DNS + cert are the long pole and live outside this repo.** `monitor.` needs
|
||||
an A record, an `LE_SUBDOMAINS` entry, and `tooling/issue-letsencrypt.sh`.
|
||||
Nothing works until that lands; do it first.
|
||||
- **Per-host.** cochi and oxps diverge — gate with `MONITOR_ENABLED` rather than
|
||||
assuming both hosts want it.
|
||||
|
||||
## Build phases
|
||||
|
||||
Ship the thin slice first. Every hard part of this project is plumbing — new
|
||||
subdomain, cert, nginx vhost, basic auth through two proxies, `mc_data:ro`,
|
||||
tail-across-rotation. None of it is in the tiles. Once a password-gated page
|
||||
shows raw log lines, everything after is a small additive commit with a rolling
|
||||
one-container restart.
|
||||
|
||||
### Phase 1 — thin slice: logs only
|
||||
|
||||
- [ ] DNS A record for `monitor.${BASE_DOMAIN}`; add to `LE_SUBDOMAINS`; issue cert.
|
||||
- [ ] `docker/monitor/` — Go service: ring buffer, tailer (inode/shrink reopen),
|
||||
cold-start backfill, `GET /api/log?since=`, `GET /healthz`, embedded UI.
|
||||
- [ ] `ui/index.html` — log pane, sticky autoscroll, filter, level chips.
|
||||
- [ ] compose service (no `ports:`, `mc_data:/data:ro`).
|
||||
- [ ] `caddy/conf.d/80-monitor.caddy` + `MONITOR_USER`/`MONITOR_PASSWORD_HASH`.
|
||||
- [ ] nginx vhost + `MONITOR_ENABLED` gate; render + reload.
|
||||
- [ ] Deploy with `--hard` (world downtime). Verify below.
|
||||
|
||||
### Phase 2 — tiles
|
||||
|
||||
- [ ] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
|
||||
- [ ] mc-status reuse: online/MOTD/icon, players online w/ NMSR heads,
|
||||
registered count.
|
||||
- [ ] Join/leave session history parsed from the buffer.
|
||||
|
||||
### Phase 3 — TPS
|
||||
|
||||
- [ ] RCON client, compile-time allowlist, 30s poll.
|
||||
- [ ] Confirm `/neoforge tps` is the right command on the running server.
|
||||
|
||||
### Follow-ups
|
||||
|
||||
- [ ] Uptime Kuma HTTP monitor against `http://monitor:8080/healthz` (internal,
|
||||
unauthenticated by construction).
|
||||
- [ ] Decide whether "search all history" (server-side grep over rotated
|
||||
`.log.gz`) is missed. Deferred from v1 on purpose: `latest.log` on a
|
||||
164-mod server is mostly boot spam, and the real workflow is watching the
|
||||
last few hundred lines filtered by a player name or `ERROR`.
|
||||
|
||||
## Verification
|
||||
|
||||
A 200 on `/` proves the server is up, not that the page works (see
|
||||
`spa-curl-200-is-not-verification`). After deploy, check all of:
|
||||
|
||||
```bash
|
||||
# auth actually gates it (expect 401)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://monitor.${BASE_DOMAIN}/
|
||||
|
||||
# with creds, expect 200 + a non-empty buffer
|
||||
curl -s -u "$MONITOR_USER:$MONITOR_PASSWORD" \
|
||||
"https://monitor.${BASE_DOMAIN}/api/log?since=0" | jq '.seq, (.lines|length)'
|
||||
|
||||
# the port is NOT reachable from outside the compose network
|
||||
nmap -Pn -p 8080 <host-public-ip> # expect closed/filtered
|
||||
docker compose port monitor 8080 # expect an error: no published port
|
||||
```
|
||||
|
||||
Then the one that actually matters, because it is the failure this design is
|
||||
most likely to hit: **restart the Minecraft server and confirm the stream keeps
|
||||
moving.** That is the rotation path, it fires on every deploy, and a broken
|
||||
tailer stays HTTP-200 while going silent.
|
||||
Reference in new issue
Block a user