diff --git a/.gitignore b/.gitignore index c1e124f..888bc8b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index e35f3df..04c05fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,22 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at classify/sync time. See `plan/04-mods.md`. +### Writable bind mounts — seed `./server-config` first +`/mods`, `/data/config` and the four kubejs source subdirs are mounted **rw** +(2026-07-26). Two consequences: +- `./server-config:/data/config` is a NEW bind — config used to live inside + `mc_data`. A bind mount **shadows** what's under it, so booting with an empty + `./server-config` makes every mod regenerate defaults. Run + `tooling/export-server-config.sh` once per host, server stopped, before that + deploy. The old files survive in `mc_data` under the mount, but the server + stops seeing them. +- itzg runs as **uid 1000**; a rw mount only works if the host dir permits it. + `cochi` is uid 1000, **`oxps` `oier` is uid 1002** → pass `MC_UID`/`MC_GID`. +- kubejs writes now land in `$DISTRIBUTION_WEB_ROOT` — i.e. as uncommitted + changes in that host's distribution checkout. +The kubejs ROOT still must not be mounted (NPE at init) — unchanged by the mode. +Full detail: `plan/05-minecraft.md` → "Writable bind mounts". + ### Filestash (upload.) — four traps Filestash serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a diff --git a/docker-compose.yml b/docker-compose.yml index 651d9bf..d8160e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,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 diff --git a/plan/05-minecraft.md b/plan/05-minecraft.md index 80b8cad..242b6a3 100644 --- a/plan/05-minecraft.md +++ b/plan/05-minecraft.md @@ -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` diff --git a/plan/08-tooling.md b/plan/08-tooling.md index 56343bf..ae20ea2 100644 --- a/plan/08-tooling.md +++ b/plan/08-tooling.md @@ -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//` 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 ``` diff --git a/plan/14-deploy.md b/plan/14-deploy.md index 12a5cf7..719929f 100644 --- a/plan/14-deploy.md +++ b/plan/14-deploy.md @@ -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 diff --git a/tooling/export-server-config.sh b/tooling/export-server-config.sh new file mode 100755 index 0000000..546bacd --- /dev/null +++ b/tooling/export-server-config.sh @@ -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"