diff --git a/.env.example b/.env.example index 9c1654a..127f7ba 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/caddy/conf.d/80-monitor.off.caddy b/caddy/conf.d/80-monitor.off.caddy new file mode 100644 index 0000000..391c859 --- /dev/null +++ b/caddy/conf.d/80-monitor.off.caddy @@ -0,0 +1,10 @@ +# Monitor DISABLED (MONITOR_ENABLED=off in .env). +# +# This stub is mounted at /etc/caddy/conf.d/80-monitor.caddy instead of +# 80-monitor.on.caddy, so caddy defines NO vhost for monitor.${BASE_DOMAIN} and +# the log stream is unreachable on this host. The monitor container may still be +# running — it just has no ingress, and it publishes no port, so nothing can +# reach it. +# +# Caddy needs a parseable file here, not an absent one: the Caddyfile imports +# conf.d/*.caddy and the mount target is fixed. Comments only, on purpose. diff --git a/caddy/conf.d/80-monitor.on.caddy b/caddy/conf.d/80-monitor.on.caddy new file mode 100644 index 0000000..fe6d5a5 --- /dev/null +++ b/caddy/conf.d/80-monitor.on.caddy @@ -0,0 +1,20 @@ +# Monitor — admin-only latest.log stream (monitor service). Read-only: the UI +# only ever issues GETs, and the service exposes no mutating route at all. +# Full design: plan/23-monitor.md. +# +# basic_auth here IS the access control. The monitor container has no auth of +# its own and publishes no port, so caddy is the only path to it — there is no +# second door to also lock. +# +# The hash must be BCRYPT (`caddy hash-password`). Note this is the OPPOSITE of +# filestash (60-upload.caddy / plan/20-files.md), which only accepts $6$ and +# silently rejects bcrypt. Do not copy a hash between the two services. +# +# No path is exempted. Uptime Kuma probes /healthz as http://monitor:8080/healthz +# over mcnet, bypassing caddy entirely, so the whole vhost stays gated. +http://monitor.{$BASE_DOMAIN} { + basic_auth { + {$MONITOR_USER} {$MONITOR_PASSWORD_HASH} + } + reverse_proxy monitor:8080 +} diff --git a/docker-compose.yml b/docker-compose.yml index 6b37219..a319720 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -164,6 +164,13 @@ services: - "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80" environment: BASE_DOMAIN: ${BASE_DOMAIN} + # basic_auth credentials for monitor.${BASE_DOMAIN} (80-monitor.on.caddy). + # MONITOR_PASSWORD_HASH must be BCRYPT (`caddy hash-password`) and must be + # SINGLE-QUOTED in .env — compose interpolates $-sequences in .env values, + # so a bare bcrypt hash gets silently mangled ($2a$14$abc… -> $$2a$$14…) + # and every login fails. See plan/23-monitor.md. + MONITOR_USER: ${MONITOR_USER:-admin} + MONITOR_PASSWORD_HASH: ${MONITOR_PASSWORD_HASH:-} volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro @@ -181,6 +188,10 @@ services: # reads the same var); this one keeps caddy itself from serving the share. - ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy:/etc/caddy/conf.d/62-files.caddy:ro - ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro + # monitor.${BASE_DOMAIN} admin log stream, gated by MONITOR_ENABLED + # (on|off) — same substitute-into-the-source-path trick as FILES_BROWSE + # above, and the same recreate requirement when the value changes. + - ./caddy/conf.d/80-monitor.${MONITOR_ENABLED:-on}.caddy:/etc/caddy/conf.d/80-monitor.caddy:ro - ./www:/srv/www:ro - ./launcher:/srv/launcher:ro # Read-only browse of the shared upload dir at files.${BASE_DOMAIN} @@ -313,6 +324,35 @@ services: networks: - mcnet + # Admin-only log stream at monitor.${BASE_DOMAIN} (built from docker/monitor). + # Phase 1 of plan/23-monitor.md: tails the Minecraft server's latest.log into + # an in-memory ring buffer and serves it to a small embedded UI by polling. + # + # Read-only in every sense: /data is mounted :ro, the service exposes no + # mutating route, and it holds no credentials. It publishes NO port — the only + # way in is caddy's basic_auth vhost, which is exactly the point (the container + # has no auth of its own, so an accidental port publish would expose the raw + # log — chat, IPs, admin commands — to the internet). Keep it portless. + # + # Uptime Kuma can probe http://monitor:8080/healthz directly over mcnet, + # bypassing caddy, so no auth exemption is needed on the vhost. + monitor: + build: + context: ./docker/monitor + image: ulicraft/monitor:local + container_name: monitor + restart: unless-stopped + environment: + MONITOR_LOG_PATH: "/data/logs/latest.log" + MONITOR_BUFFER_LINES: "5000" + volumes: + # The whole Minecraft data dir, read-only. Only logs/latest.log is read; + # mounting /data rather than /data/logs keeps the path stable if itzg ever + # moves the log dir, and :ro means it cannot touch the world either way. + - mc_data:/data:ro + networks: + - mcnet + # Status monitoring + public status page. caddy proxies status. -> :3001. # Joins mcnet so monitors probe the stack by internal service name. uptime-kuma: diff --git a/docker/monitor/Dockerfile b/docker/monitor/Dockerfile new file mode 100644 index 0000000..a7c3e78 --- /dev/null +++ b/docker/monitor/Dockerfile @@ -0,0 +1,15 @@ +# monitor — admin-only log stream + dashboard. Multi-stage: build a static +# binary (UI is //go:embed-ed into it), ship it on scratch. +# +# No CA roots needed: phase 1 talks to nothing over the network — it only reads +# a file. Add them here if a later phase makes outbound TLS calls. +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/monitor . + +FROM scratch +COPY --from=build /out/monitor /monitor +EXPOSE 8080 +ENTRYPOINT ["/monitor"] diff --git a/docker/monitor/go.mod b/docker/monitor/go.mod new file mode 100644 index 0000000..5d4f1e0 --- /dev/null +++ b/docker/monitor/go.mod @@ -0,0 +1,3 @@ +module ulicraft/monitor + +go 1.25.0 diff --git a/docker/monitor/main.go b/docker/monitor/main.go new file mode 100644 index 0000000..c068874 --- /dev/null +++ b/docker/monitor/main.go @@ -0,0 +1,418 @@ +// monitor — admin-only, read-only view of the Minecraft server's latest.log. +// +// Phase 1 (see plan/23-monitor.md): a tailer that follows /data/logs/latest.log +// into an in-memory ring buffer, a polling JSON API over that buffer, and an +// embedded single-page UI. Nothing else. No RCON, no dashboard tiles yet. +// +// Design notes that are NOT obvious from the code: +// +// - The cursor is a SEQUENCE NUMBER, not a byte offset. itzg rotates +// latest.log -> YYYY-MM-DD-N.log.gz on every server restart (i.e. on every +// deploy), which makes byte offsets wrong routinely rather than rarely. +// +// - The tailer reopens on inode change OR size shrink. Both happen on every +// deploy. A tailer that merely follows an already-open fd goes permanently +// silent after the first restart while still answering HTTP 200 — the +// "monitor says everything is fine" failure. This is the single most likely +// way this service breaks, so it is checked every tick. +// +// - Transport is POLLING, not SSE. The request chain is host nginx -> caddy -> +// here, and nginx buffers proxied responses by default; an EventSource would +// stall on prod (where the vhost is an untracked per-host file) and not +// reproduce locally. Delay is acceptable by design, so polling is strictly +// better here. +// +// - No persistence. On boot we prime the ring from the tail of the file on +// disk, so a restart costs nothing that isn't still there. +// +// This service publishes no port (mcnet only) and is reached exclusively through +// caddy's basic_auth vhost. It holds no credentials in this phase. +package main + +import ( + "bufio" + "bytes" + _ "embed" + "encoding/json" + "io" + "log" + "net/http" + "os" + "strconv" + "sync" + "syscall" + "time" +) + +//go:embed ui/index.html +var indexHTML []byte + +// --- ring buffer ----------------------------------------------------------- + +// ring is a fixed-capacity circular buffer of log lines with monotonic sequence +// numbers. Sequence numbers never restart while the process lives, so a client +// cursor stays valid across log rotations. +type ring struct { + mu sync.Mutex + buf []string + head int // index of the oldest line + n int // lines currently held + next int64 // sequence number the next appended line will get +} + +func newRing(capacity int) *ring { + if capacity < 1 { + capacity = 1 + } + return &ring{buf: make([]string, capacity)} +} + +func (r *ring) append(s string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.n == len(r.buf) { + // Full: overwrite the oldest and advance head. O(1) — deliberately not a + // slice shuffle, because boot spam appends thousands of lines a second. + r.buf[r.head] = s + r.head = (r.head + 1) % len(r.buf) + } else { + r.buf[(r.head+r.n)%len(r.buf)] = s + r.n++ + } + r.next++ +} + +// snapshot returns every buffered line plus the cursor to resume from. +func (r *ring) snapshot() ([]string, int64) { + r.mu.Lock() + defer r.mu.Unlock() + return r.collect(r.next - int64(r.n)), r.next +} + +// since returns the lines after `seq`. +// +// dropped > 0 means the caller fell behind the buffer and lines were lost; +// reset means the caller must discard what it has and take these lines as the +// new truth (it fell behind, or the server restarted and its cursor is now in +// the future). Without this the UI would lie by omission after any burst. +func (r *ring) since(seq int64) (lines []string, next int64, dropped int64, reset bool) { + r.mu.Lock() + defer r.mu.Unlock() + + first := r.next - int64(r.n) + switch { + case seq > r.next: + // Cursor from a previous process lifetime — sequence numbering restarted. + seq, reset = first, true + case seq < first: + dropped, seq, reset = first-seq, first, true + } + return r.collect(seq), r.next, dropped, reset +} + +// collect copies lines from `seq` onward. Caller holds r.mu. +func (r *ring) collect(seq int64) []string { + first := r.next - int64(r.n) + start := int(seq - first) + if start < 0 { + start = 0 + } + out := make([]string, 0, r.n-start) + for i := start; i < r.n; i++ { + out = append(out, r.buf[(r.head+i)%len(r.buf)]) + } + return out +} + +func (r *ring) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.n +} + +// --- tailer ---------------------------------------------------------------- + +// tailState is what the UI shows about the file itself. Mtime is the useful +// one: it lets the page distinguish "the server is quiet" from "the tailer is +// stuck", which HTTP status alone can never do. +type tailState struct { + Path string `json:"path"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Size int64 `json:"size"` + Mtime int64 `json:"mtime"` + Rotations int64 `json:"rotations"` +} + +type tailer struct { + path string + backfill int + r *ring + + f *os.File + pos int64 // byte offset of the next unread COMPLETE line + dev uint64 + ino uint64 + + mu sync.Mutex + state tailState +} + +func (t *tailer) snapshotState() tailState { + t.mu.Lock() + defer t.mu.Unlock() + return t.state +} + +func (t *tailer) setErr(err error) { + t.mu.Lock() + defer t.mu.Unlock() + t.state.OK = false + if err != nil { + t.state.Error = err.Error() + } +} + +func (t *tailer) run(interval time.Duration) { + for { + t.tick() + time.Sleep(interval) + } +} + +func (t *tailer) tick() { + fi, err := os.Stat(t.path) + if err != nil { + // Missing file is normal before the server's first boot, and during the + // window where itzg has rotated but not yet recreated latest.log. + t.closeFile() + t.setErr(err) + return + } + + dev, ino, ok := statKey(fi) + if !ok { + t.setErr(errUnsupportedStat) + return + } + + switch { + case t.f == nil: + // First open of this process: seed the ring from the tail of the file so + // the page has history immediately. Subsequent opens start at 0. + if err := t.open(fi, t.backfill > 0); err != nil { + t.setErr(err) + return + } + case dev != t.dev || ino != t.ino: + // Rotated: latest.log is a different file now. Read the new one whole. + log.Printf("tail: %s rotated (ino %d -> %d), reopening", t.path, t.ino, ino) + t.closeFile() + if err := t.open(fi, false); err != nil { + t.setErr(err) + return + } + t.mu.Lock() + t.state.Rotations++ + t.mu.Unlock() + case fi.Size() < t.pos: + // Truncated in place (same inode, smaller). Re-read from the start. + log.Printf("tail: %s truncated (%d -> %d), rewinding", t.path, t.pos, fi.Size()) + t.pos = 0 + } + + t.drain(fi.Size()) + + t.mu.Lock() + t.state.OK = true + t.state.Error = "" + t.state.Size = fi.Size() + t.state.Mtime = fi.ModTime().Unix() + t.mu.Unlock() +} + +func (t *tailer) open(fi os.FileInfo, backfill bool) error { + f, err := os.Open(t.path) + if err != nil { + return err + } + t.f = f + t.dev, t.ino, _ = statKey(fi) + t.pos = 0 + if backfill { + t.pos = backfillOffset(f, fi.Size(), t.backfill) + } + return nil +} + +func (t *tailer) closeFile() { + if t.f != nil { + t.f.Close() + t.f = nil + } +} + +// drain reads every COMPLETE line between pos and size into the ring. +// +// A trailing partial line (the writer is mid-write) does not advance pos, so it +// is re-read on the next tick once its newline lands. That keeps the "never emit +// half a line" guarantee without any partial-line state to get wrong. +func (t *tailer) drain(size int64) { + if t.f == nil || size <= t.pos { + return + } + if _, err := t.f.Seek(t.pos, io.SeekStart); err != nil { + t.setErr(err) + return + } + rd := bufio.NewReaderSize(t.f, 64<<10) + for { + line, err := rd.ReadString('\n') + if err != nil { + // io.EOF with a partial line: leave pos before it and retry next tick. + return + } + t.pos += int64(len(line)) + t.r.append(trimEOL(line)) + } +} + +// backfillOffset returns the byte offset of the start of the last `want` lines, +// scanning at most maxScan bytes back from the end. Bounded on purpose: a +// 164-mod server's latest.log is mostly boot spam and can be large, and we only +// ever want the tail of it. +func backfillOffset(f *os.File, size int64, want int) int64 { + const maxScan = 8 << 20 + + start := size - maxScan + if start < 0 { + start = 0 + } + buf := make([]byte, size-start) + if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF { + return size + } + + count := 0 + for i := len(buf) - 1; i >= 0; i-- { + if buf[i] != '\n' || i == len(buf)-1 { + continue // the final newline terminates the last line, it starts nothing + } + if count++; count >= want { + return start + int64(i) + 1 + } + } + if start == 0 { + return 0 + } + // Fewer than `want` lines in the window — begin at the first COMPLETE line so + // we never emit the tail half of a line we sliced through. + if idx := bytes.IndexByte(buf, '\n'); idx >= 0 { + return start + int64(idx) + 1 + } + return size +} + +func trimEOL(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} + +type stringError string + +func (e stringError) Error() string { return string(e) } + +const errUnsupportedStat = stringError("stat: unsupported platform (no syscall.Stat_t)") + +func statKey(fi os.FileInfo) (dev, ino uint64, ok bool) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, false + } + return uint64(st.Dev), uint64(st.Ino), true +} + +// --- http ------------------------------------------------------------------ + +type logResponse struct { + Lines []string `json:"lines"` + Seq int64 `json:"seq"` + Dropped int64 `json:"dropped"` + Reset bool `json:"reset"` + Total int `json:"total"` + Tail tailState `json:"tail"` +} + +func main() { + listen := envOr("MONITOR_LISTEN", ":8080") + path := envOr("MONITOR_LOG_PATH", "/data/logs/latest.log") + + bufLines, err := strconv.Atoi(envOr("MONITOR_BUFFER_LINES", "5000")) + if err != nil || bufLines < 1 { + log.Fatalf("invalid MONITOR_BUFFER_LINES: %q", os.Getenv("MONITOR_BUFFER_LINES")) + } + interval, err := time.ParseDuration(envOr("MONITOR_TAIL_INTERVAL", "500ms")) + if err != nil { + log.Fatalf("invalid MONITOR_TAIL_INTERVAL: %v", err) + } + + r := newRing(bufLines) + t := &tailer{path: path, backfill: bufLines, r: r, state: tailState{Path: path}} + go t.run(interval) + + mux := http.NewServeMux() + + // GET /api/log[?since=] — omit `since` for a full buffer without a + // spurious "dropped" notice on first load. + mux.HandleFunc("/api/log", func(w http.ResponseWriter, req *http.Request) { + var resp logResponse + if raw := req.URL.Query().Get("since"); raw != "" { + seq, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + http.Error(w, "bad since", http.StatusBadRequest) + return + } + resp.Lines, resp.Seq, resp.Dropped, resp.Reset = r.since(seq) + } else { + resp.Lines, resp.Seq = r.snapshot() + } + resp.Total = r.count() + resp.Tail = t.snapshotState() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(resp) + }) + + // Liveness only. Deliberately NOT tied to the log file: Minecraft being down + // is a legitimate state, and Uptime Kuma probes this over mcnet (bypassing + // caddy, hence unauthenticated by construction). + mux.HandleFunc("/healthz", func(w http.ResponseWriter, req *http.Request) { + w.Write([]byte("ok")) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/" { + http.NotFound(w, req) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Write(indexHTML) + }) + + log.Printf("monitor listening on %s, tailing %s (buffer=%d lines, interval=%s)", + listen, path, bufLines, interval) + log.Fatal(http.ListenAndServe(listen, mux)) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/docker/monitor/ui/index.html b/docker/monitor/ui/index.html new file mode 100644 index 0000000..57cb25f --- /dev/null +++ b/docker/monitor/ui/index.html @@ -0,0 +1,337 @@ + + + + + + +Ulicraft — monitor + + + + +
+ +

