Compare commits
32
Commits
0bf42d5797
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bd786140 | ||
|
|
d457713186 | ||
|
|
556f86f852 | ||
|
|
6b9b8bd96c | ||
|
|
fe91bf7778 | ||
|
|
0176a674e3 | ||
|
|
671e6af05a | ||
|
|
fbed9a5af7 | ||
|
|
40fb50e86f | ||
|
|
d105fc9acf | ||
|
|
3eb20054a0 | ||
|
|
bac2372c95 | ||
|
|
bc99427ef9 | ||
|
|
00984e1a70 | ||
|
|
53604a6127 | ||
|
|
fca6747bcc | ||
|
|
8507ffd020 | ||
|
|
92c6f5fc8f | ||
|
|
dfb8719074 | ||
|
|
a3c97af374 | ||
|
|
5e5367e6d5 | ||
|
|
95f97a3b75 | ||
|
|
e831d8b9f3 | ||
|
|
6b317d179a | ||
|
|
16523e4f62 | ||
|
|
243787c530 | ||
|
|
9322e39a8c | ||
|
|
05a98efe57 | ||
|
|
b9bcc13b73 | ||
|
|
00e9815307 | ||
|
|
b6b02398da | ||
|
|
7164371d34 |
No files matched your search
+69
-2
@@ -49,7 +49,8 @@ CADDY_HTTP_BIND=127.0.0.1
|
||||
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||
|
||||
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
|
||||
# ── upload.${BASE_DOMAIN} — Filestash player file share (write) ───────
|
||||
# (files.${BASE_DOMAIN} = read-only, no-auth caddy browse of the same dir)
|
||||
# Screenshots / schematics / maps. ONE shared login for all players, writable.
|
||||
# Full design + gotchas: plan/20-files.md.
|
||||
|
||||
@@ -63,6 +64,26 @@ FILES_DATA_DIR=/srv/ulicraft-files
|
||||
# value is substituted straight into the volume string.
|
||||
FILES_MOUNT_MODE=rw
|
||||
|
||||
# files.${BASE_DOMAIN} — the read-only, NO-AUTH browse of the same share.
|
||||
# Per-host toggle, `on` | `off` (default on). It gates two layers, both reading
|
||||
# this var: the caddy vhost (docker-compose.yml substitutes the value into the
|
||||
# conf.d mount path — 62-files.on.caddy vs the 62-files.off.caddy stub) and the
|
||||
# public nginx vhost (tooling/render-nginx.sh). `off` also drops the name from
|
||||
# the port-80 redirect, so no cert is needed — drop `files` from LE_SUBDOMAINS
|
||||
# too on that host. upload. (filestash, authenticated) is NOT affected.
|
||||
# After changing it: tooling/render-nginx.sh --install
|
||||
# docker compose up -d --force-recreate caddy
|
||||
FILES_BROWSE=on
|
||||
|
||||
# Runtime uid:gid of the filestash process (docker `user:`). Leave at the image
|
||||
# default 1000:1000 UNLESS you want files it writes into the share owned by a
|
||||
# host account. To own the share as `oier` on oxps: set both to 1002 and run
|
||||
# tooling/filestash-set-run-user.sh (it chowns the state volume + share to match
|
||||
# — a bare override without the chown makes filestash fail to write its sqlite).
|
||||
# The compose keeps group_add 1000 so any uid can still exec the image binary.
|
||||
FILES_UID=1000
|
||||
FILES_GID=1000
|
||||
|
||||
# Auth middleware (rendered into config.json by render-config.sh):
|
||||
# htpasswd one shared user/pass — the only internet-safe value
|
||||
# passthrough NO LOGIN. Anyone reaching the vhost is in.
|
||||
@@ -95,14 +116,60 @@ FILES_SECRET_KEY=
|
||||
# server-side; players never see it. openssl rand -hex 24.
|
||||
FILES_LOCAL_SECRET=
|
||||
|
||||
# ── generators.${BASE_DOMAIN} — offline Minecraft data pack generators ────
|
||||
# A pinned fork of misode.github.io, built from source and served with its own
|
||||
# data mirror so it works with ZERO internet at the LAN party. The app is 100%
|
||||
# client-side, so the mirror is what makes it offline-capable — not the fact
|
||||
# that the host has no WAN. Full design: plan/21-generators.md.
|
||||
|
||||
# Absolute host path to the ulicraft-generators checkout (the fork). This is the
|
||||
# docker build context; the fork carries its own Dockerfile + Caddyfile.
|
||||
GENERATORS_REPO=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-generators
|
||||
|
||||
# Where tooling/sync-generators-data.sh puts the ~107M mcmeta + vanilla-mcdoc
|
||||
# mirror. Gitignored, fetched deliberately with WAN, mounted read-only.
|
||||
# Must be re-synced (with a matching MC_REF) if the pinned MC version moves.
|
||||
GENERATORS_DATA_DIR=./generators-data
|
||||
|
||||
# Only needed if using CurseForge-exclusive mods:
|
||||
# CF_API_KEY=your-curseforge-api-key
|
||||
|
||||
# ── Monitor (monitor.${BASE_DOMAIN}) — admin log stream ───────────────
|
||||
# Read-only view of the Minecraft server's latest.log. ADMIN ONLY: the log
|
||||
# carries player IPs, chat and admin commands verbatim, and nothing sanitizes
|
||||
# it. Full design + gotchas: plan/23-monitor.md.
|
||||
#
|
||||
# Per-host toggle, `on` | `off` (default on), same two-layer gating as
|
||||
# FILES_BROWSE: the caddy conf mount (docker-compose.yml substitutes the value
|
||||
# into the path — 80-monitor.on.caddy vs the .off.caddy stub) and the public
|
||||
# nginx vhost (tooling/render-nginx.sh). `off` also drops the name from the
|
||||
# port-80 redirect, so no cert is needed — drop `monitor` from LE_SUBDOMAINS
|
||||
# too on that host.
|
||||
# After changing it: tooling/render-nginx.sh --install
|
||||
# docker compose up -d --force-recreate caddy
|
||||
MONITOR_ENABLED=on
|
||||
|
||||
# The ONLY credential guarding the log stream — caddy basic_auth. The monitor
|
||||
# container has no auth of its own and publishes no port, so this is the whole
|
||||
# access control.
|
||||
MONITOR_USER=admin
|
||||
#
|
||||
# MUST be bcrypt, generated with:
|
||||
# docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
|
||||
# (This is the OPPOSITE of FILES_PASSWORD_HASH, which must be $6$ and silently
|
||||
# rejects bcrypt. Do not reuse a hash between the two.)
|
||||
#
|
||||
# MUST be SINGLE-QUOTED here. Docker Compose interpolates $-sequences in .env
|
||||
# values, so an unquoted bcrypt hash is silently mangled — $2a$14$abcdefg…
|
||||
# becomes $$2a$$14… with the $abcdefg… segment eaten as an unset variable, and
|
||||
# every login fails with no hint as to why. Verified behaviour, not a guess.
|
||||
MONITOR_PASSWORD_HASH='$2a$14$replace.me.with.a.real.bcrypt.hash'
|
||||
|
||||
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
|
||||
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||
LE_EMAIL=you@example.com
|
||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||
LE_SUBDOMAINS=auth distribution www avatar status files
|
||||
LE_SUBDOMAINS=auth distribution www avatar status upload files generators monitor
|
||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||
OVH_END_POINT=ovh-eu
|
||||
|
||||
+13
@@ -14,9 +14,19 @@ filestash/config.json
|
||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||
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
|
||||
launcher/
|
||||
|
||||
# offline data mirror for generators.${BASE_DOMAIN} (~107M of mcmeta +
|
||||
# vanilla-mcdoc), synced by tooling/sync-generators-data.sh. Needs WAN, so it is
|
||||
# fetched deliberately before the party — never at the venue.
|
||||
generators-data/
|
||||
|
||||
# landing page: generated build output + deps
|
||||
www/
|
||||
landing/node_modules/
|
||||
@@ -38,3 +48,6 @@ docker/mc-status/mc-status
|
||||
|
||||
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
||||
certs/
|
||||
|
||||
# migration bundles (migrate-export.sh) — hold .env secrets + LE private keys
|
||||
ulicraft-bundle-*.tar.gz
|
||||
@@ -12,7 +12,9 @@ with `plan/` or the code, those win.
|
||||
|
||||
- **Players**: 4–10 friends
|
||||
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
||||
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
||||
- **Pack approach**: Manually curated, NOT a forked existing pack. Original target
|
||||
~50–100 mods; **actual is 164 jars** (2026-07-20, kitchen-sink expansion) —
|
||||
ceiling knowingly exceeded, watch RAM
|
||||
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
|
||||
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
|
||||
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
|
||||
@@ -32,7 +34,11 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
|
||||
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||
files.${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;
|
||||
per-host on/off via FILES_BROWSE — off on cochi)
|
||||
monitor.${BASE_DOMAIN}─► monitor (ADMIN ONLY: latest.log stream + dashboard tiles,
|
||||
caddy basic_auth; per-host on/off MONITOR_ENABLED)
|
||||
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||
|
||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||
@@ -106,11 +112,31 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
||||
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||
classify/sync time. See `plan/04-mods.md`.
|
||||
|
||||
### Filestash (files.) — four traps
|
||||
### 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 serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA
|
||||
hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a
|
||||
separate caddy `file_server browse` of the same `FILES_DATA_DIR` — read-only,
|
||||
**no auth**, every uploaded file publicly listable.
|
||||
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
|
||||
- **`general.host` is a BARE HOSTNAME** (`upload.${BASE_DOMAIN}`, no scheme) +
|
||||
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
|
||||
yields `http://https://files...` and every client-side redirect dies. The
|
||||
yields `http://https://upload...` and every client-side redirect dies. The
|
||||
server strips the scheme before its own Host check, so **curl and the uptime
|
||||
monitor stay green while every browser is broken**. Cost a debugging round on
|
||||
2026-07-14.
|
||||
@@ -129,9 +155,39 @@ works. After any filestash config change, check the browser-facing values
|
||||
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||
while every other check passed.
|
||||
|
||||
### Compose eats `$` in `.env` values — quote every hash
|
||||
`docker compose` interpolates `$`-sequences inside `.env` **values**, so a bare
|
||||
bcrypt hash is silently corrupted before it ever reaches a container:
|
||||
|
||||
```
|
||||
MONITOR_PASSWORD_HASH=$2a$14$abcdefgHIJKLmnop.qrstuv # WRONG
|
||||
→ container sees $2a$14.qrstuv # $abcdefg… eaten as an unset var
|
||||
MONITOR_PASSWORD_HASH='$2a$14$abcdefgHIJKLmnop.qrstuv' # single quotes fix it
|
||||
```
|
||||
|
||||
Verified, not folklore. Every login fails with no hint as to why — the hash
|
||||
looks fine in `.env` and `docker compose config` re-escapes it to `$$…`, which
|
||||
looks wrong but is correct. **The same bug bites over SSH**: writing a hash to a
|
||||
remote `.env` through `ssh host "...$HASH..."` lets the remote shell expand it.
|
||||
Transport it base64-encoded and verify with `md5sum` on both ends.
|
||||
|
||||
Note the two services want *opposite* formats: caddy `basic_auth` needs
|
||||
**bcrypt** (`caddy hash-password`), filestash needs **`$6$`** (`openssl passwd
|
||||
-6`) and silently rejects bcrypt. Never copy a hash between them.
|
||||
|
||||
### Monitor runs as root on purpose
|
||||
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
|
||||
cannot traverse it, so the log tailer would read nothing — **silently**, while
|
||||
still answering HTTP 200. Adding a `USER` directive to `docker/monitor/` as
|
||||
"hardening" breaks it. What actually bounds the risk there: every mount is `:ro`,
|
||||
no port is published, and the one credential it holds (RCON) can only send
|
||||
commands from a compile-time allowlist.
|
||||
|
||||
### Memory sizing
|
||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||
`USE_AIKAR_FLAGS: TRUE`.
|
||||
`USE_AIKAR_FLAGS: TRUE`. **The pack is now 164 jars / 133 server-side** (Create
|
||||
ecosystem + Ars Nouveau + full worldgen) — sized for the old 50–100 assumption, so
|
||||
`MAX_MEMORY` may need a bump. Re-check heap usage under load before adding more.
|
||||
|
||||
## Config (.env)
|
||||
|
||||
@@ -157,13 +213,12 @@ mirrored at apex `/launcher/`. Per guest:
|
||||
## Open / Pending Work
|
||||
|
||||
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
|
||||
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** tomllib auto-flagged 11
|
||||
hard client-only mods; the other 65 default `both`, including client-cosmetic
|
||||
ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody,
|
||||
welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) that
|
||||
load harmlessly on the server but waste RAM. Hand-edit them to `client` so
|
||||
`sync-server-mods.sh` drops them from `./server-mods`. (Also review `appleskin`,
|
||||
flagged `client` — flip to `both` if server-side food data wanted.)
|
||||
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** Pack is now **164 jars**
|
||||
(2026-07-20): **31 `client` / 133 `both`**. The `both` set still contains
|
||||
client-cosmetic mods that load harmlessly on the server but waste RAM — hand-edit
|
||||
them to `client` so `sync-server-mods.sh` drops them from `./server-mods`.
|
||||
(Also review `appleskin`, flagged `client` — flip to `both` if server-side food
|
||||
data wanted.)
|
||||
### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
|
||||
Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
||||
- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
|
||||
@@ -181,16 +236,24 @@ Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
||||
- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged).
|
||||
|
||||
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
|
||||
Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
|
||||
magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
|
||||
mod-list component = auto-generated `mods.json` + extracted logos from the
|
||||
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
||||
names, no categories/links; feeds the "50+" stat tile.
|
||||
- [ ] Server-side perf mods (C2ME, etc.).
|
||||
Gap doc part **DONE**: pack filled out to **164 jars** (2026-07-20) — Create
|
||||
ecosystem (tech), Ars Nouveau + ars_creo (magic), full YUNG's + Moog's +
|
||||
TerraBlender (worldgen); `ponderjs` orphan resolved by Create. Still open: **no
|
||||
map mod** (Xaero's), `Necronomicon` orphan (no Iron's Spells), and 164 > the
|
||||
stated 50–100 ceiling. Still to build: landing mod-list component =
|
||||
auto-generated `mods.json` + extracted logos from the distribution forgemods
|
||||
(`tooling/build-modlist.py`), flat alphabetical, pretty names, no
|
||||
categories/links; feeds the "50+" stat tile.
|
||||
- [ ] Server-side perf mods (C2ME, etc. — `modernfix` already in the pack).
|
||||
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||
- [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream +
|
||||
dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups,
|
||||
joins/leaves) + opt-in browser notifications on join/leave (bell toggle,
|
||||
client-side only). Live on cochi, all four phases done. See `plan/23-monitor.md`.
|
||||
Follow-up: add the Uptime Kuma monitor for `http://monitor:8080/healthz`.
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
Easiest — exec into container:
|
||||
ssh oxps
|
||||
cd ~/mc/ulicraft-server
|
||||
docker compose exec minecraft bash # world at /data
|
||||
|
||||
Host path (needs sudo, root-owned):
|
||||
ssh oxps
|
||||
docker volume inspect ulicraft-server_mc_data -f '{{.Mountpoint}}'
|
||||
# -> /var/lib/docker/volumes/ulicraft-server_mc_data/_data
|
||||
sudo ls /var/lib/docker/volumes/ulicraft-server_mc_data/_data
|
||||
Project name = dir name, so verify with docker volume ls | grep mc_data if dir differs.
|
||||
|
||||
Copy files out/in without root:
|
||||
docker compose cp minecraft:/data/logs/latest.log ./latest.log
|
||||
docker compose cp ./foo.json minecraft:/data/config/
|
||||
|
||||
Caveats:
|
||||
- Writing world files while server runs → corruption. Stop first: docker compose stop minecraft.
|
||||
- oxps LAN-only, so ssh oxps from LAN only.
|
||||
- mc-backup mounts same volume :ro — backups in backups/ are safer read source for world snapshots.
|
||||
|
||||
@@ -17,8 +17,9 @@ One compose file, one `docker compose up -d`:
|
||||
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||
| `mc-status` | built from source | live server-status JSON for the landing card |
|
||||
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` |
|
||||
| `filestash` | machines/filestash (digest-pinned) | player file share (upload) at `upload.${BASE_DOMAIN}` |
|
||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||
| `monitor` | built from source | admin-only log stream + dashboard at `monitor.${BASE_DOMAIN}` |
|
||||
|
||||
Vhosts (all proxied through the host nginx → caddy):
|
||||
|
||||
@@ -27,6 +28,7 @@ Vhosts (all proxied through the host nginx → caddy):
|
||||
- `avatar.` — NMSR
|
||||
- `status.` — Uptime Kuma
|
||||
- `files.` — Filestash player file share (one shared login, writable)
|
||||
- `monitor.` — **admin only**, log stream + dashboard (caddy basic_auth)
|
||||
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||
|
||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
@@ -35,7 +37,8 @@ Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
## DNS
|
||||
|
||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||
(`auth. avatar. status. files. monitor. distribution. www.`) at the host running
|
||||
nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -124,7 +127,8 @@ persists in the `kuma_data` volume):
|
||||
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||
| Upload (Filestash) | HTTP(s) | `https://upload.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||
| Files (static browse) | HTTP(s) | `https://files.${BASE_DOMAIN}` (caddy dir listing of the share, no auth) |
|
||||
|
||||
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
||||
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
|
||||
@@ -157,11 +161,55 @@ curl -H "Host: ulicraft.net" \
|
||||
|
||||
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
||||
|
||||
## Monitor (admin log stream + dashboard)
|
||||
|
||||
`monitor.${BASE_DOMAIN}` — a small Go service (`docker/monitor/`) that tails the
|
||||
Minecraft server's `latest.log` and shows it live with autoscroll, a filter and
|
||||
ERROR/WARN/INFO chips, above a row of tiles: server status, TPS, players online
|
||||
(with NMSR heads), registered accounts, disk free, backup freshness and recent
|
||||
joins/leaves.
|
||||
|
||||
The bell in the header turns on **browser notifications for player join/leave**.
|
||||
Opt-in, off by default, remembered per browser; it fires only while the page is
|
||||
open (a background tab counts, a closed one doesn't).
|
||||
|
||||
**Admin only, and read-only.** The log carries player IPs, chat and admin
|
||||
commands verbatim and nothing sanitizes it, so the whole vhost sits behind caddy
|
||||
`basic_auth`. There are no controls in the UI — every route is a `GET`.
|
||||
|
||||
```bash
|
||||
# generate the credential (BCRYPT — the opposite of FILES_PASSWORD_HASH, which needs $6$)
|
||||
docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
|
||||
```
|
||||
|
||||
Then in `.env` — **single-quote the hash**, Compose mangles bare `$`-sequences:
|
||||
|
||||
```sh
|
||||
MONITOR_ENABLED=on # per-host on|off, like FILES_BROWSE
|
||||
MONITOR_USER=admin
|
||||
MONITOR_PASSWORD_HASH='$2a$14$...'
|
||||
```
|
||||
|
||||
The container publishes **no port**: caddy is the only way in, so there is no
|
||||
host listener to firewall. It holds one credential (`RCON_PASSWORD`, for the TPS
|
||||
tile) and can only send commands from a compile-time allowlist that no HTTP
|
||||
input can reach.
|
||||
|
||||
Adding it needs a cert (`monitor` in `LE_SUBDOMAINS` →
|
||||
`tooling/issue-letsencrypt.sh`) and an nginx vhost (`tooling/render-nginx.sh
|
||||
--install`). Because it adds a caddy conf file, the **first** deploy needs
|
||||
`./update-all.sh --hard` (world downtime); later changes are rolling.
|
||||
|
||||
Full design, gotchas and verification: **`plan/23-monitor.md`**.
|
||||
|
||||
## Player file share (Filestash)
|
||||
|
||||
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||
`upload.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||
schematics, maps, guides. **One shared username/password for everybody**
|
||||
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
|
||||
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. The same share is
|
||||
also exposed **read-only, no-auth** as a plain caddy directory listing at
|
||||
`files.${BASE_DOMAIN}` — every uploaded file is publicly downloadable there.
|
||||
It is *not* a backup
|
||||
browser and *not* a mod mirror: world snapshots contain every player's inventory
|
||||
and coordinates, and the modset already ships via `distribution.`.
|
||||
|
||||
@@ -183,9 +231,9 @@ Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version
|
||||
|
||||
Four traps that cost real time (all enforced or documented in the tooling):
|
||||
|
||||
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
|
||||
- **`general.host` is a bare hostname**, no scheme (`upload.${BASE_DOMAIN}`), with
|
||||
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
||||
URL there produces `http://https://files...` and every client-side redirect
|
||||
URL there produces `http://https://upload...` and every client-side redirect
|
||||
breaks. The server strips the scheme before its *own* Host check, so `curl`
|
||||
and the uptime monitor stay **green while every browser is broken**.
|
||||
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
|
||||
@@ -199,8 +247,8 @@ Four traps that cost real time (all enforced or documented in the tooling):
|
||||
|
||||
> Verifying this service: a 200 on `/` only proves the server is up. Check the
|
||||
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
|
||||
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||
> `https://upload.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||
> `https://upload.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||
|
||||
## Join (guests)
|
||||
|
||||
@@ -220,6 +268,8 @@ caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||
docker/nmsr/ # built-from-source NMSR image
|
||||
docker/mc-status/ # built-from-source SLP→JSON pinger
|
||||
docker/monitor/ # built-from-source admin log stream + dashboard
|
||||
landing/ # Astro site → www/
|
||||
launcher/ # FjordLauncher assets (gitignored)
|
||||
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||
@@ -230,3 +280,6 @@ plan/ # design docs
|
||||
```
|
||||
|
||||
See `plan/00-overview.md` for the full design.
|
||||
|
||||
## Ban IPs
|
||||
`docker compose exec minecraft rcon-cli ban-ip 1.1.1.1`
|
||||
@@ -4,6 +4,10 @@
|
||||
# conf.d/30-distribution.caddy static site from another repo
|
||||
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||
# conf.d/50-status.caddy uptime-kuma status page
|
||||
# conf.d/60-upload.caddy filestash player file share
|
||||
# conf.d/62-files.caddy read-only browse of the share (FILES_BROWSE)
|
||||
# conf.d/70-generators.caddy offline misode data-pack generators
|
||||
# conf.d/80-monitor.caddy admin log stream (basic_auth, MONITOR_ENABLED)
|
||||
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
|
||||
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||
# upgrading to its own https.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Files — Filestash player file share (filestash service). Shared login,
|
||||
# writable. See plan/20-files.md.
|
||||
#
|
||||
# Filestash blocks any request whose Host header doesn't match its configured
|
||||
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
|
||||
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||
# through untouched — do NOT rewrite it here or every request 403s.
|
||||
http://files.{$BASE_DOMAIN} {
|
||||
reverse_proxy filestash:8334
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Upload — Filestash player file share (filestash service). Shared login,
|
||||
# writable. See plan/20-files.md.
|
||||
#
|
||||
# Filestash blocks any request whose Host header doesn't match its configured
|
||||
# `general.host` (https://upload.${BASE_DOMAIN}), with "only traffic from X is
|
||||
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||
# through untouched — do NOT rewrite it here or every request 403s.
|
||||
#
|
||||
# Filestash CANNOT be served under a subpath (SPA hardcodes /api, /assets, /login
|
||||
# at root), so it gets its own subdomain. The read-only browse of the same share
|
||||
# lives at files.${BASE_DOMAIN} (62-files.caddy).
|
||||
http://upload.{$BASE_DOMAIN} {
|
||||
reverse_proxy filestash:8334
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Files — read-only static browse of the shared upload dir (FILES_DATA_DIR,
|
||||
# mounted at /srv/files). NO AUTH: every uploaded file is publicly listable and
|
||||
# downloadable here. Writes happen through filestash at upload.${BASE_DOMAIN}
|
||||
# (60-upload.caddy); this vhost is browse-only.
|
||||
http://files.{$BASE_DOMAIN} {
|
||||
root * /srv/files
|
||||
file_server browse
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generators — a pinned, self-contained fork of misode.github.io (Minecraft
|
||||
# data pack generators) at generators.${BASE_DOMAIN}. Full design: plan/21-generators.md.
|
||||
#
|
||||
# The container serves BOTH the app bundle and the ~107M offline data mirror
|
||||
# (mcmeta + vanilla-mcdoc) that tooling/sync-generators-data.sh produces, so
|
||||
# the whole thing is single-origin by construction.
|
||||
#
|
||||
# WHY THAT MATTERS: this app is 100% client-side. Every data fetch comes from
|
||||
# the PLAYER'S BROWSER, not from this host. Blocking the container's egress
|
||||
# proves nothing, and "cochi has no WAN at the party" protects nothing — a
|
||||
# single leftover upstream URL simply fails in the browser, at the venue, with
|
||||
# no internet available to debug it.
|
||||
http://generators.{$BASE_DOMAIN} {
|
||||
# The CSP is the ENFORCEMENT mechanism for offline operation, not decoration.
|
||||
#
|
||||
# `connect-src 'self'` is the load-bearing directive: it hard-blocks any
|
||||
# fetch/XHR to an origin we do not serve. If an upstream URL survives the
|
||||
# fork patch — or a future upstream merge reintroduces one — it breaks
|
||||
# LOUDLY in prod, on an ordinary day with internet available to fix it,
|
||||
# instead of silently at the party. This is the same lesson as the filestash
|
||||
# `http://https://` incident: a 200 on `/` proves the server is up, never
|
||||
# that the app works.
|
||||
#
|
||||
# script-src/style-src stay permissive ON PURPOSE for now. Spyglass and
|
||||
# deepslate may need wasm/eval and this has NOT been characterised yet;
|
||||
# tightening them is a post-party task. Loosening `connect-src` is not —
|
||||
# that one is the whole point.
|
||||
header {
|
||||
Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; worker-src 'self' blob:; base-uri 'self'; form-action 'self'"
|
||||
# The mirror is pinned to one MC version and changes only when
|
||||
# sync-generators-data.sh is re-run, so let browsers keep it. This is
|
||||
# also what makes a second visit cheap on a LAN with no upstream.
|
||||
?Cache-Control "public, max-age=604800"
|
||||
}
|
||||
reverse_proxy generators:80
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+139
-12
@@ -83,6 +83,7 @@ services:
|
||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||
|
||||
ENABLE_COMMAND_BLOCK: "TRUE"
|
||||
# ── Mod source: filtered subset of the distribution modset ──
|
||||
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
||||
# (both/server entries from mods-sides.json) and mounted at /mods below;
|
||||
@@ -99,17 +100,28 @@ services:
|
||||
volumes:
|
||||
- mc_data:/data
|
||||
- ./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 (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
|
||||
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
||||
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
||||
# mc_data). Mounting the whole kubejs dir makes BaseProperties.save() NPE
|
||||
# and kubejs never initialises.
|
||||
# These write back into the distribution checkout — an in-game /kubejs
|
||||
# reload + edit lands in DISTRIBUTION_WEB_ROOT, i.e. in git. Intentional.
|
||||
- ${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/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
|
||||
depends_on:
|
||||
- drasl # authlib (auth.) must resolve at boot
|
||||
@@ -152,6 +164,13 @@ services:
|
||||
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
|
||||
environment:
|
||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
||||
# basic_auth credentials for monitor.${BASE_DOMAIN} (80-monitor.on.caddy).
|
||||
# MONITOR_PASSWORD_HASH must be BCRYPT (`caddy hash-password`) and must be
|
||||
# SINGLE-QUOTED in .env — compose interpolates $-sequences in .env values,
|
||||
# so a bare bcrypt hash gets silently mangled ($2a$14$abc… -> $$2a$$14…)
|
||||
# and every login fails. See plan/23-monitor.md.
|
||||
MONITOR_USER: ${MONITOR_USER:-admin}
|
||||
MONITOR_PASSWORD_HASH: ${MONITOR_PASSWORD_HASH:-}
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||
@@ -159,9 +178,25 @@ services:
|
||||
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.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/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
|
||||
- ./caddy/conf.d/60-upload.caddy:/etc/caddy/conf.d/60-upload.caddy:ro
|
||||
# files.${BASE_DOMAIN} read-only browse, gated by FILES_BROWSE (on|off).
|
||||
# The value is substituted straight into the source path — `on` mounts the
|
||||
# real vhost, `off` a comment-only stub — because compose has no
|
||||
# conditionals (same trick as FILES_MOUNT_MODE). Changing it needs
|
||||
# `docker compose up -d --force-recreate caddy`: a single-file bind mount
|
||||
# pins the inode. The public gate is the nginx vhost (render-nginx.sh
|
||||
# reads the same var); this one keeps caddy itself from serving the share.
|
||||
- ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy:/etc/caddy/conf.d/62-files.caddy:ro
|
||||
- ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro
|
||||
# monitor.${BASE_DOMAIN} admin log stream, gated by MONITOR_ENABLED
|
||||
# (on|off) — same substitute-into-the-source-path trick as FILES_BROWSE
|
||||
# above, and the same recreate requirement when the value changes.
|
||||
- ./caddy/conf.d/80-monitor.${MONITOR_ENABLED:-on}.caddy:/etc/caddy/conf.d/80-monitor.caddy:ro
|
||||
- ./www:/srv/www:ro
|
||||
- ./launcher:/srv/launcher:ro
|
||||
# Read-only browse of the shared upload dir at files.${BASE_DOMAIN}
|
||||
# (62-files.caddy). Same host path filestash writes to via /data/files.
|
||||
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/srv/files:ro
|
||||
# Static site from ANOTHER repo (absolute host path in .env).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
||||
networks:
|
||||
@@ -170,6 +205,36 @@ services:
|
||||
aliases:
|
||||
- "auth.${BASE_DOMAIN}"
|
||||
|
||||
# Minecraft data pack generators at generators.${BASE_DOMAIN} — a pinned fork
|
||||
# of misode.github.io, built from source. caddy proxies generators. -> :80.
|
||||
# Full design + the offline rationale: plan/21-generators.md.
|
||||
#
|
||||
# Built from the FORK's own checkout (GENERATORS_REPO), not from ./docker/,
|
||||
# because the fork is not published yet — unlike nmsr, its Dockerfile cannot
|
||||
# git-clone a pinned upstream ref. The fork carries its own Dockerfile+Caddyfile.
|
||||
#
|
||||
# The container serves the app AND the data mirror on one origin. That is the
|
||||
# entire offline strategy: this app is 100% client-side, so every fetch comes
|
||||
# from the player's browser. At the party there is no internet — anything
|
||||
# off-origin is simply dead, which is why caddy/conf.d/70-generators.caddy
|
||||
# enforces `connect-src 'self'`.
|
||||
generators:
|
||||
build:
|
||||
context: ${GENERATORS_REPO:?GENERATORS_REPO unset in .env (path to the ulicraft-generators checkout)}
|
||||
args:
|
||||
# Must match the mcmeta ref mirrored into GENERATORS_DATA_DIR and the
|
||||
# MIRRORED_VERSION pin in the fork's src/app/services/Versions.ts.
|
||||
LATEST_VERSION: "1.21.1"
|
||||
image: ulicraft/generators:local
|
||||
container_name: generators
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# ~107M mcmeta + vanilla-mcdoc mirror from tooling/sync-generators-data.sh.
|
||||
# Read-only: it is a fetched artifact, never something the app writes.
|
||||
- ${GENERATORS_DATA_DIR:-./generators-data}:/srv/data:ro
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
|
||||
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
|
||||
nmsr:
|
||||
@@ -214,8 +279,9 @@ services:
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
|
||||
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
|
||||
# Player file share (screenshots, schematics, maps) at upload.${BASE_DOMAIN}.
|
||||
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. The same
|
||||
# share is browsable read-only + no-auth at files.${BASE_DOMAIN} (via caddy). See
|
||||
# plan/20-files.md — it documents every non-obvious bit of this service.
|
||||
#
|
||||
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
|
||||
@@ -226,6 +292,18 @@ services:
|
||||
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
|
||||
container_name: filestash
|
||||
restart: unless-stopped
|
||||
# Runtime uid/gid of the filestash process. Default 1000:1000 = the image's
|
||||
# own `filestash` user (cochi runs this — files in the share end up 1000).
|
||||
# A host can override so share files are owned by a host account instead
|
||||
# (oxps: FILES_UID/FILES_GID=1002 → files owned by `oier`). When overriding
|
||||
# to a non-1000 uid you MUST also chown the filestash_state volume + the
|
||||
# FILES_DATA_DIR share to that uid — see plan/20-files.md. group_add keeps
|
||||
# the image binary (/app/filestash is 0730, group filestash) executable and
|
||||
# /app/data (0770) traversable no matter which uid runs; on cochi it is the
|
||||
# process's own gid, harmlessly redundant.
|
||||
user: "${FILES_UID:-1000}:${FILES_GID:-1000}"
|
||||
group_add:
|
||||
- "1000"
|
||||
environment:
|
||||
# The `local` storage backend is admin-gated in filestash: it refuses to
|
||||
# init unless the connection params carry this secret (or the admin
|
||||
@@ -246,10 +324,59 @@ services:
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Admin-only log stream at monitor.${BASE_DOMAIN} (built from docker/monitor).
|
||||
# Phase 1 of plan/23-monitor.md: tails the Minecraft server's latest.log into
|
||||
# an in-memory ring buffer and serves it to a small embedded UI by polling.
|
||||
#
|
||||
# Read-only in every sense: /data is mounted :ro, the service exposes no
|
||||
# mutating route, and it holds no credentials. It publishes NO port — the only
|
||||
# way in is caddy's basic_auth vhost, which is exactly the point (the container
|
||||
# has no auth of its own, so an accidental port publish would expose the raw
|
||||
# log — chat, IPs, admin commands — to the internet). Keep it portless.
|
||||
#
|
||||
# Uptime Kuma can probe http://monitor:8080/healthz directly over mcnet,
|
||||
# bypassing caddy, so no auth exemption is needed on the vhost.
|
||||
monitor:
|
||||
build:
|
||||
context: ./docker/monitor
|
||||
image: ulicraft/monitor:local
|
||||
container_name: monitor
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONITOR_LOG_PATH: "/data/logs/latest.log"
|
||||
MONITOR_BUFFER_LINES: "5000"
|
||||
# Dashboard tiles. Server status + rosters are read from mc-status over
|
||||
# mcnet rather than from Drasl directly — mc-status already holds the
|
||||
# admin credentials and exposes a sanitized list, so this container does
|
||||
# not need a second copy of that secret.
|
||||
MONITOR_MCSTATUS_URL: "http://mc-status:8080"
|
||||
# Player heads. Rendered by NMSR on its own PUBLIC origin, so the browser
|
||||
# fetches them cross-origin (no auth involved, avatars are public anyway).
|
||||
MONITOR_AVATAR_URL: "https://avatar.${BASE_DOMAIN}"
|
||||
MONITOR_AVATAR_MODE: "${AVATAR_MODE:-headiso}"
|
||||
# TPS via RCON. This is the ONLY credential the monitor holds, and the
|
||||
# only command it can send is the compile-time allowlist in rcon.go
|
||||
# (`neoforge tps`, `list`) — no HTTP input reaches a command string, so
|
||||
# the UI stays incapable of running anything. NeoForge 1.21.1 uses
|
||||
# `neoforge tps`; `forge tps` does not exist here.
|
||||
MONITOR_RCON_ADDR: "minecraft:25575"
|
||||
MONITOR_RCON_PASSWORD: ${RCON_PASSWORD}
|
||||
MONITOR_TPS_INTERVAL: "30s"
|
||||
volumes:
|
||||
# The whole Minecraft data dir, read-only. Only logs/latest.log is read;
|
||||
# mounting /data rather than /data/logs keeps the path stable if itzg ever
|
||||
# moves the log dir, and :ro means it cannot touch the world either way.
|
||||
# It is also what the disk-free tile statfs's.
|
||||
- mc_data:/data:ro
|
||||
# Backup freshness tile. Same dir mc-backup writes to, read-only here.
|
||||
- ./backups:/backups:ro
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||
# Joins mcnet so monitors probe the stack by internal service name.
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
image: louislam/uptime-kuma:2
|
||||
container_name: uptime-kuma
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# monitor — admin-only log stream + dashboard. Multi-stage: build a static
|
||||
# binary (UI is //go:embed-ed into it), ship it on scratch.
|
||||
#
|
||||
# No CA roots needed: phase 1 talks to nothing over the network — it only reads
|
||||
# a file. Add them here if a later phase makes outbound TLS calls.
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/monitor .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /out/monitor /monitor
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/monitor"]
|
||||
@@ -0,0 +1,363 @@
|
||||
// Dashboard collectors (phase 2 + 3 of plan/23-monitor.md).
|
||||
//
|
||||
// Every tile is fed by its own independently-cached collector. That isolation
|
||||
// is the whole design: mc-status being slow, the backups dir being unreadable,
|
||||
// or RCON refusing a connection must degrade exactly one tile and never block
|
||||
// the summary response or the log stream. On error a collector keeps serving
|
||||
// its last good value and reports the error alongside it — stale-with-a-reason
|
||||
// beats a blank page, and beats a spinner that never resolves.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- TTL cache -------------------------------------------------------------
|
||||
|
||||
// cached memoizes one collector for ttl. Zero value is not usable; see newCached.
|
||||
type cached[T any] struct {
|
||||
mu sync.Mutex
|
||||
val T
|
||||
have bool
|
||||
lastErr string
|
||||
expires time.Time
|
||||
|
||||
ttl time.Duration
|
||||
fn func() (T, error)
|
||||
}
|
||||
|
||||
func newCached[T any](ttl time.Duration, fn func() (T, error)) *cached[T] {
|
||||
return &cached[T]{ttl: ttl, fn: fn}
|
||||
}
|
||||
|
||||
// get returns the cached value (refreshing if stale) and the last error, if any.
|
||||
// It never returns an error *instead* of a value — a failed refresh yields the
|
||||
// previous value plus the error text.
|
||||
func (c *cached[T]) get() (T, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.have && time.Now().Before(c.expires) {
|
||||
return c.val, c.lastErr
|
||||
}
|
||||
v, err := c.fn()
|
||||
c.expires = time.Now().Add(c.ttl)
|
||||
if err != nil {
|
||||
c.lastErr = err.Error()
|
||||
return c.val, c.lastErr // stale-or-zero, with the reason
|
||||
}
|
||||
c.val, c.have, c.lastErr = v, true, ""
|
||||
return c.val, ""
|
||||
}
|
||||
|
||||
// --- server status + rosters (via mc-status over mcnet) --------------------
|
||||
|
||||
// The monitor deliberately does NOT hold Drasl admin credentials. mc-status
|
||||
// already logs in server-side and exposes a sanitized [{uuid,name}], so we read
|
||||
// its output instead of duplicating the secret. See plan/15-mc-status.md.
|
||||
|
||||
type mcPlayer struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type statusInfo struct {
|
||||
Online bool `json:"online"`
|
||||
Count int64 `json:"count"`
|
||||
Max int64 `json:"max"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
Players []mcPlayer `json:"players"`
|
||||
}
|
||||
|
||||
// v2 subset of the mcstatus.io shape mc-status re-emits.
|
||||
type v2Wire struct {
|
||||
Online bool `json:"online"`
|
||||
Players struct {
|
||||
Online int64 `json:"online"`
|
||||
Max int64 `json:"max"`
|
||||
List []struct {
|
||||
UUID string `json:"uuid"`
|
||||
NameClean string `json:"name_clean"`
|
||||
} `json:"list"`
|
||||
} `json:"players"`
|
||||
MOTD struct {
|
||||
Clean string `json:"clean"`
|
||||
} `json:"motd"`
|
||||
Version struct {
|
||||
NameClean string `json:"name_clean"`
|
||||
} `json:"version"`
|
||||
}
|
||||
|
||||
func fetchStatus(client *http.Client, base string) (statusInfo, error) {
|
||||
var wire v2Wire
|
||||
// The address segment is ignored by mc-status (it only ever pings its own
|
||||
// configured target) — any placeholder works.
|
||||
if err := getJSON(client, base+"/v2/status/java/self", &wire); err != nil {
|
||||
return statusInfo{}, err
|
||||
}
|
||||
out := statusInfo{
|
||||
Online: wire.Online,
|
||||
Count: wire.Players.Online,
|
||||
Max: wire.Players.Max,
|
||||
MOTD: wire.MOTD.Clean,
|
||||
Version: wire.Version.NameClean,
|
||||
}
|
||||
for _, p := range wire.Players.List {
|
||||
out.Players = append(out.Players, mcPlayer{UUID: p.UUID, Name: p.NameClean})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fetchRegistered(client *http.Client, base string) ([]mcPlayer, error) {
|
||||
var players []mcPlayer
|
||||
if err := getJSON(client, base+"/players", &players); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func getJSON(client *http.Client, url string, dst any) error {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &statusCodeError{url: url, code: resp.StatusCode}
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(dst)
|
||||
}
|
||||
|
||||
type statusCodeError struct {
|
||||
url string
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *statusCodeError) Error() string {
|
||||
return e.url + ": HTTP " + itoa(e.code)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [12]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// --- disk ------------------------------------------------------------------
|
||||
|
||||
type diskInfo struct {
|
||||
Path string `json:"path"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsedPct float64 `json:"used_pct"`
|
||||
}
|
||||
|
||||
// diskUsage statfs's the world volume. This reports the underlying host
|
||||
// filesystem, which is exactly what matters: a full disk is the failure that
|
||||
// actually takes the server down, and backups (15G+ and growing) share it.
|
||||
func diskUsage(path string) (diskInfo, error) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return diskInfo{Path: path}, err
|
||||
}
|
||||
bs := uint64(st.Bsize)
|
||||
total := st.Blocks * bs
|
||||
free := st.Bavail * bs // Bavail, not Bfree — exclude root-reserved blocks
|
||||
info := diskInfo{Path: path, TotalBytes: total, FreeBytes: free}
|
||||
if total > 0 {
|
||||
info.UsedPct = float64(total-free) / float64(total) * 100
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------
|
||||
|
||||
type backupInfo struct {
|
||||
Newest string `json:"newest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Mtime int64 `json:"mtime"`
|
||||
AgeSeconds int64 `json:"age_seconds"`
|
||||
Count int `json:"count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
// backupStatus finds the newest archive written by mc-backup (world-*.tar.gz).
|
||||
// Age is the number that matters — "backups exist" is not the same claim as
|
||||
// "a backup ran recently", and only the second one is worth a tile.
|
||||
func backupStatus(dir string) (backupInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return backupInfo{}, err
|
||||
}
|
||||
var out backupInfo
|
||||
var newest time.Time
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out.Count++
|
||||
out.TotalBytes += fi.Size()
|
||||
if fi.ModTime().After(newest) {
|
||||
newest = fi.ModTime()
|
||||
out.Newest = e.Name()
|
||||
out.SizeBytes = fi.Size()
|
||||
}
|
||||
}
|
||||
if out.Count > 0 {
|
||||
out.Mtime = newest.Unix()
|
||||
out.AgeSeconds = int64(time.Since(newest).Seconds())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- session history (derived from the log we already tail) ----------------
|
||||
|
||||
type sessionEvent struct {
|
||||
Time string `json:"time"`
|
||||
Player string `json:"player"`
|
||||
Action string `json:"action"` // "joined" | "left"
|
||||
}
|
||||
|
||||
// Matches the message tail of e.g.
|
||||
//
|
||||
// [01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft...]: Oier joined the game
|
||||
var sessionRe = regexp.MustCompile(`]: (\S+) (joined|left) the game\b`)
|
||||
|
||||
// leading [timestamp] of a log line, whatever its format
|
||||
var stampRe = regexp.MustCompile(`^\[([^\]]+)\]`)
|
||||
|
||||
// events is a bounded history of joins/leaves, filled by the tailer as lines
|
||||
// arrive. Free: the bytes are already being read for the log stream. It also
|
||||
// means the cold-start backfill populates history without any persistence.
|
||||
type events struct {
|
||||
mu sync.Mutex
|
||||
buf []sessionEvent
|
||||
max int
|
||||
}
|
||||
|
||||
func newEvents(max int) *events { return &events{max: max} }
|
||||
|
||||
func (e *events) observe(line string) {
|
||||
m := sessionRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
ev := sessionEvent{Player: m[1], Action: m[2]}
|
||||
if ts := stampRe.FindStringSubmatch(line); ts != nil {
|
||||
ev.Time = ts[1]
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.buf = append(e.buf, ev)
|
||||
if len(e.buf) > e.max {
|
||||
e.buf = e.buf[len(e.buf)-e.max:]
|
||||
}
|
||||
}
|
||||
|
||||
// recent returns up to n events, newest first.
|
||||
func (e *events) recent(n int) []sessionEvent {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]sessionEvent, 0, n)
|
||||
for i := len(e.buf) - 1; i >= 0 && len(out) < n; i-- {
|
||||
out = append(out, e.buf[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- assembled summary -----------------------------------------------------
|
||||
|
||||
type summary struct {
|
||||
Server statusInfo `json:"server"`
|
||||
ServerErr string `json:"server_err,omitempty"`
|
||||
Registered int `json:"registered"`
|
||||
RosterErr string `json:"roster_err,omitempty"`
|
||||
Disk diskInfo `json:"disk"`
|
||||
DiskErr string `json:"disk_err,omitempty"`
|
||||
Backup backupInfo `json:"backup"`
|
||||
BackupErr string `json:"backup_err,omitempty"`
|
||||
TPS *tpsInfo `json:"tps"`
|
||||
TPSErr string `json:"tps_err,omitempty"`
|
||||
Sessions []sessionEvent `json:"sessions"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
AvatarMode string `json:"avatar_mode"`
|
||||
Now int64 `json:"now"`
|
||||
}
|
||||
|
||||
// collectors owns every tile source. Assembled once in main and shared.
|
||||
type collectors struct {
|
||||
status *cached[statusInfo]
|
||||
registered *cached[[]mcPlayer]
|
||||
disk *cached[diskInfo]
|
||||
backup *cached[backupInfo]
|
||||
tps *cached[*tpsInfo]
|
||||
events *events
|
||||
|
||||
avatarURL string
|
||||
avatarMode string
|
||||
}
|
||||
|
||||
func (c *collectors) summary() summary {
|
||||
// Refresh concurrently. Sequentially, a summary during an outage would cost
|
||||
// the SUM of every timeout (~15s with mc-status hanging and RCON blackholed)
|
||||
// — and an outage is exactly when someone is staring at this page. Each
|
||||
// cached has its own mutex, so this is just fan-out.
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
status statusInfo
|
||||
statusErr string
|
||||
registered []mcPlayer
|
||||
rosterErr string
|
||||
disk diskInfo
|
||||
diskErr string
|
||||
backup backupInfo
|
||||
backupErr string
|
||||
tps *tpsInfo
|
||||
tpsErr string
|
||||
)
|
||||
wg.Add(5)
|
||||
go func() { defer wg.Done(); status, statusErr = c.status.get() }()
|
||||
go func() { defer wg.Done(); registered, rosterErr = c.registered.get() }()
|
||||
go func() { defer wg.Done(); disk, diskErr = c.disk.get() }()
|
||||
go func() { defer wg.Done(); backup, backupErr = c.backup.get() }()
|
||||
go func() { defer wg.Done(); tps, tpsErr = c.tps.get() }()
|
||||
wg.Wait()
|
||||
|
||||
return summary{
|
||||
Server: status,
|
||||
ServerErr: statusErr,
|
||||
Registered: len(registered),
|
||||
RosterErr: rosterErr,
|
||||
Disk: disk,
|
||||
DiskErr: diskErr,
|
||||
Backup: backup,
|
||||
BackupErr: backupErr,
|
||||
TPS: tps,
|
||||
TPSErr: tpsErr,
|
||||
Sessions: c.events.recent(40),
|
||||
AvatarURL: c.avatarURL,
|
||||
AvatarMode: c.avatarMode,
|
||||
Now: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module ulicraft/monitor
|
||||
|
||||
go 1.25.0
|
||||
@@ -0,0 +1,501 @@
|
||||
// monitor — admin-only, read-only view of the Minecraft server's latest.log.
|
||||
//
|
||||
// See plan/23-monitor.md. A tailer follows /data/logs/latest.log into an
|
||||
// in-memory ring buffer (/api/log), a set of independently-cached collectors
|
||||
// feed the dashboard tiles (/api/summary, collect.go), and both are served to
|
||||
// an embedded single-page UI by polling.
|
||||
//
|
||||
// The service is read-only in every sense. /data and /backups are mounted :ro,
|
||||
// no route mutates anything, and the single credential it holds — the RCON
|
||||
// password — can only send commands from a compile-time allowlist that no HTTP
|
||||
// input can reach (rcon.go).
|
||||
//
|
||||
// Design notes that are NOT obvious from the code:
|
||||
//
|
||||
// - The cursor is a SEQUENCE NUMBER, not a byte offset. itzg rotates
|
||||
// latest.log -> YYYY-MM-DD-N.log.gz on every server restart (i.e. on every
|
||||
// deploy), which makes byte offsets wrong routinely rather than rarely.
|
||||
//
|
||||
// - The tailer reopens on inode change OR size shrink. Both happen on every
|
||||
// deploy. A tailer that merely follows an already-open fd goes permanently
|
||||
// silent after the first restart while still answering HTTP 200 — the
|
||||
// "monitor says everything is fine" failure. This is the single most likely
|
||||
// way this service breaks, so it is checked every tick.
|
||||
//
|
||||
// - Transport is POLLING, not SSE. The request chain is host nginx -> caddy ->
|
||||
// here, and nginx buffers proxied responses by default; an EventSource would
|
||||
// stall on prod (where the vhost is an untracked per-host file) and not
|
||||
// reproduce locally. Delay is acceptable by design, so polling is strictly
|
||||
// better here.
|
||||
//
|
||||
// - No persistence. On boot we prime the ring from the tail of the file on
|
||||
// disk, so a restart costs nothing that isn't still there.
|
||||
//
|
||||
// This service publishes no port (mcnet only) and is reached exclusively through
|
||||
// caddy's basic_auth vhost.
|
||||
//
|
||||
// It runs as root inside the container ON PURPOSE: /data is drwxr-x--- owned by
|
||||
// uid 1000 (itzg), so a non-root user cannot even traverse it and the tailer
|
||||
// would silently read nothing. Every mount is :ro, which is what actually bounds
|
||||
// the damage here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed ui/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
// --- ring buffer -----------------------------------------------------------
|
||||
|
||||
// ring is a fixed-capacity circular buffer of log lines with monotonic sequence
|
||||
// numbers. Sequence numbers never restart while the process lives, so a client
|
||||
// cursor stays valid across log rotations.
|
||||
type ring struct {
|
||||
mu sync.Mutex
|
||||
buf []string
|
||||
head int // index of the oldest line
|
||||
n int // lines currently held
|
||||
next int64 // sequence number the next appended line will get
|
||||
}
|
||||
|
||||
func newRing(capacity int) *ring {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
return &ring{buf: make([]string, capacity)}
|
||||
}
|
||||
|
||||
func (r *ring) append(s string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.n == len(r.buf) {
|
||||
// Full: overwrite the oldest and advance head. O(1) — deliberately not a
|
||||
// slice shuffle, because boot spam appends thousands of lines a second.
|
||||
r.buf[r.head] = s
|
||||
r.head = (r.head + 1) % len(r.buf)
|
||||
} else {
|
||||
r.buf[(r.head+r.n)%len(r.buf)] = s
|
||||
r.n++
|
||||
}
|
||||
r.next++
|
||||
}
|
||||
|
||||
// snapshot returns every buffered line plus the cursor to resume from.
|
||||
func (r *ring) snapshot() ([]string, int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.collect(r.next - int64(r.n)), r.next
|
||||
}
|
||||
|
||||
// since returns the lines after `seq`.
|
||||
//
|
||||
// dropped > 0 means the caller fell behind the buffer and lines were lost;
|
||||
// reset means the caller must discard what it has and take these lines as the
|
||||
// new truth (it fell behind, or the server restarted and its cursor is now in
|
||||
// the future). Without this the UI would lie by omission after any burst.
|
||||
func (r *ring) since(seq int64) (lines []string, next int64, dropped int64, reset bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
first := r.next - int64(r.n)
|
||||
switch {
|
||||
case seq > r.next:
|
||||
// Cursor from a previous process lifetime — sequence numbering restarted.
|
||||
seq, reset = first, true
|
||||
case seq < first:
|
||||
dropped, seq, reset = first-seq, first, true
|
||||
}
|
||||
return r.collect(seq), r.next, dropped, reset
|
||||
}
|
||||
|
||||
// collect copies lines from `seq` onward. Caller holds r.mu.
|
||||
func (r *ring) collect(seq int64) []string {
|
||||
first := r.next - int64(r.n)
|
||||
start := int(seq - first)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
out := make([]string, 0, r.n-start)
|
||||
for i := start; i < r.n; i++ {
|
||||
out = append(out, r.buf[(r.head+i)%len(r.buf)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *ring) count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.n
|
||||
}
|
||||
|
||||
// --- tailer ----------------------------------------------------------------
|
||||
|
||||
// tailState is what the UI shows about the file itself. Mtime is the useful
|
||||
// one: it lets the page distinguish "the server is quiet" from "the tailer is
|
||||
// stuck", which HTTP status alone can never do.
|
||||
type tailState struct {
|
||||
Path string `json:"path"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Mtime int64 `json:"mtime"`
|
||||
Rotations int64 `json:"rotations"`
|
||||
}
|
||||
|
||||
type tailer struct {
|
||||
path string
|
||||
backfill int
|
||||
r *ring
|
||||
ev *events // session history, derived from the same bytes (may be nil)
|
||||
|
||||
f *os.File
|
||||
pos int64 // byte offset of the next unread COMPLETE line
|
||||
dev uint64
|
||||
ino uint64
|
||||
|
||||
mu sync.Mutex
|
||||
state tailState
|
||||
}
|
||||
|
||||
func (t *tailer) snapshotState() tailState {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.state
|
||||
}
|
||||
|
||||
func (t *tailer) setErr(err error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.state.OK = false
|
||||
if err != nil {
|
||||
t.state.Error = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tailer) run(interval time.Duration) {
|
||||
for {
|
||||
t.tick()
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tailer) tick() {
|
||||
fi, err := os.Stat(t.path)
|
||||
if err != nil {
|
||||
// Missing file is normal before the server's first boot, and during the
|
||||
// window where itzg has rotated but not yet recreated latest.log.
|
||||
t.closeFile()
|
||||
t.setErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
dev, ino, ok := statKey(fi)
|
||||
if !ok {
|
||||
t.setErr(errUnsupportedStat)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case t.f == nil:
|
||||
// First open of this process: seed the ring from the tail of the file so
|
||||
// the page has history immediately. Subsequent opens start at 0.
|
||||
if err := t.open(fi, t.backfill > 0); err != nil {
|
||||
t.setErr(err)
|
||||
return
|
||||
}
|
||||
case dev != t.dev || ino != t.ino:
|
||||
// Rotated: latest.log is a different file now. Read the new one whole.
|
||||
log.Printf("tail: %s rotated (ino %d -> %d), reopening", t.path, t.ino, ino)
|
||||
t.closeFile()
|
||||
if err := t.open(fi, false); err != nil {
|
||||
t.setErr(err)
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.state.Rotations++
|
||||
t.mu.Unlock()
|
||||
case fi.Size() < t.pos:
|
||||
// Truncated in place (same inode, smaller). Re-read from the start.
|
||||
log.Printf("tail: %s truncated (%d -> %d), rewinding", t.path, t.pos, fi.Size())
|
||||
t.pos = 0
|
||||
}
|
||||
|
||||
t.drain(fi.Size())
|
||||
|
||||
t.mu.Lock()
|
||||
t.state.OK = true
|
||||
t.state.Error = ""
|
||||
t.state.Size = fi.Size()
|
||||
t.state.Mtime = fi.ModTime().Unix()
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *tailer) open(fi os.FileInfo, backfill bool) error {
|
||||
f, err := os.Open(t.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.f = f
|
||||
t.dev, t.ino, _ = statKey(fi)
|
||||
t.pos = 0
|
||||
if backfill {
|
||||
t.pos = backfillOffset(f, fi.Size(), t.backfill)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tailer) closeFile() {
|
||||
if t.f != nil {
|
||||
t.f.Close()
|
||||
t.f = nil
|
||||
}
|
||||
}
|
||||
|
||||
// drain reads every COMPLETE line between pos and size into the ring.
|
||||
//
|
||||
// A trailing partial line (the writer is mid-write) does not advance pos, so it
|
||||
// is re-read on the next tick once its newline lands. That keeps the "never emit
|
||||
// half a line" guarantee without any partial-line state to get wrong.
|
||||
func (t *tailer) drain(size int64) {
|
||||
if t.f == nil || size <= t.pos {
|
||||
return
|
||||
}
|
||||
if _, err := t.f.Seek(t.pos, io.SeekStart); err != nil {
|
||||
t.setErr(err)
|
||||
return
|
||||
}
|
||||
rd := bufio.NewReaderSize(t.f, 64<<10)
|
||||
for {
|
||||
line, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
// io.EOF with a partial line: leave pos before it and retry next tick.
|
||||
return
|
||||
}
|
||||
t.pos += int64(len(line))
|
||||
clean := trimEOL(line)
|
||||
t.r.append(clean)
|
||||
if t.ev != nil {
|
||||
t.ev.observe(clean)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backfillOffset returns the byte offset of the start of the last `want` lines,
|
||||
// scanning at most maxScan bytes back from the end. Bounded on purpose: a
|
||||
// 164-mod server's latest.log is mostly boot spam and can be large, and we only
|
||||
// ever want the tail of it.
|
||||
func backfillOffset(f *os.File, size int64, want int) int64 {
|
||||
const maxScan = 8 << 20
|
||||
|
||||
start := size - maxScan
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
buf := make([]byte, size-start)
|
||||
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
|
||||
return size
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := len(buf) - 1; i >= 0; i-- {
|
||||
if buf[i] != '\n' || i == len(buf)-1 {
|
||||
continue // the final newline terminates the last line, it starts nothing
|
||||
}
|
||||
if count++; count >= want {
|
||||
return start + int64(i) + 1
|
||||
}
|
||||
}
|
||||
if start == 0 {
|
||||
return 0
|
||||
}
|
||||
// Fewer than `want` lines in the window — begin at the first COMPLETE line so
|
||||
// we never emit the tail half of a line we sliced through.
|
||||
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 {
|
||||
return start + int64(idx) + 1
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func trimEOL(s string) string {
|
||||
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type stringError string
|
||||
|
||||
func (e stringError) Error() string { return string(e) }
|
||||
|
||||
const errUnsupportedStat = stringError("stat: unsupported platform (no syscall.Stat_t)")
|
||||
|
||||
func statKey(fi os.FileInfo) (dev, ino uint64, ok bool) {
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return uint64(st.Dev), uint64(st.Ino), true
|
||||
}
|
||||
|
||||
// --- http ------------------------------------------------------------------
|
||||
|
||||
type logResponse struct {
|
||||
Lines []string `json:"lines"`
|
||||
Seq int64 `json:"seq"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
Reset bool `json:"reset"`
|
||||
Total int `json:"total"`
|
||||
Tail tailState `json:"tail"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
listen := envOr("MONITOR_LISTEN", ":8080")
|
||||
path := envOr("MONITOR_LOG_PATH", "/data/logs/latest.log")
|
||||
|
||||
bufLines, err := strconv.Atoi(envOr("MONITOR_BUFFER_LINES", "5000"))
|
||||
if err != nil || bufLines < 1 {
|
||||
log.Fatalf("invalid MONITOR_BUFFER_LINES: %q", os.Getenv("MONITOR_BUFFER_LINES"))
|
||||
}
|
||||
interval, err := time.ParseDuration(envOr("MONITOR_TAIL_INTERVAL", "500ms"))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid MONITOR_TAIL_INTERVAL: %v", err)
|
||||
}
|
||||
|
||||
r := newRing(bufLines)
|
||||
ev := newEvents(500)
|
||||
t := &tailer{path: path, backfill: bufLines, r: r, ev: ev, state: tailState{Path: path}}
|
||||
go t.run(interval)
|
||||
|
||||
col := buildCollectors(ev)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// GET /api/log[?since=<seq>] — omit `since` for a full buffer without a
|
||||
// spurious "dropped" notice on first load.
|
||||
mux.HandleFunc("/api/log", func(w http.ResponseWriter, req *http.Request) {
|
||||
var resp logResponse
|
||||
if raw := req.URL.Query().Get("since"); raw != "" {
|
||||
seq, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad since", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp.Lines, resp.Seq, resp.Dropped, resp.Reset = r.since(seq)
|
||||
} else {
|
||||
resp.Lines, resp.Seq = r.snapshot()
|
||||
}
|
||||
resp.Total = r.count()
|
||||
resp.Tail = t.snapshotState()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// GET /api/summary — every dashboard tile in one response. Each collector is
|
||||
// independently cached and degrades on its own; a failure shows up as a
|
||||
// *_err field next to a stale value rather than failing the request.
|
||||
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(col.summary())
|
||||
})
|
||||
|
||||
// Liveness only. Deliberately NOT tied to the log file: Minecraft being down
|
||||
// is a legitimate state, and Uptime Kuma probes this over mcnet (bypassing
|
||||
// caddy, hence unauthenticated by construction).
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path != "/" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Write(indexHTML)
|
||||
})
|
||||
|
||||
log.Printf("monitor listening on %s, tailing %s (buffer=%d lines, interval=%s)",
|
||||
listen, path, bufLines, interval)
|
||||
log.Fatal(http.ListenAndServe(listen, mux))
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envDur(key, def string) time.Duration {
|
||||
d, err := time.ParseDuration(envOr(key, def))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid %s: %v", key, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// buildCollectors wires every dashboard tile source. TTLs are per-collector and
|
||||
// chosen against what each thing actually costs: mc-status is a local HTTP hop
|
||||
// (cheap, and it caches its own upstream ping), statfs is free, the backups dir
|
||||
// is a directory scan, RCON opens a socket to the game server.
|
||||
func buildCollectors(ev *events) *collectors {
|
||||
base := strings.TrimRight(envOr("MONITOR_MCSTATUS_URL", "http://mc-status:8080"), "/")
|
||||
dataDir := envOr("MONITOR_DATA_DIR", "/data")
|
||||
backupDir := envOr("MONITOR_BACKUP_DIR", "/backups")
|
||||
|
||||
// Short timeout everywhere: a hung dependency must not hold the summary.
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
rconAddr := envOr("MONITOR_RCON_ADDR", "minecraft:25575")
|
||||
rconPass := os.Getenv("MONITOR_RCON_PASSWORD")
|
||||
tpsInterval := envDur("MONITOR_TPS_INTERVAL", "30s")
|
||||
|
||||
return &collectors{
|
||||
status: newCached(envDur("MONITOR_STATUS_TTL", "15s"), func() (statusInfo, error) {
|
||||
return fetchStatus(client, base)
|
||||
}),
|
||||
registered: newCached(envDur("MONITOR_ROSTER_TTL", "60s"), func() ([]mcPlayer, error) {
|
||||
return fetchRegistered(client, base)
|
||||
}),
|
||||
disk: newCached(envDur("MONITOR_DISK_TTL", "60s"), func() (diskInfo, error) {
|
||||
return diskUsage(dataDir)
|
||||
}),
|
||||
backup: newCached(envDur("MONITOR_BACKUP_TTL", "60s"), func() (backupInfo, error) {
|
||||
return backupStatus(backupDir)
|
||||
}),
|
||||
tps: newCached(tpsInterval, func() (*tpsInfo, error) {
|
||||
if rconPass == "" {
|
||||
return nil, errors.New("rcon: MONITOR_RCON_PASSWORD unset (TPS disabled)")
|
||||
}
|
||||
raw, err := rconQuery(rconAddr, rconPass, "neoforge tps", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseTPS(raw)
|
||||
}),
|
||||
events: ev,
|
||||
avatarURL: strings.TrimRight(envOr("MONITOR_AVATAR_URL", ""), "/"),
|
||||
avatarMode: envOr("MONITOR_AVATAR_MODE", "headiso"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
// Locks the two things that are easy to break silently: the RCON allowlist
|
||||
// (the guarantee that this service cannot run commands) and the `neoforge tps`
|
||||
// output format, which differs from the older Forge wording.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTPSRealOutput(t *testing.T) {
|
||||
// Captured verbatim from `docker exec minecraft rcon-cli neoforge tps` on prod,
|
||||
// ANSI resets included.
|
||||
raw := "Overworld: 20.000 TPS (0.298 ms/tick)\n\x1b[0mThe Nether: 19.100 TPS (0.014 ms/tick)\n" +
|
||||
"\x1b[0mworld_dimension_nexus:creative: 20.000 TPS (0.009 ms/tick)\n" +
|
||||
"\x1b[0mOverall: 18.500 TPS (0.357 ms/tick)\n\x1b[0m"
|
||||
got, err := parseTPS(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.TPS != 18.5 || got.MSPT != 0.357 {
|
||||
t.Fatalf("overall wrong: %+v", got)
|
||||
}
|
||||
if len(got.Dimensions) != 3 {
|
||||
t.Fatalf("want 3 dims, got %d: %+v", len(got.Dimensions), got.Dimensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTPSUnknownCommand(t *testing.T) {
|
||||
if _, err := parseTPS("Unknown or incomplete command, see below for error\nforge tps<--[HERE]"); err == nil {
|
||||
t.Fatal("want error for unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRCONAllowlist(t *testing.T) {
|
||||
for _, bad := range []string{"op Oier", "stop", "say hi", "ban x", "", "neoforge tps ", "LIST"} {
|
||||
if _, err := rconQuery("127.0.0.1:1", "pw", bad, 0); err != errRCONNotAllowed {
|
||||
t.Fatalf("command %q was not rejected by the allowlist (err=%v)", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParse(t *testing.T) {
|
||||
e := newEvents(10)
|
||||
e.observe("[01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier joined the game")
|
||||
e.observe("[01Aug2026 23:01:02.111] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier left the game")
|
||||
e.observe("[01Aug2026 23:02:00.000] [Server thread/INFO] [x/]: something else entirely")
|
||||
got := e.recent(10)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 events, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Action != "left" || got[0].Player != "Oier" || got[0].Time != "01Aug2026 23:01:02.111" {
|
||||
t.Fatalf("newest-first wrong: %+v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Minimal Source RCON client — the ONLY thing in this service that talks to
|
||||
// Minecraft rather than reading a file, and the only place it holds a secret.
|
||||
//
|
||||
// Implemented here rather than pulled in as a dependency for one reason: the
|
||||
// allowlist below. It is a compile-time constant, and there is deliberately no
|
||||
// code path from an HTTP request to a command string. The UI cannot run
|
||||
// commands because the server cannot be asked to run one — "read-only" is a
|
||||
// property of the build, not a rule someone has to remember.
|
||||
//
|
||||
// Protocol: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
|
||||
// A fresh connection per poll (every 30s). Reconnecting costs nothing at that
|
||||
// rate and removes all stale-connection handling.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
rconTypeAuth int32 = 3
|
||||
rconTypeExec int32 = 2
|
||||
rconTypeAuthResp int32 = 2
|
||||
rconMaxBody = 4096
|
||||
)
|
||||
|
||||
// rconAllowed is the complete set of commands this service can ever send.
|
||||
// Both are pure reads. Do NOT make this configurable — see the file header.
|
||||
var rconAllowed = map[string]bool{
|
||||
"neoforge tps": true,
|
||||
"list": true,
|
||||
}
|
||||
|
||||
var errRCONNotAllowed = errors.New("rcon: command not in allowlist")
|
||||
|
||||
// rconQuery dials, authenticates, runs one allowlisted command, and closes.
|
||||
func rconQuery(addr, password, cmd string, timeout time.Duration) (string, error) {
|
||||
if !rconAllowed[cmd] {
|
||||
return "", errRCONNotAllowed
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
if err := rconSend(conn, 1, rconTypeAuth, password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Some servers emit an empty RESPONSE_VALUE before the auth result; read
|
||||
// until the auth response arrives (bounded, so a chatty peer can't spin us).
|
||||
for i := 0; i < 3; i++ {
|
||||
id, typ, _, err := rconRecv(conn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if typ != rconTypeAuthResp {
|
||||
continue
|
||||
}
|
||||
if id == -1 {
|
||||
return "", errors.New("rcon: authentication failed (bad password)")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if err := rconSend(conn, 2, rconTypeExec, cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Responses may span several packets. Read until the peer goes quiet.
|
||||
var sb strings.Builder
|
||||
for {
|
||||
_, _, body, err := rconRecv(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
sb.WriteString(body)
|
||||
if sb.Len() > rconMaxBody*4 {
|
||||
break
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", errors.New("rcon: empty response")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func rconSend(w io.Writer, id, typ int32, body string) error {
|
||||
buf := new(bytes.Buffer)
|
||||
// size counts everything after itself: id + type + body + two NUL bytes.
|
||||
binary.Write(buf, binary.LittleEndian, int32(10+len(body)))
|
||||
binary.Write(buf, binary.LittleEndian, id)
|
||||
binary.Write(buf, binary.LittleEndian, typ)
|
||||
buf.WriteString(body)
|
||||
buf.Write([]byte{0, 0})
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func rconRecv(r io.Reader) (id, typ int32, body string, err error) {
|
||||
var size int32
|
||||
if err = binary.Read(r, binary.LittleEndian, &size); err != nil {
|
||||
return
|
||||
}
|
||||
if size < 10 || size > rconMaxBody+16 {
|
||||
err = errors.New("rcon: implausible packet size " + strconv.Itoa(int(size)))
|
||||
return
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err = io.ReadFull(r, buf); err != nil {
|
||||
return
|
||||
}
|
||||
id = int32(binary.LittleEndian.Uint32(buf[0:4]))
|
||||
typ = int32(binary.LittleEndian.Uint32(buf[4:8]))
|
||||
body = string(bytes.TrimRight(buf[8:], "\x00"))
|
||||
return
|
||||
}
|
||||
|
||||
// --- TPS ---------------------------------------------------------------
|
||||
|
||||
// tpsInfo is the parsed result of `neoforge tps`.
|
||||
type tpsInfo struct {
|
||||
TPS float64 `json:"tps"`
|
||||
MSPT float64 `json:"mspt"`
|
||||
Dimensions []dimensionT `json:"dimensions"`
|
||||
}
|
||||
|
||||
type dimensionT struct {
|
||||
Name string `json:"name"`
|
||||
TPS float64 `json:"tps"`
|
||||
MSPT float64 `json:"mspt"`
|
||||
}
|
||||
|
||||
// NeoForge 1.21.1 prints one line per dimension then an "Overall:" line:
|
||||
//
|
||||
// Overworld: 20.000 TPS (0.298 ms/tick)
|
||||
// Overall: 20.000 TPS (0.357 ms/tick)
|
||||
//
|
||||
// Verified against the running server — note this is NOT the older Forge
|
||||
// "Mean tick time / Mean TPS" wording, and `forge tps` does not exist here
|
||||
// ("Unknown or incomplete command").
|
||||
var tpsLineRe = regexp.MustCompile(`(?m)^(.+?):\s*([0-9.]+)\s*TPS\s*\(([0-9.]+)\s*ms/tick\)`)
|
||||
|
||||
// colorCodes strips Minecraft §-codes and any ANSI escapes a wrapper added.
|
||||
var colorCodes = regexp.MustCompile("§.|\x1b\\[[0-9;]*m")
|
||||
|
||||
func parseTPS(raw string) (*tpsInfo, error) {
|
||||
clean := colorCodes.ReplaceAllString(raw, "")
|
||||
matches := tpsLineRe.FindAllStringSubmatch(clean, -1)
|
||||
if len(matches) == 0 {
|
||||
// Most likely "Unknown or incomplete command" — treat as unavailable
|
||||
// rather than an error that kills the poll loop.
|
||||
return nil, errors.New("rcon: no TPS lines in response")
|
||||
}
|
||||
|
||||
out := &tpsInfo{}
|
||||
for _, m := range matches {
|
||||
name := strings.TrimSpace(m[1])
|
||||
tps, _ := strconv.ParseFloat(m[2], 64)
|
||||
mspt, _ := strconv.ParseFloat(m[3], 64)
|
||||
if strings.EqualFold(name, "Overall") {
|
||||
out.TPS, out.MSPT = tps, mspt
|
||||
continue
|
||||
}
|
||||
out.Dimensions = append(out.Dimensions, dimensionT{Name: name, TPS: tps, MSPT: mspt})
|
||||
}
|
||||
if out.TPS == 0 && len(out.Dimensions) > 0 {
|
||||
// No "Overall" line — fall back to the worst dimension so the tile still
|
||||
// says something useful rather than showing 0 TPS.
|
||||
worst := out.Dimensions[0]
|
||||
for _, d := range out.Dimensions[1:] {
|
||||
if d.TPS < worst.TPS {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
out.TPS, out.MSPT = worst.TPS, worst.MSPT
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Ulicraft — monitor</title>
|
||||
<style>
|
||||
/* Palette lifted from the landing's default (grass) theme so this doesn't look
|
||||
like a different product. See landing/src/styles/main.css. */
|
||||
:root {
|
||||
--bg: oklch(0.165 0.014 150);
|
||||
--bg-2: oklch(0.205 0.016 150);
|
||||
--surface: oklch(0.235 0.017 150);
|
||||
--line: oklch(0.33 0.016 150);
|
||||
--accent: oklch(0.72 0.16 145);
|
||||
--accent-ink:oklch(0.17 0.05 150);
|
||||
--gold: oklch(0.82 0.135 85);
|
||||
--red: oklch(0.68 0.19 25);
|
||||
--text: oklch(0.95 0.008 110);
|
||||
--muted: oklch(0.72 0.012 145);
|
||||
--dim: oklch(0.55 0.012 145);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
flex: none;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
h1 { font-size: 14px; margin: 0; font-weight: 600; letter-spacing: .02em; }
|
||||
h1 small { color: var(--dim); font-weight: 400; }
|
||||
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
.dot.ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||||
.dot.bad { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||
|
||||
.stat { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.stat b { color: var(--text); font-weight: 600; }
|
||||
.err { color: var(--red); font-size: 12px; }
|
||||
.spacer { flex: 1 1 auto; }
|
||||
|
||||
input[type=search] {
|
||||
flex: 1 1 220px;
|
||||
min-width: 160px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
input[type=search]:focus { outline: none; border-color: var(--accent); }
|
||||
input[type=search].bad-re { border-color: var(--red); }
|
||||
|
||||
.chips { display: flex; gap: 4px; }
|
||||
.chip {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--dim);
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip[aria-pressed="true"] { color: var(--text); border-color: var(--accent); }
|
||||
.chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); }
|
||||
.chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); }
|
||||
|
||||
.chip.bell { font-size: 13px; line-height: 1.15; padding: 4px 7px; margin-left: 4px; }
|
||||
.chip.bell[data-state="on"] { color: var(--accent); border-color: var(--accent); }
|
||||
.chip.bell[data-state="blocked"],
|
||||
.chip.bell[data-state="unsupported"] { color: var(--red); cursor: not-allowed; }
|
||||
|
||||
/* --- dashboard tiles --- */
|
||||
#tiles {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tile h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
}
|
||||
.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; }
|
||||
.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.tile .sub.warnish { color: var(--gold); }
|
||||
.tile .sub.badish { color: var(--red); }
|
||||
.good { color: var(--accent); }
|
||||
.warn { color: var(--gold); }
|
||||
.bad { color: var(--red); }
|
||||
|
||||
.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||
.bar > i.warn { background: var(--gold); }
|
||||
.bar > i.bad { background: var(--red); }
|
||||
|
||||
.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||
.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); }
|
||||
.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); }
|
||||
|
||||
.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; }
|
||||
.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sessions .j { color: var(--accent); }
|
||||
.sessions .l { color: var(--dim); }
|
||||
|
||||
#log {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 8px 12px 40px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ln { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.ln.error { color: var(--red); }
|
||||
.ln.warn { color: var(--gold); }
|
||||
.ln.meta { color: var(--dim); font-style: italic; padding: 4px 0; }
|
||||
|
||||
#live {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / .4);
|
||||
}
|
||||
#live[hidden] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span class="dot" id="dot"></span>
|
||||
<h1>Ulicraft <small>latest.log</small></h1>
|
||||
<span class="stat" id="age">—</span>
|
||||
<span class="stat" id="counts">—</span>
|
||||
<span class="err" id="error"></span>
|
||||
<span class="spacer"></span>
|
||||
<input type="search" id="filter" placeholder="filter… (/regex/ for regex, press / to focus)" spellcheck="false">
|
||||
<span class="chips">
|
||||
<button class="chip" data-level="ERROR" aria-pressed="true">ERROR</button>
|
||||
<button class="chip" data-level="WARN" aria-pressed="true">WARN</button>
|
||||
<button class="chip" data-level="INFO" aria-pressed="true">INFO</button>
|
||||
</span>
|
||||
<button class="chip bell" id="bell" data-state="off" aria-pressed="false" title="join/leave notifications">🔕</button>
|
||||
</header>
|
||||
|
||||
<div id="tiles">
|
||||
<div class="tile" id="t-server"> <h2>Server</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-tps"> <h2>TPS</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-players"> <h2>Online</h2> <div class="big">—</div><div class="heads"></div></div>
|
||||
<div class="tile" id="t-reg"> <h2>Registered</h2><div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-disk"> <h2>Disk</h2> <div class="big">—</div><div class="sub"></div><div class="bar"><i></i></div></div>
|
||||
<div class="tile" id="t-backup"> <h2>Last backup</h2><div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-sessions"><h2>Recent joins</h2><div class="sessions"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="log" role="log" aria-live="off"></div>
|
||||
<button id="live" hidden>jump to live ↓</button>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const MAX = 5000; // must not exceed the server's ring capacity
|
||||
const POLL_MS = 2000; // delay is acceptable by design; see plan/23-monitor.md
|
||||
const STICK_PX = 40; // "close enough to the bottom" tolerance
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const dotEl = document.getElementById("dot");
|
||||
const ageEl = document.getElementById("age");
|
||||
const countsEl = document.getElementById("counts");
|
||||
const errEl = document.getElementById("error");
|
||||
const filterEl = document.getElementById("filter");
|
||||
const liveEl = document.getElementById("live");
|
||||
|
||||
// records: {text, level, rendered}. `rendered` tracks whether a DOM node
|
||||
// exists for this record, so trimming can remove exactly the right number of
|
||||
// leading nodes without a full re-render on every poll.
|
||||
let records = [];
|
||||
let seq = null; // null => first fetch, ask for the whole buffer
|
||||
let needle = "";
|
||||
let regex = null;
|
||||
const levelOn = { ERROR: true, WARN: true, INFO: true };
|
||||
|
||||
// Two bracket groups, then the level: the timestamp format differs between
|
||||
// itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]),
|
||||
// so match the *shape* rather than the timestamp:
|
||||
// [<anything>] [<thread>/LEVEL] [<logger>]: message
|
||||
const LEVEL_RE = /^\[[^\]]*\]\s*\[[^\]]*\/([A-Z]+)\]/;
|
||||
|
||||
function levelOf(text) {
|
||||
const m = LEVEL_RE.exec(text);
|
||||
if (!m) return "INFO"; // stack traces, mod spam, etc.
|
||||
const lv = m[1];
|
||||
if (lv === "ERROR" || lv === "FATAL" || lv === "SEVERE") return "ERROR";
|
||||
if (lv === "WARN" || lv === "WARNING") return "WARN";
|
||||
return "INFO";
|
||||
}
|
||||
|
||||
function passes(rec) {
|
||||
if (rec.level === "META") return true; // dropped-lines markers always show
|
||||
if (!levelOn[rec.level]) return false;
|
||||
if (regex) return regex.test(rec.text);
|
||||
if (needle) return rec.text.toLowerCase().includes(needle);
|
||||
return true;
|
||||
}
|
||||
|
||||
function nodeFor(rec) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "ln " + rec.level.toLowerCase();
|
||||
el.textContent = rec.text; // textContent, never innerHTML
|
||||
return el;
|
||||
}
|
||||
|
||||
const atBottom = () =>
|
||||
logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < STICK_PX;
|
||||
|
||||
const toBottom = () => { logEl.scrollTop = logEl.scrollHeight; };
|
||||
|
||||
function addRecords(recs) {
|
||||
const stick = atBottom();
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const rec of recs) {
|
||||
records.push(rec);
|
||||
if (passes(rec)) { frag.appendChild(nodeFor(rec)); rec.rendered = true; }
|
||||
}
|
||||
logEl.appendChild(frag);
|
||||
trim();
|
||||
if (stick) toBottom();
|
||||
updateCounts();
|
||||
updateLiveButton();
|
||||
}
|
||||
|
||||
function trim() {
|
||||
const excess = records.length - MAX;
|
||||
if (excess <= 0) return;
|
||||
let nodes = 0;
|
||||
for (let i = 0; i < excess; i++) if (records[i].rendered) nodes++;
|
||||
records.splice(0, excess);
|
||||
for (let i = 0; i < nodes && logEl.firstChild; i++) logEl.removeChild(logEl.firstChild);
|
||||
}
|
||||
|
||||
// Full rebuild — only on a filter change, never on a poll.
|
||||
function rerender() {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const rec of records) {
|
||||
rec.rendered = passes(rec);
|
||||
if (rec.rendered) frag.appendChild(nodeFor(rec));
|
||||
}
|
||||
logEl.replaceChildren(frag);
|
||||
toBottom();
|
||||
updateCounts();
|
||||
updateLiveButton();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
records = [];
|
||||
logEl.replaceChildren();
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
const shown = logEl.childElementCount;
|
||||
countsEl.textContent = shown === records.length
|
||||
? `${records.length} lines`
|
||||
: `${shown} / ${records.length} lines`;
|
||||
}
|
||||
|
||||
function updateLiveButton() { liveEl.hidden = atBottom(); }
|
||||
|
||||
function updateTail(tail) {
|
||||
if (!tail) return;
|
||||
if (!tail.ok) {
|
||||
dotEl.className = "dot bad";
|
||||
errEl.textContent = tail.error || "log unavailable";
|
||||
ageEl.textContent = "—";
|
||||
return;
|
||||
}
|
||||
errEl.textContent = "";
|
||||
dotEl.className = "dot ok";
|
||||
// Browser vs container clock skew can make this slightly off; clamped at 0.
|
||||
const age = Math.max(0, Math.floor(Date.now() / 1000) - tail.mtime);
|
||||
const rot = tail.rotations ? ` · ${tail.rotations} rotation${tail.rotations > 1 ? "s" : ""}` : "";
|
||||
ageEl.innerHTML = `last write <b>${age}s</b> ago${rot}`;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const url = seq === null ? "/api/log" : "/api/log?since=" + seq;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.reset) {
|
||||
reset();
|
||||
if (data.dropped > 0) {
|
||||
addRecords([{ text: `— fell behind, ${data.dropped} line(s) skipped —`, level: "META", rendered: false }]);
|
||||
}
|
||||
}
|
||||
seq = data.seq;
|
||||
|
||||
if (data.lines.length) {
|
||||
addRecords(data.lines.map(text => ({ text, level: levelOf(text), rendered: false })));
|
||||
} else {
|
||||
updateCounts();
|
||||
}
|
||||
updateTail(data.tail);
|
||||
} catch (e) {
|
||||
dotEl.className = "dot bad";
|
||||
errEl.textContent = String(e.message || e);
|
||||
} finally {
|
||||
setTimeout(poll, POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dashboard tiles ------------------------------------------------------
|
||||
|
||||
// Tiles move far slower than the log does. Poll harder only while join/leave
|
||||
// notifications are on, since then the poll interval IS the alert latency.
|
||||
const SUMMARY_IDLE_MS = 10000;
|
||||
const SUMMARY_NOTIFY_MS = 5000;
|
||||
|
||||
const fmtBytes = (n) => {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
|
||||
};
|
||||
|
||||
const fmtAge = (s) => {
|
||||
if (s == null) return "—";
|
||||
if (s < 60) return s + "s ago";
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
||||
return Math.floor(s / 86400) + "d ago";
|
||||
};
|
||||
|
||||
// Set a tile's big value, its class, and its subtitle in one go. `err` wins
|
||||
// over the value: a stale number with no warning would be a lie.
|
||||
function tile(id, big, bigClass, sub, subClass, err) {
|
||||
const el = document.getElementById(id);
|
||||
el.querySelector(".big").textContent = big;
|
||||
el.querySelector(".big").className = "big " + (bigClass || "");
|
||||
const s = el.querySelector(".sub");
|
||||
if (s) {
|
||||
s.textContent = err ? err : (sub || "");
|
||||
s.className = "sub " + (err ? "badish" : (subClass || ""));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderSummary(d) {
|
||||
// Server
|
||||
tile("t-server",
|
||||
d.server.online ? "online" : "offline",
|
||||
d.server.online ? "good" : "bad",
|
||||
d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "",
|
||||
"", d.server_err);
|
||||
|
||||
// TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad.
|
||||
if (d.tps) {
|
||||
const t = d.tps.tps;
|
||||
tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad",
|
||||
d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err);
|
||||
} else {
|
||||
tile("t-tps", "—", "", "", "", d.tps_err || "unavailable");
|
||||
}
|
||||
|
||||
// Online players + heads (rendered by NMSR on its own public origin)
|
||||
const pl = d.server.players || [];
|
||||
tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : "");
|
||||
const heads = document.querySelector("#t-players .heads");
|
||||
heads.replaceChildren();
|
||||
for (const p of pl) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "head";
|
||||
if (d.avatar_url && p.uuid) {
|
||||
const img = document.createElement("img");
|
||||
img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
wrap.appendChild(img);
|
||||
}
|
||||
const nm = document.createElement("span");
|
||||
nm.textContent = p.name || "?";
|
||||
wrap.appendChild(nm);
|
||||
heads.appendChild(wrap);
|
||||
}
|
||||
|
||||
tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err);
|
||||
|
||||
// Disk — the failure that actually takes the server down.
|
||||
const used = d.disk.used_pct || 0;
|
||||
const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good";
|
||||
tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls,
|
||||
used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err);
|
||||
const bar = document.querySelector("#t-disk .bar > i");
|
||||
bar.style.width = Math.min(100, used) + "%";
|
||||
bar.className = cls === "good" ? "" : cls;
|
||||
|
||||
// Backup freshness. mc-backup runs every 6h, so >8h means it stopped.
|
||||
if (d.backup && d.backup.count) {
|
||||
const age = d.backup.age_seconds;
|
||||
tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good",
|
||||
`${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err);
|
||||
} else {
|
||||
tile("t-backup", "none", "bad", "", "", d.backup_err);
|
||||
}
|
||||
|
||||
handleSessions(d.sessions);
|
||||
|
||||
// Recent joins/leaves, parsed out of the log we already hold.
|
||||
const box = document.querySelector("#t-sessions .sessions");
|
||||
box.replaceChildren();
|
||||
if (!d.sessions || !d.sessions.length) {
|
||||
const e = document.createElement("div");
|
||||
e.textContent = "no joins in buffer";
|
||||
box.appendChild(e);
|
||||
} else {
|
||||
for (const s of d.sessions.slice(0, 12)) {
|
||||
const row = document.createElement("div");
|
||||
const who = document.createElement("span");
|
||||
who.className = s.action === "joined" ? "j" : "l";
|
||||
who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`;
|
||||
row.appendChild(who);
|
||||
row.appendChild(document.createTextNode(" " + (s.time || "")));
|
||||
box.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSummary() {
|
||||
try {
|
||||
const res = await fetch("/api/summary", { cache: "no-store" });
|
||||
if (res.ok) renderSummary(await res.json());
|
||||
} catch { /* tiles keep their last values; the log poll owns the status dot */ }
|
||||
finally { setTimeout(pollSummary, notifyOn ? SUMMARY_NOTIFY_MS : SUMMARY_IDLE_MS); }
|
||||
}
|
||||
|
||||
// --- join/leave notifications ---------------------------------------------
|
||||
//
|
||||
// Rides the same sessions[] the "Recent joins" tile renders — no second
|
||||
// source, no re-parsing of the log in JS.
|
||||
//
|
||||
// The whole difficulty is not notifying for history, and there are two ways
|
||||
// history shows up: the buffer already holds events when the page loads, and
|
||||
// the monitor's cold-start backfill re-reads 5000 log lines on every deploy,
|
||||
// repopulating the events ring with the SAME events. So newness is decided by
|
||||
// a persisted seen-set keyed on the log's own timestamp — stable across both a
|
||||
// page reload and a container restart, which a server-side counter would not
|
||||
// be (it resets to zero and every event looks new again).
|
||||
|
||||
const NOTIFY_KEY = "ulicraft.monitor.notify";
|
||||
const SEEN_KEY = "ulicraft.monitor.seen";
|
||||
const SEEN_MAX = 500;
|
||||
const BURST_MIN = 3; // more than this in one poll collapses to one notification
|
||||
|
||||
let notifyOn = localStorage.getItem(NOTIFY_KEY) === "1";
|
||||
let seen = new Set(loadSeen());
|
||||
let baselined = false;
|
||||
|
||||
function loadSeen() {
|
||||
try { return JSON.parse(localStorage.getItem(SEEN_KEY)) || []; } catch { return []; }
|
||||
}
|
||||
function saveSeen() {
|
||||
const arr = [...seen].slice(-SEEN_MAX); // Set preserves insertion order
|
||||
seen = new Set(arr);
|
||||
try { localStorage.setItem(SEEN_KEY, JSON.stringify(arr)); } catch { /* quota */ }
|
||||
}
|
||||
|
||||
const evKey = (s) => `${s.time}|${s.player}|${s.action}`;
|
||||
|
||||
function handleSessions(sessions) {
|
||||
if (!Array.isArray(sessions)) return;
|
||||
|
||||
// Server returns newest-first; walk backwards so `seen` stays chronological
|
||||
// and the burst summary reads in the right order.
|
||||
const fresh = [];
|
||||
for (let i = sessions.length - 1; i >= 0; i--) {
|
||||
const k = evKey(sessions[i]);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
fresh.push(sessions[i]);
|
||||
}
|
||||
if (fresh.length) saveSeen();
|
||||
|
||||
// The first response after a page load is history by definition. Flip the
|
||||
// flag unconditionally — doing it only when fresh.length > 0 would mean a
|
||||
// returning user (everything already seen) burns their baseline on the
|
||||
// FIRST REAL event instead, silently swallowing it.
|
||||
const isBaseline = !baselined;
|
||||
baselined = true;
|
||||
if (isBaseline || !fresh.length) return;
|
||||
|
||||
if (!notifyOn || !("Notification" in window) || Notification.permission !== "granted") return;
|
||||
|
||||
if (fresh.length > BURST_MIN) {
|
||||
// A server restart makes everyone rejoin at once; one line beats six.
|
||||
const joined = fresh.filter(s => s.action === "joined").length;
|
||||
const left = fresh.length - joined;
|
||||
const parts = [];
|
||||
if (joined) parts.push(`${joined} joined`);
|
||||
if (left) parts.push(`${left} left`);
|
||||
notify("Player activity", parts.join(", "), "burst:" + evKey(fresh[fresh.length - 1]));
|
||||
} else {
|
||||
for (const s of fresh) notify(`${s.player} ${s.action}`, s.time, evKey(s));
|
||||
}
|
||||
}
|
||||
|
||||
function notify(title, body, tag) {
|
||||
try {
|
||||
const n = new Notification(title, { body, tag }); // tag: identical events can't stack
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
} catch { /* some browsers throw outside a service worker; nothing to do */ }
|
||||
}
|
||||
|
||||
const bellEl = document.getElementById("bell");
|
||||
|
||||
function paintBell() {
|
||||
const supported = "Notification" in window;
|
||||
const blocked = supported && Notification.permission === "denied";
|
||||
const state = !supported ? "unsupported" : blocked ? "blocked" : notifyOn ? "on" : "off";
|
||||
bellEl.dataset.state = state;
|
||||
bellEl.setAttribute("aria-pressed", String(state === "on"));
|
||||
bellEl.textContent = state === "on" ? "🔔" : "🔕";
|
||||
bellEl.title = {
|
||||
unsupported: "this browser has no Notification API",
|
||||
// Say this out loud: "enabled it and nothing happened" is otherwise
|
||||
// indistinguishable from the feature being broken.
|
||||
blocked: "blocked in browser settings — allow notifications for this site",
|
||||
on: "join/leave notifications ON",
|
||||
off: "join/leave notifications off — click to enable",
|
||||
}[state];
|
||||
}
|
||||
|
||||
bellEl.addEventListener("click", async () => {
|
||||
if (!("Notification" in window)) return;
|
||||
if (notifyOn) {
|
||||
notifyOn = false;
|
||||
} else {
|
||||
// requestPermission must be called from a user gesture — i.e. this click,
|
||||
// never on page load.
|
||||
if (Notification.permission === "default") {
|
||||
try { await Notification.requestPermission(); } catch { /* ignore */ }
|
||||
}
|
||||
if (Notification.permission !== "granted") { paintBell(); return; }
|
||||
notifyOn = true;
|
||||
}
|
||||
localStorage.setItem(NOTIFY_KEY, notifyOn ? "1" : "0");
|
||||
paintBell();
|
||||
});
|
||||
|
||||
// --- filter ---------------------------------------------------------------
|
||||
|
||||
let debounce;
|
||||
filterEl.addEventListener("input", () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
const raw = filterEl.value.trim();
|
||||
const re = raw.length > 1 && raw.startsWith("/") ? raw.lastIndexOf("/") : -1;
|
||||
regex = null;
|
||||
needle = "";
|
||||
filterEl.classList.remove("bad-re");
|
||||
if (re > 0) {
|
||||
try {
|
||||
regex = new RegExp(raw.slice(1, re), raw.slice(re + 1) || "i");
|
||||
} catch {
|
||||
filterEl.classList.add("bad-re"); // keep showing everything, don't lie
|
||||
}
|
||||
} else {
|
||||
needle = raw.toLowerCase();
|
||||
}
|
||||
rerender();
|
||||
}, 120);
|
||||
});
|
||||
|
||||
// [data-level] only — the bell is also a .chip but is not a level filter.
|
||||
for (const chip of document.querySelectorAll(".chip[data-level]")) {
|
||||
chip.addEventListener("click", () => {
|
||||
const on = chip.getAttribute("aria-pressed") !== "true";
|
||||
chip.setAttribute("aria-pressed", String(on));
|
||||
levelOn[chip.dataset.level] = on;
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && document.activeElement !== filterEl) {
|
||||
e.preventDefault();
|
||||
filterEl.focus();
|
||||
filterEl.select();
|
||||
}
|
||||
});
|
||||
|
||||
logEl.addEventListener("scroll", updateLiveButton, { passive: true });
|
||||
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
|
||||
|
||||
paintBell();
|
||||
poll();
|
||||
pollSummary();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"general": {
|
||||
"name": "Ulicraft Files",
|
||||
"host": "files.${BASE_DOMAIN}",
|
||||
"host": "upload.${BASE_DOMAIN}",
|
||||
"force_ssl": true,
|
||||
"secret_key": "${FILES_SECRET_KEY}",
|
||||
"editor": "base",
|
||||
|
||||
@@ -161,6 +161,10 @@ export const site = {
|
||||
// Uptime Kuma status page (external link in footer).
|
||||
statusUrl: `https://status.${BASE_DOMAIN}`,
|
||||
|
||||
// Public read-only file share (caddy file_server browse of FILES_DATA_DIR).
|
||||
// Linked from the nav + hero. Uploads live at upload.${BASE_DOMAIN} (auth'd).
|
||||
filesUrl: `https://files.${BASE_DOMAIN}`,
|
||||
|
||||
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||
|
||||
@@ -35,13 +35,14 @@ export const LANG_ACCOUNT_PATH: Record<Lang, string> = {
|
||||
export interface UI {
|
||||
htmlLang: string;
|
||||
copied: string; // transient copy-button feedback
|
||||
nav: { status: string; members: string; features: string; mods: string; join: string; play: string; register: string; login: string };
|
||||
nav: { status: string; members: string; features: string; mods: string; join: string; files: string; play: string; register: string; login: string };
|
||||
hero: {
|
||||
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
|
||||
tagline: string;
|
||||
sub: string;
|
||||
copyIp: string;
|
||||
howToJoin: string;
|
||||
files: string;
|
||||
metaModpack: string;
|
||||
};
|
||||
status: {
|
||||
@@ -182,7 +183,7 @@ export const ui: Record<Lang, UI> = {
|
||||
en: {
|
||||
htmlLang: "en",
|
||||
copied: "Copied!",
|
||||
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play", register: "Register", login: "Account" },
|
||||
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", files: "Files", play: "Play", register: "Register", login: "Account" },
|
||||
hero: {
|
||||
eyebrow: "Modded LAN · NeoForge",
|
||||
tagline: "Kitchen-sink survival, built to outlast the weekend.",
|
||||
@@ -192,6 +193,7 @@ export const ui: Record<Lang, UI> = {
|
||||
"import the pack, dig in.",
|
||||
copyIp: "Copy IP",
|
||||
howToJoin: "How to Join",
|
||||
files: "Files",
|
||||
metaModpack: "NeoForge modpack",
|
||||
},
|
||||
status: {
|
||||
@@ -363,7 +365,7 @@ export const ui: Record<Lang, UI> = {
|
||||
es: {
|
||||
htmlLang: "es",
|
||||
copied: "¡Copiado!",
|
||||
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar", register: "Registro", login: "Cuenta" },
|
||||
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", files: "Archivos", play: "Jugar", register: "Registro", login: "Cuenta" },
|
||||
hero: {
|
||||
eyebrow: "LAN con mods · NeoForge",
|
||||
tagline: "Supervivencia todo incluido, para durar más que el finde.",
|
||||
@@ -373,6 +375,7 @@ export const ui: Record<Lang, UI> = {
|
||||
"clic. Copia la dirección, importa el pack y a cavar.",
|
||||
copyIp: "Copiar IP",
|
||||
howToJoin: "Cómo entrar",
|
||||
files: "Archivos",
|
||||
metaModpack: "Pack NeoForge",
|
||||
},
|
||||
status: {
|
||||
@@ -544,7 +547,7 @@ export const ui: Record<Lang, UI> = {
|
||||
eu: {
|
||||
htmlLang: "eu",
|
||||
copied: "Kopiatuta!",
|
||||
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jolastu", register: "Erregistroa", login: "Kontua" },
|
||||
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", files: "Fitxategiak", play: "Jolastu", register: "Erregistroa", login: "Kontua" },
|
||||
hero: {
|
||||
eyebrow: "Mod-dun LANa · NeoForge",
|
||||
tagline: "Mod ugariko biziraupena, irauteko egina.",
|
||||
@@ -554,6 +557,7 @@ export const ui: Record<Lang, UI> = {
|
||||
"Hartu helbidea, inportatu packa eta hasi zulatzen.",
|
||||
copyIp: "Kopiatu IPa",
|
||||
howToJoin: "Nola sartu",
|
||||
files: "Fitxategiak",
|
||||
metaModpack: "NeoForge packa",
|
||||
},
|
||||
status: {
|
||||
|
||||
@@ -70,6 +70,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#join">{t.nav.join}</a>
|
||||
<a href={site.filesUrl} target="_blank" rel="noopener">{t.nav.files}</a>
|
||||
<a href="#status">{t.nav.status}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
@@ -106,6 +107,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<p class="hero-sub">{t.hero.sub}</p>
|
||||
<div class="hero-ip-row">
|
||||
<a class="mc-btn primary" href="#join">{t.hero.howToJoin}</a>
|
||||
<a class="mc-btn" href={site.filesUrl} target="_blank" rel="noopener">{t.hero.files}</a>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span>Java {site.mcVersion}</span>
|
||||
|
||||
+37
-8
@@ -6,9 +6,11 @@
|
||||
"CreateDragonsPlus-1.11.2b.jar": "both",
|
||||
"CreativeCore_NEOFORGE_v2.13.41_mc1.21.1.jar": "both",
|
||||
"CustomSkinLoader_Universal-14.28.jar": "client",
|
||||
"DefaultSettings-1.20.x-4.0.8-NeoForge.jar": "client",
|
||||
"EasierSleeping-1.21.1-4.0.1.jar": "both",
|
||||
"EasyAnvils-v21.1.0-1.21.1-NeoForge.jar": "both",
|
||||
"EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both",
|
||||
"Emojiful-Neoforge-1.21-5.2.4-all.jar": "client",
|
||||
"EnderStorage-1.21.1-2.13.0.191.jar": "both",
|
||||
"ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both",
|
||||
"ExtremeSoundMuffler-3.56_NeoForge-1.21.jar": "client",
|
||||
@@ -19,6 +21,7 @@
|
||||
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
|
||||
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
|
||||
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client",
|
||||
"Loot Beams Refork-neoforge-1.21.1-3.4.7.jar": "both",
|
||||
"MoogsEndStructures-1.21-2.0.3.jar": "both",
|
||||
"MoogsMissingVillages-1.21-2.1.1.jar": "both",
|
||||
"MoogsNetherStructures-1.21-3.0.0-alpha.2.jar": "both",
|
||||
@@ -26,10 +29,11 @@
|
||||
"MouseTweaks-neoforge-mc1.21-2.26.1.jar": "client",
|
||||
"Neat-1.21-47-NEOFORGE.jar": "both",
|
||||
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
|
||||
"Nirvana Lib-neoforge-1.21.1-2.2.0.jar": "both",
|
||||
"Patchouli-1.21.1-93-NEOFORGE.jar": "both",
|
||||
"Ping-Wheel-1.12.2-neoforge-1.21.1.jar": "both",
|
||||
"Placebo-1.21.1-9.9.1.jar": "both",
|
||||
"PuzzlesLib-v21.1.51-1.21.1-NeoForge.jar": "both",
|
||||
"PuzzlesLib-v21.1.52-1.21.1-NeoForge.jar": "both",
|
||||
"Searchables-neoforge-1.21.1-1.0.2.jar": "client",
|
||||
"TerraBlender-neoforge-1.21.1-4.1.0.8.jar": "both",
|
||||
"ToastControl-1.21.1-9.0.1.jar": "client",
|
||||
@@ -50,27 +54,33 @@
|
||||
"YungsCaveBiomes-1.21.1-NeoForge-3.1.1.jar": "both",
|
||||
"amendments-neoforge-1.21-2.1.5.jar": "both",
|
||||
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
|
||||
"architectury-13.0.8-neoforge.jar": "both",
|
||||
"architectury-13.0.11-neoforge.jar": "both",
|
||||
"ars_creo-1.21.1-5.4.0.jar": "both",
|
||||
"ars_nouveau-1.21.1-5.12.1.jar": "both",
|
||||
"ars_technica-1.21.1-2.7.6.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",
|
||||
"buildinggadgets2-1.3.9.jar": "both",
|
||||
"caelus-neoforge-7.0.1+1.21.1.jar": "both",
|
||||
"cardboardboxes-1.21.1-0.1.2.jar": "both",
|
||||
"charginggadgets-1.14.1.jar": "both",
|
||||
"cloth-config-15.0.140-neoforge.jar": "both",
|
||||
"collective-1.21.1-8.39.jar": "both",
|
||||
"colorfulhearts-neoforge-1.21.1-10.5.9.jar": "client",
|
||||
"comforts-neoforge-9.0.5+1.21.1.jar": "both",
|
||||
"common-networking-neoforge-1.0.21-1.21.1.jar": "both",
|
||||
"configured-neoforge-1.21.1-2.6.3.jar": "both",
|
||||
"copycats-3.0.4+mc.1.21.1-neoforge.jar": "both",
|
||||
"coroutil-neoforge-1.21.0-1.3.9.jar": "both",
|
||||
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
|
||||
"corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar": "both",
|
||||
"craftabletotemofundying-1.21.1-0.1.jar": "both",
|
||||
"create-1.21.1-6.0.10.jar": "both",
|
||||
"create-central-kitchen-2.5.0.jar": "both",
|
||||
"create-enchantment-industry-2.4.1.jar": "both",
|
||||
"create-integrated-farming-1.2.6.jar": "both",
|
||||
"create_connected-1.2.2-mc1.21.1.jar": "both",
|
||||
"create_jet_boots-1.21.1-1.1.3-6.0.6.jar": "both",
|
||||
"create_jetpack-forge-5.1.2.jar": "both",
|
||||
"create_jetpack_curios-1.2.0-neoforge-1.21.1.jar": "both",
|
||||
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
|
||||
@@ -81,12 +91,16 @@
|
||||
"createornithopterglider-1.2.0-1.21.1.jar": "both",
|
||||
"createsifter-1.21.1-2.2.1.jar": "both",
|
||||
"createultimine-1.21.1-neoforge-1.3.2.jar": "both",
|
||||
"cupboard-1.21.1-3.9.jar": "both",
|
||||
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
||||
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
||||
"defaultoptions-neoforge-1.21.1-21.1.6.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",
|
||||
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
||||
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
||||
"easy_npc-neoforge-1.21.1-7.4.1.jar": "both",
|
||||
"easy_npc_config_ui-neoforge-1.21.1-7.4.1.jar": "both",
|
||||
"easydisenchanting-neoforge-1.0.0-1.21.1.jar": "both",
|
||||
"emotecraft-for-MC1.21.1-2.4.12-neoforge.jar": "both",
|
||||
"enderrf-1.21.1-1.0.1.jar": "both",
|
||||
@@ -98,9 +112,11 @@
|
||||
"fastleafdecay-35.jar": "both",
|
||||
"ferritecore-7.0.3-neoforge.jar": "both",
|
||||
"fishingoverhaul-1.21.1-1.2.1.jar": "both",
|
||||
"flightapi-neoforge-1.1.1.jar": "both",
|
||||
"friendsforlife-1.2.1.jar": "both",
|
||||
"ftb-chunks-neoforge-2101.1.17.jar": "both",
|
||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||
"ftb-library-neoforge-2101.1.31.jar": "both",
|
||||
"ftb-library-neoforge-2101.1.33.jar": "both",
|
||||
"ftb-teams-neoforge-2101.1.10.jar": "both",
|
||||
"ftb-ultimine-neoforge-2101.1.14.jar": "both",
|
||||
"functionalstorage-1.21.1-1.5.7.jar": "both",
|
||||
@@ -110,7 +126,6 @@
|
||||
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
|
||||
"immersive_paintings-neoforge-1.21.1-0.7.8.jar": "both",
|
||||
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
|
||||
"iris-neoforge-1.8.12+mc1.21.1.jar": "client",
|
||||
"jamlib-neoforge-1.3.6+1.21.1.jar": "both",
|
||||
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
||||
"konkrete_neoforge_1.9.9_MC_1.21.jar": "client",
|
||||
@@ -120,10 +135,14 @@
|
||||
"kubejsdelight-1.1.6.jar": "both",
|
||||
"laserio-1.9.11.jar": "both",
|
||||
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
|
||||
"lootintegrations-1.21.1-4.7.jar": "both",
|
||||
"lootintegrations_vanilla-1.7.jar": "both",
|
||||
"lootintegrations_yungs-1.6.jar": "both",
|
||||
"lootjs-neoforge-1.21.1-3.7.0.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",
|
||||
"mechanicals-1.21.1-1.1.0.jar": "both",
|
||||
"mechtrowel-1.3.2.1.jar": "both",
|
||||
"melody_neoforge_1.0.10_MC_1.21.jar": "client",
|
||||
"melter-1.21.1-1.8.2.jar": "both",
|
||||
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
||||
@@ -132,33 +151,43 @@
|
||||
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
|
||||
"morefunctionalstorage-1.2.3.jar": "both",
|
||||
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
||||
"owo-lib-neoforge-0.12.15.5-beta.1+1.21.jar": "both",
|
||||
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
||||
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
||||
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
||||
"repeatable_trial_vaults-neoforge-1.21-1.0.2.jar": "both",
|
||||
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
||||
"rhino-2101.2.7-build.85.jar": "both",
|
||||
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
|
||||
"sawmill-1.21-1.7.2-neoforge.jar": "both",
|
||||
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
|
||||
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
||||
"sliceanddice-forge-4.2.4.jar": "both",
|
||||
"smartfeedingtroughs-neoforge-1.21.1-1.2.0.jar": "both",
|
||||
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
|
||||
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
||||
"sodium-neoforge-0.8.13+mc1.21.1.jar": "client",
|
||||
"solcarrot-1.21.1-1.16.6.jar": "both",
|
||||
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
||||
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
||||
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
|
||||
"spark-1.10.124-neoforge.jar": "both",
|
||||
"starbunclemania-1.21.1-1.5.7.jar": "both",
|
||||
"starterkit-1.21.1-8.1.jar": "both",
|
||||
"stattweaks-neoforge-v2.0.0.jar": "both",
|
||||
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
|
||||
"supermartijn642corelib-1.1.21-neoforge-mc1.21.jar": "both",
|
||||
"supplementaries-neoforge-1.21.1-3.7.7.jar": "both",
|
||||
"tagtooltips-neoforge-1.21.1-1.2.0.jar": "client",
|
||||
"titanium-1.21-4.0.43.jar": "both",
|
||||
"toolsjs-0.1.1.jar": "both",
|
||||
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
|
||||
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
|
||||
"trophymanager-1.21.1-2.5.0.jar": "both",
|
||||
"txnilib-neoforge-1.0.24-1.21.1.jar": "both",
|
||||
"ulicraftmod-1.21.1-1.0.0-6.0.10.jar": "both",
|
||||
"ulicraftmod-1.21.1-1.1.3-6.0.10.jar": "both",
|
||||
"utilitarian-1.21.1-0.19.2.jar": "both",
|
||||
"voicechat-neoforge-1.21.1-2.6.18.jar": "both",
|
||||
"watut-neoforge-1.21.0-1.2.7.jar": "both",
|
||||
"wm_binaries-3.0.0-rc.1.jar": "client",
|
||||
"world_dimension_nexus-neoforge-1.21.1-0.11.0.jar": "both",
|
||||
"yeetusexperimentus-neoforge-87.0.0.jar": "client",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,14 @@ upstream ulicraft_caddy {
|
||||
}
|
||||
|
||||
# Brute-force throttle for the publicly-exposed Filestash /admin console (it is
|
||||
# the only credential on files.), applied in its location block below.
|
||||
# the only credential on upload.), applied in its location block below.
|
||||
limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
|
||||
|
||||
# HTTP -> HTTPS for every name.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${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;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,29 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Data pack generators — a pinned, self-contained fork of misode.github.io.
|
||||
# Serves its own mcmeta/vanilla-mcdoc mirror from the same origin, so it works
|
||||
# with ZERO internet at party time (the app is 100% client-side: every data
|
||||
# fetch comes from the player's browser, not from this host). caddy sets the
|
||||
# CSP that enforces that — see caddy/conf.d/70-generators.caddy.
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name generators.${BASE_DOMAIN};
|
||||
|
||||
ssl_certificate ${APP_DIR}/certs/generators.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/generators.${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;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
@@ -129,17 +152,18 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Filestash player file share. Uploads need a real body limit (nginx defaults to
|
||||
# 1m, which rejects almost anything worth sharing) and streaming rather than
|
||||
# spooling — a 2g upload buffered to disk would write it twice on a host whose
|
||||
# disk is already the shared failure domain for Minecraft + Drasl + backups.
|
||||
# Filestash player file share (upload.). Uploads need a real body limit (nginx
|
||||
# defaults to 1m, which rejects almost anything worth sharing) and streaming
|
||||
# rather than spooling — a 2g upload buffered to disk would write it twice on a
|
||||
# host whose disk is already the shared failure domain for Minecraft + Drasl +
|
||||
# backups.
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name files.${BASE_DOMAIN};
|
||||
server_name upload.${BASE_DOMAIN};
|
||||
|
||||
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
||||
ssl_certificate ${APP_DIR}/certs/upload.${BASE_DOMAIN}/cert.pem;
|
||||
ssl_certificate_key ${APP_DIR}/certs/upload.${BASE_DOMAIN}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 2g; # uploads; other vhosts stay at 4m
|
||||
@@ -167,6 +191,16 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# files. (read-only browse) goes here — the whole server block, or a single
|
||||
# comment line when FILES_BROWSE=off. Source: nginx/files-browse.server.tmpl,
|
||||
# spliced in by render-nginx.sh.
|
||||
${FILES_BROWSE_SERVER}
|
||||
|
||||
# monitor. (admin log stream, basic_auth in caddy) goes here — the whole server
|
||||
# block, or a single comment line when MONITOR_ENABLED=off. Source:
|
||||
# nginx/monitor.server.tmpl, spliced in by render-nginx.sh.
|
||||
${MONITOR_SERVER}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
+6
-2
@@ -152,9 +152,13 @@ mc-status, uptime-kuma and filestash.
|
||||
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
|
||||
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
|
||||
- `20-files.md` — Filestash player file share (`files.`, one shared login)
|
||||
- `23-monitor.md` — admin log stream + dashboard (`monitor.`, basic auth)
|
||||
|
||||
## Boot/dependency order
|
||||
|
||||
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
||||
`nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
|
||||
(no ordering constraints — none of them are on the auth/boot path).
|
||||
`nmsr`, `mc-status`, `uptime-kuma`, `filestash` and `monitor` are always-on and
|
||||
join `mcnet` (no ordering constraints — none of them are on the auth/boot path).
|
||||
`monitor` reads Minecraft's log volume and queries its RCON, but starts fine
|
||||
without either: a missing log file and a refused RCON dial are ordinary states
|
||||
it reports rather than errors it dies on.
|
||||
+9
-5
@@ -8,9 +8,10 @@ on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
|
||||
nginx reverse-proxies every public vhost to it by Host header. Roles:
|
||||
|
||||
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
|
||||
`files.` → filestash.
|
||||
`upload.` → filestash.
|
||||
2. **Static** — apex serves the landing page + `/launcher/` downloads;
|
||||
`distribution.` serves a static site from another repo.
|
||||
`distribution.` serves a static site from another repo; `files.` serves a
|
||||
read-only browse of the upload share.
|
||||
|
||||
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
|
||||
no template render needed.
|
||||
@@ -37,12 +38,15 @@ The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
||||
`DISTRIBUTION_WEB_ROOT`, another repo).
|
||||
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
||||
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
||||
- `60-files.caddy` — `files.` → `filestash:8334` (player file share, `20-files.md`).
|
||||
- `60-upload.caddy` — `upload.` → `filestash:8334` (player file share, `20-files.md`).
|
||||
- `62-files.caddy` — `files.` → static `/srv/files` (read-only, no-auth `file_server
|
||||
browse` of the same `FILES_DATA_DIR` filestash writes to).
|
||||
|
||||
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
|
||||
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
|
||||
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`,
|
||||
`${FILES_DATA_DIR}:/srv/files:ro`.
|
||||
|
||||
> **Don't rewrite the `Host` header for `files.`** — filestash rejects any
|
||||
> **Don't rewrite the `Host` header for `upload.`** — filestash rejects any
|
||||
> request whose Host doesn't match its configured `general.host`, with *"only
|
||||
> traffic from X is allowed"* (a 403 that reads like an auth failure). nginx
|
||||
> forwards the original Host and caddy passes it through untouched. Keep it that
|
||||
|
||||
+6
-4
@@ -6,7 +6,8 @@
|
||||
|
||||
## Why drop packwiz
|
||||
|
||||
- The packwiz index tracked only **9** mods while the real modset (76 jars) lives
|
||||
- The packwiz index tracked only **9** mods while the real modset (76 jars at the
|
||||
time of cutover; 164 as of 2026-07-20) lives
|
||||
in the distribution repo — packwiz was half-built and stale.
|
||||
- Manual packwiz curation (`.tmpl` render → `refresh` → re-hash) is the flagged
|
||||
pain point, and a single missing file (`custom/.gitkeep`) crashlooped the
|
||||
@@ -20,7 +21,7 @@
|
||||
```
|
||||
ulicraft-distribution/dist (another repo, = $DISTRIBUTION_WEB_ROOT)
|
||||
servers/ulicraft-1.21.1/
|
||||
forgemods/required/*.jar ← 76 jars, Nebula-managed (SOURCE OF TRUTH)
|
||||
forgemods/required/*.jar ← 164 jars (2026-07-20), Nebula-managed (SOURCE OF TRUTH)
|
||||
distribution.json ← generated by Nebula; CLIENT install list
|
||||
│ │
|
||||
│ caddy distribution. vhost │ HeliosLauncher reads it →
|
||||
@@ -196,8 +197,9 @@ welcomescreen, yeetusexperimentus` — on top of the 11 tomllib auto-caught
|
||||
(sodium, sodium-extra, iris, ImmediatelyFast, appleskin, Controlling, BridgingMod,
|
||||
CustomSkinLoader, entity_model_features, entity_texture_features, tagtooltips).
|
||||
|
||||
**Current state: 24 `client` / 52 `both`.** The 52 may still hide a client-only
|
||||
mod that loaded without crashing; if a future restart trips another
|
||||
**State at first cutover: 24 `client` / 52 `both`. Current (2026-07-20, post
|
||||
tech/magic/worldgen expansion): 31 `client` / 133 `both`.** The `both` set may
|
||||
still hide a client-only mod that loaded without crashing; if a future restart trips another
|
||||
`invalid dist DEDICATED_SERVER`, flip that one jar to `client` and re-sync — same
|
||||
one-line loop. When excluding, prefer leaf mods; if a `both` mod hard-depends on
|
||||
the one you exclude you'll get a `Missing mandatory dependency` instead — put it
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- [ ] 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)
|
||||
- `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)
|
||||
- `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)
|
||||
- `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)
|
||||
@@ -68,6 +69,15 @@ Downloads FjordLauncherUnlocked release assets → `launcher/<tag>/` and maintai
|
||||
a `latest` symlink + stable per-OS filename symlinks. Full script in
|
||||
`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
|
||||
|
||||
```
|
||||
@@ -76,6 +86,8 @@ nmsr/config.toml # rendered
|
||||
launcher/ # launcher assets
|
||||
www/ # landing build output
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
> card. Both ping `minecraft` over `mcnet` but serve different purposes.
|
||||
|
||||
Status monitoring + public status page. Service `uptime-kuma`
|
||||
(`louislam/uptime-kuma:1`), web UI on `:3001`, reached only through caddy at
|
||||
(`louislam/uptime-kuma:2`), web UI on `:3001`, reached only through caddy at
|
||||
`https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other
|
||||
service by its internal container name.
|
||||
|
||||
## Add a Minecraft monitor
|
||||
|
||||
Uptime Kuma 1.x ships a built-in **Minecraft Server** monitor type. It uses the
|
||||
Uptime Kuma ships a built-in **Minecraft Server** monitor type. It uses the
|
||||
Server List Ping protocol (the same ping the vanilla multiplayer list uses):
|
||||
unauthenticated, so it works fine with `ONLINE_MODE=FALSE` /
|
||||
`ENFORCE_SECURE_PROFILE=FALSE`. It reports latency, online/max players, and MOTD.
|
||||
@@ -53,6 +53,12 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
|
||||
`https://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
|
||||
`https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
|
||||
`distribution.` here explains client mod-mismatch join failures.
|
||||
- **1 → 2 upgrade (2026-10-25)** — moved off `:1`, whose image runs Node 18
|
||||
(EOL, no fix for the Oct 2026 Node CVEs). The first `:2` start migrates
|
||||
`kuma.db` in place (one-way, minutes on a ~140M DB; status page down until it
|
||||
finishes — don't interrupt). Per host: `docker compose stop uptime-kuma`, tar
|
||||
the `kuma_data` volume into `./backups/`, then `pull` + `up -d uptime-kuma`.
|
||||
Rollback = restore the tarball + revert the tag to `:1`.
|
||||
|
||||
> **An HTTP monitor cannot see an SPA break.** It asserts "the server returned
|
||||
> 200", which is a much weaker claim than "the app works". `files.` (Filestash)
|
||||
|
||||
@@ -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`.
|
||||
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
||||
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
|
||||
|
||||
|
||||
@@ -36,6 +36,49 @@ Certs install to `certs/<name>/{cert.pem,key.pem}` (gitignored). acme.sh adds a
|
||||
cron for renewal; `--reloadcmd` reloads nginx automatically on renew. Override
|
||||
the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
|
||||
|
||||
### Gotcha — install paths must be ABSOLUTE (silent expiry, 2026-09-06)
|
||||
|
||||
`--install-cert` paths are **persisted in acme.sh's domain conf**
|
||||
(`~/.acme.sh/<name>_ecc/<name>.conf` → `Le_RealKeyPath` /
|
||||
`Le_RealFullChainPath`) and replayed on every cron renewal. The cron runs with
|
||||
`cwd=$HOME`, not the repo — so a **relative** path (`certs/<name>/key.pem`)
|
||||
resolves to `~/certs/...` and the install writes nothing.
|
||||
|
||||
Failure mode is silent and delayed: `acme.sh --list` shows every cert freshly
|
||||
renewed with a future `Renew` date, `~/.acme.sh/<name>_ecc/fullchain.cer` is
|
||||
valid — while `certs/<name>/cert.pem` is frozen at the last **manual** run and
|
||||
nginx serves that until it expires. There is no error anywhere. Cost: apex,
|
||||
`auth.` and `pack.` served expired certs on 2026-09-06.
|
||||
|
||||
Fix / recovery — re-run `--install-cert` with absolute paths, which both copies
|
||||
the current cert and rewrites the persisted paths:
|
||||
|
||||
```bash
|
||||
APP=/home/ubuntu/mc/ulicraft-server-v1
|
||||
for d in $(ls $APP/certs); do
|
||||
~/.acme.sh/acme.sh --install-cert -d "$d" --ecc \
|
||||
--key-file "$APP/certs/$d/key.pem" \
|
||||
--fullchain-file "$APP/certs/$d/cert.pem" \
|
||||
--reloadcmd "sudo systemctl reload nginx || true"
|
||||
done
|
||||
```
|
||||
|
||||
`issue-letsencrypt.sh` now absolutises `CERT_DIR` so a fresh issue can't
|
||||
reintroduce it.
|
||||
|
||||
**Verify from off-host, not from the files** — the installed file and what nginx
|
||||
serves are the same thing here, but the acme.sh copy is not:
|
||||
|
||||
```bash
|
||||
for h in ulicraft.net auth.ulicraft.net avatar.ulicraft.net status.ulicraft.net \
|
||||
distribution.ulicraft.net files.ulicraft.net upload.ulicraft.net \
|
||||
monitor.ulicraft.net generators.ulicraft.net; do
|
||||
printf '%-30s ' "$h"
|
||||
echo | openssl s_client -servername $h -connect $h:443 2>/dev/null \
|
||||
| openssl x509 -noout -enddate
|
||||
done
|
||||
```
|
||||
|
||||
## nginx
|
||||
|
||||
1. Bring the stack up (caddy publishes on `127.0.0.1:${CADDY_HTTP_PORT}`):
|
||||
|
||||
+48
-33
@@ -1,14 +1,19 @@
|
||||
# Mod shortlist + landing mod-list component (WS6)
|
||||
|
||||
> **Status: PLANNED — not started.** Two separable deliverables: (1) a curated
|
||||
> kitchen-sink gap doc (this file), (2) an auto-generated `mods.json` + logos that
|
||||
> feed a flat mod-list component on the landing page (see also `18-landing-rework.md`).
|
||||
> **Status: gap doc DONE (pack filled out 2026-07-20), landing component still
|
||||
> PLANNED.** Two separable deliverables: (1) a curated kitchen-sink gap doc (this
|
||||
> file), (2) an auto-generated `mods.json` + logos that feed a flat mod-list
|
||||
> component on the landing page (see also `18-landing-rework.md`).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Current pack (76 jars, `mods-sides.json`) is a polished **vanilla+ QoL/perf** base,
|
||||
**not yet kitchen-sink**. Strong perf + QoL + storage + FTB + voice; **empty** on
|
||||
tech and magic, **thin** on worldgen, **no** map mod.
|
||||
- The pack is now **kitchen-sink** — **164 jars** (`mods-sides.json`: 133 `both`,
|
||||
31 `client`), up from the 76-jar vanilla+ base this doc originally described.
|
||||
Tech (Create + ~18 addons), magic (Ars Nouveau + ars_creo) and worldgen (full
|
||||
YUNG's set + Moog's structures + TerraBlender) all landed. **Map is still the
|
||||
only open gap.**
|
||||
- **164 jars is over the stated 50–100 ceiling** in `CLAUDE.md`. Accepted for now;
|
||||
it moves the RAM/stability bar (`INIT_MEMORY 4G`/`MAX_MEMORY 10G` — watch it).
|
||||
- Jars live in the **external distribution repo** (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||
ulicraft-1.21.1/forgemods/required/*.jar`, Nebula-managed). Adding mods happens
|
||||
there, **not** in this repo. This doc is a shortlist only; depth = **light** (names
|
||||
@@ -17,48 +22,58 @@
|
||||
from the jars — a different dataset from this shortlist. Flat alphabetical, pretty
|
||||
names, extracted logos, no categories, no Modrinth/CF links.
|
||||
|
||||
## Current pack — what's already in (76 jars)
|
||||
## Current pack — what's in (164 jars)
|
||||
|
||||
- **Tech — Create ecosystem (the anchor):** create, createaddition, createdeco,
|
||||
create_connected, copycats, create-central-kitchen, create-enchantment-industry,
|
||||
create-integrated-farming, create_jetpack(+curios), create_mechanical_chicken,
|
||||
create_mechanical_extruder, create_mechanical_spawner, mechanical_cow,
|
||||
mechanicals, createsifter, createultimine, createornithopterglider,
|
||||
CreateDragonsPlus, sliceanddice, sophisticatedbackpackscreateintegration,
|
||||
kubejs-create. Non-Create tech: laserio, enderrf, EnderStorage, melter,
|
||||
mob_grinding_utils, buildinggadgets2, charginggadgets, titanium, CodeChickenLib.
|
||||
- **Magic:** ars_nouveau, ars_creo (Ars×Create bridge).
|
||||
- **Worldgen/exploration:** full YUNG's set (BetterCaves, CaveBiomes, Bridges,
|
||||
BetterDungeons, BetterMineshafts, BetterStrongholds, BetterDesertTemples,
|
||||
BetterJungleTemples, BetterOceanMonuments, BetterWitchHuts,
|
||||
BetterNetherFortresses, BetterEndIsland), Moog's (Voyager, End, Nether,
|
||||
MissingVillages, moogs_structures), TerraBlender, ExplorersCompass,
|
||||
world_dimension_nexus, HangGlider, fishingoverhaul.
|
||||
- **Perf:** sodium, sodium-extra, iris, immediatelyfast, entityculling, ferritecore,
|
||||
lithium, spark.
|
||||
lithium, modernfix, spark.
|
||||
- **QoL:** JEI, Jade(+addons), JER, JEProfessions, appleskin, MouseTweaks, Controlling,
|
||||
Searchables, inventoryessentials, polymorph, rightclickharvest, simplemagnets,
|
||||
fastleafdecay, torchmaster, trashcans, corpse(+curioscompat), lootr, comforts,
|
||||
BetterF3, tagtooltips, cardboardboxes.
|
||||
BetterF3, tagtooltips, cardboardboxes, EasyAnvils, EasyMagic, easydisenchanting,
|
||||
easy-villagers, easy-piglins, EasierSleeping, solcarrot, Neat, Ping-Wheel,
|
||||
moreoverlays, ToastControl, ExtremeSoundMuffler, colorfulhearts, UsefulSlime.
|
||||
- **Storage:** sophisticatedbackpacks(+core), functionalstorage(+more).
|
||||
- **Farm/decor/build:** FarmersDelight, FramedBlocks, handcrafted.
|
||||
- **Farm/decor/build:** FarmersDelight, handcrafted, rechiseled, amendments,
|
||||
immersive_paintings, etched, emotecraft.
|
||||
- **FTB utils:** chunks, essentials, library, teams, ultimine.
|
||||
- **Voice:** Simple Voice Chat (`voicechat`).
|
||||
- **Scripting/guide:** KubeJS(+rhino, +delight), ponderjs, Patchouli.
|
||||
- **Libs:** architectury, balm, cloth-config, configured, curios, resourcefullib,
|
||||
supermartijn642 core/config, jamlib, baguettelib, titanium, utilitarian, YungsApi,
|
||||
Necronomicon, yacl.
|
||||
- **Scripting/guide:** KubeJS(+rhino, +delight, +create), lootjs, toolsjs, ponderjs,
|
||||
Patchouli.
|
||||
- **Server-custom:** `ulicraftmod` (this group's own mod).
|
||||
- **Libs:** architectury, balm, cloth-config, configured, curios, caelus, geckolib,
|
||||
kotlinforforge, moonlight, Placebo, PuzzlesLib, resourcefullib, CreativeCore,
|
||||
supermartijn642 core/config, jamlib, baguettelib, txnilib, fzzy_config,
|
||||
titanium, utilitarian, YungsApi, Necronomicon, yacl, konkrete.
|
||||
|
||||
## Gaps vs kitchen-sink (tech + magic + exploration + QoL)
|
||||
## Remaining gaps
|
||||
|
||||
- [ ] **Tech — empty.** Candidates: Create, Mekanism, Immersive Engineering, Applied
|
||||
Energistics 2, Thermal Series, Powah. (Pick a subset; Create is the anchor.)
|
||||
- [ ] **Magic — empty.** Candidates: Ars Nouveau, Botania, Iron's Spells 'n Spellbooks,
|
||||
Eidolon.
|
||||
- [ ] **Worldgen — thin** (only `YungsApi` + `ExplorersCompass`). Candidates: Terralith,
|
||||
Tectonic, YUNG's structure set (Better Dungeons/Mineshafts/Strongholds…),
|
||||
Repurposed Structures, William Wythers' Overhauled Overworld.
|
||||
- [ ] **Map — none.** Candidate: Xaero's Minimap + Worldmap.
|
||||
- [ ] **Server-side perf (optional):** ModernFix, C2ME.
|
||||
- [ ] **Map — none.** Candidate: Xaero's Minimap + Worldmap. (`Ping-Wheel` and
|
||||
`ftb-chunks`' claim map do not replace a minimap.)
|
||||
- [ ] **Server-side perf (optional):** C2ME. (`modernfix` is in.)
|
||||
- [ ] Optional second magic pillar (Botania / Iron's Spells) — Ars alone covers the
|
||||
vibe; only add if the pack budget allows.
|
||||
|
||||
## Orphaned dependencies (cleanup)
|
||||
## Orphaned dependencies
|
||||
|
||||
- [ ] **`ponderjs`** present but **no Create** — ponder is Create's. Add Create or drop.
|
||||
- [x] **`ponderjs`** — resolved, Create is in the pack.
|
||||
- [ ] **`Necronomicon`** present but **no Iron's Spells** — it's Iron's Spells' lib.
|
||||
Add Iron's Spells or drop.
|
||||
|
||||
## Curation tradeoff
|
||||
|
||||
Adding the tech+magic+worldgen anchors pushes 76 → 100+ jars, near the stated
|
||||
50–100 ceiling, and raises the RAM/stability bar (`INIT_MEMORY 4G`/`MAX_MEMORY 10G`).
|
||||
Curate deliberately; verify each candidate's NeoForge **1.21.1** build + the
|
||||
`21.1.233` loader pin at add-time.
|
||||
|
||||
## Execution (external repo, when ready)
|
||||
|
||||
1. Add jars in the distribution repo (Nebula `forgemods/required/`), keep the NeoForge
|
||||
|
||||
@@ -158,7 +158,7 @@ offline) reads `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/require
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"count": 76,
|
||||
"count": 164,
|
||||
"mods": [
|
||||
{ "id": "jei", "name": "Just Enough Items (JEI)", "version": "19.27.0.340",
|
||||
"authors": "mezz", "description": "…", "logo": "/mod-logos/jei.png" }
|
||||
@@ -168,6 +168,8 @@ offline) reads `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/require
|
||||
|
||||
`ModList.astro` (new) imports `mods.json` → flat grid (logo + name + version). The
|
||||
homepage stat tile (`site.ts` `stats[0].key "50+"`) is fed from `mods.json.count`.
|
||||
(The hardcoded `"50+"` fallback is stale — the pack is 164 jars as of 2026-07-20;
|
||||
bump it to `"150+"` when wiring the tile.)
|
||||
|
||||
Tasks:
|
||||
- [ ] `tooling/build-modlist.py` (jar parse + logo extract + `mods.json`).
|
||||
|
||||
+35
-1
@@ -12,8 +12,11 @@
|
||||
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
|
||||
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
|
||||
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
|
||||
| `files.${BASE_DOMAIN}` | `60-files.caddy` | `filestash:8334` |
|
||||
| `upload.${BASE_DOMAIN}` | `60-upload.caddy` | `filestash:8334` |
|
||||
| `files.${BASE_DOMAIN}` | `62-files.caddy` | static `/srv/files` (read-only, no-auth browse of the share) |
|
||||
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||
| `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) |
|
||||
| `monitor.${BASE_DOMAIN}` | `80-monitor.caddy` | `monitor:8080` (admin log stream + dashboard, **basic_auth**) |
|
||||
|
||||
---
|
||||
|
||||
@@ -108,9 +111,40 @@ passes it through, so don't rewrite it anywhere in the chain.
|
||||
|
||||
---
|
||||
|
||||
## Monitor — `monitor.${BASE_DOMAIN}` (admin only)
|
||||
|
||||
`reverse_proxy monitor:8080`, behind caddy `basic_auth` (bcrypt,
|
||||
`MONITOR_USER` / `MONITOR_PASSWORD_HASH`). **The only vhost in the stack that is
|
||||
gated at caddy**, because the service itself has no auth and publishes no port —
|
||||
caddy is the sole way in. Per-host on/off via `MONITOR_ENABLED`.
|
||||
|
||||
Read-only: every route is a `GET`, and nothing mutates state.
|
||||
|
||||
- `GET /` — the whole UI (single HTML file embedded in the binary).
|
||||
- `GET /api/log[?since=<seq>]` → `{lines[], seq, dropped, reset, total, tail{}}`.
|
||||
Omit `since` for the full buffer without a spurious "dropped" notice.
|
||||
`since` is a **sequence number, not a byte offset** — the log rotates on every
|
||||
deploy. `reset: true` means discard what you have (fell behind, or the service
|
||||
restarted and your cursor is in the future).
|
||||
- `GET /api/summary` → all dashboard tiles in one response. Each part is
|
||||
independently cached and degrades on its own; failures appear as sibling
|
||||
`*_err` fields next to the last good value.
|
||||
- `GET /healthz` → `ok`. Liveness only, deliberately not tied to the log file.
|
||||
|
||||
Full design + gotchas: `plan/23-monitor.md`.
|
||||
|
||||
---
|
||||
|
||||
## Internal (service → service, over mcnet)
|
||||
|
||||
- `minecraft` → `-javaagent authlib-injector=http://auth.${BASE_DOMAIN}/authlib-injector` (caddy mcnet alias → drasl).
|
||||
- `nmsr` → `http://auth.${BASE_DOMAIN}` (mojang-compat session/textures/api).
|
||||
- `mc-status` → `http://drasl:25585/drasl/api/v2` (`DRASL_API_URL`) for the roster; pings `MC_STATUS_TARGET` for live status.
|
||||
- `mc-backup` → RCON `minecraft:25575`.
|
||||
- `monitor` → `http://mc-status:8080` (`/v2/status/java/*` + `/players`) for the
|
||||
status and roster tiles, so it needs no Drasl credentials of its own; RCON
|
||||
`minecraft:25575` for TPS, restricted to a compile-time allowlist
|
||||
(`neoforge tps`, `list`) that no HTTP input can reach. Reads
|
||||
`mc_data:/data:ro` (log + `statfs`) and `./backups:ro` (backup freshness).
|
||||
- `uptime-kuma` → can probe `http://monitor:8080/healthz` directly, bypassing
|
||||
caddy, so the monitor vhost needs no unauthenticated path carve-out.
|
||||
+123
-14
@@ -1,9 +1,15 @@
|
||||
# 20 — Filestash (`files.${BASE_DOMAIN}`)
|
||||
# 20 — Filestash (`upload.${BASE_DOMAIN}`)
|
||||
|
||||
> Web file share for player media (screenshots, schematics, maps, guides).
|
||||
> One [Filestash](https://github.com/mickael-kerjean/filestash) container behind
|
||||
> caddy, **one shared username/password for everybody**, **writable**.
|
||||
> Config is rendered read-only from a template, like drasl/nmsr.
|
||||
> caddy at **`upload.`**, **one shared username/password for everybody**,
|
||||
> **writable**. Config is rendered read-only from a template, like drasl/nmsr.
|
||||
>
|
||||
> The same `${FILES_DATA_DIR}` is also exposed at **`files.${BASE_DOMAIN}`** as a
|
||||
> plain caddy `file_server browse` — **read-only, no auth**, every uploaded file
|
||||
> publicly listable (`62-files.on.caddy`, toggled per host by `FILES_BROWSE`).
|
||||
> Filestash gets its own subdomain because it cannot be served under a subpath
|
||||
> (SPA hardcodes `/api`, `/assets`, `/login` at root).
|
||||
>
|
||||
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
||||
>
|
||||
@@ -14,14 +20,17 @@
|
||||
## Shape
|
||||
|
||||
```
|
||||
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334
|
||||
│
|
||||
${FILES_DATA_DIR} ──┘ (bind mount)
|
||||
Internet ── host nginx (TLS, LE cert upload.) ──► caddy ──► filestash:8334 (rw)
|
||||
│
|
||||
${FILES_DATA_DIR} ──┤ (bind mount)
|
||||
│
|
||||
Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro, no auth)
|
||||
```
|
||||
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Vhost | `files.${BASE_DOMAIN}` |
|
||||
| Vhost (filestash) | `upload.${BASE_DOMAIN}` |
|
||||
| Vhost (static browse) | `files.${BASE_DOMAIN}` — read-only, no auth, `62-files.on.caddy`, per-host `FILES_BROWSE=on\|off` |
|
||||
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
|
||||
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
||||
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
|
||||
@@ -48,13 +57,59 @@ RBAC). A two-instance split was designed and rejected as not worth it.
|
||||
|
||||
**`FILES_AUTH=none` is gated on the host being internal-only.** The off-switch
|
||||
exists for when this server moves behind a LAN/VPN. **Do not set it while
|
||||
`files.` is internet-reachable** — auth-off on a *writable* public share is an
|
||||
`upload.` is internet-reachable** — auth-off on a *writable* public share is an
|
||||
open upload relay: someone parks malware or a phishing kit on your domain, under
|
||||
your LE cert, and the abuse mail, the bandwidth, and a possible domain
|
||||
blocklisting (which would take `auth.` and the apex down with it, breaking logins
|
||||
and the launcher for every player) all land on you. `render-config.sh` prints a
|
||||
loud warning when it renders this mode.
|
||||
|
||||
**`files.` is public read wherever it is enabled.** Independent of `FILES_AUTH`,
|
||||
the static browse serves the whole share with **no auth** — anyone who reaches
|
||||
`files.${BASE_DOMAIN}` can list and download every uploaded file. This is
|
||||
deliberate (chosen 2026-07-24). The htpasswd login only gates *writes* (via
|
||||
`upload.`); it never gated reads at this URL. Don't put anything private in the
|
||||
share.
|
||||
|
||||
**`FILES_BROWSE=on|off` turns the whole `files.` vhost off per host** (added
|
||||
2026-07-30) — the answer to "I want it on `oxps` (LAN party) but not on `cochi`
|
||||
(internet-facing)". One var, two layers, because each has its own conditional
|
||||
problem:
|
||||
|
||||
| Layer | Mechanism |
|
||||
|---|---|
|
||||
| caddy | `docker-compose.yml` substitutes the value into the mount source: `./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy` → `/etc/caddy/conf.d/62-files.caddy`. `62-files.off.caddy` is a comments-only stub (compose has no conditionals, and caddy needs a parseable file at the fixed mount target — same trick as `FILES_MOUNT_MODE`). |
|
||||
| nginx | `render-nginx.sh` splices `nginx/files-browse.server.tmpl` into `${FILES_BROWSE_SERVER}` when `on`, or a comment when `off`, and adds/omits the name in the port-80 redirect (nginx has no conditionals either). |
|
||||
|
||||
nginx is the gate that matters — with no TLS vhost the name is unreachable from
|
||||
the internet regardless of caddy (caddy is bound to `127.0.0.1`). The caddy stub
|
||||
is the second layer, and it also matters on hosts reached **directly** on caddy's
|
||||
port rather than through nginx (`oxps` on the LAN). Applying a change:
|
||||
|
||||
```sh
|
||||
tooling/render-nginx.sh --install # nginx vhost in/out + reload
|
||||
docker compose up -d --force-recreate caddy # single-file bind mount pins the inode
|
||||
```
|
||||
|
||||
`restart caddy` is NOT enough (the conf mount is a single file — see
|
||||
`plan/14-deploy.md`). With `off`, `files` also needs dropping from
|
||||
`LE_SUBDOMAINS` on that host: no vhost, no cert to renew. `upload.` (filestash,
|
||||
authenticated, writable) is untouched by this var.
|
||||
|
||||
**Per-host auth status (the two prod envs diverge):**
|
||||
|
||||
| Host | `upload.` reachability | `FILES_AUTH` | Notes |
|
||||
|---|---|---|---|
|
||||
| `cochi` | internet-facing | `htpasswd` | shared `uli` login, `$6$` hash |
|
||||
| `oxps` | **LAN-only** | **`none`** (passthrough) | auth off — gate satisfied (2026-07-23) |
|
||||
|
||||
`oxps` runs auth-off deliberately: it is unreachable from the internet, so the
|
||||
open-upload-relay risk above does not apply. Applied host-locally (`.env`
|
||||
`FILES_AUTH=none` → `render-config.sh` → `docker compose up -d --force-recreate
|
||||
filestash`); nothing committed — `.env` and the rendered `config.json` are both
|
||||
gitignored per host. **If `oxps` ever gains an internet path, flip it back to
|
||||
`htpasswd` (or set `FILES_MOUNT_MODE=ro`) before exposing it.**
|
||||
|
||||
**Shared credential ⇒ no audit trail, no per-person revocation.** You will never
|
||||
know who deleted a folder; rotating means re-telling everyone. Fine for a
|
||||
friends' media share — but nothing here is backed up, so treat the share as
|
||||
@@ -76,13 +131,14 @@ See `.env.example` for the annotated block. Summary:
|
||||
|---|---|
|
||||
| `FILES_DATA_DIR` | host path of the share, outside the repo |
|
||||
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
|
||||
| `FILES_BROWSE` | `on` \| `off` — serve the `files.` read-only browse on this host |
|
||||
| `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` |
|
||||
| `FILES_USER` | shared login name |
|
||||
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
|
||||
| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'` |
|
||||
| `FILES_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
|
||||
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
|
||||
| `LE_SUBDOMAINS` | must include `files` |
|
||||
| `LE_SUBDOMAINS` | must include `upload`, plus `files` when `FILES_BROWSE=on` |
|
||||
|
||||
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||
both secrets. Only the `.tmpl` is tracked.
|
||||
@@ -91,13 +147,13 @@ both secrets. Only the `.tmpl` is tracked.
|
||||
|
||||
```sh
|
||||
tooling/render-config.sh # -> filestash/config.json (validates the JSON)
|
||||
tooling/issue-letsencrypt.sh # picks up `files` from LE_SUBDOMAINS
|
||||
tooling/render-nginx.sh --install # adds the files. vhost + reloads nginx
|
||||
tooling/issue-letsencrypt.sh # picks up `upload`+`files` from LE_SUBDOMAINS
|
||||
tooling/render-nginx.sh --install # adds the upload.+files. vhosts + reloads nginx
|
||||
mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
|
||||
docker compose up -d filestash caddy
|
||||
```
|
||||
|
||||
Then add an Uptime Kuma HTTP monitor for `https://files.${BASE_DOMAIN}` (expect
|
||||
Then add an Uptime Kuma HTTP monitor for `https://upload.${BASE_DOMAIN}` (expect
|
||||
`200` — the login page is a 200) and put it on the status page with the others.
|
||||
|
||||
## Gotchas (all of these were hit for real)
|
||||
@@ -122,10 +178,10 @@ directly, which does accept `$2y$` — but `caddy hash-password` gives `$2a$`
|
||||
anyway, so just use that for both.)
|
||||
|
||||
### `general.host` is a BARE HOSTNAME — a scheme breaks the browser only
|
||||
`"host": "files.ulicraft.net"`, **not** `"https://files.ulicraft.net"`, plus
|
||||
`"host": "upload.ulicraft.net"`, **not** `"https://upload.ulicraft.net"`, plus
|
||||
`"force_ssl": true`. The SPA computes its redirect origin as
|
||||
`(force_ssl ? "https://" : "http://") + host`, so a scheme in `host` yields
|
||||
**`http://https://files.ulicraft.net`** and every client-side redirect dies —
|
||||
**`http://https://upload.ulicraft.net`** and every client-side redirect dies —
|
||||
the page loads, then silently fails to route anywhere.
|
||||
|
||||
The vicious part: the server-side Host check (`SecureOrigin`) **strips** the
|
||||
@@ -180,6 +236,58 @@ The SPA sends it; anything else (curl, a naive monitor) trips intrusion
|
||||
detection and gets a 403 that looks like an auth bug. Only matters when probing
|
||||
by hand — the Kuma monitor hits `/`, which is fine.
|
||||
|
||||
### Share dir must be owned by uid 1000 or writes silently 403
|
||||
`filestash` runs as **uid/gid 1000**. `FILES_MOUNT_MODE=rw` and `FILES_AUTH=none`
|
||||
(or a valid login) are **not enough** — the kernel still enforces the share
|
||||
directory's own perms. If `${FILES_DATA_DIR}` is owned by a different uid with
|
||||
mode `0775`, uid 1000 lands in *other* (`r-x`) and every upload/mkdir/delete dies
|
||||
with `Permission denied`, while the UI, `curl /`, and the Kuma monitor all stay
|
||||
green (same false-green trap as the SPA gotchas). Hit on `oxps` 2026-07-24: the
|
||||
share (`/home/oier/mc/distribution-ulicraft/files`) was `1002:1002 0775`.
|
||||
|
||||
Fix — give the share to filestash's uid:
|
||||
```sh
|
||||
sudo chown -R 1000:1000 "$FILES_DATA_DIR"
|
||||
sudo chmod g+s "$FILES_DATA_DIR" # new files inherit the group
|
||||
docker exec -u 1000 filestash sh -c 'touch /data/files/.wtest && echo OK && rm /data/files/.wtest'
|
||||
```
|
||||
Reversible (`chown -R <old> "$FILES_DATA_DIR"`). If another account also writes
|
||||
the share directly, use a shared group + `g+rwx` instead of chowning it away.
|
||||
|
||||
#### Cleaner: run filestash *as* the owning uid (share files become `oier:oier`)
|
||||
Chowning the share to filestash's 1000 works, but then everything filestash
|
||||
writes is owned by an anonymous `1000` nobody logs in as. To make the share
|
||||
files owned by a **real host account** (oxps `oier` = uid 1002) so you can manage
|
||||
them over ssh, run the container as that uid. Compose exposes it:
|
||||
```yaml
|
||||
user: "${FILES_UID:-1000}:${FILES_GID:-1000}"
|
||||
group_add: ["1000"]
|
||||
```
|
||||
Two things make a bare uid override fail, both handled above:
|
||||
- The image binary `/app/filestash` is `0730 filestash:filestash` and `/app/data`
|
||||
is `0770` — a non-1000 uid is *other* and can neither exec nor traverse. The
|
||||
`group_add: ["1000"]` restores that via the filestash group.
|
||||
- The `filestash_state` volume (config, sqlite, search index) is owned `1000:1000`.
|
||||
Running as another uid without chowning it → filestash can't write its own
|
||||
state and crash-loops. **Chown the state volume too.**
|
||||
|
||||
Do all of it with **`tooling/filestash-set-run-user.sh`** (reads `FILES_UID`/
|
||||
`FILES_GID` from `.env`, chowns the state volume + `FILES_DATA_DIR`, recreates,
|
||||
write-tests). oxps: set `FILES_UID=FILES_GID=1002` in `.env`, run it. Default
|
||||
`1000:1000` leaves cochi untouched. Per-host, host-local — nothing but the
|
||||
compose knob and this script is committed.
|
||||
|
||||
### Accessing the share files directly (bypassing the web UI)
|
||||
`${FILES_DATA_DIR}` is a plain host directory — the web UI is just a view of it.
|
||||
- **On the host:** `ls "$FILES_DATA_DIR"` (oxps: `/home/oier/mc/distribution-ulicraft/files`).
|
||||
Write as uid 1000 to match filestash ownership: `sudo -u '#1000' cp foo "$FILES_DATA_DIR"/`.
|
||||
- **From your machine (oxps is LAN-only):** `scp file oxps:/home/oier/mc/distribution-ulicraft/files/`
|
||||
then `ssh oxps 'sudo chown 1000:1000 /home/oier/mc/distribution-ulicraft/files/file'`,
|
||||
or mount over sshfs: `sshfs oxps:/home/oier/mc/distribution-ulicraft/files ~/mnt/ulifiles`.
|
||||
- **Via the container:** `docker exec -u 1000 filestash ls /data/files`.
|
||||
- **Web (LAN):** `http://upload.${BASE_DOMAIN}` (filestash, no login on oxps,
|
||||
auth=none) or `http://files.${BASE_DOMAIN}` (read-only static browse, no auth).
|
||||
|
||||
### Disk is the shared failure domain
|
||||
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
|
||||
full disk on `cochi` does not just break `files.` — it takes **Minecraft, Drasl,
|
||||
@@ -196,6 +304,7 @@ writable file manager. Pin the digest; upgrade deliberately — same discipline
|
||||
|
||||
- **Keycloak SSO** — needs oauth2-proxy + `FILES_AUTH=passthrough`. Not built.
|
||||
- **Anonymous access** (`FILES_AUTH=none`) — only once the host is internal-only.
|
||||
**Done on `oxps`** (LAN-only, 2026-07-23); still `htpasswd` on `cochi`.
|
||||
- **Landing page link** — deliberately none on day one; `plan/18-landing-rework.md`
|
||||
is mid-rework, so revisit with WS5 (footer) rather than merging into a moving
|
||||
design.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# 21 — Generators (`generators.${BASE_DOMAIN}`)
|
||||
|
||||
> Minecraft data pack generators — a pinned, offline-only fork of
|
||||
> [misode.github.io](https://github.com/misode/misode.github.io), served with
|
||||
> its own data mirror so it works with **zero internet** at the LAN party.
|
||||
|
||||
## Why self-host at all
|
||||
|
||||
Upstream is public, free and CDN-hosted, so a plain mirror would buy nothing.
|
||||
Three things justify this:
|
||||
|
||||
1. **Offline.** At the party the machine is present but the network is not.
|
||||
2. **Version pin.** The generators can never drift ahead of a 1.21.1 server.
|
||||
3. **Modded schemas.** `public/mcdoc/` is the extension point for mods (see
|
||||
*C-lite / C-full* below).
|
||||
|
||||
## The one fact that drives every decision
|
||||
|
||||
**The app is 100% client-side.** Every data fetch comes from the *player's
|
||||
browser*, not from cochi. So:
|
||||
|
||||
- "cochi has no WAN at the party" protects nothing — a leftover upstream URL
|
||||
just fails in the browser, at the venue, with no internet to debug it.
|
||||
- Blocking the container's egress proves nothing either.
|
||||
- The **only** things that make it offline-capable are (a) which origins the
|
||||
bundle refers to, and (b) the mirror we serve them from.
|
||||
|
||||
That is why the CSP is enforcement, not decoration — see *Verification*.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
host nginx (TLS, LE cert for generators.) ─► caddy ─► generators:80
|
||||
│ (caddy in-container)
|
||||
caddy/conf.d/70-generators.caddy ── sets Content-Security-Policy
|
||||
│
|
||||
generators container: /srv/app ← built Preact/Vite bundle (from the fork)
|
||||
/srv/data ← ~109M mirror, bind-mounted :ro
|
||||
```
|
||||
|
||||
One origin serves both the app and its data. No CORS, no second cert, no second
|
||||
DNS name.
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Fork | `git.fosil.eu/oier/ulicraft-generators`, branch `ulicraft`, `GENERATORS_REPO` in `.env` |
|
||||
| Dockerfile + Caddyfile | **in the fork** (it is unpublished upstream, so unlike `nmsr` it cannot clone a pinned ref) |
|
||||
| Mirror sync | `tooling/sync-generators-data.sh` → `GENERATORS_DATA_DIR` (default `./generators-data`, gitignored) |
|
||||
| Stack vhost | `caddy/conf.d/70-generators.caddy` |
|
||||
| TLS vhost | `nginx/ulicraft-caddy.conf.tmpl` |
|
||||
|
||||
## The mirror (~109M)
|
||||
|
||||
mcmeta publishes per-version snapshots as **tags** (~4000 of them), not branches.
|
||||
|
||||
| Source | Ref | Size |
|
||||
|---|---|---|
|
||||
| `misode/mcmeta` | tag `1.21.1-summary` | 31M |
|
||||
| `misode/mcmeta` | tag `1.21.1-data` | 34M |
|
||||
| `misode/mcmeta` | tag `1.21.1-assets-tiny` → served **as** `1.21.1-assets` | 36M |
|
||||
| `misode/mcmeta` | tag `1.21.1-atlas` | 3.5M |
|
||||
| `misode/mcmeta` | branch `summary`, `versions/data.min.json` only | 172K |
|
||||
| `SpyglassMC/vanilla-mcdoc` | branch `generated`, `symbols.json` | 2.4M |
|
||||
|
||||
Three non-obvious points:
|
||||
|
||||
- **`assets-tiny` is assets minus the `.ogg` sounds** (hundreds of MB). It is
|
||||
served at the `-assets` path because that is what the app requests. Sound
|
||||
*playback* would 404, which is why the fork deletes the sounds generator.
|
||||
- **`summary/versions/data.min.json` comes from the branch head, not a tag**
|
||||
(`DataFetcher.ts:205`), and it runs during Spyglass init. Miss it and the app
|
||||
dead-ends offline while looking perfectly healthy online.
|
||||
- The **directory layout mirrors upstream's URL shape** (`/mcmeta/<ref>-<type>/…`).
|
||||
That is what keeps the fork patch to a couple of constants.
|
||||
|
||||
The sync script verifies a **sentinel file per source** rather than trusting a
|
||||
clean `git clone` exit — a renamed upstream path would otherwise "sync
|
||||
successfully" and die in the browser at the party.
|
||||
|
||||
## Fork diff (pinned at upstream `2bfe5522`)
|
||||
|
||||
| Change | File |
|
||||
|---|---|
|
||||
| `mcmetaUrl` → `/mcmeta`, `vanillaMcdocUrl` → `/vanilla-mcdoc` | `services/DataFetcher.ts` |
|
||||
| Removed jsdelivr fallback | `services/DataFetcher.ts` |
|
||||
| Removed hardcoded immersive_weathering raw.githubusercontent URL | `services/DataFetcher.ts` |
|
||||
| Version locked to `1.21` | `services/Versions.ts` (`MIRRORED_VERSION`) |
|
||||
| Switcher filters the **view**, not the data | `components/VersionSwitcher.tsx` |
|
||||
| Routes `/sounds`, `/changelog`, `/versions`, `/whats-new` deleted | `App.tsx`, `pages/Home.tsx` |
|
||||
| Share disabled (write API, unmirrorable) | `services/Sharing.ts` |
|
||||
| gtag → no-op, EthicalAds/Giscus/contributors removed, footer + `SOURCE_REPO_URL` | `index.html`, `Home.tsx`, `Footer.tsx`, `Utils.ts` |
|
||||
|
||||
### Version lock — the trap
|
||||
|
||||
`checkVersion()` compares versions **by index** (`config.versions.findIndex`).
|
||||
Deleting entries from `config.versions` / `VersionIds` makes a removed
|
||||
`minVersion` resolve to `-1`, so `-1 <= version` passes and generators that do
|
||||
**not** exist in 1.21 silently render. **Filter the view, never the data.**
|
||||
|
||||
### Upstream policy: pin and freeze
|
||||
|
||||
Do not chase `master`. Upstream ships 26.x while this server sits at 1.21.1, and
|
||||
every merge risks reintroducing an external origin. Merges are a rare,
|
||||
deliberate chore — and the CSP is what catches a regression.
|
||||
|
||||
## C-lite vs C-full (modded generators)
|
||||
|
||||
**Currently C-lite.** `public/mcdoc/create.mcdoc` and `neoforge.mcdoc` ship
|
||||
upstream and cover the pack's two biggest datapack surfaces. `.mcdoc` files are
|
||||
fetched **same-origin** from `/mcdoc/<dep>.mcdoc`, so adding a mod = drop a file
|
||||
in `public/mcdoc/` + add entries to `src/config.json`.
|
||||
|
||||
**C-full** (autocomplete for the pack's own items/blocks) additionally needs:
|
||||
|
||||
- a registry dump from the live server — KubeJS is already in the pack and
|
||||
already mounted, so a startup script can dump every registry;
|
||||
- a patch to `fetchRegistries`, which currently **hardcodes the `minecraft:`
|
||||
namespace** (`data[id].map(e => 'minecraft:' + e)`), so modded ids cannot
|
||||
enter autocomplete from data alone;
|
||||
- modded item icons would still be missing (the atlas is vanilla-only).
|
||||
|
||||
mcdoc is hand-written per mod (upstream's range: 19–1389 lines), so C-full needs
|
||||
a **named shortlist**, never "the pack".
|
||||
|
||||
## Deploy
|
||||
|
||||
Prerequisites on cochi, in this order — **the first one can take the whole stack
|
||||
down if skipped**:
|
||||
|
||||
1. **`GENERATORS_REPO` must be set in cochi's `.env`.** compose uses
|
||||
`${GENERATORS_REPO:?…}`, and an unset required variable makes
|
||||
`docker compose up` fail to parse the file — **every service, including
|
||||
Minecraft**, not just this one.
|
||||
2. Clone the fork on cochi at that path (branch `ulicraft`).
|
||||
3. `tooling/sync-generators-data.sh` on cochi (~109M, **needs WAN**).
|
||||
4. `generators` in `LE_SUBDOMAINS` → `tooling/issue-letsencrypt.sh`.
|
||||
5. **Only then** `tooling/render-nginx.sh --install` (nginx fails its config
|
||||
test if the cert file does not exist).
|
||||
6. DNS `A generators.${BASE_DOMAIN}` — and the **party DNS** too.
|
||||
|
||||
Then the normal deploy (`deploy` skill): git pull + `docker compose up -d --build`.
|
||||
|
||||
## Verification
|
||||
|
||||
**A 200 on `/` proves the server is up, not that the app works.** This is an SPA
|
||||
whose failure mode is a healthy-looking shell over dead data.
|
||||
|
||||
- `curl /mcmeta/1.21.1-summary/registries/data.min.json` — proves the mirror is
|
||||
actually mounted. An Uptime Kuma monitor should probe **this**, not `/`.
|
||||
- **A status code is not enough — assert the BODY.** Observed on 2026-07-21,
|
||||
with the nginx vhost installed but the container not yet deployed: caddy
|
||||
answers an unmatched `Host` with an **empty `200`**, so *both* `/` and the
|
||||
mirror path above returned `HTTP 200, 0 bytes` while literally nothing was
|
||||
running. Any check that looks only at the status code reports green on a
|
||||
completely absent service. Match on content (or a minimum body size):
|
||||
|
||||
```
|
||||
curl -s https://generators.${BASE_DOMAIN}/mcmeta/1.21.1-summary/registries/data.min.json \
|
||||
| head -c 40 # must be JSON, ~536 KB total — not empty
|
||||
```
|
||||
- **Offline acceptance test**: a real browser with every non-origin request
|
||||
blocked, walking the generators you intend to use. `tooling/`-adjacent harness
|
||||
lives in the scratchpad; the check is: zero off-origin attempts, zero
|
||||
same-origin failures, zero console errors, schemas actually rendered.
|
||||
- The **CSP** (`connect-src 'self'`) is the standing enforcement: an external
|
||||
origin that sneaks back in breaks loudly in prod, on an ordinary day, instead
|
||||
of silently at the party.
|
||||
|
||||
`script-src`/`style-src` are deliberately permissive for now — Spyglass/deepslate
|
||||
wasm+eval needs were not characterised under deadline. Tightening them is a
|
||||
post-party task; loosening `connect-src` is not.
|
||||
|
||||
## Party-day notes
|
||||
|
||||
- HSTS is `max-age=31536000; includeSubDomains` on every vhost, so **there is no
|
||||
HTTP fallback and no cert-warning click-through**. The cert must be valid.
|
||||
- LE certs bind **names, not IPs**, so a party DNS override pointing
|
||||
`generators.${BASE_DOMAIN}` at the LAN IP validates fine — provided the cert
|
||||
is unexpired and was issued while WAN existed.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Migrating the stack to another host
|
||||
|
||||
Move the Ulicraft stack from one machine to another **keeping the same domain**,
|
||||
carrying the **Drasl users + skins** (and monitors + file-share index), but
|
||||
**not the Minecraft world**. Written for the cochi → oxps move, but the scripts
|
||||
take `--from` / paths so it generalises.
|
||||
|
||||
Three scripts do the mechanical work; the rest is deploying the distribution
|
||||
repo, TLS, DNS, and firewall — the same one-time setup any fresh host needs.
|
||||
|
||||
| script | runs on | does |
|
||||
|---|---|---|
|
||||
| `tooling/migrate-check.sh` | target (or `--host`) | validate the machine meets the requirements |
|
||||
| `tooling/migrate-export.sh` | **your workstation** | pull state from the source host into one bundle |
|
||||
| `tooling/migrate-import.sh` | target | restore that bundle into this host's stack |
|
||||
|
||||
## What actually moves
|
||||
|
||||
**In the bundle** (`migrate-export.sh`):
|
||||
|
||||
- `.env` — all secrets (RCON, `FILES_*` hashes, `DRASL_ADMIN_*`, OVH DNS API).
|
||||
- `certs/` — the Let's Encrypt cert+key for every name (opt out `--without-certs`).
|
||||
- volume `drasl_state` — **accounts, passwords, skins, capes, signing key**. 144K.
|
||||
This is the whole point of the migration.
|
||||
- volume `kuma_data` — Uptime Kuma monitors + history (~38M).
|
||||
- volume `filestash_state` — filestash sqlite + search index (~700K).
|
||||
|
||||
**You provide on the target** (not in the bundle — too big, or you rebuild it):
|
||||
|
||||
- The **distribution repo** checkout at `DISTRIBUTION_WEB_ROOT` (~3.3G). The
|
||||
`minecraft` service bind-mounts kubejs source dirs from it and **will not boot**
|
||||
until it exists. You deploy this yourself (Gitea deploy key is per-repo, per host).
|
||||
- `generators-data/` (~109M, needs WAN) via `tooling/sync-generators-data.sh`.
|
||||
- `runtime/authlib-injector.jar`, `launcher/` via their `fetch-*.sh` scripts.
|
||||
|
||||
**Deliberately dropped:** the world (`mc_data`) and `backups/` — you said the
|
||||
world isn't needed. (Carry the world anyway with `migrate-export.sh --with-world`.)
|
||||
`www/`, `server-mods/`, and rendered configs are regenerated by `update-all.sh`.
|
||||
|
||||
## Requirements on the target
|
||||
|
||||
`migrate-check.sh` splits them:
|
||||
|
||||
- **`[restore]` (hard)** — docker daemon, **Compose v2** (`docker compose`, not the
|
||||
legacy `docker-compose` v1 binary), git, tar, gzip, ssh. Import refuses without these.
|
||||
- **`[deploy]` (warn)** — envsubst, rsync, node+pnpm, python ≥ 3.11, nginx,
|
||||
acme.sh, ≥12G RAM (`MAX_MEMORY: 10G`), ~10G free disk, free ports. You can
|
||||
restore without these, but need them before `./update-all.sh --hard`.
|
||||
|
||||
Installing Compose v2 by hand (if the host only has v1):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m) \
|
||||
-o ~/.docker/cli-plugins/docker-compose
|
||||
chmod +x ~/.docker/cli-plugins/docker-compose
|
||||
docker compose version # confirm v2+
|
||||
```
|
||||
|
||||
## Step by step (cochi → oxps)
|
||||
|
||||
### 0. Check the target
|
||||
|
||||
```bash
|
||||
tooling/migrate-check.sh --host oxps
|
||||
```
|
||||
|
||||
Fix every `✗`. Warnings are fine to defer to step 5.
|
||||
|
||||
### 1. Export (on your workstation)
|
||||
|
||||
```bash
|
||||
tooling/migrate-export.sh --from cochi --out ~/bundles
|
||||
# → ~/bundles/ulicraft-bundle-cochi-YYYYMMDD-HHMMSS.tar.gz (mode 0600)
|
||||
```
|
||||
|
||||
Reads the source read-only; writes nothing there. The bundle holds private keys
|
||||
and `.env` secrets — it is created `0600`, keep it that way.
|
||||
|
||||
### 2. Get the repo + bundle onto the target
|
||||
|
||||
```bash
|
||||
# clone the stack repo on oxps (add oxps's Gitea deploy key first)
|
||||
git clone <repo> ~/mc/ulicraft-server && cd ~/mc/ulicraft-server
|
||||
# copy the bundle over
|
||||
scp ~/bundles/ulicraft-bundle-cochi-*.tar.gz oxps:~/mc/ulicraft-server/
|
||||
```
|
||||
|
||||
The checkout **directory name is the Compose project name** (here
|
||||
`ulicraft-server`). Volumes restore as `<dirname>_drasl_state`, and
|
||||
`docker compose` later looks for exactly that — so whatever you name the dir,
|
||||
be consistent. (Pin `COMPOSE_PROJECT_NAME` in `.env` if you want it fixed.)
|
||||
|
||||
### 3. Import (on the target)
|
||||
|
||||
```bash
|
||||
cd ~/mc/ulicraft-server
|
||||
tooling/migrate-import.sh ulicraft-bundle-cochi-*.tar.gz \
|
||||
--path-prefix /home/ubuntu/mc=/home/$USER/mc
|
||||
```
|
||||
|
||||
`--path-prefix OLD=NEW` rewrites the source-absolute host paths in `.env`
|
||||
(`DISTRIBUTION_WEB_ROOT`, `GENERATORS_REPO`, `GENERATORS_DATA_DIR`,
|
||||
`FILES_DATA_DIR`) to where they live on oxps. Import then checks each one exists
|
||||
and **warns loudly** about any that don't — `DISTRIBUTION_WEB_ROOT` especially,
|
||||
since the server won't boot without it.
|
||||
|
||||
Guards: it refuses to overwrite volumes that already exist (pass `--force` to
|
||||
replace them — a clean wipe, not a merge), backs up any existing `.env`, and
|
||||
verifies the bundle's sha256 checksums before touching anything.
|
||||
|
||||
### 4. Deploy the distribution repo
|
||||
|
||||
Clone/publish the distribution repo to the `DISTRIBUTION_WEB_ROOT` path from
|
||||
step 3. Two checkouts drift — publish deliberately, don't blind-rsync from cochi.
|
||||
Add oxps's own Gitea deploy key for that repo.
|
||||
|
||||
### 5. Finish the host setup
|
||||
|
||||
```bash
|
||||
tooling/fetch-authlib.sh
|
||||
tooling/fetch-launcher.sh
|
||||
tooling/sync-generators-data.sh # needs WAN
|
||||
tooling/render-nginx.sh --install # host nginx vhost → caddy
|
||||
tooling/issue-letsencrypt.sh # seed acme.sh + its renewal cron
|
||||
# firewall: tcp 25565, 80, 443, CADDY_HTTP_PORT ; udp 24454
|
||||
```
|
||||
|
||||
> **Bundle certs are a bridge, not a renewal.** The certs from the bundle let
|
||||
> nginx start immediately, but nothing renews them — acme.sh's state lived on
|
||||
> cochi. Run `issue-letsencrypt.sh` on oxps once so renewals happen here. It uses
|
||||
> the OVH **DNS-01** challenge, so it works before DNS points at oxps and needs
|
||||
> no inbound ports.
|
||||
|
||||
### 6. Cut over
|
||||
|
||||
```bash
|
||||
./update-all.sh --hard # render → sync → build → up
|
||||
docker compose ps # all services healthy
|
||||
```
|
||||
|
||||
Point DNS `${BASE_DOMAIN}` + subdomains at oxps. Once a player has joined:
|
||||
|
||||
```bash
|
||||
docker compose exec minecraft rcon-cli op <name> # OPS env breaks on Drasl UUIDs
|
||||
```
|
||||
|
||||
## Rollback window — one way, mind the clock
|
||||
|
||||
Same domain ⇒ DNS points at **one** host. cochi stays as a fallback, but the
|
||||
moment a player **registers or changes a skin on oxps**, `drasl.db` diverges from
|
||||
cochi's copy. Rolling back to cochi then **silently loses** those new
|
||||
accounts/skins. Safe for hours as a fallback; not for weeks. If you must roll
|
||||
back after real use, re-export from oxps and re-import onto cochi first.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### minecraft crash-loops on a fresh host: `AccessDeniedException: /data/kubejs/config`
|
||||
|
||||
Symptom (in `docker compose logs minecraft`), restarting every ~15s:
|
||||
|
||||
```
|
||||
java.nio.file.AccessDeniedException: /data/kubejs/config
|
||||
Caused by: NullPointerException: Cannot invoke "...ConsoleJS.error(...)"
|
||||
because "dev.latvian.mods.kubejs.script.ConsoleJS.STARTUP" is null
|
||||
```
|
||||
|
||||
**Cause.** The compose file bind-mounts the kubejs *source* subdirs read-only
|
||||
(`server_scripts`, `startup_scripts`, `data`, `assets`). To place those mounts,
|
||||
Docker auto-creates their parent `/data/kubejs` — and on a **fresh `mc_data`
|
||||
volume** it creates it as **root:root**. The itzg container runs as **uid 1000**,
|
||||
so KubeJS can't create its writable siblings (`config/`, `cache/`, `generated/`,
|
||||
`logs/`) → init aborts → `ConsoleJS.STARTUP` stays null → NPE → exit 1.
|
||||
|
||||
This only bites a **fresh world** (migration, new host, wiped `mc_data`). On an
|
||||
aged host like cochi `/data/kubejs` predates the ro submounts and is already
|
||||
1000-owned, so it never surfaced there.
|
||||
|
||||
**Fix** — chown the dir to 1000 once (durable; survives restarts):
|
||||
|
||||
```bash
|
||||
docker compose stop minecraft
|
||||
docker run --rm -v <project>_mc_data:/data alpine chown 1000:1000 /data/kubejs
|
||||
docker compose up -d minecraft
|
||||
```
|
||||
|
||||
`<project>` is the checkout dir basename (e.g. `ulicraft-server`). Distinct from
|
||||
the "don't mount the whole kubejs dir ro" gotcha in `plan/05-minecraft.md` — same
|
||||
mod, different trigger (ownership of the auto-created parent, not a ro root).
|
||||
|
||||
## Re-running / testing
|
||||
|
||||
Everything is idempotent-ish and safe to rehearse:
|
||||
|
||||
- `migrate-export.sh` never writes on the source; run it as often as you like.
|
||||
- `migrate-import.sh` refuses to clobber existing volumes without `--force`, so a
|
||||
second run on a populated host stops instead of nuking data. To rehearse a full
|
||||
restore on a throwaway host, import, inspect, `docker compose down -v`, repeat.
|
||||
@@ -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"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Recreate the filestash container running as a chosen uid/gid so that every
|
||||
# file it writes into the share is owned by a host account (e.g. oier).
|
||||
#
|
||||
# Reads FILES_UID / FILES_GID from .env, chowns the filestash_state volume and
|
||||
# the FILES_DATA_DIR share to that uid, then force-recreates the container.
|
||||
# Run it FROM THE REPO ROOT on the target host (needs sudo + docker).
|
||||
#
|
||||
# Idempotent and cautious: halts on any ambiguity rather than guessing.
|
||||
# Background + rationale: plan/20-files.md ("Share dir must be owned by uid …").
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "!! $*" >&2; exit 1; }
|
||||
info() { echo ">> $*"; }
|
||||
|
||||
# --- preconditions -----------------------------------------------------------
|
||||
[ -f docker-compose.yml ] || die "run this from the repo root (no docker-compose.yml here)"
|
||||
[ -f .env ] || die ".env not found — this is a host-local file; run on the target host"
|
||||
|
||||
# The compose file must carry the env-driven `user:` directive. If it doesn't,
|
||||
# this checkout predates the change — pull before running so the recreate below
|
||||
# actually applies the uid.
|
||||
grep -q 'user: "${FILES_UID' docker-compose.yml \
|
||||
|| die "docker-compose.yml has no FILES_UID user directive — 'git pull' first"
|
||||
|
||||
# --- resolve target uid/gid from .env ---------------------------------------
|
||||
FILES_UID="$(grep -E '^FILES_UID=' .env | tail -1 | cut -d= -f2- | tr -d '\047"' )"
|
||||
FILES_GID="$(grep -E '^FILES_GID=' .env | tail -1 | cut -d= -f2- | tr -d '\047"' )"
|
||||
[ -n "${FILES_UID:-}" ] || die "FILES_UID unset in .env — add e.g. 'FILES_UID=1002' (oier)"
|
||||
[ -n "${FILES_GID:-}" ] || die "FILES_GID unset in .env — add e.g. 'FILES_GID=1002' (oier)"
|
||||
case "$FILES_UID$FILES_GID" in *[!0-9]*) die "FILES_UID/FILES_GID must be numeric (got $FILES_UID:$FILES_GID)";; esac
|
||||
info "target run user = ${FILES_UID}:${FILES_GID}"
|
||||
|
||||
# --- resolve the share dir + the state volume host path ----------------------
|
||||
FILES_DATA_DIR="$(grep -E '^FILES_DATA_DIR=' .env | tail -1 | cut -d= -f2- | tr -d '\047"' )"
|
||||
[ -n "${FILES_DATA_DIR:-}" ] || die "FILES_DATA_DIR unset in .env"
|
||||
[ -d "$FILES_DATA_DIR" ] || die "share dir does not exist: $FILES_DATA_DIR"
|
||||
|
||||
PROJECT="$(basename "$PWD")" # compose project name = dir name
|
||||
VOL="${PROJECT}_filestash_state"
|
||||
docker volume inspect "$VOL" >/dev/null 2>&1 || die "docker volume '$VOL' not found"
|
||||
STATE_DIR="$(docker volume inspect "$VOL" --format '{{ .Mountpoint }}')"
|
||||
[ -n "$STATE_DIR" ] && [ -d "$STATE_DIR" ] || die "could not resolve state volume mountpoint"
|
||||
|
||||
info "share dir = $FILES_DATA_DIR"
|
||||
info "state dir = $STATE_DIR (volume $VOL)"
|
||||
|
||||
# --- confirm (destructive-ish: chown -R) -------------------------------------
|
||||
echo
|
||||
echo "About to:"
|
||||
echo " sudo chown -R ${FILES_UID}:${FILES_GID} $STATE_DIR"
|
||||
echo " sudo chown -R ${FILES_UID}:${FILES_GID} $FILES_DATA_DIR"
|
||||
echo " docker compose up -d --force-recreate filestash (brief downtime)"
|
||||
read -r -p "Proceed? [y/N] " ans
|
||||
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || die "aborted by user"
|
||||
|
||||
# --- apply -------------------------------------------------------------------
|
||||
info "stopping filestash"
|
||||
docker compose stop filestash
|
||||
|
||||
info "chowning state volume -> ${FILES_UID}:${FILES_GID}"
|
||||
sudo chown -R "${FILES_UID}:${FILES_GID}" "$STATE_DIR"
|
||||
|
||||
info "chowning share -> ${FILES_UID}:${FILES_GID}"
|
||||
sudo chown -R "${FILES_UID}:${FILES_GID}" "$FILES_DATA_DIR"
|
||||
|
||||
info "recreating filestash as ${FILES_UID}:${FILES_GID}"
|
||||
docker compose up -d --force-recreate filestash
|
||||
|
||||
# --- verify ------------------------------------------------------------------
|
||||
sleep 3
|
||||
running="$(docker inspect -f '{{.State.Running}}' filestash 2>/dev/null || echo false)"
|
||||
[ "$running" = "true" ] || { docker compose logs --tail=40 filestash; die "filestash is not running — see logs above"; }
|
||||
|
||||
info "write test as uid ${FILES_UID}"
|
||||
if docker exec -u "$FILES_UID" filestash sh -c 'touch /data/files/.wtest && rm /data/files/.wtest'; then
|
||||
info "OK — filestash writes the share as ${FILES_UID}:${FILES_GID}; new files owned by that user"
|
||||
else
|
||||
docker compose logs --tail=40 filestash
|
||||
die "write test FAILED — check share perms + logs above"
|
||||
fi
|
||||
@@ -52,7 +52,12 @@ ACME="${ACME_SH:-$HOME/.acme.sh/acme.sh}"
|
||||
curl https://get.acme.sh | sh -s email=${LE_EMAIL}
|
||||
then re-run (or set ACME_SH=/path/to/acme.sh)."
|
||||
|
||||
CERT_DIR="${CERT_DIR:-certs}"
|
||||
# MUST be absolute: acme.sh persists these paths in its domain conf and replays
|
||||
# them on every cron renewal, where cwd is $HOME, not the repo. A relative path
|
||||
# there makes the renewal install silently write nothing -- acme.sh keeps renewing
|
||||
# happily while nginx serves the stale (eventually expired) copy. Bit us 2026-09-06.
|
||||
CERT_DIR="${CERT_DIR:-$PWD/certs}"
|
||||
case "$CERT_DIR" in /*) ;; *) CERT_DIR="$PWD/$CERT_DIR" ;; esac
|
||||
read -r -a SUBDOMAINS <<< "${LE_SUBDOMAINS:-auth pack distribution www avatar status}"
|
||||
|
||||
if [ "${LE_STAGING:-0}" = "1" ]; then
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# migrate-check.sh — validate that a machine meets the requirements to RESTORE
|
||||
# and RUN the Ulicraft stack. Run it on the TARGET host (e.g. oxps) before a
|
||||
# migration, or against a remote host with --host.
|
||||
#
|
||||
# Two tiers of checks:
|
||||
# [restore] hard requirements — without these migrate-import.sh cannot run.
|
||||
# Any failure exits non-zero.
|
||||
# [deploy] needed to build + serve the full stack (update-all.sh, TLS).
|
||||
# Missing ones WARN only — you can restore now, install later.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/migrate-check.sh # check THIS machine
|
||||
# tooling/migrate-check.sh --host oxps # check a remote host over ssh
|
||||
#
|
||||
# The --host form streams this very script to the remote and runs it there, so
|
||||
# nothing needs to be installed on the remote first.
|
||||
set -uo pipefail # NOT -e: we want to run every check and total the failures
|
||||
|
||||
# ── remote mode: ship self to the host and run it there ─────────────
|
||||
if [ "${1:-}" = "--host" ]; then
|
||||
[ -n "${2:-}" ] || { echo "usage: $0 --host <ssh-host>" >&2; exit 2; }
|
||||
exec ssh "$2" 'bash -s' < "$0"
|
||||
fi
|
||||
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf ' \033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
bad() { printf ' \033[1;31m✗\033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[1;33m!\033[0m %s\n' "$*"; }
|
||||
|
||||
HARD_FAIL=0
|
||||
SOFT_FAIL=0
|
||||
|
||||
# require CMD "human hint" — hard [restore] check
|
||||
require() {
|
||||
if command -v "$1" >/dev/null 2>&1; then ok "$1 present"; else
|
||||
bad "$1 MISSING — $2"; HARD_FAIL=$((HARD_FAIL+1)); fi
|
||||
}
|
||||
# want CMD "human hint" — soft [deploy] check
|
||||
want() {
|
||||
if command -v "$1" >/dev/null 2>&1; then ok "$1 present"; else
|
||||
warn "$1 missing — $2"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
|
||||
}
|
||||
|
||||
log "[restore] core tools"
|
||||
require docker "install Docker Engine"
|
||||
require git "install git"
|
||||
require tar "install tar"
|
||||
require gzip "install gzip"
|
||||
require ssh "install openssh-client (used to pull the bundle)"
|
||||
|
||||
log "[restore] docker daemon reachable"
|
||||
if docker info >/dev/null 2>&1; then ok "docker daemon up"; else
|
||||
bad "cannot talk to the docker daemon (permission? is it running?)"; HARD_FAIL=$((HARD_FAIL+1)); fi
|
||||
|
||||
log "[restore] docker compose v2 plugin"
|
||||
# The stack is authored for Compose v2 (docker compose ...). The legacy v1
|
||||
# docker-compose binary is NOT accepted — it mangles the project name and
|
||||
# diverges on --build.
|
||||
if cv="$(docker compose version --short 2>/dev/null)"; then
|
||||
major="${cv%%.*}"
|
||||
if [ "${major:-0}" -ge 2 ] 2>/dev/null; then
|
||||
ok "docker compose v$cv"
|
||||
else
|
||||
bad "docker compose reports v$cv — need v2+"; HARD_FAIL=$((HARD_FAIL+1))
|
||||
fi
|
||||
else
|
||||
bad "'docker compose' v2 plugin not found. Install it:
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-\$(uname -m) \\
|
||||
-o ~/.docker/cli-plugins/docker-compose
|
||||
chmod +x ~/.docker/cli-plugins/docker-compose
|
||||
(the legacy 'docker-compose' v1 binary is NOT accepted)"
|
||||
HARD_FAIL=$((HARD_FAIL+1))
|
||||
fi
|
||||
|
||||
log "[deploy] build + config tools"
|
||||
want envsubst "part of gettext — render-config.sh / render-nginx.sh need it"
|
||||
want rsync "used to publish the distribution repo"
|
||||
want openssl "used to mint FILES_PASSWORD_HASH"
|
||||
want jq "sync-server-mods.sh falls back to python3 if absent"
|
||||
want node "landing build (pnpm build) — install via nvm"
|
||||
want pnpm "landing build — never npm (see project rules)"
|
||||
|
||||
log "[deploy] python >= 3.11 (build-modlist.py)"
|
||||
PY=""
|
||||
for c in python3.13 python3.12 python3.11 python3; do
|
||||
if command -v "$c" >/dev/null 2>&1; then
|
||||
v="$($c -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null)"
|
||||
case "$v" in 3.1[1-9]|3.[2-9]*) PY="$c $v"; break;; esac
|
||||
fi
|
||||
done
|
||||
if [ -n "$PY" ]; then ok "python $PY"; else
|
||||
warn "no python >= 3.11 found — build-modlist.py will refuse"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
|
||||
|
||||
log "[deploy] TLS front (host nginx + acme.sh)"
|
||||
want nginx "host nginx terminates TLS in front of caddy"
|
||||
if [ -x "$HOME/.acme.sh/acme.sh" ] || command -v acme.sh >/dev/null 2>&1; then
|
||||
ok "acme.sh present"
|
||||
else
|
||||
warn "acme.sh missing — needed to RENEW certs (bundle covers the first ~90d):
|
||||
curl https://get.acme.sh | sh -s email=you@example.com"
|
||||
SOFT_FAIL=$((SOFT_FAIL+1))
|
||||
fi
|
||||
|
||||
log "[deploy] memory (MAX_MEMORY: 10G in compose)"
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
mem_g="$(free -g | awk '/^Mem:/{print $2}')"
|
||||
if [ "${mem_g:-0}" -ge 12 ] 2>/dev/null; then ok "${mem_g}G RAM"; else
|
||||
warn "only ${mem_g}G RAM — the JVM alone wants 10G; lower MAX_MEMORY or add RAM"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
|
||||
else
|
||||
warn "cannot read RAM (no 'free')"; SOFT_FAIL=$((SOFT_FAIL+1))
|
||||
fi
|
||||
|
||||
log "[deploy] free disk on / (need ~10G for images + volumes)"
|
||||
if command -v df >/dev/null 2>&1; then
|
||||
free_g="$(df -BG --output=avail / 2>/dev/null | awk 'NR==2{gsub("G","");print $1}')"
|
||||
if [ "${free_g:-0}" -ge 10 ] 2>/dev/null; then ok "${free_g}G free on /"; else
|
||||
warn "only ${free_g}G free on / — images + volumes want ~10G"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
|
||||
fi
|
||||
|
||||
log "[deploy] ports free (server + ingress)"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
for p in 25565 80 443 "${CADDY_HTTP_PORT:-8880}"; do
|
||||
if ss -ltn "( sport = :$p )" 2>/dev/null | grep -q ":$p"; then
|
||||
warn "tcp/$p already in use — will collide unless it's this stack"; SOFT_FAIL=$((SOFT_FAIL+1))
|
||||
else ok "tcp/$p free"; fi
|
||||
done
|
||||
if ss -lun "( sport = :24454 )" 2>/dev/null | grep -q ":24454"; then
|
||||
warn "udp/24454 already in use (Simple Voice Chat)"; SOFT_FAIL=$((SOFT_FAIL+1))
|
||||
else ok "udp/24454 free"; fi
|
||||
else
|
||||
warn "cannot check ports (no 'ss')"; SOFT_FAIL=$((SOFT_FAIL+1))
|
||||
fi
|
||||
|
||||
# ── verdict ─────────────────────────────────────────────────────────
|
||||
printf '\n'
|
||||
if [ "$HARD_FAIL" -gt 0 ]; then
|
||||
printf '\033[1;31m✗ %d restore-blocking requirement(s) missing — fix before migrate-import.sh\033[0m\n' "$HARD_FAIL"
|
||||
[ "$SOFT_FAIL" -gt 0 ] && printf '\033[1;33m (+%d deploy warning(s))\033[0m\n' "$SOFT_FAIL"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SOFT_FAIL" -gt 0 ]; then
|
||||
printf '\033[1;33m✓ restore requirements met, but %d deploy warning(s) — you can import now, but\n install the missing pieces before ./update-all.sh --hard\033[0m\n' "$SOFT_FAIL"
|
||||
exit 0
|
||||
fi
|
||||
printf '\033[1;32m✓ all checks passed — ready to migrate\033[0m\n'
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# migrate-export.sh — bundle the Ulicraft stack's PORTABLE state into a single
|
||||
# tarball, ready to restore on another host with migrate-import.sh.
|
||||
#
|
||||
# Runs on YOUR WORKSTATION and pulls everything from the source host over ssh
|
||||
# (default: cochi). Nothing is written on the source; it is read-only there.
|
||||
#
|
||||
# What goes in the bundle:
|
||||
# .env secrets + config (RCON, FILES_* hashes, DRASL_*, OVH)
|
||||
# certs/ Let's Encrypt cert+key per name (opt out: --without-certs)
|
||||
# volume drasl_state accounts, passwords, skins, capes, signing key ← the point
|
||||
# volume kuma_data Uptime Kuma monitors + history
|
||||
# volume filestash_state filestash sqlite + search index
|
||||
# MANIFEST.txt source host, project name, git commit, sha256sums
|
||||
#
|
||||
# Deliberately NOT included (regenerated or you deploy them yourself):
|
||||
# mc_data (the world — opt in with --with-world), backups/, the distribution
|
||||
# repo, generators-data/, www/, server-mods/, rendered configs.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/migrate-export.sh # from cochi, certs in, no world
|
||||
# tooling/migrate-export.sh --from cochi --out ~/bundles
|
||||
# tooling/migrate-export.sh --without-certs # reissue certs on the target instead
|
||||
# tooling/migrate-export.sh --with-world # also carry the world (~15G)
|
||||
#
|
||||
# The output tarball contains private keys → it is written mode 0600. Keep it safe.
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# ── args ────────────────────────────────────────────────────────────
|
||||
FROM="cochi"
|
||||
SRC_DIR="/home/ubuntu/mc/ulicraft-server-v1"
|
||||
OUT_DIR="."
|
||||
WITH_CERTS=1
|
||||
WITH_WORLD=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--from) FROM="${2:?}"; shift 2 ;;
|
||||
--src-dir) SRC_DIR="${2:?}"; shift 2 ;;
|
||||
--out) OUT_DIR="${2:?}"; shift 2 ;;
|
||||
--without-certs) WITH_CERTS=0; shift ;;
|
||||
--with-world) WITH_WORLD=1; shift ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
*) die "unknown arg: $1 (see --help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v ssh >/dev/null || die "ssh required"
|
||||
command -v tar >/dev/null || die "tar required"
|
||||
|
||||
# ── discover the source: project name + git commit ─────────────────
|
||||
log "inspecting source host '$FROM' at $SRC_DIR"
|
||||
ssh "$FROM" "test -f '$SRC_DIR/.env'" || die "no .env at $SRC_DIR on $FROM (wrong --src-dir?)"
|
||||
# Compose project name = the source checkout's dir basename (compose default).
|
||||
PROJ="$(ssh "$FROM" "basename '$SRC_DIR'")"
|
||||
[ -n "$PROJ" ] || die "could not resolve compose project name on $FROM"
|
||||
COMMIT="$(ssh "$FROM" "cd '$SRC_DIR' && git rev-parse --short HEAD 2>/dev/null || echo unknown")"
|
||||
warn "source compose project: $PROJ (git $COMMIT)"
|
||||
|
||||
# volumes to carry (bare suffixes; prefixed with $PROJ on the source)
|
||||
VOLS=(drasl_state kuma_data filestash_state)
|
||||
[ "$WITH_WORLD" -eq 1 ] && VOLS+=(mc_data)
|
||||
|
||||
# Verify every volume exists on the source before we start streaming.
|
||||
for v in "${VOLS[@]}"; do
|
||||
ssh "$FROM" "docker volume inspect '${PROJ}_${v}' >/dev/null 2>&1" \
|
||||
|| die "volume ${PROJ}_${v} not found on $FROM — is the stack the one at $SRC_DIR?"
|
||||
done
|
||||
|
||||
# ── staging dir on the workstation ──────────────────────────────────
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/ulicraft-migrate.XXXXXX")"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# .env
|
||||
log "pulling .env"
|
||||
ssh "$FROM" "cat '$SRC_DIR/.env'" > "$STAGE/.env"
|
||||
[ -s "$STAGE/.env" ] || die ".env came back empty"
|
||||
|
||||
# certs/
|
||||
if [ "$WITH_CERTS" -eq 1 ]; then
|
||||
if ssh "$FROM" "test -d '$SRC_DIR/certs'"; then
|
||||
log "pulling certs/ (Let's Encrypt cert+key per name)"
|
||||
ssh "$FROM" "tar -C '$SRC_DIR' -czf - certs" > "$STAGE/certs.tar.gz"
|
||||
else
|
||||
warn "no certs/ dir on source — skipping (you'll issue certs on the target)"
|
||||
WITH_CERTS=0
|
||||
fi
|
||||
else
|
||||
warn "--without-certs — certs excluded; issue-letsencrypt.sh on the target"
|
||||
fi
|
||||
|
||||
# docker volumes → one gzip'd tar each, streamed over ssh (never staged on source)
|
||||
for v in "${VOLS[@]}"; do
|
||||
log "dumping volume ${PROJ}_${v}"
|
||||
ssh "$FROM" "docker run --rm -v '${PROJ}_${v}':/v alpine tar -C /v -czf - ." \
|
||||
> "$STAGE/vol-${v}.tar.gz"
|
||||
[ -s "$STAGE/vol-${v}.tar.gz" ] || die "volume ${v} dumped empty — aborting"
|
||||
done
|
||||
|
||||
# ── manifest with checksums ─────────────────────────────────────────
|
||||
log "writing MANIFEST.txt"
|
||||
{
|
||||
echo "ulicraft migration bundle"
|
||||
echo "created: $STAMP"
|
||||
echo "source_host: $FROM"
|
||||
echo "source_dir: $SRC_DIR"
|
||||
echo "source_project: $PROJ"
|
||||
echo "git_commit: $COMMIT"
|
||||
echo "with_certs: $WITH_CERTS"
|
||||
echo "with_world: $WITH_WORLD"
|
||||
echo "volumes: ${VOLS[*]}"
|
||||
echo
|
||||
echo "sha256:"
|
||||
( cd "$STAGE" && sha256sum ./.env ./*.tar.gz 2>/dev/null )
|
||||
} > "$STAGE/MANIFEST.txt"
|
||||
|
||||
# ── seal the bundle (0600 — it holds private keys + secrets) ────────
|
||||
OUT="$OUT_DIR/ulicraft-bundle-${FROM}-${STAMP}.tar.gz"
|
||||
log "sealing $OUT"
|
||||
tar -C "$STAGE" -czf "$OUT" .
|
||||
chmod 600 "$OUT"
|
||||
|
||||
SIZE="$(du -h "$OUT" | cut -f1)"
|
||||
log "done — $OUT ($SIZE)"
|
||||
printf ' next: copy it to the target and run tooling/migrate-import.sh %s\n' "$(basename "$OUT")"
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# migrate-import.sh — restore a bundle made by migrate-export.sh onto THIS host.
|
||||
#
|
||||
# Run it from inside the target repo checkout (the compose project directory).
|
||||
# It restores .env, certs/, and the docker volumes into THIS host's compose
|
||||
# project, then prints the manual steps that finish the cutover.
|
||||
#
|
||||
# It assumes the machine already meets the requirements — run
|
||||
# tooling/migrate-check.sh
|
||||
# first. It does NOT deploy the distribution repo, issue certs, or start the
|
||||
# stack; those stay in your hands (see the printed tail + plan/22-migration.md).
|
||||
#
|
||||
# Usage:
|
||||
# tooling/migrate-import.sh BUNDLE.tar.gz
|
||||
# tooling/migrate-import.sh BUNDLE.tar.gz --path-prefix /home/ubuntu/mc=/home/you/mc
|
||||
# tooling/migrate-import.sh BUNDLE.tar.gz --without-certs
|
||||
# tooling/migrate-import.sh BUNDLE.tar.gz --force # overwrite existing volumes
|
||||
#
|
||||
# --path-prefix OLD=NEW rewrites the source-absolute paths in .env
|
||||
# (DISTRIBUTION_WEB_ROOT, GENERATORS_REPO, GENERATORS_DATA_DIR, FILES_DATA_DIR)
|
||||
# to where they live on this host. Repeatable.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # target repo root
|
||||
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# ── args ────────────────────────────────────────────────────────────
|
||||
BUNDLE=""
|
||||
WITH_CERTS=1
|
||||
FORCE=0
|
||||
declare -a PREFIXES=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--without-certs) WITH_CERTS=0; shift ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
--path-prefix) PREFIXES+=("${2:?}"); shift 2 ;;
|
||||
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
|
||||
-*) die "unknown flag: $1 (see --help)" ;;
|
||||
*) [ -z "$BUNDLE" ] || die "extra arg: $1"; BUNDLE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$BUNDLE" ] || die "usage: $0 BUNDLE.tar.gz [--path-prefix OLD=NEW] [--without-certs] [--force]"
|
||||
[ -f "$BUNDLE" ] || die "bundle not found: $BUNDLE"
|
||||
BUNDLE="$(cd "$(dirname "$BUNDLE")" && pwd)/$(basename "$BUNDLE")" # absolutize
|
||||
|
||||
command -v docker >/dev/null || die "docker required"
|
||||
docker info >/dev/null 2>&1 || die "cannot reach the docker daemon"
|
||||
docker compose version >/dev/null 2>&1 || die "docker compose v2 plugin required (see migrate-check.sh)"
|
||||
[ -f docker-compose.yml ] || die "run this from inside the target repo checkout (no docker-compose.yml here)"
|
||||
|
||||
# ── target compose project name (default: this dir's basename) ──────
|
||||
PROJ="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
log "target compose project: $PROJ (in $PWD)"
|
||||
warn "volumes will be restored as ${PROJ}_<name> — this MUST match what 'docker compose' uses here."
|
||||
warn "if that looks wrong, set COMPOSE_PROJECT_NAME in .env and re-run."
|
||||
|
||||
# ── unpack the bundle to a temp staging dir ─────────────────────────
|
||||
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/ulicraft-import.XXXXXX")"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
log "unpacking $(basename "$BUNDLE")"
|
||||
tar -C "$STAGE" -xzf "$BUNDLE"
|
||||
[ -f "$STAGE/MANIFEST.txt" ] || die "no MANIFEST.txt in bundle — is this a ulicraft migration bundle?"
|
||||
|
||||
log "manifest:"
|
||||
sed 's/^/ /' "$STAGE/MANIFEST.txt"
|
||||
|
||||
# ── verify checksums ────────────────────────────────────────────────
|
||||
log "verifying sha256 checksums"
|
||||
( cd "$STAGE" && awk '/^[0-9a-f]{64} /{print}' MANIFEST.txt | sha256sum -c - ) \
|
||||
|| die "checksum mismatch — bundle is corrupt or truncated"
|
||||
|
||||
# volumes present in the bundle
|
||||
mapfile -t VOL_FILES < <(cd "$STAGE" && ls vol-*.tar.gz 2>/dev/null || true)
|
||||
[ "${#VOL_FILES[@]}" -gt 0 ] || die "no volume archives in bundle"
|
||||
|
||||
# ── refuse to clobber existing volumes unless --force ───────────────
|
||||
CLASH=0
|
||||
for f in "${VOL_FILES[@]}"; do
|
||||
name="${f#vol-}"; name="${name%.tar.gz}"
|
||||
if docker volume inspect "${PROJ}_${name}" >/dev/null 2>&1; then
|
||||
warn "volume ${PROJ}_${name} already exists on this host"
|
||||
CLASH=1
|
||||
fi
|
||||
done
|
||||
if [ "$CLASH" -eq 1 ] && [ "$FORCE" -eq 0 ]; then
|
||||
die "existing volumes would be overwritten. Re-run with --force to replace them
|
||||
(their current contents are discarded). If this is the WRONG host, stop now."
|
||||
fi
|
||||
|
||||
# ── restore .env (backup any existing one first) ────────────────────
|
||||
if [ -f .env ]; then
|
||||
BK=".env.bak.pre-migrate.$(date +%Y%m%d-%H%M%S)"
|
||||
warn "existing .env → $BK"
|
||||
cp -a .env "$BK"
|
||||
fi
|
||||
log "restoring .env"
|
||||
cp "$STAGE/.env" .env
|
||||
chmod 600 .env
|
||||
|
||||
# apply --path-prefix rewrites
|
||||
for p in "${PREFIXES[@]}"; do
|
||||
old="${p%%=*}"; new="${p#*=}"
|
||||
[ -n "$old" ] && [ "$old" != "$p" ] || die "bad --path-prefix '$p' (want OLD=NEW)"
|
||||
log "rewriting .env paths: $old -> $new"
|
||||
# only touch the RHS of KEY=... lines; escape / for sed
|
||||
esc_old="$(printf '%s' "$old" | sed 's/[.[\*^$/]/\\&/g')"
|
||||
esc_new="$(printf '%s' "$new" | sed 's/[&/]/\\&/g')"
|
||||
sed -i "s/${esc_old}/${esc_new}/g" .env
|
||||
done
|
||||
|
||||
# ── sanity-check the host-path vars in .env resolve on THIS host ─────
|
||||
log "checking host paths referenced by .env"
|
||||
set -a; . ./.env; set +a
|
||||
MISSING=0
|
||||
for var in DISTRIBUTION_WEB_ROOT GENERATORS_REPO GENERATORS_DATA_DIR FILES_DATA_DIR; do
|
||||
val="${!var:-}"
|
||||
[ -n "$val" ] || { warn "$var unset in .env"; continue; }
|
||||
if [ -e "$val" ]; then
|
||||
printf ' \033[1;32m✓\033[0m %s = %s\n' "$var" "$val"
|
||||
else
|
||||
printf ' \033[1;33m!\033[0m %s = %s (does NOT exist here)\n' "$var" "$val"
|
||||
MISSING=$((MISSING+1))
|
||||
fi
|
||||
done
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
warn "$MISSING path(s) above don't exist on this host yet."
|
||||
warn "DISTRIBUTION_WEB_ROOT especially: the minecraft service bind-mounts kubejs"
|
||||
warn "from it and WON'T BOOT until it's populated. Deploy the distribution repo"
|
||||
warn "(and re-run with --path-prefix if the paths belong elsewhere) before starting."
|
||||
fi
|
||||
|
||||
# ── restore certs/ ──────────────────────────────────────────────────
|
||||
if [ "$WITH_CERTS" -eq 1 ] && [ -f "$STAGE/certs.tar.gz" ]; then
|
||||
log "restoring certs/"
|
||||
[ -d certs ] && warn "existing certs/ will be merged/overwritten from the bundle"
|
||||
tar -C . -xzf "$STAGE/certs.tar.gz"
|
||||
elif [ "$WITH_CERTS" -eq 0 ]; then
|
||||
warn "--without-certs — not restoring certs; run tooling/issue-letsencrypt.sh here"
|
||||
else
|
||||
warn "bundle has no certs.tar.gz — run tooling/issue-letsencrypt.sh here"
|
||||
fi
|
||||
|
||||
# ── restore the docker volumes ──────────────────────────────────────
|
||||
for f in "${VOL_FILES[@]}"; do
|
||||
name="${f#vol-}"; name="${name%.tar.gz}"
|
||||
vol="${PROJ}_${name}"
|
||||
log "restoring volume $vol"
|
||||
docker volume create "$vol" >/dev/null
|
||||
if [ "$FORCE" -eq 1 ]; then
|
||||
# wipe first so a --force restore is a clean replace, not a merge
|
||||
docker run --rm -v "$vol":/v alpine sh -c 'rm -rf /v/* /v/..?* /v/.[!.]* 2>/dev/null || true'
|
||||
fi
|
||||
docker run --rm -i -v "$vol":/v alpine tar -C /v -xzf - < "$STAGE/$f"
|
||||
done
|
||||
|
||||
# ── done: print the manual tail ─────────────────────────────────────
|
||||
cat <<EOF
|
||||
|
||||
$(printf '\033[1;32m==>\033[0m volumes + .env + certs restored.')
|
||||
|
||||
Remaining manual steps (see plan/22-migration.md for detail):
|
||||
|
||||
1. Deploy the distribution repo so DISTRIBUTION_WEB_ROOT is populated
|
||||
(the minecraft service bind-mounts kubejs from it; it won't boot empty).
|
||||
Also add this host's Gitea deploy key for that repo.
|
||||
2. Fetch vendored bits: tooling/fetch-authlib.sh tooling/fetch-launcher.sh
|
||||
and (needs WAN) tooling/sync-generators-data.sh
|
||||
3. Host nginx vhost: tooling/render-nginx.sh --install
|
||||
4. Certificates: tooling/issue-letsencrypt.sh
|
||||
(bundle certs boot nginx now, but nothing renews them until you run this)
|
||||
5. Open the firewall: tcp 25565, 80, 443, ${CADDY_HTTP_PORT:-8880} ; udp 24454
|
||||
6. Point DNS ${BASE_DOMAIN:-\$BASE_DOMAIN} + subdomains at THIS host.
|
||||
7. Bring it up: ./update-all.sh --hard
|
||||
8. Re-op players once they've joined: docker compose exec minecraft rcon-cli op <name>
|
||||
|
||||
Fresh-world gotcha: if minecraft crash-loops on a fresh mc_data with
|
||||
"AccessDeniedException: /data/kubejs/config" (ConsoleJS.STARTUP null), Docker
|
||||
created /data/kubejs as root while the container runs as uid 1000. Fix once:
|
||||
docker compose stop minecraft
|
||||
docker run --rm -v ${PROJ}_mc_data:/data alpine chown 1000:1000 /data/kubejs
|
||||
docker compose up -d minecraft
|
||||
Details: plan/22-migration.md (Troubleshooting).
|
||||
|
||||
Rollback note: same domain means DNS points at ONE host. Once a player registers
|
||||
or changes a skin here, this drasl.db diverges from cochi — rolling back to cochi
|
||||
silently loses those changes. Keep the fallback window short.
|
||||
EOF
|
||||
@@ -29,15 +29,15 @@ render() { # $1=template $2=output
|
||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||
|
||||
# ── filestash (files.${BASE_DOMAIN}) ────────────────────────────────────
|
||||
# ── filestash (upload.${BASE_DOMAIN}) ───────────────────────────────────
|
||||
# Filestash has NO env-var config: config.json is the source of truth and the
|
||||
# admin console writes it back at runtime. We render it and mount it read-only,
|
||||
# so git stays authoritative (the console renders but cannot save — by design).
|
||||
#
|
||||
# general.host in the template is a BARE HOSTNAME (files.$BASE_DOMAIN, no
|
||||
# general.host in the template is a BARE HOSTNAME (upload.$BASE_DOMAIN, no
|
||||
# scheme) and general.force_ssl is true. The SPA builds its redirect origin as
|
||||
# (force_ssl ? "https://" : "http://") + host, so a scheme in `host` produces
|
||||
# "http://https://files..." and every client-side redirect breaks. The
|
||||
# "http://https://upload..." and every client-side redirect breaks. The
|
||||
# server-side Host check strips the scheme first, so curl/monitors see a healthy
|
||||
# 200 and ONLY browsers fail. Don't "fix" it by putting the URL back.
|
||||
#
|
||||
@@ -72,7 +72,7 @@ case "$FILES_AUTH" in
|
||||
# NO LOGIN — anyone who reaches the vhost gets in. Only safe once this host
|
||||
# is NOT reachable from the internet (see plan/20-files.md).
|
||||
echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2
|
||||
echo "!! Only valid if files.${BASE_DOMAIN} is unreachable from the internet." >&2
|
||||
echo "!! Only valid if upload.${BASE_DOMAIN} is unreachable from the internet." >&2
|
||||
FILES_IDP_TYPE="passthrough"
|
||||
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
|
||||
;;
|
||||
|
||||
+51
-2
@@ -17,6 +17,14 @@
|
||||
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
|
||||
# — defaults to the repo root (this checkout)
|
||||
# SITE_NAME installed filename (default ulicraft.conf)
|
||||
# FILES_BROWSE on|off (default on) — emit the files.${BASE_DOMAIN}
|
||||
# read-only browse vhost. `off` omits the server block AND
|
||||
# the name from the port-80 redirect, so the host serves
|
||||
# nothing for it (no cert needed either). Same var gates the
|
||||
# caddy side in docker-compose.yml.
|
||||
# MONITOR_ENABLED on|off (default on) — emit the monitor.${BASE_DOMAIN}
|
||||
# admin log-stream vhost. Same on/off semantics as
|
||||
# FILES_BROWSE, same var gates the caddy side.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
@@ -40,7 +48,48 @@ SITE_NAME="${SITE_NAME:-ulicraft.conf}"
|
||||
export BASE_DOMAIN APP_DIR
|
||||
export CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-80}"
|
||||
|
||||
rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT' < "$TEMPLATE")"
|
||||
# files.${BASE_DOMAIN} — the no-auth read-only browse. Per-host toggle: on for
|
||||
# the LAN-party box, off where the share must not be publicly listable. nginx
|
||||
# has no conditionals, so the vhost is a separate template spliced in here (or
|
||||
# replaced by a comment). Keep in sync with the caddy mount in docker-compose.yml,
|
||||
# which reads the same var.
|
||||
: "${FILES_BROWSE:=on}"
|
||||
FILES_BROWSE_TEMPLATE="${FILES_BROWSE_TEMPLATE:-nginx/files-browse.server.tmpl}"
|
||||
case "$FILES_BROWSE" in
|
||||
on)
|
||||
[ -f "$FILES_BROWSE_TEMPLATE" ] || die "template not found: $FILES_BROWSE_TEMPLATE"
|
||||
FILES_BROWSE_NAME=" files.${BASE_DOMAIN}"
|
||||
FILES_BROWSE_SERVER="$(envsubst '$BASE_DOMAIN $APP_DIR' < "$FILES_BROWSE_TEMPLATE")"
|
||||
;;
|
||||
off)
|
||||
FILES_BROWSE_NAME=""
|
||||
FILES_BROWSE_SERVER="# files.${BASE_DOMAIN} disabled on this host (FILES_BROWSE=off in .env)."
|
||||
;;
|
||||
*) die "FILES_BROWSE must be 'on' or 'off' (got '$FILES_BROWSE')" ;;
|
||||
esac
|
||||
export FILES_BROWSE_NAME FILES_BROWSE_SERVER
|
||||
|
||||
# monitor.${BASE_DOMAIN} — admin-only log stream (plan/23-monitor.md). Same
|
||||
# splice-or-comment trick as FILES_BROWSE above, and the same var gates the
|
||||
# caddy conf mount in docker-compose.yml. Turning it off here is what removes
|
||||
# the public name entirely; the container itself publishes no port either way.
|
||||
: "${MONITOR_ENABLED:=on}"
|
||||
MONITOR_TEMPLATE="${MONITOR_TEMPLATE:-nginx/monitor.server.tmpl}"
|
||||
case "$MONITOR_ENABLED" in
|
||||
on)
|
||||
[ -f "$MONITOR_TEMPLATE" ] || die "template not found: $MONITOR_TEMPLATE"
|
||||
MONITOR_NAME=" monitor.${BASE_DOMAIN}"
|
||||
MONITOR_SERVER="$(envsubst '$BASE_DOMAIN $APP_DIR' < "$MONITOR_TEMPLATE")"
|
||||
;;
|
||||
off)
|
||||
MONITOR_NAME=""
|
||||
MONITOR_SERVER="# monitor.${BASE_DOMAIN} disabled on this host (MONITOR_ENABLED=off in .env)."
|
||||
;;
|
||||
*) die "MONITOR_ENABLED must be 'on' or 'off' (got '$MONITOR_ENABLED')" ;;
|
||||
esac
|
||||
export MONITOR_NAME MONITOR_SERVER
|
||||
|
||||
rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT $FILES_BROWSE_NAME $FILES_BROWSE_SERVER $MONITOR_NAME $MONITOR_SERVER' < "$TEMPLATE")"
|
||||
|
||||
if [ "${1:-}" != "--install" ]; then
|
||||
printf '%s\n' "$rendered"
|
||||
@@ -52,7 +101,7 @@ command -v nginx >/dev/null 2>&1 || die "nginx not found on PATH"
|
||||
avail="/etc/nginx/sites-available/$SITE_NAME"
|
||||
enabled="/etc/nginx/sites-enabled/$SITE_NAME"
|
||||
|
||||
log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT)"
|
||||
log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT, FILES_BROWSE=$FILES_BROWSE, MONITOR_ENABLED=$MONITOR_ENABLED)"
|
||||
printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null
|
||||
sudo ln -sf "$avail" "$enabled"
|
||||
sudo nginx -t
|
||||
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-generators-data.sh — mirror every remote data source the generators app
|
||||
# reads, so generators.${BASE_DOMAIN} works with ZERO internet at party time.
|
||||
#
|
||||
# The generators app (a pinned fork of misode.github.io) is 100% CLIENT-SIDE:
|
||||
# every data fetch comes from the player's browser, not from this host. So
|
||||
# "cochi has no WAN" protects nothing on its own — the URLs themselves have to
|
||||
# point at our own origin. The fork rewrites two constants:
|
||||
#
|
||||
# DataFetcher.ts mcmetaUrl = '/mcmeta'
|
||||
# vanillaMcdocUrl = '/vanilla-mcdoc'
|
||||
#
|
||||
# and it builds paths as `${mcmetaUrl}/${ref}-${type}/...`, so the directory
|
||||
# layout below is not arbitrary — it mirrors upstream's URL shape exactly,
|
||||
# which is what keeps the fork diff to two lines instead of a rewrite.
|
||||
#
|
||||
# Serves at:
|
||||
# /mcmeta/<ref>-{summary,data,atlas,assets}/... per-version snapshots
|
||||
# /mcmeta/summary/versions/data.min.json dynamic version list
|
||||
# /vanilla-mcdoc/generated/symbols.json THE schema source
|
||||
#
|
||||
# mcmeta publishes per-version snapshots as TAGS, not branches (~4000 of them),
|
||||
# hence `--branch <tag> --depth 1` below.
|
||||
#
|
||||
# Needs WAN. Run deliberately, before the party — never at the venue.
|
||||
# Output dir is gitignored; only this script is tracked.
|
||||
#
|
||||
# Cautious by design: halts on any error, verifies each expected file exists
|
||||
# rather than trusting a clean exit, and refuses to leave a half-synced tree in
|
||||
# place. Idempotent — re-runs replace each source atomically.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v git >/dev/null || die "git required"
|
||||
|
||||
# The MC version the server runs. mcmeta tags are <ref>-<type>; the generators
|
||||
# UI is locked to the config.json entry {id: '1.21', ref: '1.21.1'}, so this
|
||||
# MUST stay in step with that ref and with NEOFORGE/VERSION in docker-compose.
|
||||
MC_REF="${MC_REF:-1.21.1}"
|
||||
|
||||
OUT="${GENERATORS_DATA_DIR:-generators-data}"
|
||||
MCMETA_REPO="https://github.com/misode/mcmeta.git"
|
||||
MCDOC_REPO="https://github.com/SpyglassMC/vanilla-mcdoc.git"
|
||||
|
||||
# Each entry: <mcmeta tag>:<directory we serve it as>.
|
||||
#
|
||||
# assets-tiny is assets MINUS the ~hundreds of MB of .ogg sound files. We serve
|
||||
# it AT the `-assets` path because that is what the app requests (lang files,
|
||||
# textures, models all live there). Consequence: sound PLAYBACK 404s, which is
|
||||
# why the fork deletes the sounds generator from the UI. If you ever re-add it,
|
||||
# swap this to the full `-assets` tag and re-measure the disk cost first.
|
||||
SOURCES=(
|
||||
"${MC_REF}-summary:${MC_REF}-summary"
|
||||
"${MC_REF}-data:${MC_REF}-data"
|
||||
"${MC_REF}-atlas:${MC_REF}-atlas"
|
||||
"${MC_REF}-assets-tiny:${MC_REF}-assets"
|
||||
)
|
||||
|
||||
# One file per source that MUST exist afterwards. A clean `git clone` exit does
|
||||
# not prove the tag held what the app needs — a renamed path upstream would
|
||||
# sync "successfully" and then die in the browser, offline, at the party.
|
||||
declare -A SENTINEL=(
|
||||
["${MC_REF}-summary"]="registries/data.min.json"
|
||||
["${MC_REF}-data"]="data/minecraft/worldgen/biome/plains.json"
|
||||
["${MC_REF}-atlas"]="all/atlas.png"
|
||||
["${MC_REF}-assets"]="assets/minecraft/lang/en_us.json"
|
||||
)
|
||||
|
||||
# Shallow-clone one ref into a temp dir, verify its sentinel, then swap it in.
|
||||
# Never deletes the live copy until the new one has been verified.
|
||||
sync_ref() {
|
||||
local repo="$1" ref="$2" dest="$3" sentinel="$4"
|
||||
local tmp="${dest}.tmp.$$"
|
||||
|
||||
rm -rf "$tmp"
|
||||
git clone -q --depth 1 --branch "$ref" "$repo" "$tmp" \
|
||||
|| die "clone failed: $repo @ $ref"
|
||||
rm -rf "$tmp/.git"
|
||||
|
||||
[ -f "$tmp/$sentinel" ] || {
|
||||
rm -rf "$tmp"
|
||||
die "$ref synced but '$sentinel' is missing — upstream layout changed.
|
||||
Refusing to install a tree the app cannot read. Investigate before retrying."
|
||||
}
|
||||
|
||||
rm -rf "$dest"
|
||||
mv "$tmp" "$dest"
|
||||
printf ' %s -> %s (%s)\n' "$ref" "$dest" "$(du -sh "$dest" | cut -f1)"
|
||||
}
|
||||
|
||||
log "mirroring generators data for MC ${MC_REF} -> ${OUT}/"
|
||||
mkdir -p "$OUT/mcmeta"
|
||||
|
||||
for entry in "${SOURCES[@]}"; do
|
||||
tag="${entry%%:*}"
|
||||
dir="${entry##*:}"
|
||||
log "mcmeta tag ${tag}"
|
||||
sync_ref "$MCMETA_REPO" "$tag" "$OUT/mcmeta/$dir" "${SENTINEL[$dir]}"
|
||||
done
|
||||
|
||||
# The dynamic version list. NOT a version tag — DataFetcher.ts:205 fetches it
|
||||
# from the live `summary` BRANCH head, and it runs during Spyglass init. Miss
|
||||
# this one and the app dead-ends offline while looking perfectly healthy online.
|
||||
# It is a single file, so fetch it directly rather than cloning the whole
|
||||
# branch (which is another ~31M of the LATEST version's summary, all unused).
|
||||
log "mcmeta branch summary (versions/data.min.json only)"
|
||||
command -v curl >/dev/null || die "curl required"
|
||||
mkdir -p "$OUT/mcmeta/summary/versions"
|
||||
curl -fsSL "https://raw.githubusercontent.com/misode/mcmeta/summary/versions/data.min.json" \
|
||||
-o "$OUT/mcmeta/summary/versions/data.min.json.tmp" \
|
||||
|| die "failed to fetch summary/versions/data.min.json"
|
||||
# Verify it parses as JSON before installing it — a captive-portal HTML error
|
||||
# page would download with a clean exit code and break Spyglass init offline.
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" \
|
||||
"$OUT/mcmeta/summary/versions/data.min.json.tmp" \
|
||||
|| die "summary/versions/data.min.json is not valid JSON — refusing to install it"
|
||||
mv "$OUT/mcmeta/summary/versions/data.min.json.tmp" \
|
||||
"$OUT/mcmeta/summary/versions/data.min.json"
|
||||
printf ' summary/versions/data.min.json (%s)\n' \
|
||||
"$(du -h "$OUT/mcmeta/summary/versions/data.min.json" | cut -f1)"
|
||||
|
||||
# The schema source. Without it there are no schemas, no validation, no
|
||||
# generator — this is the single most load-bearing file in the mirror.
|
||||
#
|
||||
# The app requests /vanilla-mcdoc/generated/symbols.json. In the upstream URL
|
||||
# that decomposes as <repo>/<ref>/<path> = vanilla-mcdoc / branch `generated` /
|
||||
# `symbols.json` — `generated` is a BRANCH NAME, not a directory, and
|
||||
# symbols.json sits at the branch root. So the clone lands in a directory named
|
||||
# after the branch to reproduce the same URL shape locally.
|
||||
log "vanilla-mcdoc branch generated"
|
||||
sync_ref "$MCDOC_REPO" "generated" "$OUT/vanilla-mcdoc/generated" "symbols.json"
|
||||
|
||||
log "done — $(du -sh "$OUT" | cut -f1) in ${OUT}/"
|
||||
echo " mount it read-only into the generators container (docker-compose.yml)"
|
||||
echo " verify offline BEFORE the party: block all non-origin requests in"
|
||||
echo " DevTools and walk the generators you actually intend to use."
|
||||
Reference in new issue
Block a user