Compare commits
17
Commits
92c6f5fc8f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bd786140 | ||
|
|
d457713186 | ||
|
|
556f86f852 | ||
|
|
6b9b8bd96c | ||
|
|
fe91bf7778 | ||
|
|
0176a674e3 | ||
|
|
671e6af05a | ||
|
|
fbed9a5af7 | ||
|
|
40fb50e86f | ||
|
|
d105fc9acf | ||
|
|
3eb20054a0 | ||
|
|
bac2372c95 | ||
|
|
bc99427ef9 | ||
|
|
00984e1a70 | ||
|
|
53604a6127 | ||
|
|
fca6747bcc | ||
|
|
8507ffd020 |
No files matched your search
+43
-1
@@ -64,6 +64,17 @@ FILES_DATA_DIR=/srv/ulicraft-files
|
||||
# value is substituted straight into the volume string.
|
||||
FILES_MOUNT_MODE=rw
|
||||
|
||||
# files.${BASE_DOMAIN} — the read-only, NO-AUTH browse of the same share.
|
||||
# Per-host toggle, `on` | `off` (default on). It gates two layers, both reading
|
||||
# this var: the caddy vhost (docker-compose.yml substitutes the value into the
|
||||
# conf.d mount path — 62-files.on.caddy vs the 62-files.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 `files` from LE_SUBDOMAINS
|
||||
# too on that host. upload. (filestash, authenticated) is NOT affected.
|
||||
# After changing it: tooling/render-nginx.sh --install
|
||||
# docker compose up -d --force-recreate caddy
|
||||
FILES_BROWSE=on
|
||||
|
||||
# Runtime uid:gid of the filestash process (docker `user:`). Leave at the image
|
||||
# default 1000:1000 UNLESS you want files it writes into the share owned by a
|
||||
# host account. To own the share as `oier` on oxps: set both to 1002 and run
|
||||
@@ -123,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
|
||||
|
||||
@@ -35,7 +35,10 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
|
||||
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||
upload.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||
files.${BASE_DOMAIN} ─► caddy (read-only, no-auth browse of the same share dir)
|
||||
files.${BASE_DOMAIN} ─► caddy (read-only, no-auth browse of the same share dir;
|
||||
per-host on/off via FILES_BROWSE — off on cochi)
|
||||
monitor.${BASE_DOMAIN}─► monitor (ADMIN ONLY: latest.log stream + dashboard tiles,
|
||||
caddy basic_auth; per-host on/off MONITOR_ENABLED)
|
||||
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||
|
||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||
@@ -152,6 +155,34 @@ works. After any filestash config change, check the browser-facing values
|
||||
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||
while every other check passed.
|
||||
|
||||
### Compose eats `$` in `.env` values — quote every hash
|
||||
`docker compose` interpolates `$`-sequences inside `.env` **values**, so a bare
|
||||
bcrypt hash is silently corrupted before it ever reaches a container:
|
||||
|
||||
```
|
||||
MONITOR_PASSWORD_HASH=$2a$14$abcdefgHIJKLmnop.qrstuv # WRONG
|
||||
→ container sees $2a$14.qrstuv # $abcdefg… eaten as an unset var
|
||||
MONITOR_PASSWORD_HASH='$2a$14$abcdefgHIJKLmnop.qrstuv' # single quotes fix it
|
||||
```
|
||||
|
||||
Verified, not folklore. Every login fails with no hint as to why — the hash
|
||||
looks fine in `.env` and `docker compose config` re-escapes it to `$$…`, which
|
||||
looks wrong but is correct. **The same bug bites over SSH**: writing a hash to a
|
||||
remote `.env` through `ssh host "...$HASH..."` lets the remote shell expand it.
|
||||
Transport it base64-encoded and verify with `md5sum` on both ends.
|
||||
|
||||
Note the two services want *opposite* formats: caddy `basic_auth` needs
|
||||
**bcrypt** (`caddy hash-password`), filestash needs **`$6$`** (`openssl passwd
|
||||
-6`) and silently rejects bcrypt. Never copy a hash between them.
|
||||
|
||||
### Monitor runs as root on purpose
|
||||
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
|
||||
cannot traverse it, so the log tailer would read nothing — **silently**, while
|
||||
still answering HTTP 200. Adding a `USER` directive to `docker/monitor/` as
|
||||
"hardening" breaks it. What actually bounds the risk there: every mount is `:ro`,
|
||||
no port is published, and the one credential it holds (RCON) can only send
|
||||
commands from a compile-time allowlist.
|
||||
|
||||
### Memory sizing
|
||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||
`USE_AIKAR_FLAGS: TRUE`. **The pack is now 164 jars / 133 server-side** (Create
|
||||
@@ -218,6 +249,11 @@ Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
||||
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||
- [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream +
|
||||
dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups,
|
||||
joins/leaves) + opt-in browser notifications on join/leave (bell toggle,
|
||||
client-side only). Live on cochi, all four phases done. See `plan/23-monitor.md`.
|
||||
Follow-up: add the Uptime Kuma monitor for `http://monitor:8080/healthz`.
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
Easiest — exec into container:
|
||||
ssh oxps
|
||||
cd ~/mc/ulicraft-server
|
||||
docker compose exec minecraft bash # world at /data
|
||||
|
||||
Host path (needs sudo, root-owned):
|
||||
ssh oxps
|
||||
docker volume inspect ulicraft-server_mc_data -f '{{.Mountpoint}}'
|
||||
# -> /var/lib/docker/volumes/ulicraft-server_mc_data/_data
|
||||
sudo ls /var/lib/docker/volumes/ulicraft-server_mc_data/_data
|
||||
Project name = dir name, so verify with docker volume ls | grep mc_data if dir differs.
|
||||
|
||||
Copy files out/in without root:
|
||||
docker compose cp minecraft:/data/logs/latest.log ./latest.log
|
||||
docker compose cp ./foo.json minecraft:/data/config/
|
||||
|
||||
Caveats:
|
||||
- Writing world files while server runs → corruption. Stop first: docker compose stop minecraft.
|
||||
- oxps LAN-only, so ssh oxps from LAN only.
|
||||
- mc-backup mounts same volume :ro — backups in backups/ are safer read source for world snapshots.
|
||||
|
||||
@@ -19,6 +19,7 @@ One compose file, one `docker compose up -d`:
|
||||
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||
| `filestash` | machines/filestash (digest-pinned) | player file share (upload) at `upload.${BASE_DOMAIN}` |
|
||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||
| `monitor` | built from source | admin-only log stream + dashboard at `monitor.${BASE_DOMAIN}` |
|
||||
|
||||
Vhosts (all proxied through the host nginx → caddy):
|
||||
|
||||
@@ -27,6 +28,7 @@ Vhosts (all proxied through the host nginx → caddy):
|
||||
- `avatar.` — NMSR
|
||||
- `status.` — Uptime Kuma
|
||||
- `files.` — Filestash player file share (one shared login, writable)
|
||||
- `monitor.` — **admin only**, log stream + dashboard (caddy basic_auth)
|
||||
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||
|
||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
@@ -35,7 +37,8 @@ Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
## DNS
|
||||
|
||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||
(`auth. avatar. status. files. monitor. distribution. www.`) at the host running
|
||||
nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -158,6 +161,47 @@ curl -H "Host: ulicraft.net" \
|
||||
|
||||
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
||||
|
||||
## Monitor (admin log stream + dashboard)
|
||||
|
||||
`monitor.${BASE_DOMAIN}` — a small Go service (`docker/monitor/`) that tails the
|
||||
Minecraft server's `latest.log` and shows it live with autoscroll, a filter and
|
||||
ERROR/WARN/INFO chips, above a row of tiles: server status, TPS, players online
|
||||
(with NMSR heads), registered accounts, disk free, backup freshness and recent
|
||||
joins/leaves.
|
||||
|
||||
The bell in the header turns on **browser notifications for player join/leave**.
|
||||
Opt-in, off by default, remembered per browser; it fires only while the page is
|
||||
open (a background tab counts, a closed one doesn't).
|
||||
|
||||
**Admin only, and read-only.** The log carries player IPs, chat and admin
|
||||
commands verbatim and nothing sanitizes it, so the whole vhost sits behind caddy
|
||||
`basic_auth`. There are no controls in the UI — every route is a `GET`.
|
||||
|
||||
```bash
|
||||
# generate the credential (BCRYPT — the opposite of FILES_PASSWORD_HASH, which needs $6$)
|
||||
docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
|
||||
```
|
||||
|
||||
Then in `.env` — **single-quote the hash**, Compose mangles bare `$`-sequences:
|
||||
|
||||
```sh
|
||||
MONITOR_ENABLED=on # per-host on|off, like FILES_BROWSE
|
||||
MONITOR_USER=admin
|
||||
MONITOR_PASSWORD_HASH='$2a$14$...'
|
||||
```
|
||||
|
||||
The container publishes **no port**: caddy is the only way in, so there is no
|
||||
host listener to firewall. It holds one credential (`RCON_PASSWORD`, for the TPS
|
||||
tile) and can only send commands from a compile-time allowlist that no HTTP
|
||||
input can reach.
|
||||
|
||||
Adding it needs a cert (`monitor` in `LE_SUBDOMAINS` →
|
||||
`tooling/issue-letsencrypt.sh`) and an nginx vhost (`tooling/render-nginx.sh
|
||||
--install`). Because it adds a caddy conf file, the **first** deploy needs
|
||||
`./update-all.sh --hard` (world downtime); later changes are rolling.
|
||||
|
||||
Full design, gotchas and verification: **`plan/23-monitor.md`**.
|
||||
|
||||
## Player file share (Filestash)
|
||||
|
||||
`upload.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||
@@ -224,6 +268,8 @@ caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||
docker/nmsr/ # built-from-source NMSR image
|
||||
docker/mc-status/ # built-from-source SLP→JSON pinger
|
||||
docker/monitor/ # built-from-source admin log stream + dashboard
|
||||
landing/ # Astro site → www/
|
||||
launcher/ # FjordLauncher assets (gitignored)
|
||||
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||
@@ -234,3 +280,6 @@ plan/ # design docs
|
||||
```
|
||||
|
||||
See `plan/00-overview.md` for the full design.
|
||||
|
||||
## Ban IPs
|
||||
`docker compose exec minecraft rcon-cli ban-ip 1.1.1.1`
|
||||
@@ -4,6 +4,10 @@
|
||||
# conf.d/30-distribution.caddy static site from another repo
|
||||
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||
# conf.d/50-status.caddy uptime-kuma status page
|
||||
# conf.d/60-upload.caddy filestash player file share
|
||||
# conf.d/62-files.caddy read-only browse of the share (FILES_BROWSE)
|
||||
# conf.d/70-generators.caddy offline misode data-pack generators
|
||||
# conf.d/80-monitor.caddy admin log stream (basic_auth, MONITOR_ENABLED)
|
||||
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
|
||||
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||
# upgrading to its own https.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Files browse DISABLED (FILES_BROWSE=off in .env).
|
||||
#
|
||||
# This stub is mounted at /etc/caddy/conf.d/62-files.caddy instead of
|
||||
# 62-files.on.caddy, so caddy defines NO vhost for files.${BASE_DOMAIN} and the
|
||||
# share is not served read-only anywhere. Uploads via upload.${BASE_DOMAIN}
|
||||
# (filestash) are unaffected — that is a separate service.
|
||||
#
|
||||
# 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.
|
||||
File renamed without changes.
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
+69
-2
@@ -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
|
||||
@@ -172,8 +179,19 @@ services:
|
||||
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
||||
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
||||
- ./caddy/conf.d/60-upload.caddy:/etc/caddy/conf.d/60-upload.caddy:ro
|
||||
- ./caddy/conf.d/62-files.caddy:/etc/caddy/conf.d/62-files.caddy:ro
|
||||
# files.${BASE_DOMAIN} read-only browse, gated by FILES_BROWSE (on|off).
|
||||
# The value is substituted straight into the source path — `on` mounts the
|
||||
# real vhost, `off` a comment-only stub — because compose has no
|
||||
# conditionals (same trick as FILES_MOUNT_MODE). Changing it needs
|
||||
# `docker compose up -d --force-recreate caddy`: a single-file bind mount
|
||||
# pins the inode. The public gate is the nginx vhost (render-nginx.sh
|
||||
# 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}
|
||||
@@ -306,10 +324,59 @@ 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"
|
||||
# Dashboard tiles. Server status + rosters are read from mc-status over
|
||||
# mcnet rather than from Drasl directly — mc-status already holds the
|
||||
# admin credentials and exposes a sanitized list, so this container does
|
||||
# not need a second copy of that secret.
|
||||
MONITOR_MCSTATUS_URL: "http://mc-status:8080"
|
||||
# Player heads. Rendered by NMSR on its own PUBLIC origin, so the browser
|
||||
# fetches them cross-origin (no auth involved, avatars are public anyway).
|
||||
MONITOR_AVATAR_URL: "https://avatar.${BASE_DOMAIN}"
|
||||
MONITOR_AVATAR_MODE: "${AVATAR_MODE:-headiso}"
|
||||
# TPS via RCON. This is the ONLY credential the monitor holds, and the
|
||||
# only command it can send is the compile-time allowlist in rcon.go
|
||||
# (`neoforge tps`, `list`) — no HTTP input reaches a command string, so
|
||||
# the UI stays incapable of running anything. NeoForge 1.21.1 uses
|
||||
# `neoforge tps`; `forge tps` does not exist here.
|
||||
MONITOR_RCON_ADDR: "minecraft:25575"
|
||||
MONITOR_RCON_PASSWORD: ${RCON_PASSWORD}
|
||||
MONITOR_TPS_INTERVAL: "30s"
|
||||
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.
|
||||
# It is also what the disk-free tile statfs's.
|
||||
- mc_data:/data:ro
|
||||
# Backup freshness tile. Same dir mc-backup writes to, read-only here.
|
||||
- ./backups:/backups: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:
|
||||
image: louislam/uptime-kuma:1
|
||||
image: louislam/uptime-kuma:2
|
||||
container_name: uptime-kuma
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,363 @@
|
||||
// Dashboard collectors (phase 2 + 3 of plan/23-monitor.md).
|
||||
//
|
||||
// Every tile is fed by its own independently-cached collector. That isolation
|
||||
// is the whole design: mc-status being slow, the backups dir being unreadable,
|
||||
// or RCON refusing a connection must degrade exactly one tile and never block
|
||||
// the summary response or the log stream. On error a collector keeps serving
|
||||
// its last good value and reports the error alongside it — stale-with-a-reason
|
||||
// beats a blank page, and beats a spinner that never resolves.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- TTL cache -------------------------------------------------------------
|
||||
|
||||
// cached memoizes one collector for ttl. Zero value is not usable; see newCached.
|
||||
type cached[T any] struct {
|
||||
mu sync.Mutex
|
||||
val T
|
||||
have bool
|
||||
lastErr string
|
||||
expires time.Time
|
||||
|
||||
ttl time.Duration
|
||||
fn func() (T, error)
|
||||
}
|
||||
|
||||
func newCached[T any](ttl time.Duration, fn func() (T, error)) *cached[T] {
|
||||
return &cached[T]{ttl: ttl, fn: fn}
|
||||
}
|
||||
|
||||
// get returns the cached value (refreshing if stale) and the last error, if any.
|
||||
// It never returns an error *instead* of a value — a failed refresh yields the
|
||||
// previous value plus the error text.
|
||||
func (c *cached[T]) get() (T, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.have && time.Now().Before(c.expires) {
|
||||
return c.val, c.lastErr
|
||||
}
|
||||
v, err := c.fn()
|
||||
c.expires = time.Now().Add(c.ttl)
|
||||
if err != nil {
|
||||
c.lastErr = err.Error()
|
||||
return c.val, c.lastErr // stale-or-zero, with the reason
|
||||
}
|
||||
c.val, c.have, c.lastErr = v, true, ""
|
||||
return c.val, ""
|
||||
}
|
||||
|
||||
// --- server status + rosters (via mc-status over mcnet) --------------------
|
||||
|
||||
// The monitor deliberately does NOT hold Drasl admin credentials. mc-status
|
||||
// already logs in server-side and exposes a sanitized [{uuid,name}], so we read
|
||||
// its output instead of duplicating the secret. See plan/15-mc-status.md.
|
||||
|
||||
type mcPlayer struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type statusInfo struct {
|
||||
Online bool `json:"online"`
|
||||
Count int64 `json:"count"`
|
||||
Max int64 `json:"max"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
Players []mcPlayer `json:"players"`
|
||||
}
|
||||
|
||||
// v2 subset of the mcstatus.io shape mc-status re-emits.
|
||||
type v2Wire struct {
|
||||
Online bool `json:"online"`
|
||||
Players struct {
|
||||
Online int64 `json:"online"`
|
||||
Max int64 `json:"max"`
|
||||
List []struct {
|
||||
UUID string `json:"uuid"`
|
||||
NameClean string `json:"name_clean"`
|
||||
} `json:"list"`
|
||||
} `json:"players"`
|
||||
MOTD struct {
|
||||
Clean string `json:"clean"`
|
||||
} `json:"motd"`
|
||||
Version struct {
|
||||
NameClean string `json:"name_clean"`
|
||||
} `json:"version"`
|
||||
}
|
||||
|
||||
func fetchStatus(client *http.Client, base string) (statusInfo, error) {
|
||||
var wire v2Wire
|
||||
// The address segment is ignored by mc-status (it only ever pings its own
|
||||
// configured target) — any placeholder works.
|
||||
if err := getJSON(client, base+"/v2/status/java/self", &wire); err != nil {
|
||||
return statusInfo{}, err
|
||||
}
|
||||
out := statusInfo{
|
||||
Online: wire.Online,
|
||||
Count: wire.Players.Online,
|
||||
Max: wire.Players.Max,
|
||||
MOTD: wire.MOTD.Clean,
|
||||
Version: wire.Version.NameClean,
|
||||
}
|
||||
for _, p := range wire.Players.List {
|
||||
out.Players = append(out.Players, mcPlayer{UUID: p.UUID, Name: p.NameClean})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fetchRegistered(client *http.Client, base string) ([]mcPlayer, error) {
|
||||
var players []mcPlayer
|
||||
if err := getJSON(client, base+"/players", &players); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func getJSON(client *http.Client, url string, dst any) error {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &statusCodeError{url: url, code: resp.StatusCode}
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(dst)
|
||||
}
|
||||
|
||||
type statusCodeError struct {
|
||||
url string
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *statusCodeError) Error() string {
|
||||
return e.url + ": HTTP " + itoa(e.code)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [12]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// --- disk ------------------------------------------------------------------
|
||||
|
||||
type diskInfo struct {
|
||||
Path string `json:"path"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsedPct float64 `json:"used_pct"`
|
||||
}
|
||||
|
||||
// diskUsage statfs's the world volume. This reports the underlying host
|
||||
// filesystem, which is exactly what matters: a full disk is the failure that
|
||||
// actually takes the server down, and backups (15G+ and growing) share it.
|
||||
func diskUsage(path string) (diskInfo, error) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return diskInfo{Path: path}, err
|
||||
}
|
||||
bs := uint64(st.Bsize)
|
||||
total := st.Blocks * bs
|
||||
free := st.Bavail * bs // Bavail, not Bfree — exclude root-reserved blocks
|
||||
info := diskInfo{Path: path, TotalBytes: total, FreeBytes: free}
|
||||
if total > 0 {
|
||||
info.UsedPct = float64(total-free) / float64(total) * 100
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------
|
||||
|
||||
type backupInfo struct {
|
||||
Newest string `json:"newest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Mtime int64 `json:"mtime"`
|
||||
AgeSeconds int64 `json:"age_seconds"`
|
||||
Count int `json:"count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
// backupStatus finds the newest archive written by mc-backup (world-*.tar.gz).
|
||||
// Age is the number that matters — "backups exist" is not the same claim as
|
||||
// "a backup ran recently", and only the second one is worth a tile.
|
||||
func backupStatus(dir string) (backupInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return backupInfo{}, err
|
||||
}
|
||||
var out backupInfo
|
||||
var newest time.Time
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out.Count++
|
||||
out.TotalBytes += fi.Size()
|
||||
if fi.ModTime().After(newest) {
|
||||
newest = fi.ModTime()
|
||||
out.Newest = e.Name()
|
||||
out.SizeBytes = fi.Size()
|
||||
}
|
||||
}
|
||||
if out.Count > 0 {
|
||||
out.Mtime = newest.Unix()
|
||||
out.AgeSeconds = int64(time.Since(newest).Seconds())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- session history (derived from the log we already tail) ----------------
|
||||
|
||||
type sessionEvent struct {
|
||||
Time string `json:"time"`
|
||||
Player string `json:"player"`
|
||||
Action string `json:"action"` // "joined" | "left"
|
||||
}
|
||||
|
||||
// Matches the message tail of e.g.
|
||||
//
|
||||
// [01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft...]: Oier joined the game
|
||||
var sessionRe = regexp.MustCompile(`]: (\S+) (joined|left) the game\b`)
|
||||
|
||||
// leading [timestamp] of a log line, whatever its format
|
||||
var stampRe = regexp.MustCompile(`^\[([^\]]+)\]`)
|
||||
|
||||
// events is a bounded history of joins/leaves, filled by the tailer as lines
|
||||
// arrive. Free: the bytes are already being read for the log stream. It also
|
||||
// means the cold-start backfill populates history without any persistence.
|
||||
type events struct {
|
||||
mu sync.Mutex
|
||||
buf []sessionEvent
|
||||
max int
|
||||
}
|
||||
|
||||
func newEvents(max int) *events { return &events{max: max} }
|
||||
|
||||
func (e *events) observe(line string) {
|
||||
m := sessionRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
ev := sessionEvent{Player: m[1], Action: m[2]}
|
||||
if ts := stampRe.FindStringSubmatch(line); ts != nil {
|
||||
ev.Time = ts[1]
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.buf = append(e.buf, ev)
|
||||
if len(e.buf) > e.max {
|
||||
e.buf = e.buf[len(e.buf)-e.max:]
|
||||
}
|
||||
}
|
||||
|
||||
// recent returns up to n events, newest first.
|
||||
func (e *events) recent(n int) []sessionEvent {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]sessionEvent, 0, n)
|
||||
for i := len(e.buf) - 1; i >= 0 && len(out) < n; i-- {
|
||||
out = append(out, e.buf[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- assembled summary -----------------------------------------------------
|
||||
|
||||
type summary struct {
|
||||
Server statusInfo `json:"server"`
|
||||
ServerErr string `json:"server_err,omitempty"`
|
||||
Registered int `json:"registered"`
|
||||
RosterErr string `json:"roster_err,omitempty"`
|
||||
Disk diskInfo `json:"disk"`
|
||||
DiskErr string `json:"disk_err,omitempty"`
|
||||
Backup backupInfo `json:"backup"`
|
||||
BackupErr string `json:"backup_err,omitempty"`
|
||||
TPS *tpsInfo `json:"tps"`
|
||||
TPSErr string `json:"tps_err,omitempty"`
|
||||
Sessions []sessionEvent `json:"sessions"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
AvatarMode string `json:"avatar_mode"`
|
||||
Now int64 `json:"now"`
|
||||
}
|
||||
|
||||
// collectors owns every tile source. Assembled once in main and shared.
|
||||
type collectors struct {
|
||||
status *cached[statusInfo]
|
||||
registered *cached[[]mcPlayer]
|
||||
disk *cached[diskInfo]
|
||||
backup *cached[backupInfo]
|
||||
tps *cached[*tpsInfo]
|
||||
events *events
|
||||
|
||||
avatarURL string
|
||||
avatarMode string
|
||||
}
|
||||
|
||||
func (c *collectors) summary() summary {
|
||||
// Refresh concurrently. Sequentially, a summary during an outage would cost
|
||||
// the SUM of every timeout (~15s with mc-status hanging and RCON blackholed)
|
||||
// — and an outage is exactly when someone is staring at this page. Each
|
||||
// cached has its own mutex, so this is just fan-out.
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
status statusInfo
|
||||
statusErr string
|
||||
registered []mcPlayer
|
||||
rosterErr string
|
||||
disk diskInfo
|
||||
diskErr string
|
||||
backup backupInfo
|
||||
backupErr string
|
||||
tps *tpsInfo
|
||||
tpsErr string
|
||||
)
|
||||
wg.Add(5)
|
||||
go func() { defer wg.Done(); status, statusErr = c.status.get() }()
|
||||
go func() { defer wg.Done(); registered, rosterErr = c.registered.get() }()
|
||||
go func() { defer wg.Done(); disk, diskErr = c.disk.get() }()
|
||||
go func() { defer wg.Done(); backup, backupErr = c.backup.get() }()
|
||||
go func() { defer wg.Done(); tps, tpsErr = c.tps.get() }()
|
||||
wg.Wait()
|
||||
|
||||
return summary{
|
||||
Server: status,
|
||||
ServerErr: statusErr,
|
||||
Registered: len(registered),
|
||||
RosterErr: rosterErr,
|
||||
Disk: disk,
|
||||
DiskErr: diskErr,
|
||||
Backup: backup,
|
||||
BackupErr: backupErr,
|
||||
TPS: tps,
|
||||
TPSErr: tpsErr,
|
||||
Sessions: c.events.recent(40),
|
||||
AvatarURL: c.avatarURL,
|
||||
AvatarMode: c.avatarMode,
|
||||
Now: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module ulicraft/monitor
|
||||
|
||||
go 1.25.0
|
||||
@@ -0,0 +1,501 @@
|
||||
// monitor — admin-only, read-only view of the Minecraft server's latest.log.
|
||||
//
|
||||
// See plan/23-monitor.md. A tailer follows /data/logs/latest.log into an
|
||||
// in-memory ring buffer (/api/log), a set of independently-cached collectors
|
||||
// feed the dashboard tiles (/api/summary, collect.go), and both are served to
|
||||
// an embedded single-page UI by polling.
|
||||
//
|
||||
// The service is read-only in every sense. /data and /backups are mounted :ro,
|
||||
// no route mutates anything, and the single credential it holds — the RCON
|
||||
// password — can only send commands from a compile-time allowlist that no HTTP
|
||||
// input can reach (rcon.go).
|
||||
//
|
||||
// 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 runs as root inside the container ON PURPOSE: /data is drwxr-x--- owned by
|
||||
// uid 1000 (itzg), so a non-root user cannot even traverse it and the tailer
|
||||
// would silently read nothing. Every mount is :ro, which is what actually bounds
|
||||
// the damage here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
ev *events // session history, derived from the same bytes (may be nil)
|
||||
|
||||
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))
|
||||
clean := trimEOL(line)
|
||||
t.r.append(clean)
|
||||
if t.ev != nil {
|
||||
t.ev.observe(clean)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
ev := newEvents(500)
|
||||
t := &tailer{path: path, backfill: bufLines, r: r, ev: ev, state: tailState{Path: path}}
|
||||
go t.run(interval)
|
||||
|
||||
col := buildCollectors(ev)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// GET /api/summary — every dashboard tile in one response. Each collector is
|
||||
// independently cached and degrades on its own; a failure shows up as a
|
||||
// *_err field next to a stale value rather than failing the request.
|
||||
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(col.summary())
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func envDur(key, def string) time.Duration {
|
||||
d, err := time.ParseDuration(envOr(key, def))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid %s: %v", key, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// buildCollectors wires every dashboard tile source. TTLs are per-collector and
|
||||
// chosen against what each thing actually costs: mc-status is a local HTTP hop
|
||||
// (cheap, and it caches its own upstream ping), statfs is free, the backups dir
|
||||
// is a directory scan, RCON opens a socket to the game server.
|
||||
func buildCollectors(ev *events) *collectors {
|
||||
base := strings.TrimRight(envOr("MONITOR_MCSTATUS_URL", "http://mc-status:8080"), "/")
|
||||
dataDir := envOr("MONITOR_DATA_DIR", "/data")
|
||||
backupDir := envOr("MONITOR_BACKUP_DIR", "/backups")
|
||||
|
||||
// Short timeout everywhere: a hung dependency must not hold the summary.
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
rconAddr := envOr("MONITOR_RCON_ADDR", "minecraft:25575")
|
||||
rconPass := os.Getenv("MONITOR_RCON_PASSWORD")
|
||||
tpsInterval := envDur("MONITOR_TPS_INTERVAL", "30s")
|
||||
|
||||
return &collectors{
|
||||
status: newCached(envDur("MONITOR_STATUS_TTL", "15s"), func() (statusInfo, error) {
|
||||
return fetchStatus(client, base)
|
||||
}),
|
||||
registered: newCached(envDur("MONITOR_ROSTER_TTL", "60s"), func() ([]mcPlayer, error) {
|
||||
return fetchRegistered(client, base)
|
||||
}),
|
||||
disk: newCached(envDur("MONITOR_DISK_TTL", "60s"), func() (diskInfo, error) {
|
||||
return diskUsage(dataDir)
|
||||
}),
|
||||
backup: newCached(envDur("MONITOR_BACKUP_TTL", "60s"), func() (backupInfo, error) {
|
||||
return backupStatus(backupDir)
|
||||
}),
|
||||
tps: newCached(tpsInterval, func() (*tpsInfo, error) {
|
||||
if rconPass == "" {
|
||||
return nil, errors.New("rcon: MONITOR_RCON_PASSWORD unset (TPS disabled)")
|
||||
}
|
||||
raw, err := rconQuery(rconAddr, rconPass, "neoforge tps", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseTPS(raw)
|
||||
}),
|
||||
events: ev,
|
||||
avatarURL: strings.TrimRight(envOr("MONITOR_AVATAR_URL", ""), "/"),
|
||||
avatarMode: envOr("MONITOR_AVATAR_MODE", "headiso"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
// Locks the two things that are easy to break silently: the RCON allowlist
|
||||
// (the guarantee that this service cannot run commands) and the `neoforge tps`
|
||||
// output format, which differs from the older Forge wording.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTPSRealOutput(t *testing.T) {
|
||||
// Captured verbatim from `docker exec minecraft rcon-cli neoforge tps` on prod,
|
||||
// ANSI resets included.
|
||||
raw := "Overworld: 20.000 TPS (0.298 ms/tick)\n\x1b[0mThe Nether: 19.100 TPS (0.014 ms/tick)\n" +
|
||||
"\x1b[0mworld_dimension_nexus:creative: 20.000 TPS (0.009 ms/tick)\n" +
|
||||
"\x1b[0mOverall: 18.500 TPS (0.357 ms/tick)\n\x1b[0m"
|
||||
got, err := parseTPS(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.TPS != 18.5 || got.MSPT != 0.357 {
|
||||
t.Fatalf("overall wrong: %+v", got)
|
||||
}
|
||||
if len(got.Dimensions) != 3 {
|
||||
t.Fatalf("want 3 dims, got %d: %+v", len(got.Dimensions), got.Dimensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTPSUnknownCommand(t *testing.T) {
|
||||
if _, err := parseTPS("Unknown or incomplete command, see below for error\nforge tps<--[HERE]"); err == nil {
|
||||
t.Fatal("want error for unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRCONAllowlist(t *testing.T) {
|
||||
for _, bad := range []string{"op Oier", "stop", "say hi", "ban x", "", "neoforge tps ", "LIST"} {
|
||||
if _, err := rconQuery("127.0.0.1:1", "pw", bad, 0); err != errRCONNotAllowed {
|
||||
t.Fatalf("command %q was not rejected by the allowlist (err=%v)", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParse(t *testing.T) {
|
||||
e := newEvents(10)
|
||||
e.observe("[01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier joined the game")
|
||||
e.observe("[01Aug2026 23:01:02.111] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier left the game")
|
||||
e.observe("[01Aug2026 23:02:00.000] [Server thread/INFO] [x/]: something else entirely")
|
||||
got := e.recent(10)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 events, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Action != "left" || got[0].Player != "Oier" || got[0].Time != "01Aug2026 23:01:02.111" {
|
||||
t.Fatalf("newest-first wrong: %+v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Minimal Source RCON client — the ONLY thing in this service that talks to
|
||||
// Minecraft rather than reading a file, and the only place it holds a secret.
|
||||
//
|
||||
// Implemented here rather than pulled in as a dependency for one reason: the
|
||||
// allowlist below. It is a compile-time constant, and there is deliberately no
|
||||
// code path from an HTTP request to a command string. The UI cannot run
|
||||
// commands because the server cannot be asked to run one — "read-only" is a
|
||||
// property of the build, not a rule someone has to remember.
|
||||
//
|
||||
// Protocol: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
|
||||
// A fresh connection per poll (every 30s). Reconnecting costs nothing at that
|
||||
// rate and removes all stale-connection handling.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
rconTypeAuth int32 = 3
|
||||
rconTypeExec int32 = 2
|
||||
rconTypeAuthResp int32 = 2
|
||||
rconMaxBody = 4096
|
||||
)
|
||||
|
||||
// rconAllowed is the complete set of commands this service can ever send.
|
||||
// Both are pure reads. Do NOT make this configurable — see the file header.
|
||||
var rconAllowed = map[string]bool{
|
||||
"neoforge tps": true,
|
||||
"list": true,
|
||||
}
|
||||
|
||||
var errRCONNotAllowed = errors.New("rcon: command not in allowlist")
|
||||
|
||||
// rconQuery dials, authenticates, runs one allowlisted command, and closes.
|
||||
func rconQuery(addr, password, cmd string, timeout time.Duration) (string, error) {
|
||||
if !rconAllowed[cmd] {
|
||||
return "", errRCONNotAllowed
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
if err := rconSend(conn, 1, rconTypeAuth, password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Some servers emit an empty RESPONSE_VALUE before the auth result; read
|
||||
// until the auth response arrives (bounded, so a chatty peer can't spin us).
|
||||
for i := 0; i < 3; i++ {
|
||||
id, typ, _, err := rconRecv(conn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if typ != rconTypeAuthResp {
|
||||
continue
|
||||
}
|
||||
if id == -1 {
|
||||
return "", errors.New("rcon: authentication failed (bad password)")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if err := rconSend(conn, 2, rconTypeExec, cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Responses may span several packets. Read until the peer goes quiet.
|
||||
var sb strings.Builder
|
||||
for {
|
||||
_, _, body, err := rconRecv(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
sb.WriteString(body)
|
||||
if sb.Len() > rconMaxBody*4 {
|
||||
break
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", errors.New("rcon: empty response")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func rconSend(w io.Writer, id, typ int32, body string) error {
|
||||
buf := new(bytes.Buffer)
|
||||
// size counts everything after itself: id + type + body + two NUL bytes.
|
||||
binary.Write(buf, binary.LittleEndian, int32(10+len(body)))
|
||||
binary.Write(buf, binary.LittleEndian, id)
|
||||
binary.Write(buf, binary.LittleEndian, typ)
|
||||
buf.WriteString(body)
|
||||
buf.Write([]byte{0, 0})
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func rconRecv(r io.Reader) (id, typ int32, body string, err error) {
|
||||
var size int32
|
||||
if err = binary.Read(r, binary.LittleEndian, &size); err != nil {
|
||||
return
|
||||
}
|
||||
if size < 10 || size > rconMaxBody+16 {
|
||||
err = errors.New("rcon: implausible packet size " + strconv.Itoa(int(size)))
|
||||
return
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err = io.ReadFull(r, buf); err != nil {
|
||||
return
|
||||
}
|
||||
id = int32(binary.LittleEndian.Uint32(buf[0:4]))
|
||||
typ = int32(binary.LittleEndian.Uint32(buf[4:8]))
|
||||
body = string(bytes.TrimRight(buf[8:], "\x00"))
|
||||
return
|
||||
}
|
||||
|
||||
// --- TPS ---------------------------------------------------------------
|
||||
|
||||
// tpsInfo is the parsed result of `neoforge tps`.
|
||||
type tpsInfo struct {
|
||||
TPS float64 `json:"tps"`
|
||||
MSPT float64 `json:"mspt"`
|
||||
Dimensions []dimensionT `json:"dimensions"`
|
||||
}
|
||||
|
||||
type dimensionT struct {
|
||||
Name string `json:"name"`
|
||||
TPS float64 `json:"tps"`
|
||||
MSPT float64 `json:"mspt"`
|
||||
}
|
||||
|
||||
// NeoForge 1.21.1 prints one line per dimension then an "Overall:" line:
|
||||
//
|
||||
// Overworld: 20.000 TPS (0.298 ms/tick)
|
||||
// Overall: 20.000 TPS (0.357 ms/tick)
|
||||
//
|
||||
// Verified against the running server — note this is NOT the older Forge
|
||||
// "Mean tick time / Mean TPS" wording, and `forge tps` does not exist here
|
||||
// ("Unknown or incomplete command").
|
||||
var tpsLineRe = regexp.MustCompile(`(?m)^(.+?):\s*([0-9.]+)\s*TPS\s*\(([0-9.]+)\s*ms/tick\)`)
|
||||
|
||||
// colorCodes strips Minecraft §-codes and any ANSI escapes a wrapper added.
|
||||
var colorCodes = regexp.MustCompile("§.|\x1b\\[[0-9;]*m")
|
||||
|
||||
func parseTPS(raw string) (*tpsInfo, error) {
|
||||
clean := colorCodes.ReplaceAllString(raw, "")
|
||||
matches := tpsLineRe.FindAllStringSubmatch(clean, -1)
|
||||
if len(matches) == 0 {
|
||||
// Most likely "Unknown or incomplete command" — treat as unavailable
|
||||
// rather than an error that kills the poll loop.
|
||||
return nil, errors.New("rcon: no TPS lines in response")
|
||||
}
|
||||
|
||||
out := &tpsInfo{}
|
||||
for _, m := range matches {
|
||||
name := strings.TrimSpace(m[1])
|
||||
tps, _ := strconv.ParseFloat(m[2], 64)
|
||||
mspt, _ := strconv.ParseFloat(m[3], 64)
|
||||
if strings.EqualFold(name, "Overall") {
|
||||
out.TPS, out.MSPT = tps, mspt
|
||||
continue
|
||||
}
|
||||
out.Dimensions = append(out.Dimensions, dimensionT{Name: name, TPS: tps, MSPT: mspt})
|
||||
}
|
||||
if out.TPS == 0 && len(out.Dimensions) > 0 {
|
||||
// No "Overall" line — fall back to the worst dimension so the tile still
|
||||
// says something useful rather than showing 0 TPS.
|
||||
worst := out.Dimensions[0]
|
||||
for _, d := range out.Dimensions[1:] {
|
||||
if d.TPS < worst.TPS {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
out.TPS, out.MSPT = worst.TPS, worst.MSPT
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
<!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); }
|
||||
|
||||
.chip.bell { font-size: 13px; line-height: 1.15; padding: 4px 7px; margin-left: 4px; }
|
||||
.chip.bell[data-state="on"] { color: var(--accent); border-color: var(--accent); }
|
||||
.chip.bell[data-state="blocked"],
|
||||
.chip.bell[data-state="unsupported"] { color: var(--red); cursor: not-allowed; }
|
||||
|
||||
/* --- dashboard tiles --- */
|
||||
#tiles {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tile h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
}
|
||||
.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; }
|
||||
.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.tile .sub.warnish { color: var(--gold); }
|
||||
.tile .sub.badish { color: var(--red); }
|
||||
.good { color: var(--accent); }
|
||||
.warn { color: var(--gold); }
|
||||
.bad { color: var(--red); }
|
||||
|
||||
.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||
.bar > i.warn { background: var(--gold); }
|
||||
.bar > i.bad { background: var(--red); }
|
||||
|
||||
.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||
.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); }
|
||||
.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); }
|
||||
|
||||
.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; }
|
||||
.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sessions .j { color: var(--accent); }
|
||||
.sessions .l { color: var(--dim); }
|
||||
|
||||
#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>
|
||||
<button class="chip bell" id="bell" data-state="off" aria-pressed="false" title="join/leave notifications">🔕</button>
|
||||
</header>
|
||||
|
||||
<div id="tiles">
|
||||
<div class="tile" id="t-server"> <h2>Server</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-tps"> <h2>TPS</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-players"> <h2>Online</h2> <div class="big">—</div><div class="heads"></div></div>
|
||||
<div class="tile" id="t-reg"> <h2>Registered</h2><div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-disk"> <h2>Disk</h2> <div class="big">—</div><div class="sub"></div><div class="bar"><i></i></div></div>
|
||||
<div class="tile" id="t-backup"> <h2>Last backup</h2><div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-sessions"><h2>Recent joins</h2><div class="sessions"></div></div>
|
||||
</div>
|
||||
|
||||
<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 };
|
||||
|
||||
// Two bracket groups, then the level: the timestamp format differs between
|
||||
// itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]),
|
||||
// so match the *shape* rather than the timestamp:
|
||||
// [<anything>] [<thread>/LEVEL] [<logger>]: message
|
||||
const LEVEL_RE = /^\[[^\]]*\]\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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dashboard tiles ------------------------------------------------------
|
||||
|
||||
// Tiles move far slower than the log does. Poll harder only while join/leave
|
||||
// notifications are on, since then the poll interval IS the alert latency.
|
||||
const SUMMARY_IDLE_MS = 10000;
|
||||
const SUMMARY_NOTIFY_MS = 5000;
|
||||
|
||||
const fmtBytes = (n) => {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
|
||||
};
|
||||
|
||||
const fmtAge = (s) => {
|
||||
if (s == null) return "—";
|
||||
if (s < 60) return s + "s ago";
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
||||
return Math.floor(s / 86400) + "d ago";
|
||||
};
|
||||
|
||||
// Set a tile's big value, its class, and its subtitle in one go. `err` wins
|
||||
// over the value: a stale number with no warning would be a lie.
|
||||
function tile(id, big, bigClass, sub, subClass, err) {
|
||||
const el = document.getElementById(id);
|
||||
el.querySelector(".big").textContent = big;
|
||||
el.querySelector(".big").className = "big " + (bigClass || "");
|
||||
const s = el.querySelector(".sub");
|
||||
if (s) {
|
||||
s.textContent = err ? err : (sub || "");
|
||||
s.className = "sub " + (err ? "badish" : (subClass || ""));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderSummary(d) {
|
||||
// Server
|
||||
tile("t-server",
|
||||
d.server.online ? "online" : "offline",
|
||||
d.server.online ? "good" : "bad",
|
||||
d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "",
|
||||
"", d.server_err);
|
||||
|
||||
// TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad.
|
||||
if (d.tps) {
|
||||
const t = d.tps.tps;
|
||||
tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad",
|
||||
d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err);
|
||||
} else {
|
||||
tile("t-tps", "—", "", "", "", d.tps_err || "unavailable");
|
||||
}
|
||||
|
||||
// Online players + heads (rendered by NMSR on its own public origin)
|
||||
const pl = d.server.players || [];
|
||||
tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : "");
|
||||
const heads = document.querySelector("#t-players .heads");
|
||||
heads.replaceChildren();
|
||||
for (const p of pl) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "head";
|
||||
if (d.avatar_url && p.uuid) {
|
||||
const img = document.createElement("img");
|
||||
img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
wrap.appendChild(img);
|
||||
}
|
||||
const nm = document.createElement("span");
|
||||
nm.textContent = p.name || "?";
|
||||
wrap.appendChild(nm);
|
||||
heads.appendChild(wrap);
|
||||
}
|
||||
|
||||
tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err);
|
||||
|
||||
// Disk — the failure that actually takes the server down.
|
||||
const used = d.disk.used_pct || 0;
|
||||
const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good";
|
||||
tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls,
|
||||
used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err);
|
||||
const bar = document.querySelector("#t-disk .bar > i");
|
||||
bar.style.width = Math.min(100, used) + "%";
|
||||
bar.className = cls === "good" ? "" : cls;
|
||||
|
||||
// Backup freshness. mc-backup runs every 6h, so >8h means it stopped.
|
||||
if (d.backup && d.backup.count) {
|
||||
const age = d.backup.age_seconds;
|
||||
tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good",
|
||||
`${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err);
|
||||
} else {
|
||||
tile("t-backup", "none", "bad", "", "", d.backup_err);
|
||||
}
|
||||
|
||||
handleSessions(d.sessions);
|
||||
|
||||
// Recent joins/leaves, parsed out of the log we already hold.
|
||||
const box = document.querySelector("#t-sessions .sessions");
|
||||
box.replaceChildren();
|
||||
if (!d.sessions || !d.sessions.length) {
|
||||
const e = document.createElement("div");
|
||||
e.textContent = "no joins in buffer";
|
||||
box.appendChild(e);
|
||||
} else {
|
||||
for (const s of d.sessions.slice(0, 12)) {
|
||||
const row = document.createElement("div");
|
||||
const who = document.createElement("span");
|
||||
who.className = s.action === "joined" ? "j" : "l";
|
||||
who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`;
|
||||
row.appendChild(who);
|
||||
row.appendChild(document.createTextNode(" " + (s.time || "")));
|
||||
box.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSummary() {
|
||||
try {
|
||||
const res = await fetch("/api/summary", { cache: "no-store" });
|
||||
if (res.ok) renderSummary(await res.json());
|
||||
} catch { /* tiles keep their last values; the log poll owns the status dot */ }
|
||||
finally { setTimeout(pollSummary, notifyOn ? SUMMARY_NOTIFY_MS : SUMMARY_IDLE_MS); }
|
||||
}
|
||||
|
||||
// --- join/leave notifications ---------------------------------------------
|
||||
//
|
||||
// Rides the same sessions[] the "Recent joins" tile renders — no second
|
||||
// source, no re-parsing of the log in JS.
|
||||
//
|
||||
// The whole difficulty is not notifying for history, and there are two ways
|
||||
// history shows up: the buffer already holds events when the page loads, and
|
||||
// the monitor's cold-start backfill re-reads 5000 log lines on every deploy,
|
||||
// repopulating the events ring with the SAME events. So newness is decided by
|
||||
// a persisted seen-set keyed on the log's own timestamp — stable across both a
|
||||
// page reload and a container restart, which a server-side counter would not
|
||||
// be (it resets to zero and every event looks new again).
|
||||
|
||||
const NOTIFY_KEY = "ulicraft.monitor.notify";
|
||||
const SEEN_KEY = "ulicraft.monitor.seen";
|
||||
const SEEN_MAX = 500;
|
||||
const BURST_MIN = 3; // more than this in one poll collapses to one notification
|
||||
|
||||
let notifyOn = localStorage.getItem(NOTIFY_KEY) === "1";
|
||||
let seen = new Set(loadSeen());
|
||||
let baselined = false;
|
||||
|
||||
function loadSeen() {
|
||||
try { return JSON.parse(localStorage.getItem(SEEN_KEY)) || []; } catch { return []; }
|
||||
}
|
||||
function saveSeen() {
|
||||
const arr = [...seen].slice(-SEEN_MAX); // Set preserves insertion order
|
||||
seen = new Set(arr);
|
||||
try { localStorage.setItem(SEEN_KEY, JSON.stringify(arr)); } catch { /* quota */ }
|
||||
}
|
||||
|
||||
const evKey = (s) => `${s.time}|${s.player}|${s.action}`;
|
||||
|
||||
function handleSessions(sessions) {
|
||||
if (!Array.isArray(sessions)) return;
|
||||
|
||||
// Server returns newest-first; walk backwards so `seen` stays chronological
|
||||
// and the burst summary reads in the right order.
|
||||
const fresh = [];
|
||||
for (let i = sessions.length - 1; i >= 0; i--) {
|
||||
const k = evKey(sessions[i]);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
fresh.push(sessions[i]);
|
||||
}
|
||||
if (fresh.length) saveSeen();
|
||||
|
||||
// The first response after a page load is history by definition. Flip the
|
||||
// flag unconditionally — doing it only when fresh.length > 0 would mean a
|
||||
// returning user (everything already seen) burns their baseline on the
|
||||
// FIRST REAL event instead, silently swallowing it.
|
||||
const isBaseline = !baselined;
|
||||
baselined = true;
|
||||
if (isBaseline || !fresh.length) return;
|
||||
|
||||
if (!notifyOn || !("Notification" in window) || Notification.permission !== "granted") return;
|
||||
|
||||
if (fresh.length > BURST_MIN) {
|
||||
// A server restart makes everyone rejoin at once; one line beats six.
|
||||
const joined = fresh.filter(s => s.action === "joined").length;
|
||||
const left = fresh.length - joined;
|
||||
const parts = [];
|
||||
if (joined) parts.push(`${joined} joined`);
|
||||
if (left) parts.push(`${left} left`);
|
||||
notify("Player activity", parts.join(", "), "burst:" + evKey(fresh[fresh.length - 1]));
|
||||
} else {
|
||||
for (const s of fresh) notify(`${s.player} ${s.action}`, s.time, evKey(s));
|
||||
}
|
||||
}
|
||||
|
||||
function notify(title, body, tag) {
|
||||
try {
|
||||
const n = new Notification(title, { body, tag }); // tag: identical events can't stack
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
} catch { /* some browsers throw outside a service worker; nothing to do */ }
|
||||
}
|
||||
|
||||
const bellEl = document.getElementById("bell");
|
||||
|
||||
function paintBell() {
|
||||
const supported = "Notification" in window;
|
||||
const blocked = supported && Notification.permission === "denied";
|
||||
const state = !supported ? "unsupported" : blocked ? "blocked" : notifyOn ? "on" : "off";
|
||||
bellEl.dataset.state = state;
|
||||
bellEl.setAttribute("aria-pressed", String(state === "on"));
|
||||
bellEl.textContent = state === "on" ? "🔔" : "🔕";
|
||||
bellEl.title = {
|
||||
unsupported: "this browser has no Notification API",
|
||||
// Say this out loud: "enabled it and nothing happened" is otherwise
|
||||
// indistinguishable from the feature being broken.
|
||||
blocked: "blocked in browser settings — allow notifications for this site",
|
||||
on: "join/leave notifications ON",
|
||||
off: "join/leave notifications off — click to enable",
|
||||
}[state];
|
||||
}
|
||||
|
||||
bellEl.addEventListener("click", async () => {
|
||||
if (!("Notification" in window)) return;
|
||||
if (notifyOn) {
|
||||
notifyOn = false;
|
||||
} else {
|
||||
// requestPermission must be called from a user gesture — i.e. this click,
|
||||
// never on page load.
|
||||
if (Notification.permission === "default") {
|
||||
try { await Notification.requestPermission(); } catch { /* ignore */ }
|
||||
}
|
||||
if (Notification.permission !== "granted") { paintBell(); return; }
|
||||
notifyOn = true;
|
||||
}
|
||||
localStorage.setItem(NOTIFY_KEY, notifyOn ? "1" : "0");
|
||||
paintBell();
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
|
||||
// [data-level] only — the bell is also a .chip but is not a level filter.
|
||||
for (const chip of document.querySelectorAll(".chip[data-level]")) {
|
||||
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(); });
|
||||
|
||||
paintBell();
|
||||
poll();
|
||||
pollSummary();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+11
-1
@@ -74,11 +74,13 @@
|
||||
"coroutil-neoforge-1.21.0-1.3.9.jar": "both",
|
||||
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
|
||||
"corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar": "both",
|
||||
"craftabletotemofundying-1.21.1-0.1.jar": "both",
|
||||
"create-1.21.1-6.0.10.jar": "both",
|
||||
"create-central-kitchen-2.5.0.jar": "both",
|
||||
"create-enchantment-industry-2.4.1.jar": "both",
|
||||
"create-integrated-farming-1.2.6.jar": "both",
|
||||
"create_connected-1.2.2-mc1.21.1.jar": "both",
|
||||
"create_jet_boots-1.21.1-1.1.3-6.0.6.jar": "both",
|
||||
"create_jetpack-forge-5.1.2.jar": "both",
|
||||
"create_jetpack_curios-1.2.0-neoforge-1.21.1.jar": "both",
|
||||
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
|
||||
@@ -93,6 +95,7 @@
|
||||
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
||||
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
||||
"defaultoptions-neoforge-1.21.1-21.1.7.jar": "both",
|
||||
"dirigo-neoforge-1.21.1-1.3.0.jar": "both",
|
||||
"drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar": "client",
|
||||
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
||||
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
||||
@@ -109,6 +112,8 @@
|
||||
"fastleafdecay-35.jar": "both",
|
||||
"ferritecore-7.0.3-neoforge.jar": "both",
|
||||
"fishingoverhaul-1.21.1-1.2.1.jar": "both",
|
||||
"flightapi-neoforge-1.1.1.jar": "both",
|
||||
"friendsforlife-1.2.1.jar": "both",
|
||||
"ftb-chunks-neoforge-2101.1.17.jar": "both",
|
||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||
"ftb-library-neoforge-2101.1.33.jar": "both",
|
||||
@@ -137,6 +142,7 @@
|
||||
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
|
||||
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
|
||||
"mechanicals-1.21.1-1.1.0.jar": "both",
|
||||
"mechtrowel-1.3.2.1.jar": "both",
|
||||
"melody_neoforge_1.0.10_MC_1.21.jar": "client",
|
||||
"melter-1.21.1-1.8.2.jar": "both",
|
||||
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
||||
@@ -145,6 +151,7 @@
|
||||
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
|
||||
"morefunctionalstorage-1.2.3.jar": "both",
|
||||
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
||||
"owo-lib-neoforge-0.12.15.5-beta.1+1.21.jar": "both",
|
||||
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
||||
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
||||
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
||||
@@ -152,11 +159,13 @@
|
||||
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
||||
"rhino-2101.2.7-build.85.jar": "both",
|
||||
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
|
||||
"sawmill-1.21-1.7.2-neoforge.jar": "both",
|
||||
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
|
||||
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
||||
"sliceanddice-forge-4.2.4.jar": "both",
|
||||
"smartfeedingtroughs-neoforge-1.21.1-1.2.0.jar": "both",
|
||||
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
|
||||
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
||||
"sodium-neoforge-0.8.13+mc1.21.1.jar": "client",
|
||||
"solcarrot-1.21.1-1.16.6.jar": "both",
|
||||
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
||||
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
||||
@@ -167,6 +176,7 @@
|
||||
"stattweaks-neoforge-v2.0.0.jar": "both",
|
||||
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
|
||||
"supermartijn642corelib-1.1.21-neoforge-mc1.21.jar": "both",
|
||||
"supplementaries-neoforge-1.21.1-3.7.7.jar": "both",
|
||||
"tagtooltips-neoforge-1.21.1-1.2.0.jar": "client",
|
||||
"titanium-1.21-4.0.43.jar": "both",
|
||||
"toolsjs-0.1.1.jar": "both",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Read-only static browse of the same share (files.) — caddy serves the dir
|
||||
# listing, no auth. Read-only, so no upload tuning: standard 4m body is fine.
|
||||
#
|
||||
# Rendered into ulicraft-caddy.conf.tmpl at ${FILES_BROWSE_SERVER} by
|
||||
# render-nginx.sh, ONLY when FILES_BROWSE=on. With `off` the placeholder
|
||||
# collapses to a comment, so the name has no TLS vhost at all (and needs no
|
||||
# cert — drop `files` from LE_SUBDOMAINS on that host).
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name files.${BASE_DOMAIN};
|
||||
|
||||
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.${BASE_DOMAIN} 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;
|
||||
}
|
||||
|
||||
@@ -191,25 +191,15 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Read-only static browse of the same share (files.) — caddy serves the dir
|
||||
# listing, no auth. Read-only, so no upload tuning: standard 4m body is fine.
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name files.${BASE_DOMAIN};
|
||||
# files. (read-only browse) goes here — the whole server block, or a single
|
||||
# comment line when FILES_BROWSE=off. Source: nginx/files-browse.server.tmpl,
|
||||
# spliced in by render-nginx.sh.
|
||||
${FILES_BROWSE_SERVER}
|
||||
|
||||
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
|
||||
}
|
||||
}
|
||||
# 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;
|
||||
|
||||
+6
-2
@@ -152,9 +152,13 @@ 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)
|
||||
|
||||
## Boot/dependency order
|
||||
|
||||
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
||||
`nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
|
||||
(no ordering constraints — none of them are on the auth/boot path).
|
||||
`nmsr`, `mc-status`, `uptime-kuma`, `filestash` and `monitor` are always-on and
|
||||
join `mcnet` (no ordering constraints — none of them are on the auth/boot path).
|
||||
`monitor` reads Minecraft's log volume and queries its RCON, but starts fine
|
||||
without either: a missing log file and a refused RCON dial are ordinary states
|
||||
it reports rather than errors it dies on.
|
||||
@@ -5,13 +5,13 @@
|
||||
> card. Both ping `minecraft` over `mcnet` but serve different purposes.
|
||||
|
||||
Status monitoring + public status page. Service `uptime-kuma`
|
||||
(`louislam/uptime-kuma:1`), web UI on `:3001`, reached only through caddy at
|
||||
(`louislam/uptime-kuma:2`), web UI on `:3001`, reached only through caddy at
|
||||
`https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other
|
||||
service by its internal container name.
|
||||
|
||||
## Add a Minecraft monitor
|
||||
|
||||
Uptime Kuma 1.x ships a built-in **Minecraft Server** monitor type. It uses the
|
||||
Uptime Kuma ships a built-in **Minecraft Server** monitor type. It uses the
|
||||
Server List Ping protocol (the same ping the vanilla multiplayer list uses):
|
||||
unauthenticated, so it works fine with `ONLINE_MODE=FALSE` /
|
||||
`ENFORCE_SECURE_PROFILE=FALSE`. It reports latency, online/max players, and MOTD.
|
||||
@@ -53,6 +53,12 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
|
||||
`https://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
|
||||
`https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
|
||||
`distribution.` here explains client mod-mismatch join failures.
|
||||
- **1 → 2 upgrade (2026-10-25)** — moved off `:1`, whose image runs Node 18
|
||||
(EOL, no fix for the Oct 2026 Node CVEs). The first `:2` start migrates
|
||||
`kuma.db` in place (one-way, minutes on a ~140M DB; status page down until it
|
||||
finishes — don't interrupt). Per host: `docker compose stop uptime-kuma`, tar
|
||||
the `kuma_data` volume into `./backups/`, then `pull` + `up -d uptime-kuma`.
|
||||
Rollback = restore the tarball + revert the tag to `:1`.
|
||||
|
||||
> **An HTTP monitor cannot see an SPA break.** It asserts "the server returned
|
||||
> 200", which is a much weaker claim than "the app works". `files.` (Filestash)
|
||||
|
||||
@@ -36,6 +36,49 @@ Certs install to `certs/<name>/{cert.pem,key.pem}` (gitignored). acme.sh adds a
|
||||
cron for renewal; `--reloadcmd` reloads nginx automatically on renew. Override
|
||||
the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
|
||||
|
||||
### Gotcha — install paths must be ABSOLUTE (silent expiry, 2026-09-06)
|
||||
|
||||
`--install-cert` paths are **persisted in acme.sh's domain conf**
|
||||
(`~/.acme.sh/<name>_ecc/<name>.conf` → `Le_RealKeyPath` /
|
||||
`Le_RealFullChainPath`) and replayed on every cron renewal. The cron runs with
|
||||
`cwd=$HOME`, not the repo — so a **relative** path (`certs/<name>/key.pem`)
|
||||
resolves to `~/certs/...` and the install writes nothing.
|
||||
|
||||
Failure mode is silent and delayed: `acme.sh --list` shows every cert freshly
|
||||
renewed with a future `Renew` date, `~/.acme.sh/<name>_ecc/fullchain.cer` is
|
||||
valid — while `certs/<name>/cert.pem` is frozen at the last **manual** run and
|
||||
nginx serves that until it expires. There is no error anywhere. Cost: apex,
|
||||
`auth.` and `pack.` served expired certs on 2026-09-06.
|
||||
|
||||
Fix / recovery — re-run `--install-cert` with absolute paths, which both copies
|
||||
the current cert and rewrites the persisted paths:
|
||||
|
||||
```bash
|
||||
APP=/home/ubuntu/mc/ulicraft-server-v1
|
||||
for d in $(ls $APP/certs); do
|
||||
~/.acme.sh/acme.sh --install-cert -d "$d" --ecc \
|
||||
--key-file "$APP/certs/$d/key.pem" \
|
||||
--fullchain-file "$APP/certs/$d/cert.pem" \
|
||||
--reloadcmd "sudo systemctl reload nginx || true"
|
||||
done
|
||||
```
|
||||
|
||||
`issue-letsencrypt.sh` now absolutises `CERT_DIR` so a fresh issue can't
|
||||
reintroduce it.
|
||||
|
||||
**Verify from off-host, not from the files** — the installed file and what nginx
|
||||
serves are the same thing here, but the acme.sh copy is not:
|
||||
|
||||
```bash
|
||||
for h in ulicraft.net auth.ulicraft.net avatar.ulicraft.net status.ulicraft.net \
|
||||
distribution.ulicraft.net files.ulicraft.net upload.ulicraft.net \
|
||||
monitor.ulicraft.net generators.ulicraft.net; do
|
||||
printf '%-30s ' "$h"
|
||||
echo | openssl s_client -servername $h -connect $h:443 2>/dev/null \
|
||||
| openssl x509 -noout -enddate
|
||||
done
|
||||
```
|
||||
|
||||
## nginx
|
||||
|
||||
1. Bring the stack up (caddy publishes on `127.0.0.1:${CADDY_HTTP_PORT}`):
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
| `files.${BASE_DOMAIN}` | `62-files.caddy` | static `/srv/files` (read-only, no-auth browse of the share) |
|
||||
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||
| `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) |
|
||||
| `monitor.${BASE_DOMAIN}` | `80-monitor.caddy` | `monitor:8080` (admin log stream + dashboard, **basic_auth**) |
|
||||
|
||||
---
|
||||
|
||||
@@ -110,9 +111,40 @@ passes it through, so don't rewrite it anywhere in the chain.
|
||||
|
||||
---
|
||||
|
||||
## Monitor — `monitor.${BASE_DOMAIN}` (admin only)
|
||||
|
||||
`reverse_proxy monitor:8080`, behind caddy `basic_auth` (bcrypt,
|
||||
`MONITOR_USER` / `MONITOR_PASSWORD_HASH`). **The only vhost in the stack that is
|
||||
gated at caddy**, because the service itself has no auth and publishes no port —
|
||||
caddy is the sole way in. Per-host on/off via `MONITOR_ENABLED`.
|
||||
|
||||
Read-only: every route is a `GET`, and nothing mutates state.
|
||||
|
||||
- `GET /` — the whole UI (single HTML file embedded in the binary).
|
||||
- `GET /api/log[?since=<seq>]` → `{lines[], seq, dropped, reset, total, tail{}}`.
|
||||
Omit `since` for the full buffer without a spurious "dropped" notice.
|
||||
`since` is a **sequence number, not a byte offset** — the log rotates on every
|
||||
deploy. `reset: true` means discard what you have (fell behind, or the service
|
||||
restarted and your cursor is in the future).
|
||||
- `GET /api/summary` → all dashboard tiles in one response. Each part is
|
||||
independently cached and degrades on its own; failures appear as sibling
|
||||
`*_err` fields next to the last good value.
|
||||
- `GET /healthz` → `ok`. Liveness only, deliberately not tied to the log file.
|
||||
|
||||
Full design + gotchas: `plan/23-monitor.md`.
|
||||
|
||||
---
|
||||
|
||||
## Internal (service → service, over mcnet)
|
||||
|
||||
- `minecraft` → `-javaagent authlib-injector=http://auth.${BASE_DOMAIN}/authlib-injector` (caddy mcnet alias → drasl).
|
||||
- `nmsr` → `http://auth.${BASE_DOMAIN}` (mojang-compat session/textures/api).
|
||||
- `mc-status` → `http://drasl:25585/drasl/api/v2` (`DRASL_API_URL`) for the roster; pings `MC_STATUS_TARGET` for live status.
|
||||
- `mc-backup` → RCON `minecraft:25575`.
|
||||
- `monitor` → `http://mc-status:8080` (`/v2/status/java/*` + `/players`) for the
|
||||
status and roster tiles, so it needs no Drasl credentials of its own; RCON
|
||||
`minecraft:25575` for TPS, restricted to a compile-time allowlist
|
||||
(`neoforge tps`, `list`) that no HTTP input can reach. Reads
|
||||
`mc_data:/data:ro` (log + `statfs`) and `./backups:ro` (backup freshness).
|
||||
- `uptime-kuma` → can probe `http://monitor:8080/healthz` directly, bypassing
|
||||
caddy, so the monitor vhost needs no unauthenticated path carve-out.
|
||||
+35
-9
@@ -7,9 +7,9 @@
|
||||
>
|
||||
> The same `${FILES_DATA_DIR}` is also exposed at **`files.${BASE_DOMAIN}`** as a
|
||||
> plain caddy `file_server browse` — **read-only, no auth**, every uploaded file
|
||||
> publicly listable (`62-files.caddy`). Filestash gets its own subdomain because
|
||||
> it cannot be served under a subpath (SPA hardcodes `/api`, `/assets`, `/login`
|
||||
> at root).
|
||||
> publicly listable (`62-files.on.caddy`, toggled per host by `FILES_BROWSE`).
|
||||
> Filestash gets its own subdomain because it cannot be served under a subpath
|
||||
> (SPA hardcodes `/api`, `/assets`, `/login` at root).
|
||||
>
|
||||
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
||||
>
|
||||
@@ -30,7 +30,7 @@ Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Vhost (filestash) | `upload.${BASE_DOMAIN}` |
|
||||
| Vhost (static browse) | `files.${BASE_DOMAIN}` — read-only, no auth, `62-files.caddy` |
|
||||
| Vhost (static browse) | `files.${BASE_DOMAIN}` — read-only, no auth, `62-files.on.caddy`, per-host `FILES_BROWSE=on\|off` |
|
||||
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
|
||||
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
||||
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
|
||||
@@ -64,13 +64,38 @@ blocklisting (which would take `auth.` and the apex down with it, breaking login
|
||||
and the launcher for every player) all land on you. `render-config.sh` prints a
|
||||
loud warning when it renders this mode.
|
||||
|
||||
**`files.` is unconditionally public read.** Independent of `FILES_AUTH`, the
|
||||
`62-files.caddy` static browse serves the whole share with **no auth** — anyone
|
||||
who knows `files.${BASE_DOMAIN}` can list and download every uploaded file. This
|
||||
is deliberate (chosen 2026-07-24). The htpasswd login only gates *writes* (via
|
||||
**`files.` is public read wherever it is enabled.** Independent of `FILES_AUTH`,
|
||||
the static browse serves the whole share with **no auth** — anyone who reaches
|
||||
`files.${BASE_DOMAIN}` can list and download every uploaded file. This is
|
||||
deliberate (chosen 2026-07-24). The htpasswd login only gates *writes* (via
|
||||
`upload.`); it never gated reads at this URL. Don't put anything private in the
|
||||
share.
|
||||
|
||||
**`FILES_BROWSE=on|off` turns the whole `files.` vhost off per host** (added
|
||||
2026-07-30) — the answer to "I want it on `oxps` (LAN party) but not on `cochi`
|
||||
(internet-facing)". One var, two layers, because each has its own conditional
|
||||
problem:
|
||||
|
||||
| Layer | Mechanism |
|
||||
|---|---|
|
||||
| caddy | `docker-compose.yml` substitutes the value into the mount source: `./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy` → `/etc/caddy/conf.d/62-files.caddy`. `62-files.off.caddy` is a comments-only stub (compose has no conditionals, and caddy needs a parseable file at the fixed mount target — same trick as `FILES_MOUNT_MODE`). |
|
||||
| nginx | `render-nginx.sh` splices `nginx/files-browse.server.tmpl` into `${FILES_BROWSE_SERVER}` when `on`, or a comment when `off`, and adds/omits the name in the port-80 redirect (nginx has no conditionals either). |
|
||||
|
||||
nginx is the gate that matters — with no TLS vhost the name is unreachable from
|
||||
the internet regardless of caddy (caddy is bound to `127.0.0.1`). The caddy stub
|
||||
is the second layer, and it also matters on hosts reached **directly** on caddy's
|
||||
port rather than through nginx (`oxps` on the LAN). Applying a change:
|
||||
|
||||
```sh
|
||||
tooling/render-nginx.sh --install # nginx vhost in/out + reload
|
||||
docker compose up -d --force-recreate caddy # single-file bind mount pins the inode
|
||||
```
|
||||
|
||||
`restart caddy` is NOT enough (the conf mount is a single file — see
|
||||
`plan/14-deploy.md`). With `off`, `files` also needs dropping from
|
||||
`LE_SUBDOMAINS` on that host: no vhost, no cert to renew. `upload.` (filestash,
|
||||
authenticated, writable) is untouched by this var.
|
||||
|
||||
**Per-host auth status (the two prod envs diverge):**
|
||||
|
||||
| Host | `upload.` reachability | `FILES_AUTH` | Notes |
|
||||
@@ -106,13 +131,14 @@ See `.env.example` for the annotated block. Summary:
|
||||
|---|---|
|
||||
| `FILES_DATA_DIR` | host path of the share, outside the repo |
|
||||
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
|
||||
| `FILES_BROWSE` | `on` \| `off` — serve the `files.` read-only browse on this host |
|
||||
| `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` |
|
||||
| `FILES_USER` | shared login name |
|
||||
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
|
||||
| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'` |
|
||||
| `FILES_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
|
||||
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
|
||||
| `LE_SUBDOMAINS` | must include `upload` **and** `files` (both are vhosts) |
|
||||
| `LE_SUBDOMAINS` | must include `upload`, plus `files` when `FILES_BROWSE=on` |
|
||||
|
||||
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||
both secrets. Only the `.tmpl` is tracked.
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
# 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).
|
||||
|
||||
### `/api/summary` — one response, independently-degrading parts
|
||||
|
||||
Every tile has its own TTL cache and its own failure mode (`collect.go`). A
|
||||
collector that fails keeps serving its last good value and reports the reason in
|
||||
a sibling `*_err` field, so the page shows *stale with a warning* rather than a
|
||||
blank tile or a spinner that never resolves. Verified: with mc-status killed,
|
||||
`server_err` is populated while the disk, backup and log tiles keep working.
|
||||
|
||||
The five collectors are refreshed **concurrently**. Sequentially, one request
|
||||
during an outage would cost the sum of every timeout (~15s with mc-status
|
||||
hanging and RCON blackholed) — and an outage is precisely when someone is
|
||||
staring at the page. Measured with a dead dependency: 0.24s.
|
||||
|
||||
TTLs: status 15s, roster 60s, disk 60s, backup 60s, TPS 30s. The UI polls
|
||||
`/api/summary` every 10s and `/api/log` every 2s — tiles move far more slowly
|
||||
than the log does.
|
||||
|
||||
## Join/leave browser notifications (opt-in)
|
||||
|
||||
A bell toggle in the header raises a desktop notification when a player joins or
|
||||
leaves. It rides the **same `sessions[]`** the Recent joins tile renders — no
|
||||
second data source, no re-parsing of the log in JS. Entirely client-side:
|
||||
no server change, no new egress, no secret. The container still reaches nothing
|
||||
outside `mcnet`.
|
||||
|
||||
Deliberately **not** a push channel. Chat push (Telegram/Discord/ntfy) would
|
||||
reach a phone with nothing open, but it needs a CA bundle in the `scratch` image
|
||||
(this container currently makes no TLS calls at all), a token in `.env`, and
|
||||
outbound internet egress it does not have today. Browser notifications cost none
|
||||
of that, at the price of only firing while the page is open — a background tab
|
||||
is fine, a closed tab or sleeping laptop is not.
|
||||
|
||||
### Newness is a seen-set keyed on the log timestamp
|
||||
|
||||
The whole difficulty is *not* notifying for history, and history arrives two
|
||||
ways: the events ring is already populated when the page loads, and **the
|
||||
cold-start backfill re-reads 5000 log lines on every deploy**, repopulating the
|
||||
ring with the same events.
|
||||
|
||||
So the client keeps a `time|player|action` seen-set in `localStorage` (capped at
|
||||
the 500 newest keys). That key is stable across both a page reload and a
|
||||
container restart. A server-side sequence number would **not** be — it resets to
|
||||
zero on restart, so every historical event would look new and every deploy would
|
||||
fire a notification burst.
|
||||
|
||||
### The baseline flag must flip unconditionally
|
||||
|
||||
The first response after a page load is history by definition, so it seeds the
|
||||
seen-set and notifies nothing. The subtle part, caught by the tests rather than
|
||||
by reading the code: that flag has to flip **whether or not any fresh events were
|
||||
found**. A returning user already has every key in `localStorage`, so their first
|
||||
response yields nothing fresh — flipping the flag lazily would burn the baseline
|
||||
on their first *real* event instead and silently swallow it.
|
||||
|
||||
### Other behaviour
|
||||
|
||||
- **Opt-in, off by default.** Permission is requested from the bell click (a
|
||||
user gesture), never on page load.
|
||||
- **A denied permission shows a distinct blocked state**, because "I enabled it
|
||||
and nothing happened" is otherwise indistinguishable from a bug.
|
||||
- **Bursts coalesce**: more than 3 events in one poll (everyone rejoining after a
|
||||
restart) becomes one "N joined, M left".
|
||||
- **While enabled, `/api/summary` polls every 5s instead of 10s** — with this
|
||||
feature on, the poll interval *is* the alert latency.
|
||||
- Events are marked seen even while the toggle is off, so enabling it does not
|
||||
replay a backlog.
|
||||
- Each notification carries a `tag` derived from its event key, so a duplicate
|
||||
cannot stack.
|
||||
|
||||
### Running as root, on purpose
|
||||
|
||||
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
|
||||
cannot traverse it, so the tailer would read nothing — silently. The container
|
||||
therefore runs as root, and what actually bounds the damage is that every mount
|
||||
is `:ro` and no port is published. Do not "harden" this by adding a `USER`
|
||||
directive without re-checking those permissions first.
|
||||
|
||||
## 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`.
|
||||
Confirmed on the running server; `forge tps` answers *"Unknown or incomplete
|
||||
command"*. An unknown-command reply is treated as "TPS unavailable" rather
|
||||
than being allowed to kill the poll loop.
|
||||
- **The output format is NOT the old Forge "Mean tick time / Mean TPS" wording**
|
||||
this doc originally assumed. Actual, captured from prod:
|
||||
|
||||
```
|
||||
Overworld: 20.000 TPS (0.298 ms/tick)
|
||||
The Nether: 20.000 TPS (0.014 ms/tick)
|
||||
Overall: 20.000 TPS (0.357 ms/tick)
|
||||
```
|
||||
|
||||
One line per dimension plus an `Overall:` line, with `§`/ANSI colour codes
|
||||
mixed in that have to be stripped. `main_test.go` pins this against the real
|
||||
captured string — if a NeoForge update changes the wording, that test fails
|
||||
instead of the tile silently reading zero.
|
||||
- The RCON client is implemented in-repo (`rcon.go`, ~120 lines of the Source
|
||||
RCON protocol) rather than pulled in as a dependency, specifically so the
|
||||
allowlist is a compile-time constant with no code path from HTTP input to a
|
||||
command string. `main_test.go` asserts `op`, `stop`, `say`, `ban` and
|
||||
near-misses like `LIST` and a trailing-space `"neoforge tps "` are all
|
||||
rejected.
|
||||
- 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 — **DONE, live on cochi 2026-08-01**
|
||||
|
||||
- [x] DNS A record for `monitor.${BASE_DOMAIN}`; add to `LE_SUBDOMAINS`; issue cert.
|
||||
(DNS already resolved — the zone has a wildcard, so no record was needed.)
|
||||
- [x] `docker/monitor/` — Go service: ring buffer, tailer (inode/shrink reopen),
|
||||
cold-start backfill, `GET /api/log?since=`, `GET /healthz`, embedded UI.
|
||||
- [x] `ui/index.html` — log pane, sticky autoscroll, filter, level chips.
|
||||
- [x] compose service (no `ports:`, `mc_data:/data:ro`).
|
||||
- [x] `caddy/conf.d/80-monitor.caddy` + `MONITOR_USER`/`MONITOR_PASSWORD_HASH`.
|
||||
- [x] nginx vhost + `MONITOR_ENABLED` gate; render + reload.
|
||||
- [x] Deploy with `--hard` (world downtime, 0 players online). Verified below.
|
||||
|
||||
### Phase 2 — tiles — **DONE**
|
||||
|
||||
- [x] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
|
||||
- [x] mc-status reuse: online/MOTD, players online w/ NMSR heads,
|
||||
registered count. (Icon skipped — the MOTD and version carry the same
|
||||
information in a row of small tiles, and the favicon is a base64 blob.)
|
||||
- [x] Join/leave session history parsed from the buffer.
|
||||
- [x] **Fixed a phase-1 bug found while building this:** the UI's level regex
|
||||
assumed itzg's console format (`[12:34:56]`), but the *file* uses
|
||||
`[01Aug2026 22:55:34.358]`. Every line was classified INFO, so the
|
||||
ERROR/WARN chips silently did nothing. Now matches on shape
|
||||
(`[…] […/LEVEL]`) instead of on the timestamp.
|
||||
|
||||
### Phase 3 — TPS — **DONE**
|
||||
|
||||
- [x] RCON client, compile-time allowlist, 30s poll.
|
||||
- [x] Confirmed `neoforge tps` is the right command, and captured its real
|
||||
output format (see *RCON* above — it is not what this doc first assumed).
|
||||
|
||||
### Phase 4 — join/leave browser notifications — **DONE, live 2026-08-02**
|
||||
|
||||
- [x] Bell toggle, opt-in, persisted in `localStorage`; permission requested from
|
||||
the click gesture; blocked state surfaced.
|
||||
- [x] Seen-set newness detection + unconditional first-response baseline.
|
||||
- [x] Burst coalescing; 5s poll while enabled.
|
||||
- [x] Fixed a bug this introduced: the level-chip handler bound to every
|
||||
`.chip`, which now includes the bell. Scoped to `.chip[data-level]`.
|
||||
|
||||
Verified by extracting the shipped block from `index.html` and running it in node
|
||||
against stubbed `localStorage`/`Notification`: silent baseline, new event
|
||||
notifies, re-polling silent, restart-replay silent (same instance *and* after a
|
||||
reload), burst coalescing counts, the returning-user edge case, toggle-off still
|
||||
tracking, denied permission silent, seen-set bounded at the 500 newest keys.
|
||||
|
||||
**Not covered by that harness:** the actual browser permission prompt and whether
|
||||
notifications render — those need a real browser and were not tested. Everything
|
||||
downstream of `Notification.permission === "granted"` is stubbed.
|
||||
|
||||
### 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.
|
||||
|
||||
### Phase 1 results (cochi, 2026-08-01)
|
||||
|
||||
The deploy itself exercised the rotation path: monitor came up while the old
|
||||
`latest.log` still existed, Minecraft then created a new one, and the tailer
|
||||
reported `rotations: 1` and kept streaming. Verified in prod:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `GET /` no creds / wrong creds / right creds | 401 / 401 / 200 |
|
||||
| TLS | `CN=monitor.ulicraft.net`, Let's Encrypt |
|
||||
| Buffer after boot | 908 lines, `tail.ok: true` |
|
||||
| Stream advancing | +109 lines in 12s, `dropped: 0`, `reset: false` |
|
||||
| Rotation across the MC restart | `rotations: 1`, stream uninterrupted |
|
||||
| Port 8080 from the internet | connection refused |
|
||||
| `docker compose port monitor 8080` | `:0` (nothing published) |
|
||||
| Host listeners on :8080 | none |
|
||||
|
||||
Also exercised locally before deploy, against a synthetic log: backfill capped
|
||||
to buffer size, partial (unterminated) lines withheld until their newline lands,
|
||||
`mv`-style rotation, in-place truncation, delete-and-recreate, ring overflow
|
||||
(`dropped: 15, reset: true`), a cursor from the future, malformed `since` (400),
|
||||
unknown path (404).
|
||||
|
||||
**Known cosmetic gap:** a rotation done as delete-then-recreate (rather than
|
||||
`mv`) does not increment the `rotations` counter — the tailer takes the
|
||||
"file was gone, reopen" branch instead of the rotation branch. Streaming is
|
||||
unaffected; only the displayed count is.
|
||||
|
||||
### Phase 2 + 3 results (cochi, 2026-08-01)
|
||||
|
||||
Deployed **rolling**, not `--hard` — no caddy conf changed, so only the monitor
|
||||
container was recreated and Minecraft was never restarted (zero downtime).
|
||||
|
||||
Live values on first request: server online, MOTD "Uli LAN party", 1.21.1 ·
|
||||
**14** registered accounts · **20.000 TPS / 0.307 ms/tick across 6 dimensions** ·
|
||||
88 GB free, 59% used · newest backup 0h old, 57 archives, 14 GB.
|
||||
|
||||
Verified against *real* data rather than assumptions:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `neoforge tps` over the in-repo RCON client | 20.000 TPS, 6 dimensions parsed |
|
||||
| RCON allowlist rejects `op`/`stop`/`say`/`ban`/`LIST`/`"neoforge tps "` | all rejected (unit test) |
|
||||
| Wrong RCON password | clean `authentication failed`, other tiles unaffected |
|
||||
| Session parser vs. real rotated-log join lines | `Patxakita joined/left`, timestamps intact |
|
||||
| UI level regex vs. real log lines | WARN and ERROR now distinguished (was: all INFO) |
|
||||
| NMSR avatar URL the heads use | HTTP 200, `image/png` |
|
||||
| mc-status killed | `server_err` set; disk, backup and log stream unaffected |
|
||||
| 25 concurrent `/api/summary` under `-race` | 0 data races |
|
||||
| `/api/summary` with a dead dependency | 0.24s (sequential would stack timeouts) |
|
||||
@@ -52,7 +52,12 @@ ACME="${ACME_SH:-$HOME/.acme.sh/acme.sh}"
|
||||
curl https://get.acme.sh | sh -s email=${LE_EMAIL}
|
||||
then re-run (or set ACME_SH=/path/to/acme.sh)."
|
||||
|
||||
CERT_DIR="${CERT_DIR:-certs}"
|
||||
# MUST be absolute: acme.sh persists these paths in its domain conf and replays
|
||||
# them on every cron renewal, where cwd is $HOME, not the repo. A relative path
|
||||
# there makes the renewal install silently write nothing -- acme.sh keeps renewing
|
||||
# happily while nginx serves the stale (eventually expired) copy. Bit us 2026-09-06.
|
||||
CERT_DIR="${CERT_DIR:-$PWD/certs}"
|
||||
case "$CERT_DIR" in /*) ;; *) CERT_DIR="$PWD/$CERT_DIR" ;; esac
|
||||
read -r -a SUBDOMAINS <<< "${LE_SUBDOMAINS:-auth pack distribution www avatar status}"
|
||||
|
||||
if [ "${LE_STAGING:-0}" = "1" ]; then
|
||||
|
||||
+51
-2
@@ -17,6 +17,14 @@
|
||||
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
|
||||
# — defaults to the repo root (this checkout)
|
||||
# SITE_NAME installed filename (default ulicraft.conf)
|
||||
# FILES_BROWSE on|off (default on) — emit the files.${BASE_DOMAIN}
|
||||
# read-only browse vhost. `off` omits the server block AND
|
||||
# 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
|
||||
@@ -40,7 +48,48 @@ SITE_NAME="${SITE_NAME:-ulicraft.conf}"
|
||||
export BASE_DOMAIN APP_DIR
|
||||
export CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-80}"
|
||||
|
||||
rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT' < "$TEMPLATE")"
|
||||
# files.${BASE_DOMAIN} — the no-auth read-only browse. Per-host toggle: on for
|
||||
# the LAN-party box, off where the share must not be publicly listable. nginx
|
||||
# has no conditionals, so the vhost is a separate template spliced in here (or
|
||||
# replaced by a comment). Keep in sync with the caddy mount in docker-compose.yml,
|
||||
# which reads the same var.
|
||||
: "${FILES_BROWSE:=on}"
|
||||
FILES_BROWSE_TEMPLATE="${FILES_BROWSE_TEMPLATE:-nginx/files-browse.server.tmpl}"
|
||||
case "$FILES_BROWSE" in
|
||||
on)
|
||||
[ -f "$FILES_BROWSE_TEMPLATE" ] || die "template not found: $FILES_BROWSE_TEMPLATE"
|
||||
FILES_BROWSE_NAME=" files.${BASE_DOMAIN}"
|
||||
FILES_BROWSE_SERVER="$(envsubst '$BASE_DOMAIN $APP_DIR' < "$FILES_BROWSE_TEMPLATE")"
|
||||
;;
|
||||
off)
|
||||
FILES_BROWSE_NAME=""
|
||||
FILES_BROWSE_SERVER="# files.${BASE_DOMAIN} disabled on this host (FILES_BROWSE=off in .env)."
|
||||
;;
|
||||
*) die "FILES_BROWSE must be 'on' or 'off' (got '$FILES_BROWSE')" ;;
|
||||
esac
|
||||
export FILES_BROWSE_NAME FILES_BROWSE_SERVER
|
||||
|
||||
# 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"
|
||||
@@ -52,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)"
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user