Ulicraft latest.log

+ — + — + + + + + + + + +
+ +
+ + + + + diff --git a/nginx/monitor.server.tmpl b/nginx/monitor.server.tmpl new file mode 100644 index 0000000..bedc480 --- /dev/null +++ b/nginx/monitor.server.tmpl @@ -0,0 +1,31 @@ +# Monitor (monitor.) — admin-only log stream, basic_auth enforced by caddy. +# +# Rendered into ulicraft-caddy.conf.tmpl at ${MONITOR_SERVER} by render-nginx.sh, +# ONLY when MONITOR_ENABLED=on. With `off` the placeholder collapses to a +# comment, so the name has no TLS vhost at all (and needs no cert — drop +# `monitor` from LE_SUBDOMAINS on that host). +# +# proxy_buffering is left at the default ON: the log API is plain polling, not +# SSE, precisely so this vhost needs no special treatment. If a future phase +# switches to streaming, `proxy_buffering off;` MUST be added here or the stream +# stalls on prod only — see plan/23-monitor.md. +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name monitor.${BASE_DOMAIN}; + + ssl_certificate ${APP_DIR}/certs/monitor.${BASE_DOMAIN}/cert.pem; + ssl_certificate_key ${APP_DIR}/certs/monitor.${BASE_DOMAIN}/key.pem; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Admin-only surface — keep it out of search engines even though it 401s. + add_header X-Robots-Tag "noindex, nofollow" always; + + location / { + proxy_pass http://ulicraft_caddy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/nginx/ulicraft-caddy.conf.tmpl b/nginx/ulicraft-caddy.conf.tmpl index aeaa41c..aa0bd99 100644 --- a/nginx/ulicraft-caddy.conf.tmpl +++ b/nginx/ulicraft-caddy.conf.tmpl @@ -34,7 +34,7 @@ limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m; server { listen 80; listen [::]:80; - server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN}${FILES_BROWSE_NAME} generators.${BASE_DOMAIN}; + server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN}${FILES_BROWSE_NAME}${MONITOR_NAME} generators.${BASE_DOMAIN}; return 301 https://$host$request_uri; } @@ -196,6 +196,11 @@ server { # spliced in by render-nginx.sh. ${FILES_BROWSE_SERVER} +# monitor. (admin log stream, basic_auth in caddy) goes here — the whole server +# block, or a single comment line when MONITOR_ENABLED=off. Source: +# nginx/monitor.server.tmpl, spliced in by render-nginx.sh. +${MONITOR_SERVER} + server { listen 443 ssl http2; listen [::]:443 ssl http2; diff --git a/plan/00-overview.md b/plan/00-overview.md index d89149b..21e8d1c 100644 --- a/plan/00-overview.md +++ b/plan/00-overview.md @@ -152,6 +152,7 @@ mc-status, uptime-kuma and filestash. - `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED) - `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal) - `20-files.md` — Filestash player file share (`files.`, one shared login) +- `23-monitor.md` — admin log stream + dashboard (`monitor.`, basic auth) (PLANNED) ## Boot/dependency order diff --git a/plan/23-monitor.md b/plan/23-monitor.md new file mode 100644 index 0000000..7e0c8b9 --- /dev/null +++ b/plan/23-monitor.md @@ -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=` → `{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 # 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. diff --git a/tooling/render-nginx.sh b/tooling/render-nginx.sh index 4ae4339..8958217 100755 --- a/tooling/render-nginx.sh +++ b/tooling/render-nginx.sh @@ -22,6 +22,9 @@ # the name from the port-80 redirect, so the host serves # nothing for it (no cert needed either). Same var gates the # caddy side in docker-compose.yml. +# MONITOR_ENABLED on|off (default on) — emit the monitor.${BASE_DOMAIN} +# admin log-stream vhost. Same on/off semantics as +# FILES_BROWSE, same var gates the caddy side. set -euo pipefail cd "$(dirname "$0")/.." # repo root @@ -66,7 +69,27 @@ case "$FILES_BROWSE" in esac export FILES_BROWSE_NAME FILES_BROWSE_SERVER -rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT $FILES_BROWSE_NAME $FILES_BROWSE_SERVER' < "$TEMPLATE")" +# monitor.${BASE_DOMAIN} — admin-only log stream (plan/23-monitor.md). Same +# splice-or-comment trick as FILES_BROWSE above, and the same var gates the +# caddy conf mount in docker-compose.yml. Turning it off here is what removes +# the public name entirely; the container itself publishes no port either way. +: "${MONITOR_ENABLED:=on}" +MONITOR_TEMPLATE="${MONITOR_TEMPLATE:-nginx/monitor.server.tmpl}" +case "$MONITOR_ENABLED" in + on) + [ -f "$MONITOR_TEMPLATE" ] || die "template not found: $MONITOR_TEMPLATE" + MONITOR_NAME=" monitor.${BASE_DOMAIN}" + MONITOR_SERVER="$(envsubst '$BASE_DOMAIN $APP_DIR' < "$MONITOR_TEMPLATE")" + ;; + off) + MONITOR_NAME="" + MONITOR_SERVER="# monitor.${BASE_DOMAIN} disabled on this host (MONITOR_ENABLED=off in .env)." + ;; + *) die "MONITOR_ENABLED must be 'on' or 'off' (got '$MONITOR_ENABLED')" ;; +esac +export MONITOR_NAME MONITOR_SERVER + +rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT $FILES_BROWSE_NAME $FILES_BROWSE_SERVER $MONITOR_NAME $MONITOR_SERVER' < "$TEMPLATE")" if [ "${1:-}" != "--install" ]; then printf '%s\n' "$rendered" @@ -78,7 +101,7 @@ command -v nginx >/dev/null 2>&1 || die "nginx not found on PATH" avail="/etc/nginx/sites-available/$SITE_NAME" enabled="/etc/nginx/sites-enabled/$SITE_NAME" -log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT, FILES_BROWSE=$FILES_BROWSE)" +log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT, FILES_BROWSE=$FILES_BROWSE, MONITOR_ENABLED=$MONITOR_ENABLED)" printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null sudo ln -sf "$avail" "$enabled" sudo nginx -t