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

14 KiB
Raw Blame History

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)
   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.

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).

  • 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).

  • Verify NeoForge version against current stable before first deploy.

  • 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

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.