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: # Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key # 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) ─────── # ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports. # Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included # 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 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_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu OVH_END_POINT=ovh-eu
+10
View File
@@ -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.
+20
View File
@@ -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
}
+40
View File
@@ -164,6 +164,13 @@ services:
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80" - "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
environment: environment:
BASE_DOMAIN: ${BASE_DOMAIN} 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: volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy: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. # 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/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 - ./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 - ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro - ./launcher:/srv/launcher:ro
# Read-only browse of the shared upload dir at files.${BASE_DOMAIN} # Read-only browse of the shared upload dir at files.${BASE_DOMAIN}
@@ -313,6 +324,35 @@ services:
networks: networks:
- mcnet - 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. # Status monitoring + public status page. caddy proxies status. -> :3001.
# Joins mcnet so monitors probe the stack by internal service name. # Joins mcnet so monitors probe the stack by internal service name.
uptime-kuma: uptime-kuma:
+15
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
module ulicraft/monitor
go 1.25.0
+418
View File
@@ -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=<seq>] — 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
}
+337
View File
@@ -0,0 +1,337 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Ulicraft — monitor</title>
<style>
/* Palette lifted from the landing's default (grass) theme so this doesn't look
like a different product. See landing/src/styles/main.css. */
:root {
--bg: oklch(0.165 0.014 150);
--bg-2: oklch(0.205 0.016 150);
--surface: oklch(0.235 0.017 150);
--line: oklch(0.33 0.016 150);
--accent: oklch(0.72 0.16 145);
--accent-ink:oklch(0.17 0.05 150);
--gold: oklch(0.82 0.135 85);
--red: oklch(0.68 0.19 25);
--text: oklch(0.95 0.008 110);
--muted: oklch(0.72 0.012 145);
--dim: oklch(0.55 0.012 145);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
display: flex;
flex-direction: column;
}
header {
flex: none;
background: var(--bg-2);
border-bottom: 1px solid var(--line);
padding: 8px 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
h1 { font-size: 14px; margin: 0; font-weight: 600; letter-spacing: .02em; }
h1 small { color: var(--dim); font-weight: 400; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--dim); flex: none; }
.dot.ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.dot.bad { background: var(--red); box-shadow: 0 0 6px var(--red); }
.stat { color: var(--muted); font-size: 12px; white-space: nowrap; }
.stat b { color: var(--text); font-weight: 600; }
.err { color: var(--red); font-size: 12px; }
.spacer { flex: 1 1 auto; }
input[type=search] {
flex: 1 1 220px;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 4px;
color: var(--text);
padding: 5px 8px;
font: inherit;
font-size: 13px;
}
input[type=search]:focus { outline: none; border-color: var(--accent); }
input[type=search].bad-re { border-color: var(--red); }
.chips { display: flex; gap: 4px; }
.chip {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 4px;
color: var(--dim);
padding: 4px 8px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.chip[aria-pressed="true"] { color: var(--text); border-color: var(--accent); }
.chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); }
.chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); }
#log {
flex: 1 1 auto;
overflow: auto;
padding: 8px 12px 40px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.45;
}
.ln { white-space: pre-wrap; overflow-wrap: anywhere; }
.ln.error { color: var(--red); }
.ln.warn { color: var(--gold); }
.ln.meta { color: var(--dim); font-style: italic; padding: 4px 0; }
#live {
position: fixed;
right: 18px;
bottom: 18px;
background: var(--accent);
color: var(--accent-ink);
border: none;
border-radius: 999px;
padding: 8px 14px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 10px rgb(0 0 0 / .4);
}
#live[hidden] { display: none; }
</style>
</head>
<body>
<header>
<span class="dot" id="dot"></span>
<h1>Ulicraft <small>latest.log</small></h1>
<span class="stat" id="age">—</span>
<span class="stat" id="counts">—</span>
<span class="err" id="error"></span>
<span class="spacer"></span>
<input type="search" id="filter" placeholder="filter… (/regex/ for regex, press / to focus)" spellcheck="false">
<span class="chips">
<button class="chip" data-level="ERROR" aria-pressed="true">ERROR</button>
<button class="chip" data-level="WARN" aria-pressed="true">WARN</button>
<button class="chip" data-level="INFO" aria-pressed="true">INFO</button>
</span>
</header>
<div id="log" role="log" aria-live="off"></div>
<button id="live" hidden>jump to live ↓</button>
<script>
(() => {
"use strict";
const MAX = 5000; // must not exceed the server's ring capacity
const POLL_MS = 2000; // delay is acceptable by design; see plan/23-monitor.md
const STICK_PX = 40; // "close enough to the bottom" tolerance
const logEl = document.getElementById("log");
const dotEl = document.getElementById("dot");
const ageEl = document.getElementById("age");
const countsEl = document.getElementById("counts");
const errEl = document.getElementById("error");
const filterEl = document.getElementById("filter");
const liveEl = document.getElementById("live");
// records: {text, level, rendered}. `rendered` tracks whether a DOM node
// exists for this record, so trimming can remove exactly the right number of
// leading nodes without a full re-render on every poll.
let records = [];
let seq = null; // null => first fetch, ask for the whole buffer
let needle = "";
let regex = null;
const levelOn = { ERROR: true, WARN: true, INFO: true };
// log4j lines look like: [12:34:56] [Server thread/INFO] [mod/] message
const LEVEL_RE = /^\[\d{2}:\d{2}:\d{2}\]\s*\[[^\]]*\/([A-Z]+)\]/;
function levelOf(text) {
const m = LEVEL_RE.exec(text);
if (!m) return "INFO"; // stack traces, mod spam, etc.
const lv = m[1];
if (lv === "ERROR" || lv === "FATAL" || lv === "SEVERE") return "ERROR";
if (lv === "WARN" || lv === "WARNING") return "WARN";
return "INFO";
}
function passes(rec) {
if (rec.level === "META") return true; // dropped-lines markers always show
if (!levelOn[rec.level]) return false;
if (regex) return regex.test(rec.text);
if (needle) return rec.text.toLowerCase().includes(needle);
return true;
}
function nodeFor(rec) {
const el = document.createElement("div");
el.className = "ln " + rec.level.toLowerCase();
el.textContent = rec.text; // textContent, never innerHTML
return el;
}
const atBottom = () =>
logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < STICK_PX;
const toBottom = () => { logEl.scrollTop = logEl.scrollHeight; };
function addRecords(recs) {
const stick = atBottom();
const frag = document.createDocumentFragment();
for (const rec of recs) {
records.push(rec);
if (passes(rec)) { frag.appendChild(nodeFor(rec)); rec.rendered = true; }
}
logEl.appendChild(frag);
trim();
if (stick) toBottom();
updateCounts();
updateLiveButton();
}
function trim() {
const excess = records.length - MAX;
if (excess <= 0) return;
let nodes = 0;
for (let i = 0; i < excess; i++) if (records[i].rendered) nodes++;
records.splice(0, excess);
for (let i = 0; i < nodes && logEl.firstChild; i++) logEl.removeChild(logEl.firstChild);
}
// Full rebuild — only on a filter change, never on a poll.
function rerender() {
const frag = document.createDocumentFragment();
for (const rec of records) {
rec.rendered = passes(rec);
if (rec.rendered) frag.appendChild(nodeFor(rec));
}
logEl.replaceChildren(frag);
toBottom();
updateCounts();
updateLiveButton();
}
function reset() {
records = [];
logEl.replaceChildren();
}
function updateCounts() {
const shown = logEl.childElementCount;
countsEl.textContent = shown === records.length
? `${records.length} lines`
: `${shown} / ${records.length} lines`;
}
function updateLiveButton() { liveEl.hidden = atBottom(); }
function updateTail(tail) {
if (!tail) return;
if (!tail.ok) {
dotEl.className = "dot bad";
errEl.textContent = tail.error || "log unavailable";
ageEl.textContent = "—";
return;
}
errEl.textContent = "";
dotEl.className = "dot ok";
// Browser vs container clock skew can make this slightly off; clamped at 0.
const age = Math.max(0, Math.floor(Date.now() / 1000) - tail.mtime);
const rot = tail.rotations ? ` · ${tail.rotations} rotation${tail.rotations > 1 ? "s" : ""}` : "";
ageEl.innerHTML = `last write <b>${age}s</b> ago${rot}`;
}
async function poll() {
try {
const url = seq === null ? "/api/log" : "/api/log?since=" + seq;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
if (data.reset) {
reset();
if (data.dropped > 0) {
addRecords([{ text: `— fell behind, ${data.dropped} line(s) skipped —`, level: "META", rendered: false }]);
}
}
seq = data.seq;
if (data.lines.length) {
addRecords(data.lines.map(text => ({ text, level: levelOf(text), rendered: false })));
} else {
updateCounts();
}
updateTail(data.tail);
} catch (e) {
dotEl.className = "dot bad";
errEl.textContent = String(e.message || e);
} finally {
setTimeout(poll, POLL_MS);
}
}
// --- filter ---------------------------------------------------------------
let debounce;
filterEl.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => {
const raw = filterEl.value.trim();
const re = raw.length > 1 && raw.startsWith("/") ? raw.lastIndexOf("/") : -1;
regex = null;
needle = "";
filterEl.classList.remove("bad-re");
if (re > 0) {
try {
regex = new RegExp(raw.slice(1, re), raw.slice(re + 1) || "i");
} catch {
filterEl.classList.add("bad-re"); // keep showing everything, don't lie
}
} else {
needle = raw.toLowerCase();
}
rerender();
}, 120);
});
for (const chip of document.querySelectorAll(".chip")) {
chip.addEventListener("click", () => {
const on = chip.getAttribute("aria-pressed") !== "true";
chip.setAttribute("aria-pressed", String(on));
levelOn[chip.dataset.level] = on;
rerender();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "/" && document.activeElement !== filterEl) {
e.preventDefault();
filterEl.focus();
filterEl.select();
}
});
logEl.addEventListener("scroll", updateLiveButton, { passive: true });
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
poll();
})();
</script>
</body>
</html>
+31
View File
@@ -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;
}
}
+6 -1
View File
@@ -34,7 +34,7 @@ limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
server { server {
listen 80; listen 80;
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; return 301 https://$host$request_uri;
} }
@@ -196,6 +196,11 @@ server {
# spliced in by render-nginx.sh. # spliced in by render-nginx.sh.
${FILES_BROWSE_SERVER} ${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 { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
+1
View File
@@ -152,6 +152,7 @@ mc-status, uptime-kuma and filestash.
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED) - `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal) - `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
- `20-files.md` — Filestash player file share (`files.`, one shared login) - `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 ## Boot/dependency order
+303
View File
@@ -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.
+25 -2
View File
@@ -22,6 +22,9 @@
# the name from the port-80 redirect, so the host serves # the name from the port-80 redirect, so the host serves
# nothing for it (no cert needed either). Same var gates the # nothing for it (no cert needed either). Same var gates the
# caddy side in docker-compose.yml. # 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 set -euo pipefail
cd "$(dirname "$0")/.." # repo root cd "$(dirname "$0")/.." # repo root
@@ -66,7 +69,27 @@ case "$FILES_BROWSE" in
esac esac
export FILES_BROWSE_NAME FILES_BROWSE_SERVER 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 if [ "${1:-}" != "--install" ]; then
printf '%s\n' "$rendered" 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" avail="/etc/nginx/sites-available/$SITE_NAME"
enabled="/etc/nginx/sites-enabled/$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 printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null
sudo ln -sf "$avail" "$enabled" sudo ln -sf "$avail" "$enabled"
sudo nginx -t sudo nginx -t