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>
283 lines
16 KiB
Markdown
283 lines
16 KiB
Markdown
# 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) + 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.
|
||
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.
|