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>
14 KiB
23 — Monitor (monitor.${BASE_DOMAIN})
Admin-only, read-only monitoring page: realtime
latest.logstream 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.
- 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. - Read-only. No controls. No console, no start/stop, no command input, no
kick/ban. The UI issues
GETs and nothing else. This is a design rule, not a v1 limitation — see RCON for how it survives contact with TPS. latest.logonly. Not whole-stack container logs. This is what kills the docker-socket requirement outright (see Rejected).- Delay is acceptable. Explicitly accepted at design time, and it is what makes polling viable over SSE.
- 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.gzon 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 tpson 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
sparkmod. 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.
# 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_authneeds a bcrypt hash (caddy hash-password). Note this is the opposite of the filestash trap in20-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_HASHin.env). - Uptime Kuma does not need an auth exemption. Kuma is on
mcnet, so it probeshttp://monitor:8080/healthzdirectly 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 class20-files.mddocuments 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.sockinto a container is equivalent to granting that container root on the host, and the:roflag 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, istecnativa/docker-socket-proxyrestricted 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—monitorservice (noports:)caddy/conf.d/80-monitor.caddynginx/ulicraft-caddy.conf.tmpl— oneserverblock + the:80redirect name.env.example—MONITOR_USER,MONITOR_PASSWORD_HASH,MONITOR_ENABLED,LE_SUBDOMAINS += monitorplan/00-overview.md,plan/19-routes.md— index + route table
Gotchas:
update-all.shrolling mode cannot see caddy conf changes. It has no pre-pull baseline forcaddy/conf.d/*.caddy, so the first deploy needs--hard— a fulldown/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, anLE_SUBDOMAINSentry, andtooling/issue-letsencrypt.sh. Nothing works until that lands; do it first. - Per-host. cochi and oxps diverge — gate with
MONITOR_ENABLEDrather 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 toLE_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_ENABLEDgate; render + reload. - Deploy with
--hard(world downtime). Verify below.
Phase 2 — tiles
./backups:/backups:romount; 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 tpsis 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.logon a 164-mod server is mostly boot spam, and the real workflow is watching the last few hundred lines filtered by a player name orERROR.
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:
# 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.