Compare commits

..
30 Commits
Author SHA1 Message Date
oierandClaude Opus 5.5 f4bd786140 chore(uptime-kuma): bump image to :2
The :1 image runs Node 18 (EOL), which gets no fix for the October 2026
Node.js security release. :2 migrates kuma.db in place on first start;
back up kuma_data per host before pulling (see plan/10-uptime-kuma.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:33:37 +01:00
oierandClaude Opus 5 d457713186 chore(mods): classify owo-lib as both
Dependency library declaring side=BOTH; keep server-side so any
server-loaded dependent doesn't crash on a missing lib.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAvxmMjspjDw7Fkkmy86EN
2026-09-12 17:00:04 +02:00
oierandClaude Opus 5 556f86f852 chore(mods): classify 2 new distribution jars as both
craftabletotemofundying (recipe addition) and friendsforlife (pet QoL)
both declare side=BOTH and need server-side loading. Pack now 192 jars,
160 server-side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAvxmMjspjDw7Fkkmy86EN
2026-09-12 16:50:39 +02:00
oierandClaude Opus 5 6b9b8bd96c chore(mods): bump sodium + supplementaries jar names
Distribution updated sodium 0.8.12-alpha.4 → 0.8.13 and
supplementaries 3.5.26 → 3.7.7. Rename the mods-sides.json keys so
sync-server-mods.sh doesn't halt on the missing old jars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6PydPB5dvyQ7wxrzS7JvQ
2026-09-06 15:41:04 +02:00
oierandClaude Opus 5 fe91bf7778 chore(mods): resync mods-sides.json with 2026-09-06 distribution
The distro release dropped craftingstation and added create_jet_boots +
flightapi, but mods-sides.json still listed craftingstation as `both`.
sync-server-mods.sh halts on a selected jar missing from the source, so
update-all.sh would have failed — and under --hard that lands after
`docker compose down`, leaving the stack down.

classify-mods.py also picked up sawmill and supplementaries, which had no
entry at all and were therefore never synced to the server. All four new
entries default to `both`.

mods-sides.json entries now 190 = jars in distribution 190.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Wz4tizJzjivT4FhdsqAMb
2026-09-06 15:05:52 +02:00
oierandClaude Opus 5 0176a674e3 fix(tls): absolutise CERT_DIR so cron renewals actually install
acme.sh persists the --install-cert paths and replays them on every cron
renewal, where cwd is $HOME rather than the repo. The relative default
resolved to ~/certs/... so renewals wrote nothing: acme.sh kept renewing
cleanly while nginx served the stale copy until it expired. Apex, auth. and
pack. served expired certs on 2026-09-06.

Absolutise CERT_DIR in issue-letsencrypt.sh and document the failure mode,
the recovery command, and off-host verification in plan/15-letsencrypt.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UJzZrBaZUoU6qHMLpLz64
2026-09-06 00:36:23 +02:00
oier 671e6af05a doc: ip ban command 2026-08-15 13:03:52 +02:00
oierandClaude Opus 5 fbed9a5af7 chore(mods): classify 4 new distribution jars as both
craftingstation, dirigo, mechtrowel and smartfeedingtroughs landed in the
cochi distribution but were never classified, so sync-server-mods.sh left
the server on the old 151-jar set while clients pulled 155. All four
declare BOTH-side deps in neoforge.mods.toml and register blocks/items,
so the server needs them or joining clients hit a registry mismatch.

cochi is Python 3.10 (no tomllib), so classify-mods.py can't run there —
the tomls were read directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:37:52 +02:00
oier 40fb50e86f Cosas 2026-08-02 06:53:09 +02:00
oierandClaude Opus 5 d105fc9acf docs(monitor): document join/leave browser notifications
Records why it is browser-only rather than chat push (a push channel needs a CA
bundle in the scratch image, a token, and outbound egress this container does
not have), why newness is a localStorage seen-set keyed on the log timestamp
rather than a server-side sequence number (the counter resets on restart, the
timestamp does not), and the unconditional-baseline subtlety.

Also states plainly what the node harness does NOT cover: the real permission
prompt and actual notification rendering are stubbed, so those are untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 03:39:47 +02:00
oierandClaude Opus 5 3eb20054a0 feat(monitor): opt-in browser notifications for player join/leave
A bell toggle in the header. Rides the same sessions[] the "Recent joins" tile
already renders — no second data source and no re-parsing of the log in JS.
UI-only: no server change, no new egress, no secret, and the container still
reaches nothing but mcnet.

The hard part is not notifying for history, which arrives two ways: the events
buffer is already populated when the page loads, and the monitor's cold-start
backfill re-reads 5000 log lines on every deploy, repopulating the ring with the
SAME events. So newness is a seen-set in localStorage keyed on the log's own
timestamp (time|player|action). That key is stable across both a page reload and
a container restart, where a server-side sequence number would reset to zero and
make every historical event look new.

One subtle ordering bug worth calling out, caught while writing the tests: the
first-response baseline flag must flip unconditionally, not only when fresh
events were found. A returning user has everything already 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.

Notifications are opt-in and off by default; permission is requested from the
bell click (a user gesture), never on load. A denied permission shows a distinct
blocked state, because "I enabled it and nothing happened" is otherwise
indistinguishable from a bug. Bursts (>3 in one poll, e.g. everyone rejoining
after a restart) collapse to one "N joined, M left". Summary polling tightens
from 10s to 5s only while enabled, since the poll interval is the alert latency.

Also fixes a bug this would have introduced: the level-chip click handler bound
to every .chip, which now includes the bell. Scoped to .chip[data-level].

Verified by extracting the shipped block and running it against stubs: silent
baseline, new event notifies, repolling silent, restart-replay silent (both
same-instance and after reload), burst coalescing, the returning-user edge case,
toggle-off still tracks (no backlog when re-enabled), denied permission silent,
and the seen-set staying bounded at 500 newest keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 03:38:33 +02:00
oierandClaude Opus 5 bac2372c95 docs: fold the monitor service into the stack-level docs
plan/23-monitor.md already covered the design; this makes the monitor
discoverable from the places someone actually starts reading.

- README: stack table, vhost list, DNS names, layout, and a setup section
  covering the bcrypt credential, the no-published-port model, and the
  --hard-on-first-deploy requirement.
- plan/00-overview.md: drop (PLANNED), add to the always-on service list, and
  note that it starts fine with Minecraft down (missing log and refused RCON
  are reported states, not fatal).
- plan/19-routes.md: vhost row, the four GET routes with their contracts, and
  the internal mcnet edges (mc-status for rosters so it needs no Drasl creds,
  RCON for TPS, Kuma probing healthz around caddy).
- caddy/Caddyfile: the snippet index had drifted — it listed five of nine
  vhosts. Added the four missing ones.

Two gotchas promoted to CLAUDE.md because they cost real time and will recur:

- Compose interpolates $-sequences in .env VALUES, so a bare bcrypt hash is
  silently corrupted before reaching the container, and the same bug bites
  again when writing one to a remote .env over ssh. Also records that caddy
  and filestash want opposite hash formats.
- The monitor container runs as root deliberately: /data is drwxr-x--- owned
  by uid 1000, so a USER directive would make the tailer silently read nothing
  while still returning 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:30:42 +02:00
oierandClaude Opus 5 bc99427ef9 docs(monitor): record phases 2+3 as deployed, with prod verification
Rolling deploy — only the monitor container was recreated, Minecraft was never
restarted, so no world downtime.

Records what was verified against real data rather than assumptions: the TPS
parse and RCON allowlist, the session parser against actual rotated-log join
lines, the level-regex fix (WARN/ERROR now distinguished where every line
previously read INFO), collector isolation with mc-status killed, and the
concurrent-refresh latency win.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:17:56 +02:00
oierandClaude Opus 5 00984e1a70 feat(monitor): dashboard tiles + TPS via allowlisted RCON
Phases 2 and 3 of plan/23-monitor.md. /api/summary adds: server online/MOTD/
version, players online with NMSR heads, registered-account count, disk free on
the world volume, backup freshness, join/leave history, and TPS.

Each tile has its own TTL cache and its own failure mode. A failed collector
keeps serving its last good value and reports the reason in a sibling *_err
field, so the page goes stale-with-a-warning rather than blank. The five
collectors refresh concurrently: sequentially, one request during an outage
would cost the sum of every timeout (~15s), and an outage is exactly when
someone is looking at this page. Measured with a dead dependency: 0.24s.

TPS needs RCON, so the monitor now holds RCON_PASSWORD. Read-only survives
because the allowlist is a compile-time constant and no HTTP input reaches a
command string — the UI cannot run commands because the server cannot be asked
to. The Source RCON client is implemented in-repo rather than added as a
dependency precisely to keep that property local and auditable.

Two things the real server corrected:

- `neoforge tps` prints "Overall: 20.000 TPS (0.357 ms/tick)", not the older
  Forge "Mean tick time / Mean TPS" wording the plan assumed. main_test.go
  pins the real captured output so a format change fails a test instead of
  silently zeroing the tile.

- Rosters come from mc-status, which already holds the Drasl admin credentials
  and exposes a sanitized list, so this container does not get a second copy.

Also fixes a phase-1 bug: the UI's level regex assumed itzg's console timestamp
([12:34:56]) but the file uses [01Aug2026 22:55:34.358], so every line was
classified INFO and the ERROR/WARN chips did nothing. It now matches on shape.

The container runs as root deliberately — /data is drwxr-x--- owned by uid 1000,
so a non-root user cannot traverse it and the tailer would silently read
nothing. The :ro mounts and absent port are what bound the damage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:16:11 +02:00
oierandClaude Opus 5 53604a6127 docs(monitor): record phase 1 as deployed + prod verification results
Phase 1 is live on cochi. The deploy exercised the rotation path on its own —
monitor came up against the pre-restart latest.log, Minecraft created a new
one, tailer reported rotations:1 and kept streaming — which was the single
failure this design was most likely to hit.

Also records the one known cosmetic gap: a delete-then-recreate rotation takes
the "file was gone" branch, so the rotations counter does not tick. Streaming
is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:56:26 +02:00
oierandClaude Opus 5 fca6747bcc feat(monitor): admin-only latest.log stream at monitor.
Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft
server's latest.log into an in-memory ring buffer and serves it to an embedded
single-page UI (autoscroll, substring/regex filter, level chips).

Read-only by construction — /data is mounted :ro, there is no mutating route,
and the container holds no credentials. It publishes NO port: caddy's
basic_auth vhost is the only way in, which is the point, since the service has
no auth of its own and the log carries player IPs, chat and admin commands.

Two non-obvious choices, both to avoid prod-only failures:

- Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers
  proxied responses by default, so an EventSource would stall on cochi (whose
  vhost is an untracked per-host file) and not reproduce locally. Delay was
  accepted at design time, so polling is strictly better.

- The cursor is a sequence number, not a byte offset, and the tailer reopens on
  inode change or size shrink. itzg rotates latest.log on every server restart,
  i.e. every deploy — a naive tailer goes silent afterwards while still
  answering HTTP 200. Verified against rotate, truncate, delete/recreate,
  partial-line and ring-overflow cases.

MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE.

MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose
interpolates $-sequences in .env values, so a bare hash is silently mangled
($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why.
Confirmed empirically, and it is the opposite of filestash, which needs $6$.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:51:39 +02:00
oierandClaude Opus 5 8507ffd020 feat(files): gate files. browse behind FILES_BROWSE (on|off)
Per-host toggle for the read-only, no-auth browse of the shared upload dir,
so it can stay on for the LAN-party host (oxps) and be off on the
internet-facing one (cochi). Defaults to `on` — existing hosts unchanged.

Neither layer has conditionals, so each gets the value substituted in:
- caddy: docker-compose mounts 62-files.${FILES_BROWSE:-on}.caddy at the
  fixed conf.d target (62-files.caddy -> 62-files.on.caddy, plus a
  comments-only 62-files.off.caddy stub — caddy needs a parseable file
  there). Same trick as FILES_MOUNT_MODE.
- nginx: render-nginx.sh splices nginx/files-browse.server.tmpl into
  ${FILES_BROWSE_SERVER} and adds/omits the name in the port-80 redirect.
  This is the gate that matters; caddy is bound to 127.0.0.1.

Applying a change needs render-nginx.sh --install plus a caddy
--force-recreate (single-file bind mount pins the inode). With `off`,
drop `files` from LE_SUBDOMAINS on that host. upload. is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:08:33 +02:00
oierandClaude Opus 5 92c6f5fc8f feat(compose): mount mods, config and kubejs writable
Drop :ro from ./server-mods:/mods and the four kubejs source subdirs, and
add a new ./server-config:/data/config bind — mod config used to live only
inside the mc_data volume, unreachable from the host without root.

The config bind needs seeding: a bind mount shadows what is under it, so an
empty ./server-config hides the generated config and every mod falls back to
defaults. tooling/export-server-config.sh copies /data/config out of the
volume first, halting if the target is non-empty, if the container is still
running, or if the mc_data volume name is ambiguous.

Writable mounts also need the server's uid (itzg runs as 1000) to have write
access on the host dir; oxps' host user is 1002, hence MC_UID/MC_GID.

The kubejs ROOT still must not be mounted — BaseProperties.save() NPEs and
kubejs never initialises. That is about mounting the root at all, not about
the mode, so dropping :ro does not change it. It does mean kubejs writes now
land in $DISTRIBUTION_WEB_ROOT as uncommitted changes in that host's checkout.

Also documents how to reach the mc_data volume (exec/cp, resolved host path,
stop-before-write rule) in plan/05-minecraft.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:46:30 +02:00
oier dfb8719074 enable command blocks 2026-07-25 16:56:49 +02:00
oierandClaude Opus 5 a3c97af374 chore(mods): drop superseded architectury/balm/defaultoptions + chat_heads keys
The distribution replaced architectury 13.0.8 -> 13.0.11, balm 21.0.58 ->
21.0.63 and defaultoptions 21.1.6 -> 21.1.7 (both versions of each were
briefly on disk, so both got classified and both were synced into /mods),
and dropped chat_heads. Removing the stale keys — sync-server-mods refuses
to run while a selected jar is missing from the distribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 23:20:21 +02:00
oierandClaude Opus 5 5e5367e6d5 chore(mods): ftb-library 2101.1.33, add nirvana_lib + common-networking
Distribution bumped ftb-library to 2101.1.33 (satisfies ulicraftmod's
[2101.1.33,) requirement) and added Nirvana Lib + its Common Networking
dependency. Drops build_dim, removed from the distribution.

All three new jars declare side BOTH in neoforge.mods.toml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 22:47:46 +02:00
oierandClaude Opus 5 95f97a3b75 chore(mods): classify cupboard/collective, bump ftb-library key to 2101.1.32
Distribution added the two missing hard deps (cupboard for lootintegrations,
collective for starterkit) and replaced ftb-library 2101.1.31 with 2101.1.32.
Drops the 2101.1.31 key so sync-server-mods stops halting on the absent jar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 22:10:48 +02:00
oierandClaude Opus 5 e831d8b9f3 chore(mods): classify 23 new distribution jars, drop 3 stale entries
Distribution grew to 186 required jars (Create/Ars addons, YUNG's loot
integrations, easy_npc, ulicraftmod 1.1.3). classify-mods.py seeds the 23
new keys (3 client, 20 both).

Drops PuzzlesLib-v21.1.51 and ulicraftmod-1.0.0 (superseded versions still
sitting on the oxps dist disk — keeping them mapped `both` would sync two
copies of the same mod id into /mods) and iris (moved to forgemods/optionaloff).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 22:04:33 +02:00
oierandClaude Opus 5 6b317d179a feat(landing): link files. share from nav + hero
Read-only file browse was only reachable by typing the URL. Add
site.filesUrl (files.${BASE_DOMAIN}) plus nav/hero labels in en/es/eu,
and surface it as a nav link and a secondary hero CTA beside "Cómo entrar".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:01:32 +02:00
oierandClaude Opus 4.8 16523e4f62 feat(files): move filestash to upload., serve files. as read-only static browse
Filestash cannot be served under a subpath (its SPA hardcodes /api, /assets,
/login at root), so it moves to its own subdomain upload.${BASE_DOMAIN}. The
apex-level ask was a static, no-auth file browser at files.${BASE_DOMAIN}: caddy
now file_server browse's the same ${FILES_DATA_DIR} share read-only there.

- filestash general.host -> upload.; nginx upload. vhost keeps the 2g body +
  upload streaming + /admin throttle; new files. vhost is a plain static proxy.
- caddy: 60-files.caddy split into 60-upload.caddy (filestash) + 62-files.caddy
  (file_server browse of /srv/files); caddy mounts FILES_DATA_DIR :ro.
- LE_SUBDOMAINS += upload (both upload. and files. are vhosts needing certs).

Security: files. is unconditionally public read (no auth), independent of
FILES_AUTH. The htpasswd login only gates writes via upload. Documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:03:33 +02:00
oierandClaude Opus 4.8 243787c530 feat(files): run filestash as a chosen uid so share files are host-owned
Add an env-driven `user:`/`group_add` to the filestash service plus
tooling/filestash-set-run-user.sh. Running the container as a real host
account (oxps: oier=1002) makes every file it writes into the share owned
by that account, so scp/ssh and the web UI share one owner — no chown dance.

Defaults FILES_UID/FILES_GID=1000, leaving cochi (image's own uid) untouched.
group_add 1000 keeps the 0730 image binary + 0770 /app/data usable under any
uid; the helper also chowns the filestash_state volume (1000:1000) so a
non-1000 uid doesn't crash-loop unable to write its own sqlite/config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:25:03 +02:00
oierandClaude Opus 4.8 9322e39a8c docs(files): oxps runs filestash auth-off (LAN-only)
Two prod hosts now diverge on filestash auth: cochi stays htpasswd
(internet-facing), oxps runs FILES_AUTH=none/passthrough since it is
LAN-only, satisfying the internal-only gate. Document the per-host
status table and the flip-back rule for if oxps ever gains an internet
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:57:56 +02:00
oierandClaude Opus 4.8 05a98efe57 docs(migration): fresh-world kubejs root-owned crash-loop + fix
On a fresh mc_data volume the minecraft service crash-loops with
"AccessDeniedException: /data/kubejs/config" (ConsoleJS.STARTUP null NPE):
Docker auto-creates the bind-mount parent /data/kubejs as root:root, but
the itzg container runs as uid 1000, so KubeJS can't create its writable
siblings (config/cache/generated/logs). Only bites fresh worlds — cochi's
aged volume predates the ro submounts and is already 1000-owned.

Fix (durable): chown 1000:1000 /data/kubejs once, then restart.

- plan/22-migration.md: Troubleshooting section with symptom + fix.
- migrate-import.sh: manual tail now flags it with the exact commands.

Hit + resolved during the cochi->oxps migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:38:13 +02:00
oierandClaude Opus 4.8 b9bcc13b73 feat(tooling): host migration scripts (export/import/check)
Move the stack to a new host keeping the same domain, carrying Drasl
users + skins (+ kuma monitors, filestash index) but not the world.

- migrate-check.sh: validate the target meets requirements. [restore]
  hard-fails (docker, compose v2, git, tar, ssh); [deploy] warns
  (node/pnpm, py>=3.11, nginx, acme.sh, RAM, disk, ports). --host runs
  it remotely by streaming itself over ssh.
- migrate-export.sh: workstation-run, pulls the source read-only over
  ssh into one 0600 bundle (.env, certs/, drasl_state/kuma_data/
  filestash_state volumes, checksummed MANIFEST). Certs in by default
  (--without-certs); world opt-in (--with-world).
- migrate-import.sh: target-run, sha256-verify, refuse existing volumes
  (--force), backup+restore .env with --path-prefix rewrite, check host
  paths resolve, restore certs+volumes, print the manual tail.
- plan/22-migration.md: human walkthrough (cochi -> oxps), rollback
  window warning, rehearsal notes.
- .gitignore: ignore ulicraft-bundle-*.tar.gz (holds secrets + LE keys).

Assumes Compose v2 on the target (no update-all.sh changes). Distribution
repo, TLS issuance, DNS and firewall stay manual. Round-trip tested
against prod: 9.2M bundle, drasl.db restored byte-exact, clobber guard
verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:41:39 +02:00
oier 00e9815307 docs(generators): an empty 200 is not a healthy service
Observed while staging generators. on cochi: with the nginx vhost installed
but the container not yet deployed, caddy answers an unmatched Host with an
EMPTY 200. Both / and the mirror path returned "HTTP 200, 0 bytes" while
nothing at all was running.

So the guidance to probe a mirror path instead of / was necessary but not
sufficient — a status-code-only check reports green on a completely absent
service. Assert the body.

Same failure family as the filestash green-monitor: the server answering
is not the app working.
2026-07-21 19:37:28 +02:00
48 changed files with 4002 additions and 89 deletions

No files matched your search

+54 -2
View File
@@ -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.
@@ -113,11 +134,42 @@ GENERATORS_DATA_DIR=./generators-data
# Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key
# ── Monitor (monitor.${BASE_DOMAIN}) — admin log stream ───────────────
# Read-only view of the Minecraft server's latest.log. ADMIN ONLY: the log
# carries player IPs, chat and admin commands verbatim, and nothing sanitizes
# it. Full design + gotchas: plan/23-monitor.md.
#
# Per-host toggle, `on` | `off` (default on), same two-layer gating as
# FILES_BROWSE: the caddy conf mount (docker-compose.yml substitutes the value
# into the path — 80-monitor.on.caddy vs the .off.caddy stub) and the public
# nginx vhost (tooling/render-nginx.sh). `off` also drops the name from the
# port-80 redirect, so no cert is needed — drop `monitor` from LE_SUBDOMAINS
# too on that host.
# After changing it: tooling/render-nginx.sh --install
# docker compose up -d --force-recreate caddy
MONITOR_ENABLED=on
# The ONLY credential guarding the log stream — caddy basic_auth. The monitor
# container has no auth of its own and publishes no port, so this is the whole
# access control.
MONITOR_USER=admin
#
# MUST be bcrypt, generated with:
# docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
# (This is the OPPOSITE of FILES_PASSWORD_HASH, which must be $6$ and silently
# rejects bcrypt. Do not reuse a hash between the two.)
#
# MUST be SINGLE-QUOTED here. Docker Compose interpolates $-sequences in .env
# values, so an unquoted bcrypt hash is silently mangled — $2a$14$abcdefg…
# becomes $$2a$$14… with the $abcdefg… segment eaten as an unset variable, and
# every login fails with no hint as to why. Verified behaviour, not a guess.
MONITOR_PASSWORD_HASH='$2a$14$replace.me.with.a.real.bcrypt.hash'
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth distribution www avatar status files generators
LE_SUBDOMAINS=auth distribution www avatar status upload files generators monitor
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu
+8
View File
@@ -14,6 +14,11 @@ 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/
@@ -43,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
+61 -4
View File
@@ -34,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)
@@ -108,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.
@@ -131,6 +155,34 @@ works. After any filestash config change, check the browser-facing values
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
while every other check passed.
### Compose eats `$` in `.env` values — quote every hash
`docker compose` interpolates `$`-sequences inside `.env` **values**, so a bare
bcrypt hash is silently corrupted before it ever reaches a container:
```
MONITOR_PASSWORD_HASH=$2a$14$abcdefgHIJKLmnop.qrstuv # WRONG
→ container sees $2a$14.qrstuv # $abcdefg… eaten as an unset var
MONITOR_PASSWORD_HASH='$2a$14$abcdefgHIJKLmnop.qrstuv' # single quotes fix it
```
Verified, not folklore. Every login fails with no hint as to why — the hash
looks fine in `.env` and `docker compose config` re-escapes it to `$$…`, which
looks wrong but is correct. **The same bug bites over SSH**: writing a hash to a
remote `.env` through `ssh host "...$HASH..."` lets the remote shell expand it.
Transport it base64-encoded and verify with `md5sum` on both ends.
Note the two services want *opposite* formats: caddy `basic_auth` needs
**bcrypt** (`caddy hash-password`), filestash needs **`$6$`** (`openssl passwd
-6`) and silently rejects bcrypt. Never copy a hash between them.
### Monitor runs as root on purpose
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
cannot traverse it, so the log tailer would read nothing — **silently**, while
still answering HTTP 200. Adding a `USER` directive to `docker/monitor/` as
"hardening" breaks it. What actually bounds the risk there: every mount is `:ro`,
no port is published, and the one credential it holds (RCON) can only send
commands from a compile-time allowlist.
### Memory sizing
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
`USE_AIKAR_FLAGS: TRUE`. **The pack is now 164 jars / 133 server-side** (Create
@@ -197,6 +249,11 @@ Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
**outside mc-backup** and unquota'd (accepted — eyeballed).
- [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream +
dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups,
joins/leaves) + opt-in browser notifications on join/leave (bell toggle,
client-side only). Live on cochi, all four phases done. See `plan/23-monitor.md`.
Follow-up: add the Uptime Kuma monitor for `http://monitor:8080/healthz`.
- [ ] **Verify NeoForge version** against current stable before first deploy.
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
+22
View File
@@ -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.
+62 -9
View File
@@ -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
View File
@@ -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.
-10
View File
@@ -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
}
+14
View File
@@ -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
}
+9
View File
@@ -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.
+8
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
# Monitor DISABLED (MONITOR_ENABLED=off in .env).
#
# This stub is mounted at /etc/caddy/conf.d/80-monitor.caddy instead of
# 80-monitor.on.caddy, so caddy defines NO vhost for monitor.${BASE_DOMAIN} and
# the log stream is unreachable on this host. The monitor container may still be
# running — it just has no ingress, and it publishes no port, so nothing can
# reach it.
#
# Caddy needs a parseable file here, not an absent one: the Caddyfile imports
# conf.d/*.caddy and the mount target is fixed. Comments only, on purpose.
+20
View File
@@ -0,0 +1,20 @@
# Monitor — admin-only latest.log stream (monitor service). Read-only: the UI
# only ever issues GETs, and the service exposes no mutating route at all.
# Full design: plan/23-monitor.md.
#
# basic_auth here IS the access control. The monitor container has no auth of
# its own and publishes no port, so caddy is the only path to it — there is no
# second door to also lock.
#
# The hash must be BCRYPT (`caddy hash-password`). Note this is the OPPOSITE of
# filestash (60-upload.caddy / plan/20-files.md), which only accepts $6$ and
# silently rejects bcrypt. Do not copy a hash between the two services.
#
# No path is exempted. Uptime Kuma probes /healthz as http://monitor:8080/healthz
# over mcnet, bypassing caddy entirely, so the whole vhost stays gated.
http://monitor.{$BASE_DOMAIN} {
basic_auth {
{$MONITOR_USER} {$MONITOR_PASSWORD_HASH}
}
reverse_proxy monitor:8080
}
+108 -12
View File
@@ -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,10 +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:
@@ -245,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
@@ -257,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
@@ -277,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:
+15
View File
@@ -0,0 +1,15 @@
# monitor — admin-only log stream + dashboard. Multi-stage: build a static
# binary (UI is //go:embed-ed into it), ship it on scratch.
#
# No CA roots needed: phase 1 talks to nothing over the network — it only reads
# a file. Add them here if a later phase makes outbound TLS calls.
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/monitor .
FROM scratch
COPY --from=build /out/monitor /monitor
EXPOSE 8080
ENTRYPOINT ["/monitor"]
+363
View File
@@ -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(),
}
}
+3
View File
@@ -0,0 +1,3 @@
module ulicraft/monitor
go 1.25.0
+501
View File
@@ -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"),
}
}
+53
View File
@@ -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])
}
}
+188
View File
@@ -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
}
+643
View File
@@ -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 -1
View File
@@ -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",
+4
View File
@@ -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
+8 -4
View File
@@ -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: {
+2
View File
@@ -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
View File
@@ -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",
+24
View File
@@ -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;
}
}
+31
View File
@@ -0,0 +1,31 @@
# Monitor (monitor.) — admin-only log stream, basic_auth enforced by caddy.
#
# Rendered into ulicraft-caddy.conf.tmpl at ${MONITOR_SERVER} by render-nginx.sh,
# ONLY when MONITOR_ENABLED=on. With `off` the placeholder collapses to a
# comment, so the name has no TLS vhost at all (and needs no cert — drop
# `monitor` from LE_SUBDOMAINS on that host).
#
# proxy_buffering is left at the default ON: the log API is plain polling, not
# SSE, precisely so this vhost needs no special treatment. If a future phase
# switches to streaming, `proxy_buffering off;` MUST be added here or the stream
# stalls on prod only — see plan/23-monitor.md.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name monitor.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/monitor.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/monitor.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Admin-only surface — keep it out of search engines even though it 401s.
add_header X-Robots-Tag "noindex, nofollow" always;
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+20 -9
View File
@@ -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} generators.${BASE_DOMAIN};
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN}${FILES_BROWSE_NAME}${MONITOR_NAME} generators.${BASE_DOMAIN};
return 301 https://$host$request_uri;
}
@@ -152,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
@@ -190,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
View File
@@ -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
View File
@@ -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
+107
View File
@@ -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`
+12
View File
@@ -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
```
+8 -2
View File
@@ -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)
+6
View File
@@ -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
+43
View File
@@ -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}`):
+34 -1
View File
@@ -12,9 +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**) |
---
@@ -109,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.
+122 -13
View File
@@ -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
Internet ── host nginx (TLS, LE cert upload.) ──► caddy ──► filestash:8334 (rw)
│
${FILES_DATA_DIR} ──┘ (bind mount)
${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.
+11
View File
@@ -147,6 +147,17 @@ 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
+198
View File
@@ -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.
+477
View File
@@ -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) |
+87
View File
@@ -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"
+81
View File
@@ -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
+6 -1
View File
@@ -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
+147
View File
@@ -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'
+130
View File
@@ -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")"
+190
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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