Files
ulicraft-server-v1/CLAUDE.md
Oier Bravo Urtasun 08fe8c9c53 docs(files): record the general.host trap + that a green monitor proves nothing
Propagate the bare-hostname fix (10b86ae) to the docs that still said "three
traps", and write down the meta-lesson it exposed.

The bug was invisible to every check we had: Filestash strips the scheme
before its own Host check, so `curl /` returned 200, the API answered, wrong
passwords were rejected, and the brand-new Uptime Kuma HTTP monitor stayed
green — while the SPA computed `http://https://files...` and no browser could
use the site. An HTTP monitor asserts "server returned 200", which is a much
weaker claim than "the app works".

- CLAUDE.md / README: four traps now, host-scheme first; add the post-change
  check (`/api/config` → origin must be the real https URL).
- 19-routes: login form only renders at ?action=redirect (a bare 303 is
  normal, not a bug); assert origin after config changes.
- 10-uptime-kuma: an HTTP monitor cannot see an SPA break — use a Keyword
  monitor if you want teeth, and don't read green as "users can log in".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:31:46 +02:00

12 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: 410 friends
  • Modpack vibe: Kitchen-sink (tech + magic + exploration + QoL)
  • Pack approach: Manually curated (~50100 mods), NOT a forked existing pack
  • 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)
   files.${BASE_DOMAIN}  ─► filestash    (player file share, ONE shared login, writable)
   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.

Filestash (files.) — four traps

Full list in plan/20-files.md; the ones that cost real time:

  • general.host is a BARE HOSTNAME (files.${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 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/configorigin) 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

~50100 mods, 8 players, 1.21.1: INIT_MEMORY: 4G, MAX_MEMORY: 10G, USE_AIKAR_FLAGS: TRUE.

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. tomllib auto-flagged 11 hard client-only mods; the other 65 default both, including client-cosmetic ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody, welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) that load harmlessly on the server but waste RAM. Hand-edit them to client so sync-server-mods.sh drops them from ./server-mods. (Also review appleskin, flagged client — flip to both if server-side food data wanted.)

Landing rework + mod list (PLANNED — see plan/18-landing-rework.md)

Full design settled across WS1WS6; 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). Light gap doc: current 76-jar pack is vanilla+ QoL/perf; empty on tech & magic, thin worldgen, no map; orphan deps ponderjs/Necronomicon. Landing mod-list component = auto-generated mods.json + extracted logos from the distribution forgemods (tooling/build-modlist.py), flat alphabetical, pretty names, no categories/links; feeds the "50+" stat tile.

  • Server-side perf mods (C2ME, etc.).

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