Compare commits
20
Commits
5e5367e6d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bd786140 | ||
|
|
d457713186 | ||
|
|
556f86f852 | ||
|
|
6b9b8bd96c | ||
|
|
fe91bf7778 | ||
|
|
0176a674e3 | ||
|
|
671e6af05a | ||
|
|
fbed9a5af7 | ||
|
|
40fb50e86f | ||
|
|
d105fc9acf | ||
|
|
3eb20054a0 | ||
|
|
bac2372c95 | ||
|
|
bc99427ef9 | ||
|
|
00984e1a70 | ||
|
|
53604a6127 | ||
|
|
fca6747bcc | ||
|
|
8507ffd020 | ||
|
|
92c6f5fc8f | ||
|
|
dfb8719074 | ||
|
|
a3c97af374 |
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.
|
# value is substituted straight into the volume string.
|
||||||
FILES_MOUNT_MODE=rw
|
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
|
# 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
|
# 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
|
# 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:
|
# Only needed if using CurseForge-exclusive mods:
|
||||||
# CF_API_KEY=your-curseforge-api-key
|
# CF_API_KEY=your-curseforge-api-key
|
||||||
|
|
||||||
|
# ── Monitor (monitor.${BASE_DOMAIN}) — admin log stream ───────────────
|
||||||
|
# Read-only view of the Minecraft server's latest.log. ADMIN ONLY: the log
|
||||||
|
# carries player IPs, chat and admin commands verbatim, and nothing sanitizes
|
||||||
|
# it. Full design + gotchas: plan/23-monitor.md.
|
||||||
|
#
|
||||||
|
# Per-host toggle, `on` | `off` (default on), same two-layer gating as
|
||||||
|
# FILES_BROWSE: the caddy conf mount (docker-compose.yml substitutes the value
|
||||||
|
# into the path — 80-monitor.on.caddy vs the .off.caddy stub) and the public
|
||||||
|
# nginx vhost (tooling/render-nginx.sh). `off` also drops the name from the
|
||||||
|
# port-80 redirect, so no cert is needed — drop `monitor` from LE_SUBDOMAINS
|
||||||
|
# too on that host.
|
||||||
|
# After changing it: tooling/render-nginx.sh --install
|
||||||
|
# docker compose up -d --force-recreate caddy
|
||||||
|
MONITOR_ENABLED=on
|
||||||
|
|
||||||
|
# The ONLY credential guarding the log stream — caddy basic_auth. The monitor
|
||||||
|
# container has no auth of its own and publishes no port, so this is the whole
|
||||||
|
# access control.
|
||||||
|
MONITOR_USER=admin
|
||||||
|
#
|
||||||
|
# MUST be bcrypt, generated with:
|
||||||
|
# docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
|
||||||
|
# (This is the OPPOSITE of FILES_PASSWORD_HASH, which must be $6$ and silently
|
||||||
|
# rejects bcrypt. Do not reuse a hash between the two.)
|
||||||
|
#
|
||||||
|
# MUST be SINGLE-QUOTED here. Docker Compose interpolates $-sequences in .env
|
||||||
|
# values, so an unquoted bcrypt hash is silently mangled — $2a$14$abcdefg…
|
||||||
|
# becomes $$2a$$14… with the $abcdefg… segment eaten as an unset variable, and
|
||||||
|
# every login fails with no hint as to why. Verified behaviour, not a guess.
|
||||||
|
MONITOR_PASSWORD_HASH='$2a$14$replace.me.with.a.real.bcrypt.hash'
|
||||||
|
|
||||||
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
|
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
|
||||||
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||||
LE_EMAIL=you@example.com
|
LE_EMAIL=you@example.com
|
||||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||||
LE_SUBDOMAINS=auth distribution www avatar status upload files generators
|
LE_SUBDOMAINS=auth distribution www avatar status upload files generators monitor
|
||||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||||
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||||
OVH_END_POINT=ovh-eu
|
OVH_END_POINT=ovh-eu
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ filestash/config.json
|
|||||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||||
server-mods/
|
server-mods/
|
||||||
|
|
||||||
|
# mod configs, seeded out of the mc_data volume by tooling/export-server-config.sh
|
||||||
|
# and then bind-mounted rw at /data/config. Host-specific runtime state (cochi and
|
||||||
|
# oxps diverge), regenerated by the mods themselves — not a source of truth.
|
||||||
|
server-config/
|
||||||
|
|
||||||
# vendored binaries
|
# vendored binaries
|
||||||
launcher/
|
launcher/
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||||
upload.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
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)
|
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||||
|
|
||||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||||
@@ -109,6 +112,22 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
|||||||
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||||
classify/sync time. See `plan/04-mods.md`.
|
classify/sync time. See `plan/04-mods.md`.
|
||||||
|
|
||||||
|
### Writable bind mounts — seed `./server-config` first
|
||||||
|
`/mods`, `/data/config` and the four kubejs source subdirs are mounted **rw**
|
||||||
|
(2026-07-26). Two consequences:
|
||||||
|
- `./server-config:/data/config` is a NEW bind — config used to live inside
|
||||||
|
`mc_data`. A bind mount **shadows** what's under it, so booting with an empty
|
||||||
|
`./server-config` makes every mod regenerate defaults. Run
|
||||||
|
`tooling/export-server-config.sh` once per host, server stopped, before that
|
||||||
|
deploy. The old files survive in `mc_data` under the mount, but the server
|
||||||
|
stops seeing them.
|
||||||
|
- itzg runs as **uid 1000**; a rw mount only works if the host dir permits it.
|
||||||
|
`cochi` is uid 1000, **`oxps` `oier` is uid 1002** → pass `MC_UID`/`MC_GID`.
|
||||||
|
- kubejs writes now land in `$DISTRIBUTION_WEB_ROOT` — i.e. as uncommitted
|
||||||
|
changes in that host's distribution checkout.
|
||||||
|
The kubejs ROOT still must not be mounted (NPE at init) — unchanged by the mode.
|
||||||
|
Full detail: `plan/05-minecraft.md` → "Writable bind mounts".
|
||||||
|
|
||||||
### Filestash (upload.) — four traps
|
### Filestash (upload.) — four traps
|
||||||
Filestash serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA
|
Filestash serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA
|
||||||
hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a
|
hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a
|
||||||
@@ -136,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://`)
|
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||||
while every other check passed.
|
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
|
### Memory sizing
|
||||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
~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
|
`USE_AIKAR_FLAGS: TRUE`. **The pack is now 164 jars / 133 server-side** (Create
|
||||||
@@ -202,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`.
|
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||||
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||||
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
**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.
|
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||||
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||||
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
`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}` |
|
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||||
| `filestash` | machines/filestash (digest-pinned) | player file share (upload) at `upload.${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 |
|
| `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):
|
Vhosts (all proxied through the host nginx → caddy):
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ Vhosts (all proxied through the host nginx → caddy):
|
|||||||
- `avatar.` — NMSR
|
- `avatar.` — NMSR
|
||||||
- `status.` — Uptime Kuma
|
- `status.` — Uptime Kuma
|
||||||
- `files.` — Filestash player file share (one shared login, writable)
|
- `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`)
|
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||||
|
|
||||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
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
|
## DNS
|
||||||
|
|
||||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
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
|
## 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`.
|
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)
|
## Player file share (Filestash)
|
||||||
|
|
||||||
`upload.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
`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)
|
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||||
docker/nmsr/ # built-from-source NMSR image
|
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/
|
landing/ # Astro site → www/
|
||||||
launcher/ # FjordLauncher assets (gitignored)
|
launcher/ # FjordLauncher assets (gitignored)
|
||||||
server-mods/ # filtered server mod jars (synced, 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.
|
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/30-distribution.caddy static site from another repo
|
||||||
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||||
# conf.d/50-status.caddy uptime-kuma status page
|
# 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
|
# 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
|
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||||
# upgrading to its own https.
|
# 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
|
||||||
|
}
|
||||||
+89
-10
@@ -83,6 +83,7 @@ services:
|
|||||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||||
|
|
||||||
|
ENABLE_COMMAND_BLOCK: "TRUE"
|
||||||
# ── Mod source: filtered subset of the distribution modset ──
|
# ── Mod source: filtered subset of the distribution modset ──
|
||||||
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
||||||
# (both/server entries from mods-sides.json) and mounted at /mods below;
|
# (both/server entries from mods-sides.json) and mounted at /mods below;
|
||||||
@@ -99,17 +100,28 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- mc_data:/data
|
- mc_data:/data
|
||||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
# Writable (no :ro) so edits round-trip: the container can write back into
|
||||||
|
# the host dir. All of these must be writable by the server's uid (itzg runs
|
||||||
|
# as 1000) — see plan/05-minecraft.md "Writable bind mounts".
|
||||||
|
- ./server-mods:/mods # itzg auto-syncs /mods → /data/mods
|
||||||
|
# Mod configs. NOT in mc_data anymore — bind-mounted so they are editable
|
||||||
|
# from the host without root. Seed the dir from the volume BEFORE the first
|
||||||
|
# boot with this mount (tooling/export-server-config.sh), otherwise an empty
|
||||||
|
# host dir shadows the existing /data/config and every mod regenerates
|
||||||
|
# defaults.
|
||||||
|
- ./server-config:/data/config
|
||||||
# Distribution client-override files the server also needs, from the live
|
# Distribution client-override files the server also needs, from the live
|
||||||
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
||||||
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
|
# *source* subdirs are bind-mounted — kubejs itself writes config/, cache/,
|
||||||
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
||||||
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
# mc_data). Mounting the whole kubejs dir makes BaseProperties.save() NPE
|
||||||
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
# and kubejs never initialises.
|
||||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
# These write back into the distribution checkout — an in-game /kubejs
|
||||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
# reload + edit lands in DISTRIBUTION_WEB_ROOT, i.e. in git. Intentional.
|
||||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts
|
||||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets
|
||||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- drasl # authlib (auth.) must resolve at boot
|
- drasl # authlib (auth.) must resolve at boot
|
||||||
@@ -152,6 +164,13 @@ services:
|
|||||||
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
|
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
|
||||||
environment:
|
environment:
|
||||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
BASE_DOMAIN: ${BASE_DOMAIN}
|
||||||
|
# basic_auth credentials for monitor.${BASE_DOMAIN} (80-monitor.on.caddy).
|
||||||
|
# MONITOR_PASSWORD_HASH must be BCRYPT (`caddy hash-password`) and must be
|
||||||
|
# SINGLE-QUOTED in .env — compose interpolates $-sequences in .env values,
|
||||||
|
# so a bare bcrypt hash gets silently mangled ($2a$14$abc… -> $$2a$$14…)
|
||||||
|
# and every login fails. See plan/23-monitor.md.
|
||||||
|
MONITOR_USER: ${MONITOR_USER:-admin}
|
||||||
|
MONITOR_PASSWORD_HASH: ${MONITOR_PASSWORD_HASH:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||||
@@ -160,8 +179,19 @@ services:
|
|||||||
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
- ./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/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/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
|
- ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro
|
||||||
|
# monitor.${BASE_DOMAIN} admin log stream, gated by MONITOR_ENABLED
|
||||||
|
# (on|off) — same substitute-into-the-source-path trick as FILES_BROWSE
|
||||||
|
# above, and the same recreate requirement when the value changes.
|
||||||
|
- ./caddy/conf.d/80-monitor.${MONITOR_ENABLED:-on}.caddy:/etc/caddy/conf.d/80-monitor.caddy:ro
|
||||||
- ./www:/srv/www:ro
|
- ./www:/srv/www:ro
|
||||||
- ./launcher:/srv/launcher:ro
|
- ./launcher:/srv/launcher:ro
|
||||||
# Read-only browse of the shared upload dir at files.${BASE_DOMAIN}
|
# Read-only browse of the shared upload dir at files.${BASE_DOMAIN}
|
||||||
@@ -294,10 +324,59 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
|
# Admin-only log stream at monitor.${BASE_DOMAIN} (built from docker/monitor).
|
||||||
|
# Phase 1 of plan/23-monitor.md: tails the Minecraft server's latest.log into
|
||||||
|
# an in-memory ring buffer and serves it to a small embedded UI by polling.
|
||||||
|
#
|
||||||
|
# Read-only in every sense: /data is mounted :ro, the service exposes no
|
||||||
|
# mutating route, and it holds no credentials. It publishes NO port — the only
|
||||||
|
# way in is caddy's basic_auth vhost, which is exactly the point (the container
|
||||||
|
# has no auth of its own, so an accidental port publish would expose the raw
|
||||||
|
# log — chat, IPs, admin commands — to the internet). Keep it portless.
|
||||||
|
#
|
||||||
|
# Uptime Kuma can probe http://monitor:8080/healthz directly over mcnet,
|
||||||
|
# bypassing caddy, so no auth exemption is needed on the vhost.
|
||||||
|
monitor:
|
||||||
|
build:
|
||||||
|
context: ./docker/monitor
|
||||||
|
image: ulicraft/monitor:local
|
||||||
|
container_name: monitor
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONITOR_LOG_PATH: "/data/logs/latest.log"
|
||||||
|
MONITOR_BUFFER_LINES: "5000"
|
||||||
|
# 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.
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
# Joins mcnet so monitors probe the stack by internal service name.
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
uptime-kuma:
|
uptime-kuma:
|
||||||
image: louislam/uptime-kuma:1
|
image: louislam/uptime-kuma:2
|
||||||
container_name: uptime-kuma
|
container_name: uptime-kuma
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
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
-5
@@ -55,18 +55,15 @@
|
|||||||
"amendments-neoforge-1.21-2.1.5.jar": "both",
|
"amendments-neoforge-1.21-2.1.5.jar": "both",
|
||||||
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
|
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
|
||||||
"architectury-13.0.11-neoforge.jar": "both",
|
"architectury-13.0.11-neoforge.jar": "both",
|
||||||
"architectury-13.0.8-neoforge.jar": "both",
|
|
||||||
"ars_creo-1.21.1-5.4.0.jar": "both",
|
"ars_creo-1.21.1-5.4.0.jar": "both",
|
||||||
"ars_nouveau-1.21.1-5.12.1.jar": "both",
|
"ars_nouveau-1.21.1-5.12.1.jar": "both",
|
||||||
"ars_technica-1.21.1-2.7.6.jar": "both",
|
"ars_technica-1.21.1-2.7.6.jar": "both",
|
||||||
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
|
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
|
||||||
"balm-neoforge-1.21.1-21.0.58.jar": "both",
|
|
||||||
"balm-neoforge-1.21.1-21.0.63.jar": "both",
|
"balm-neoforge-1.21.1-21.0.63.jar": "both",
|
||||||
"buildinggadgets2-1.3.9.jar": "both",
|
"buildinggadgets2-1.3.9.jar": "both",
|
||||||
"caelus-neoforge-7.0.1+1.21.1.jar": "both",
|
"caelus-neoforge-7.0.1+1.21.1.jar": "both",
|
||||||
"cardboardboxes-1.21.1-0.1.2.jar": "both",
|
"cardboardboxes-1.21.1-0.1.2.jar": "both",
|
||||||
"charginggadgets-1.14.1.jar": "both",
|
"charginggadgets-1.14.1.jar": "both",
|
||||||
"chat_heads-1.2.4-neoforge-1.21.11.jar": "client",
|
|
||||||
"cloth-config-15.0.140-neoforge.jar": "both",
|
"cloth-config-15.0.140-neoforge.jar": "both",
|
||||||
"collective-1.21.1-8.39.jar": "both",
|
"collective-1.21.1-8.39.jar": "both",
|
||||||
"colorfulhearts-neoforge-1.21.1-10.5.9.jar": "client",
|
"colorfulhearts-neoforge-1.21.1-10.5.9.jar": "client",
|
||||||
@@ -77,11 +74,13 @@
|
|||||||
"coroutil-neoforge-1.21.0-1.3.9.jar": "both",
|
"coroutil-neoforge-1.21.0-1.3.9.jar": "both",
|
||||||
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
|
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
|
||||||
"corpsecurioscompat-1.21.1-NeoForge-4.0.1.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-1.21.1-6.0.10.jar": "both",
|
||||||
"create-central-kitchen-2.5.0.jar": "both",
|
"create-central-kitchen-2.5.0.jar": "both",
|
||||||
"create-enchantment-industry-2.4.1.jar": "both",
|
"create-enchantment-industry-2.4.1.jar": "both",
|
||||||
"create-integrated-farming-1.2.6.jar": "both",
|
"create-integrated-farming-1.2.6.jar": "both",
|
||||||
"create_connected-1.2.2-mc1.21.1.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-forge-5.1.2.jar": "both",
|
||||||
"create_jetpack_curios-1.2.0-neoforge-1.21.1.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",
|
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
|
||||||
@@ -95,8 +94,8 @@
|
|||||||
"cupboard-1.21.1-3.9.jar": "both",
|
"cupboard-1.21.1-3.9.jar": "both",
|
||||||
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
||||||
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
||||||
"defaultoptions-neoforge-1.21.1-21.1.6.jar": "both",
|
|
||||||
"defaultoptions-neoforge-1.21.1-21.1.7.jar": "both",
|
"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",
|
"drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar": "client",
|
||||||
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
||||||
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
||||||
@@ -113,6 +112,8 @@
|
|||||||
"fastleafdecay-35.jar": "both",
|
"fastleafdecay-35.jar": "both",
|
||||||
"ferritecore-7.0.3-neoforge.jar": "both",
|
"ferritecore-7.0.3-neoforge.jar": "both",
|
||||||
"fishingoverhaul-1.21.1-1.2.1.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-chunks-neoforge-2101.1.17.jar": "both",
|
||||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||||
"ftb-library-neoforge-2101.1.33.jar": "both",
|
"ftb-library-neoforge-2101.1.33.jar": "both",
|
||||||
@@ -141,6 +142,7 @@
|
|||||||
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
|
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
|
||||||
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
|
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
|
||||||
"mechanicals-1.21.1-1.1.0.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",
|
"melody_neoforge_1.0.10_MC_1.21.jar": "client",
|
||||||
"melter-1.21.1-1.8.2.jar": "both",
|
"melter-1.21.1-1.8.2.jar": "both",
|
||||||
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
||||||
@@ -149,6 +151,7 @@
|
|||||||
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
|
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
|
||||||
"morefunctionalstorage-1.2.3.jar": "both",
|
"morefunctionalstorage-1.2.3.jar": "both",
|
||||||
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
"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",
|
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
||||||
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
||||||
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
||||||
@@ -156,11 +159,13 @@
|
|||||||
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
||||||
"rhino-2101.2.7-build.85.jar": "both",
|
"rhino-2101.2.7-build.85.jar": "both",
|
||||||
"rightclickharvest-neoforge-4.6.1+1.21.1.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",
|
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
|
||||||
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
||||||
"sliceanddice-forge-4.2.4.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-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",
|
"solcarrot-1.21.1-1.16.6.jar": "both",
|
||||||
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
||||||
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
||||||
@@ -171,6 +176,7 @@
|
|||||||
"stattweaks-neoforge-v2.0.0.jar": "both",
|
"stattweaks-neoforge-v2.0.0.jar": "both",
|
||||||
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
|
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
|
||||||
"supermartijn642corelib-1.1.21-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",
|
"tagtooltips-neoforge-1.21.1-1.2.0.jar": "client",
|
||||||
"titanium-1.21-4.0.43.jar": "both",
|
"titanium-1.21-4.0.43.jar": "both",
|
||||||
"toolsjs-0.1.1.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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN} files.${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;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,25 +191,15 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read-only static browse of the same share (files.) — caddy serves the dir
|
# files. (read-only browse) goes here — the whole server block, or a single
|
||||||
# listing, no auth. Read-only, so no upload tuning: standard 4m body is fine.
|
# comment line when FILES_BROWSE=off. Source: nginx/files-browse.server.tmpl,
|
||||||
server {
|
# spliced in by render-nginx.sh.
|
||||||
listen 443 ssl http2;
|
${FILES_BROWSE_SERVER}
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name files.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
# monitor. (admin log stream, basic_auth in caddy) goes here — the whole server
|
||||||
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
# block, or a single comment line when MONITOR_ENABLED=off. Source:
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
# nginx/monitor.server.tmpl, spliced in by render-nginx.sh.
|
||||||
|
${MONITOR_SERVER}
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
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)
|
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
|
||||||
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
|
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
|
||||||
- `20-files.md` — Filestash player file share (`files.`, one shared login)
|
- `20-files.md` — Filestash player file share (`files.`, one shared login)
|
||||||
|
- `23-monitor.md` — admin log stream + dashboard (`monitor.`, basic auth)
|
||||||
|
|
||||||
## Boot/dependency order
|
## Boot/dependency order
|
||||||
|
|
||||||
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
||||||
`nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
|
`nmsr`, `mc-status`, `uptime-kuma`, `filestash` and `monitor` are always-on and
|
||||||
(no ordering constraints — none of them are on the auth/boot path).
|
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.
|
||||||
@@ -114,6 +114,113 @@ Note: a red voice icon can ALSO be client-side — a VPN/zero-trust client
|
|||||||
(Cloudflare One/WARP, FortiClient) on the player's machine silently drops the
|
(Cloudflare One/WARP, FortiClient) on the player's machine silently drops the
|
||||||
voice UDP. Rule out VPNs before touching the server.
|
voice UDP. Rule out VPNs before touching the server.
|
||||||
|
|
||||||
|
## Writable bind mounts (mods / config / kubejs)
|
||||||
|
|
||||||
|
These three used to be `:ro`, which meant edits could only flow host → container
|
||||||
|
and the server could never write back. They are now mounted **rw**:
|
||||||
|
|
||||||
|
| Mount | Host source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `/mods` | `./server-mods` | still overwritten by `tooling/sync-server-mods.sh` |
|
||||||
|
| `/data/config` | `./server-config` | NEW bind — was inside `mc_data` |
|
||||||
|
| `/data/kubejs/{server_scripts,startup_scripts,data,assets}` | `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/files/kubejs/…` | writes land in the distribution git checkout |
|
||||||
|
|
||||||
|
Still `:ro` on purpose: `./runtime` → `/extras` (vendored jars) and
|
||||||
|
`CustomSkinLoader` (static json).
|
||||||
|
|
||||||
|
**Unchanged rule — the kubejs ROOT must stay writable.** Only the four *source*
|
||||||
|
subdirs are bind-mounted; kubejs writes `config/`, `cache/`, `generated/`,
|
||||||
|
`logs/` under `/data/kubejs` at boot, and mounting the whole dir makes
|
||||||
|
`BaseProperties.save()` NPE so kubejs never initialises. Dropping `:ro` does not
|
||||||
|
change that — the root is what matters, not the mode.
|
||||||
|
|
||||||
|
### Seed `./server-config` before first boot
|
||||||
|
|
||||||
|
A bind mount **shadows** what is under it. An empty `./server-config` hides the
|
||||||
|
existing `/data/config` and every mod regenerates its defaults — tuning silently
|
||||||
|
reverted. The old files are not destroyed (they stay in `mc_data`, under the
|
||||||
|
mount) but the server stops seeing them. So, once per host:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose stop minecraft
|
||||||
|
tooling/export-server-config.sh # oxps: MC_UID=1002 MC_GID=1002 tooling/…
|
||||||
|
docker compose up -d --force-recreate minecraft
|
||||||
|
```
|
||||||
|
|
||||||
|
The script halts rather than guessing: refuses if `./server-config` is non-empty,
|
||||||
|
if the container is still running, or if the `mc_data` volume name is ambiguous.
|
||||||
|
|
||||||
|
### Ownership — rw mounts need the server's uid
|
||||||
|
|
||||||
|
itzg runs the server as **uid 1000**. A writable bind mount is only writable if
|
||||||
|
the host dir permits uid 1000; otherwise the server logs permission errors (or
|
||||||
|
kubejs/config writes fail) even though the mount is rw. On `cochi` the host user
|
||||||
|
is uid 1000, so this is free. On `oxps` the host user `oier` is **uid 1002** —
|
||||||
|
`./server-config` gets chowned to `MC_UID`/`MC_GID` by the export script, so pass
|
||||||
|
the right values there, and expect the dir not to be editable as your own user
|
||||||
|
without sudo or a shared group.
|
||||||
|
|
||||||
|
`$DISTRIBUTION_WEB_ROOT` (kubejs) and `./server-mods` must satisfy the same rule
|
||||||
|
now that they are rw.
|
||||||
|
|
||||||
|
### Consequence — kubejs edits mutate the distribution repo
|
||||||
|
|
||||||
|
`/data/kubejs/{server_scripts,startup_scripts,data,assets}` are the live
|
||||||
|
distribution checkout. Anything the server writes there (or an in-game
|
||||||
|
`/kubejs reload` regenerating a file) shows up as an **uncommitted change in the
|
||||||
|
distribution repo**, on that host only. `cochi` and `oxps` hold separate
|
||||||
|
checkouts that already drift — check `git status` in `$DISTRIBUTION_WEB_ROOT`
|
||||||
|
before pulling or publishing from either.
|
||||||
|
|
||||||
|
## Accessing the `mc_data` volume
|
||||||
|
|
||||||
|
`mc_data` is a **named docker volume**, not a bind mount — there is no path in
|
||||||
|
this repo that mirrors the world. Same on both hosts (`cochi`, `oxps`); on `oxps`
|
||||||
|
the stack lives at `~/mc/ulicraft-server` and the host is LAN-only, so `ssh oxps`
|
||||||
|
works from the LAN only.
|
||||||
|
|
||||||
|
Preferred — go through the container (`container_name: minecraft`, world at
|
||||||
|
`/data`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh oxps
|
||||||
|
docker exec -it minecraft bash # interactive poke around /data
|
||||||
|
docker exec minecraft ls /data/config # one-shot read
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy files in/out without root:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker cp minecraft:/data/logs/latest.log ./latest.log
|
||||||
|
docker cp ./foo.json minecraft:/data/config/
|
||||||
|
```
|
||||||
|
|
||||||
|
Host path (root-owned, needs sudo). The volume name is prefixed with the compose
|
||||||
|
project name, which defaults to the **directory name** — `ulicraft-server_mc_data`
|
||||||
|
unless the checkout dir differs, so resolve it rather than hardcoding:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker volume ls | grep mc_data
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Root-free equivalent when the container is down (also the shape used by the
|
||||||
|
`chown` fix in `plan/22-migration.md`):
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run --rm -v ulicraft-server_mc_data:/data alpine ls /data
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Do not write world files while the server is running** — the running server
|
||||||
|
holds region files open and will overwrite or corrupt concurrent edits. Stop it
|
||||||
|
first (`docker compose stop minecraft`), edit, then start. Reads are fine.
|
||||||
|
|
||||||
|
For a consistent snapshot of the world, prefer `backups/` (mc-backup mounts
|
||||||
|
`mc_data:/data:ro` and quiesces saves over RCON — see `plan/07-mc-backup.md`)
|
||||||
|
over copying `/data/world` out from under a live server.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Surviving scripts:
|
|||||||
- `classify-mods.py` — read each jar's `neoforge.mods.toml`, seed `mods-sides.json` (see 04-mods.md)
|
- `classify-mods.py` — read each jar's `neoforge.mods.toml`, seed `mods-sides.json` (see 04-mods.md)
|
||||||
- `sync-server-mods.sh` — copy `both`/`server` jars from the distribution → `./server-mods` (see 04-mods.md)
|
- `sync-server-mods.sh` — copy `both`/`server` jars from the distribution → `./server-mods` (see 04-mods.md)
|
||||||
- `build-modlist.py` — generate the landing `mods.json` + extract logos (see 17-mod-shortlist.md)
|
- `build-modlist.py` — generate the landing `mods.json` + extract logos (see 17-mod-shortlist.md)
|
||||||
|
- `export-server-config.sh` — seed `./server-config` from `mc_data:/data/config` before the rw bind mount takes over (once per host, see 05-minecraft.md)
|
||||||
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy)
|
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy)
|
||||||
- `issue-letsencrypt.sh` — issue LE certs via OVH DNS-01 (see 15-letsencrypt.md)
|
- `issue-letsencrypt.sh` — issue LE certs via OVH DNS-01 (see 15-letsencrypt.md)
|
||||||
- `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md)
|
- `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md)
|
||||||
@@ -68,6 +69,15 @@ Downloads FjordLauncherUnlocked release assets → `launcher/<tag>/` and maintai
|
|||||||
a `latest` symlink + stable per-OS filename symlinks. Full script in
|
a `latest` symlink + stable per-OS filename symlinks. Full script in
|
||||||
`06-launcher.md`.
|
`06-launcher.md`.
|
||||||
|
|
||||||
|
## export-server-config.sh
|
||||||
|
|
||||||
|
One-shot, **once per host**. Copies `/data/config` out of the `mc_data` volume
|
||||||
|
into `./server-config` so the rw bind mount `./server-config:/data/config` does
|
||||||
|
not shadow the mod configs the server already generated. Halts if the target is
|
||||||
|
non-empty, if the `minecraft` container is still running, or if the `mc_data`
|
||||||
|
volume name is ambiguous. Chowns the result to `MC_UID`/`MC_GID` (default
|
||||||
|
`1000:1000`; `oxps` needs `1002`). See `05-minecraft.md`.
|
||||||
|
|
||||||
## Generated / gitignored
|
## Generated / gitignored
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -76,6 +86,8 @@ nmsr/config.toml # rendered
|
|||||||
launcher/ # launcher assets
|
launcher/ # launcher assets
|
||||||
www/ # landing build output
|
www/ # landing build output
|
||||||
certs/ # Let's Encrypt certs
|
certs/ # Let's Encrypt certs
|
||||||
|
server-mods/ # synced from the distribution
|
||||||
|
server-config/ # exported from mc_data, then bind-mounted rw
|
||||||
.env # secrets + host-specific
|
.env # secrets + host-specific
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
> card. Both ping `minecraft` over `mcnet` but serve different purposes.
|
> card. Both ping `minecraft` over `mcnet` but serve different purposes.
|
||||||
|
|
||||||
Status monitoring + public status page. Service `uptime-kuma`
|
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
|
`https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other
|
||||||
service by its internal container name.
|
service by its internal container name.
|
||||||
|
|
||||||
## Add a Minecraft monitor
|
## 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):
|
Server List Ping protocol (the same ping the vanilla multiplayer list uses):
|
||||||
unauthenticated, so it works fine with `ONLINE_MODE=FALSE` /
|
unauthenticated, so it works fine with `ONLINE_MODE=FALSE` /
|
||||||
`ENFORCE_SECURE_PROFILE=FALSE`. It reports latency, online/max players, and MOTD.
|
`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://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
|
||||||
`https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
|
`https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
|
||||||
`distribution.` here explains client mod-mismatch join failures.
|
`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
|
> **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)
|
> 200", which is a much weaker claim than "the app works". `files.` (Filestash)
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
|||||||
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
||||||
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
||||||
authlib into `runtime/`). Not needed for routine redeploys.
|
authlib into `runtime/`). Not needed for routine redeploys.
|
||||||
|
- **Once per host, before the first deploy carrying the `./server-config` bind
|
||||||
|
mount** — run `tooling/export-server-config.sh` with the server stopped. The
|
||||||
|
mount shadows `/data/config`, so deploying without seeding first boots the
|
||||||
|
server against an empty config dir and every mod regenerates its defaults.
|
||||||
|
`oxps`: `MC_UID=1002 MC_GID=1002 tooling/export-server-config.sh`. See
|
||||||
|
`05-minecraft.md` → "Writable bind mounts".
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
cron for renewal; `--reloadcmd` reloads nginx automatically on renew. Override
|
||||||
the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
|
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
|
## nginx
|
||||||
|
|
||||||
1. Bring the stack up (caddy publishes on `127.0.0.1:${CADDY_HTTP_PORT}`):
|
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) |
|
| `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) |
|
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||||
| `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) |
|
| `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)
|
## Internal (service → service, over mcnet)
|
||||||
|
|
||||||
- `minecraft` → `-javaagent authlib-injector=http://auth.${BASE_DOMAIN}/authlib-injector` (caddy mcnet alias → drasl).
|
- `minecraft` → `-javaagent authlib-injector=http://auth.${BASE_DOMAIN}/authlib-injector` (caddy mcnet alias → drasl).
|
||||||
- `nmsr` → `http://auth.${BASE_DOMAIN}` (mojang-compat session/textures/api).
|
- `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-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`.
|
- `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
|
> 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
|
> plain caddy `file_server browse` — **read-only, no auth**, every uploaded file
|
||||||
> publicly listable (`62-files.caddy`). Filestash gets its own subdomain because
|
> publicly listable (`62-files.on.caddy`, toggled per host by `FILES_BROWSE`).
|
||||||
> it cannot be served under a subpath (SPA hardcodes `/api`, `/assets`, `/login`
|
> Filestash gets its own subdomain because it cannot be served under a subpath
|
||||||
> at root).
|
> (SPA hardcodes `/api`, `/assets`, `/login` at root).
|
||||||
>
|
>
|
||||||
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
> **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 |
|
| Thing | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Vhost (filestash) | `upload.${BASE_DOMAIN}` |
|
| 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`) |
|
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
|
||||||
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
||||||
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
|
| 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
|
and the launcher for every player) all land on you. `render-config.sh` prints a
|
||||||
loud warning when it renders this mode.
|
loud warning when it renders this mode.
|
||||||
|
|
||||||
**`files.` is unconditionally public read.** Independent of `FILES_AUTH`, the
|
**`files.` is public read wherever it is enabled.** Independent of `FILES_AUTH`,
|
||||||
`62-files.caddy` static browse serves the whole share with **no auth** — anyone
|
the static browse serves the whole share with **no auth** — anyone who reaches
|
||||||
who knows `files.${BASE_DOMAIN}` can list and download every uploaded file. This
|
`files.${BASE_DOMAIN}` can list and download every uploaded file. This is
|
||||||
is deliberate (chosen 2026-07-24). The htpasswd login only gates *writes* (via
|
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
|
`upload.`); it never gated reads at this URL. Don't put anything private in the
|
||||||
share.
|
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):**
|
**Per-host auth status (the two prod envs diverge):**
|
||||||
|
|
||||||
| Host | `upload.` reachability | `FILES_AUTH` | Notes |
|
| 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_DATA_DIR` | host path of the share, outside the repo |
|
||||||
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
|
| `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_AUTH` | `htpasswd` \| `passthrough` \| `none` |
|
||||||
| `FILES_USER` | shared login name |
|
| `FILES_USER` | shared login name |
|
||||||
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
|
| `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_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_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
|
||||||
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
|
| `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
|
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||||
both secrets. Only the `.tmpl` is tracked.
|
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) |
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# export-server-config.sh — seed ./server-config/ from /data/config inside the
|
||||||
|
# mc_data volume, so the compose bind mount `./server-config:/data/config` can
|
||||||
|
# take over without losing the mod configs the server already generated.
|
||||||
|
#
|
||||||
|
# WHY: a bind mount shadows whatever is under it. Mounting an empty host dir over
|
||||||
|
# /data/config hides every existing config file and each mod regenerates its
|
||||||
|
# defaults — silently reverting tuning. Run this ONCE, before the first boot with
|
||||||
|
# the mount in place.
|
||||||
|
#
|
||||||
|
# Cautious by design: refuses to run if the target already has content, if the
|
||||||
|
# volume can't be resolved unambiguously, or if the server is still running.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tooling/export-server-config.sh # export, then chown to MC_UID:MC_GID
|
||||||
|
# MC_UID=1002 MC_GID=1002 tooling/… # oxps (host user oier = 1002)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
|
||||||
|
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||||
|
|
||||||
|
TARGET="./server-config"
|
||||||
|
MC_UID="${MC_UID:-1000}"
|
||||||
|
MC_GID="${MC_GID:-1000}"
|
||||||
|
|
||||||
|
# ── resolve the mc_data volume ──────────────────────────────────────────────
|
||||||
|
# Compose prefixes the volume with the project name (defaults to the repo dir
|
||||||
|
# name), so never hardcode it — match and require exactly one.
|
||||||
|
mapfile -t VOLS < <(docker volume ls -q | grep -E '(^|_)mc_data$' || true)
|
||||||
|
if [ "${#VOLS[@]}" -eq 0 ]; then
|
||||||
|
echo "FATAL: no mc_data volume found (docker volume ls). Nothing to export." >&2
|
||||||
|
echo " If this is a fresh host there is no config yet — create an empty" >&2
|
||||||
|
echo " $TARGET and let the server generate defaults into it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${#VOLS[@]}" -gt 1 ]; then
|
||||||
|
echo "FATAL: multiple mc_data volumes, refusing to guess:" >&2
|
||||||
|
printf ' %s\n' "${VOLS[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VOL="${VOLS[0]}"
|
||||||
|
|
||||||
|
# ── refuse while the server is live (it rewrites config on shutdown) ────────
|
||||||
|
if [ -n "$(docker ps -q -f name='^minecraft$')" ]; then
|
||||||
|
echo "FATAL: the minecraft container is running. Stop it first:" >&2
|
||||||
|
echo " docker compose stop minecraft" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── refuse to clobber an existing export ────────────────────────────────────
|
||||||
|
if [ -d "$TARGET" ] && [ -n "$(ls -A "$TARGET" 2>/dev/null)" ]; then
|
||||||
|
echo "FATAL: $TARGET already has content — refusing to overwrite." >&2
|
||||||
|
echo " Move it aside if you really want a fresh export." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$TARGET"
|
||||||
|
|
||||||
|
# ── copy out of the volume ──────────────────────────────────────────────────
|
||||||
|
echo "Exporting $VOL:/data/config -> $TARGET"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$VOL":/data:ro \
|
||||||
|
-v "$PWD/$TARGET":/out \
|
||||||
|
alpine sh -euc '
|
||||||
|
if [ ! -d /data/config ]; then
|
||||||
|
echo "FATAL: /data/config does not exist in the volume." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp -a /data/config/. /out/
|
||||||
|
'
|
||||||
|
|
||||||
|
# ── ownership: the server (uid 1000 under itzg) must be able to write ───────
|
||||||
|
docker run --rm -v "$PWD/$TARGET":/out \
|
||||||
|
alpine chown -R "$MC_UID:$MC_GID" /out
|
||||||
|
|
||||||
|
COUNT=$(find "$TARGET" -type f | wc -l)
|
||||||
|
echo "Exported $COUNT files, owned by $MC_UID:$MC_GID."
|
||||||
|
|
||||||
|
if [ "$(id -u)" != "$MC_UID" ]; then
|
||||||
|
echo
|
||||||
|
echo "NOTE: your host uid ($(id -u)) != $MC_UID, so $TARGET is not yours to edit." >&2
|
||||||
|
echo " Either edit via sudo, or add group write and join the group." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Next: the compose bind mount ./server-config:/data/config now takes over."
|
||||||
|
echo " docker compose up -d --force-recreate minecraft"
|
||||||
@@ -52,7 +52,12 @@ ACME="${ACME_SH:-$HOME/.acme.sh/acme.sh}"
|
|||||||
curl https://get.acme.sh | sh -s email=${LE_EMAIL}
|
curl https://get.acme.sh | sh -s email=${LE_EMAIL}
|
||||||
then re-run (or set ACME_SH=/path/to/acme.sh)."
|
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}"
|
read -r -a SUBDOMAINS <<< "${LE_SUBDOMAINS:-auth pack distribution www avatar status}"
|
||||||
|
|
||||||
if [ "${LE_STAGING:-0}" = "1" ]; then
|
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)
|
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
|
||||||
# — defaults to the repo root (this checkout)
|
# — defaults to the repo root (this checkout)
|
||||||
# SITE_NAME installed filename (default ulicraft.conf)
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")/.." # repo root
|
cd "$(dirname "$0")/.." # repo root
|
||||||
@@ -40,7 +48,48 @@ SITE_NAME="${SITE_NAME:-ulicraft.conf}"
|
|||||||
export BASE_DOMAIN APP_DIR
|
export BASE_DOMAIN APP_DIR
|
||||||
export CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-80}"
|
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
|
if [ "${1:-}" != "--install" ]; then
|
||||||
printf '%s\n' "$rendered"
|
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"
|
avail="/etc/nginx/sites-available/$SITE_NAME"
|
||||||
enabled="/etc/nginx/sites-enabled/$SITE_NAME"
|
enabled="/etc/nginx/sites-enabled/$SITE_NAME"
|
||||||
|
|
||||||
log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT)"
|
log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT, FILES_BROWSE=$FILES_BROWSE, MONITOR_ENABLED=$MONITOR_ENABLED)"
|
||||||
printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null
|
printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null
|
||||||
sudo ln -sf "$avail" "$enabled"
|
sudo ln -sf "$avail" "$enabled"
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
|
|||||||
Reference in new issue
Block a user