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:
oierandClaude Opus 5 committed 2026-08-01 22:51:39 +02:00
1 parent 8507ffd020
commit fca6747bcc
13 files changed
+1241 -4

No files matched your search

+32 -1
View File
@@ -134,11 +134,42 @@ GENERATORS_DATA_DIR=./generators-data
# Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key
# ── Monitor (monitor.${BASE_DOMAIN}) — admin log stream ───────────────
# Read-only view of the Minecraft server's latest.log. ADMIN ONLY: the log
# carries player IPs, chat and admin commands verbatim, and nothing sanitizes
# it. Full design + gotchas: plan/23-monitor.md.
#
# Per-host toggle, `on` | `off` (default on), same two-layer gating as
# FILES_BROWSE: the caddy conf mount (docker-compose.yml substitutes the value
# into the path — 80-monitor.on.caddy vs the .off.caddy stub) and the public
# nginx vhost (tooling/render-nginx.sh). `off` also drops the name from the
# port-80 redirect, so no cert is needed — drop `monitor` from LE_SUBDOMAINS
# too on that host.
# After changing it: tooling/render-nginx.sh --install
# docker compose up -d --force-recreate caddy
MONITOR_ENABLED=on
# The ONLY credential guarding the log stream — caddy basic_auth. The monitor
# container has no auth of its own and publishes no port, so this is the whole
# access control.
MONITOR_USER=admin
#
# MUST be bcrypt, generated with:
# docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
# (This is the OPPOSITE of FILES_PASSWORD_HASH, which must be $6$ and silently
# rejects bcrypt. Do not reuse a hash between the two.)
#
# MUST be SINGLE-QUOTED here. Docker Compose interpolates $-sequences in .env
# values, so an unquoted bcrypt hash is silently mangled — $2a$14$abcdefg…
# becomes $$2a$$14… with the $abcdefg… segment eaten as an unset variable, and
# every login fails with no hint as to why. Verified behaviour, not a guess.
MONITOR_PASSWORD_HASH='$2a$14$replace.me.with.a.real.bcrypt.hash'
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth distribution www avatar status upload files generators
LE_SUBDOMAINS=auth distribution www avatar status upload files generators monitor
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu