Files
ulicraft-server-v1/CLAUDE.md
T
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

282 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ulicraft Server — Project Context
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
with `plan/` or the code, those win.
## Goals & Constraints
- **Players**: 4–10 friends
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
- **Pack approach**: Manually curated, NOT a forked existing pack. Original target
~50–100 mods; **actual is 164 jars** (2026-07-20, kitchen-sink expansion) —
ceiling knowingly exceeded, watch RAM
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
- **Persistence horizon**: long-term service, not disposable
## Architecture
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
Host header. caddy is the internal router; containers reach the stack's own names
via caddy's `mcnet` aliases (`auth.`).
```
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
│ routes by Host:
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
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)
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)
└ ONLINE_MODE=true (validated against Drasl, NOT offline), -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
└ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
```
Bring-up: render configs → sync server mods → build landing → fetch launcher → `docker compose up -d --build`.
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
via the `deploy` skill.
## Key Decisions
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
drop-in, skins + capes, actively maintained. Run with **password login**
(no OIDC/Keycloak in this stack).
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
- **Distribution-fed mod volume (replaced packwiz)** — the modset's source of
truth is the HeliosLauncher distribution repo (`$DISTRIBUTION_WEB_ROOT`).
Clients install the full set via `distribution.json`. The SERVER loads a
filtered subset (`both`/`server`) from a mounted `./server-mods` volume
(`REMOVE_OLD_MODS=TRUE`). See `plan/04-mods.md`.
## Critical Gotchas
### Minecraft 1.21+ secure profile
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
- Drasl: `SignPublicKeys = false`
- Linked. Drasl docs: *"Mixed authentication does not work with
`SignPublicKeys = true` on Minecraft 1.21+."*
- **This is the ONLY auth flag that goes off. `ONLINE_MODE` stays TRUE** — see
next gotcha. Do not conflate "secure profile off" with "offline mode".
### ONLINE_MODE must be TRUE (authlib-injector ≠ offline)
authlib-injector redirects the server's normal **online**-auth handshake from
Mojang to Drasl. `ONLINE_MODE=FALSE` skips that handshake → offline-hash UUIDs,
no Drasl session validation, and **no skins** (everyone joins but everyone is
Steve; `usercache.json` shows offline-hash UUIDs). Keep `ONLINE_MODE=TRUE`.
### authlib-injector JVM agent
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
- **Must match between server and client** (clients use the public
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
silently with "Invalid session".
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
`runtime/` (mounted at `/extras/authlib-injector.jar`).
### NeoForge version pinning
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
"21.1.233"` — **must match the distribution's NeoForge** (`distribution.json`),
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
**Verify against https://projects.neoforged.net/neoforged/neoforge before
deploying.** Never `"latest"` for a stable server.
### Mod source + server filtering
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
everything via `distribution.json`. The server runs a **filtered subset**:
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
seeds `mods-sides.json` (`client|server|both`, default `both`), then
`tooling/sync-server-mods.sh` copies the `both`/`server` jars into `./server-mods`
(mounted at `/mods`). Hard client-only mods (sodium/iris) classify as `client`
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
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** (`upload.${BASE_DOMAIN}`, no scheme) +
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
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.
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
connection params, every login fails with `backend error - Not Allowed` (looks
like bad credentials, isn't).
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
never `restart`.
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
works. After any filestash config change, check the browser-facing values
(`curl /api/config` → `origin`) and re-read `docker compose logs filestash` —
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
ecosystem + Ars Nouveau + full worldgen) — sized for the old 50–100 assumption, so
`MAX_MEMORY` may need a bump. Re-check heap usage under load before adding more.
## Config (.env)
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
`DISTRIBUTION_WEB_ROOT` (source of the modset + caddy `distribution.` mount), the
Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `OVH_*`). Rendered configs
(drasl, nmsr) come from `*.tmpl` via `tooling/render-config.sh` (substitutes
`${BASE_DOMAIN}` only). Server mods are synced (not rendered) by
`tooling/sync-server-mods.sh`.
## Client Distribution (guests)
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
mirrored at apex `/launcher/`. Per guest:
1. Download from `https://${BASE_DOMAIN}` → `/launcher/`.
2. Add an authlib-injector account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
`https://auth.${BASE_DOMAIN}`).
3. The launcher installs the modpack from the distribution (`distribution.json`).
(packwiz and the `pack.` service/subdomain have been fully removed.)
4. Connect to `${BASE_DOMAIN}` (`:25565`).
## Open / Pending Work
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** Pack is now **164 jars**
(2026-07-20): **31 `client` / 133 `both`**. The `both` set still contains
client-cosmetic mods that load harmlessly on the server but waste RAM — hand-edit
them to `client` so `sync-server-mods.sh` drops them from `./server-mods`.
(Also review `appleskin`, flagged `client` — flip to `both` if server-side food
data wanted.)
### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
(register → download launcher → join); drop `packwizUrl` + packwiz step.
Per-OS downloads from a synced `launcher.json` (shape
`productName/version/files[]`, the build output of the `custom-launcher`
repo; `launcher.example.json` committed as the documented fallback). Fjord
moves to its own `/fjord` page.
- [ ] **WS2/3 — Player rosters.** Online (mc-status, live) + all-registered (new
`mc-status /api/mcstatus/players`, admin-login → Drasl `GET /players`, ~60s
cache). Shared avatar tile, `AVATAR_MODE` env (default `headiso`). Admin creds
→ mc-status only.
- [ ] **WS4 — Account page** (`/account`): skin CRUD via browser-direct Drasl
(reuses `register.astro` skin JS). Skins only, in-memory token, `players[0]`.
- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged).
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
Gap doc part **DONE**: pack filled out to **164 jars** (2026-07-20) — Create
ecosystem (tech), Ars Nouveau + ars_creo (magic), full YUNG's + Moog's +
TerraBlender (worldgen); `ponderjs` orphan resolved by Create. Still open: **no
map mod** (Xaero's), `Necronomicon` orphan (no Iron's Spells), and 164 > the
stated 50–100 ceiling. Still to build: landing mod-list component =
auto-generated `mods.json` + extracted logos from the distribution forgemods
(`tooling/build-modlist.py`), flat alphabetical, pretty names, no
categories/links; feeds the "50+" stat tile.
- [ ] Server-side perf mods (C2ME, etc. — `modernfix` already in the pack).
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
**outside mc-backup** and unquota'd (accepted — eyeballed).
- [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream +
dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups,
joins/leaves). Live on cochi, all three 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.
See `plan/03-drasl.md` gotchas.
## Reference URLs
- Drasl: https://github.com/unmojang/drasl — config docs:
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- authlib-injector: https://github.com/yushijinhun/authlib-injector
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
- NMSR: https://github.com/NickAcPT/nmsr-rs
- Uptime Kuma: https://github.com/louislam/uptime-kuma
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
## Communication Preferences (carry-over)
- Concise and direct; diagnose + resolve without demanding repro steps.
- Prefer iterative single-change updates over large rewrites.
- Cautious scripts that halt on ambiguity rather than proceeding silently.
- Avoid over-engineering when a simple answer suffices.
- Spanish or English both fine.