Compare commits

...

18 Commits

Author SHA1 Message Date
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
10b86ae23e fix(files): general.host must be a bare hostname, not a URL
The SPA computes its redirect origin as (force_ssl ? "https://" : "http://")
+ general.host. With host set to "https://files.${BASE_DOMAIN}" that produced
"http://https://files.ulicraft.net", so every client-side redirect broke and
the page never routed anywhere.

Server-side this was invisible: SecureOrigin strips the scheme before the
Host check, so `curl /` returned 200, the API answered, and the uptime
monitor stayed green — only real browsers failed. The tell is a
`POST /report?...msg=Redirecting to http://https://...` line in the log.

Set host to the bare hostname and force_ssl=true (which the origin needs to
build https://, and which only emits an HSTS header — it does not redirect,
so it can't loop behind the plain-http nginx→caddy hop).

Verified: origin is now "https://files.ulicraft.net"; login + ls still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:27:42 +02:00
cd79b0e540 docs(files): document filestash across plan/README/deploy skill
Fold files./filestash into the docs that carry the service list, the vhost
map and the deploy procedure — README stack table, 00-overview, 02-caddy,
12-build-order, 14-deploy, and the deploy skill.

Two real gaps the filestash deploy exposed, now guardrails in the skill:
- update-all.sh never touches host nginx, so a NEW SUBDOMAIN silently 404s
  after deploy. It needs issue-letsencrypt.sh + render-nginx.sh --install.
- a new service with ${FOO:?} guards fails the WHOLE compose file, so a
  missing .env var means `up` starts nothing — and --hard has already run
  `down`. Check the host's .env BEFORE tearing the stack down.

Also: filestash/config.json is a single-file bind mount re-rendered every
run, so a rolling update-all leaves it on a stale inode (same trap as the
caddy conf) — force-recreate after any FILES_* change.

Correct the cochi divergence section: `git diff HEAD` on the host is now
empty (verified this deploy — the ff-pull succeeded). The documented
docker-compose.yml/package.json divergence was converged; only untracked
dnsmasq/, caddy-root-ca.crt and a stale pack/ remain. The stash-pull-pop
procedure it prescribed is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:23:18 +02:00
25df9f9398 feat(files): add Filestash player file share at files.${BASE_DOMAIN}
One shared login (htpasswd) for all players, writable. Config is rendered
from a template and mounted read-only, so git stays authoritative — the
admin console loads but cannot save, by design.

Filestash's OIDC/SAML middlewares are enterprise-only, so Keycloak SSO is
out; FILES_AUTH keeps htpasswd/passthrough switchable for later. Auth-off
(passthrough) is only valid once the host is off the public internet —
render-config.sh warns loudly when rendering it.

Three traps, all found by testing against the pinned image:
- the `local` backend is admin-gated: without LOCAL_BACKEND_SECRET in the
  connection params every login fails with "backend error - Not Allowed"
- the htpasswd plugin only accepts $2a$ bcrypt, so a $2y$ hash from
  `htpasswd -B` fails every login silently — use `openssl passwd -6`.
  render-config.sh rejects the wrong format rather than shipping it
- APPLICATION_URL/ADMIN_PASSWORD env make filestash rewrite config.json at
  boot, which fails on the :ro mount — both live in the rendered config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:08:46 +02:00
1958a96acf chore(mods): classify 11 new distribution mods
Distribution added 11 jars. classify-mods.py seeds all as `both`
(none declare CLIENT in neoforge.mods.toml). Hand-flip the two
pure-client cosmetic ones so sync-server-mods.sh drops them:

- ExtremeSoundMuffler: client-side sound muffling UI
- ToastControl: client-side toast popup suppression

Rest stay `both` — Placebo/moonlight/caelus are libs, and
amendments/etched/fishingoverhaul/immersive_paintings/
createornithopterglider/emotecraft add server-side content or sync.

Also map iris as `client`. It is referenced by distribution.json but
absent from the local (gitignored) dist jar dir, so classify-mods.py
never sees it; mapping it keeps mods-sides.json aligned with the
manifest. Client-only, so the sync selection is unchanged.

server-mods/ now 130 jars (was 123).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:29:35 +02:00
e45ce210ba docs(mc): document Simple Voice Chat voice_host gotcha + commit prod snapshot
voice_host lives in the mc_data volume and regenerates empty on world wipe,
silently breaking remote voice (secret sent, UDP handshake never completes →
red icon). Document the set/restore steps and the VPN-client false-positive
(Cloudflare One/WARP, FortiClient drop voice UDP). Add a known-good prod
snapshot at server-configs/voicechat-server.properties (voice_host=ulicraft.net).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:16:02 +02:00
d12071df04 fix(mc): ONLINE_MODE=true — authlib-injector needs online handshake for skins
online-mode=false made the server skip the online-auth handshake entirely, so
authlib-injector never validated sessions against Drasl: players got offline-hash
UUIDs and no skin textures (everyone Steve). authlib-injector's whole purpose is
to redirect the *online* handshake from Mojang to Drasl, so online-mode must be
true; only the 1.21+ secure profile stays off (ENFORCE_SECURE_PROFILE=false +
Drasl SignPublicKeys=false). Correct the conflation in CLAUDE.md and
plan/05-minecraft.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:34:47 +02:00
a67b9cf59e fix(mc): mount only kubejs source subdirs ro, keep kubejs root writable
Mounting the whole kubejs dir read-only made KubeJS's BaseProperties.save()
throw NPE on boot (it writes config/cache/generated/logs under kubejs/),
so KubeJS never initialised. Bind only the source subdirs (server_scripts,
startup_scripts, data, assets) read-only and let the kubejs root live
writable in mc_data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:01:07 +02:00
d303d23d18 feat(mc): mount distribution kubejs + CustomSkinLoader into server
The minecraft container only received forgemods jars via ./server-mods.
The distribution's files/kubejs (server_scripts, data, startup_scripts)
and CustomSkinLoader never reached the server, so server-side kubejs
recipes/scripts were silently absent.

Bind-mount both from DISTRIBUTION_WEB_ROOT (read-only, to avoid the
server polluting the client distribution with kubejs-generated files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:53:47 +02:00
614e47598b chore(mods): repoint rechiseled (both) + fusion (client) to fixed jars
Distribution replaced the wrong-version jars: rechiseled now
mc1.21 (gate [1.21,1.21.2) — valid for 1.21.1, no longer the broken
mc1.21.11 build) so back to `both`; fusion bumped 1.3.2a→1.3.2b, stays
`client` (resource-pack texture feature). Dropped the two stale entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:39:09 +02:00
69871c8a93 fix(mods): drop rechiseled from server — wrong MC version
rechiseled-1.2.5-neoforge-mc1.21.11.jar targets Minecraft 1.21.11 /
NeoForge 21.11, not this server's 1.21.1 / 21.1.233. As `both` it crashed
the server at pre-load (FATAL ModLoader: requires minecraft 1.21.11). Mark
`client` to exclude it from server-mods so the server boots.

NOTE: the jar is still wrong-version for clients too (same gate) — it must
be removed from / replaced in the distribution to be usable at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:36:03 +02:00
015bf7fbe3 chore(mods): classify fusion (client) + rechiseled (both)
Two new distribution mods. rechiseled adds decorative blocks → both
(server needs the block registry). fusion is a client-only resource-pack
texture/model feature (no blocks/logic) → client, so sync drops it from
server-mods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:31:25 +02:00
d0bc81f0f7 chore(mods): classify ulicraftmod as both
New custom mod ulicraftmod-1.21.1-1.0.0-6.0.10 added to the distribution;
content mod (server + client), so both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:07:26 +02:00
35be2e6b76 docs(plan): scrub stale pack subdomain + packwiz from live-arch docs
The pack./packwiz service was removed operationally long ago, but several
plan docs still presented pack. as a live caddy vhost / DNS subdomain and
render-pack.sh as a current tool. Remove those references from the
current-architecture docs (overview, caddy, tooling, uptime-kuma,
letsencrypt, mc-backup); leave the migration log (04-mods.md), superseded
banner (04-packwiz.md), commit history (12-build-order.md), and planned
landing rework (18) intact as deliberate history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:15:22 +02:00
33b9ebc7f6 feat(landing): slim status section, hero, and server-card icon
- Remove the stat-grid from the status section (server card only); prune the
  now-orphaned site.stats, MOD_COUNT_KEY, statLabels (3 locales), and
  .stat-grid/.tile CSS.
- Empty-roster message -> "Nobody online", now localized (status.empty in
  en/es/eu) and wired to both ServerCard instances.
- Flatten the server-card icon (drop the inset emboss bevel on .sc-icon).
- Drop "Solo LAN" (hero metaLan) from the hero meta row; prune metaLan (3 locales).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:45:19 +02:00
b9929da54d chore(mods): add geckolib + terrablender (Yungs worldgen deps)
YUNG's Cave Biomes hard-requires geckolib>=4.7.6 and terrablender>=4.1.0.8;
both now in the distribution. Classified both (server worldgen libs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:21:56 +02:00
36c28362bc chore(mods): sync mods-sides.json with distribution
+21 new (Yungs* + Moogs* worldgen, lootjs, solcarrot, melter,
seamless-loading-screen), -8 removed (FramedBlocks, GnKinetics,
create_vibrant_vaults, createtransmission, interiors, picaxe,
bellsandwhistles, welcomescreen). seamless-loading-screen hand-edited
to client (pure client UI). Worldgen mods stay both — server generates
structures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:05:44 +02:00
07a4ae469d docs(minecraft): document OP-via-RCON, not itzg OPS env
OPS env resolves names via Mojang lookup, but the server is offline-mode
with Drasl auth (own UUIDs) → wrong UUID, inert op entry. Document the
RCON procedure that uses the cached Drasl UUID from usercache.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:00:22 +02:00
29 changed files with 923 additions and 156 deletions

View File

@@ -14,7 +14,7 @@ Redeploy the running stack on the production host. Full reference:
- Path: `/home/ubuntu/mc/ulicraft-server-v1` - Path: `/home/ubuntu/mc/ulicraft-server-v1`
- Branch: `main` - Branch: `main`
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup, - Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
caddy, nmsr, uptime-kuma) caddy, nmsr, mc-status, uptime-kuma, filestash)
- Auth: Drasl - Auth: Drasl
- Restart: **hard** (down → up) — players are disconnected for a few minutes - Restart: **hard** (down → up) — players are disconnected for a few minutes
@@ -31,6 +31,18 @@ invoking this skill is the authorization to proceed, but:
already up to date and ask whether to restart anyway (they may want a plain already up to date and ask whether to restart anyway (they may want a plain
restart). If `git fetch` fails (host unreachable, auth), STOP and report. restart). If `git fetch` fails (host unreachable, auth), STOP and report.
**If the incoming commits ADD A SERVICE, check `.env` on the host first.**
A new service with `${FOO:?...}` guards (filestash uses them) fails the
**whole compose file**, not just that service — so a missing var means
`up` refuses to start *anything*, and a `--hard` deploy has already run
`down`. The stack stays down. Check before tearing it down:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && grep -c "^FILES_" .env'
```
Same for a new **subdomain**: it needs a cert (`LE_SUBDOMAINS` +
`issue-letsencrypt.sh`) and an nginx vhost — see step 2b, which the deploy
does NOT do for you.
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session, 2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
halts on any error): halts on any error):
```bash ```bash
@@ -58,16 +70,31 @@ invoking this skill is the authorization to proceed, but:
landing rebuild is unconditional (the gitignored `www/` is never updated by landing rebuild is unconditional (the gitignored `www/` is never updated by
`git pull`), so no separate landing step is needed. `git pull`), so no separate landing step is needed.
2b. **Only if the deploy changed `nginx/ulicraft-caddy.conf.tmpl`** (a new
subdomain, a new body-size/rate-limit rule, a changed `CADDY_HTTP_PORT`).
`update-all.sh` does **not** touch the host nginx, so a new vhost simply
won't exist and the subdomain 404s / hits the wrong server block:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && tooling/render-nginx.sh --install'
```
It runs `nginx -t` before reloading, so a bad render fails safe. The cert
must already exist (`certs/<name>/cert.pem`) or nginx won't load the block —
issue it first with `tooling/issue-letsencrypt.sh` (reads `LE_SUBDOMAINS`).
3. **Verify.** 3. **Verify.**
```bash ```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps' ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
``` ```
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server `uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
ready): it reaches the ready marker (match `Done (Ns)! For help` — a bare `Done`
also matches unrelated mod-loading lines):
```bash ```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done" ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
``` ```
If a public vhost changed, verify it end-to-end from your workstation rather
than trusting `compose ps` (a container can be Up while nginx never routes to
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
## Guardrails ## Guardrails
@@ -81,6 +108,18 @@ invoking this skill is the authorization to proceed, but:
`docker compose up -d --force-recreate caddy`, then verify with `docker compose up -d --force-recreate caddy`, then verify with
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path `docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
(full `down`/`up`) already recreates, so it's unaffected. (full `down`/`up`) already recreates, so it's unaffected.
- **Same trap for `filestash/config.json`** — also a single-file bind mount, and
it is *re-rendered on every run* by `render-config.sh`, so a rolling
`update-all.sh` leaves filestash reading the stale inode after any `FILES_*`
change (rotating the shared password, flipping `FILES_AUTH`). Apply with
`docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
- **Never let `filestash/config.json` be missing when compose runs.** It is
gitignored and rendered; if it doesn't exist, Docker creates a *directory* at
that path and filestash breaks in a confusing way. `update-all.sh` renders
before `up`, so the normal path is safe — just don't hand-run `docker compose
up` on the host without rendering first. If it happened: `rm -rf
filestash/config.json && tooling/render-config.sh && docker compose up -d
--force-recreate filestash`.
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the - Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`. world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
- If a step fails, STOP and surface the exact error + the failing command. Do not - If a step fails, STOP and surface the exact error + the failing command. Do not

View File

@@ -49,6 +49,52 @@ CADDY_HTTP_BIND=127.0.0.1
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy. # static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
# Screenshots / schematics / maps. ONE shared login for all players, writable.
# Full design + gotchas: plan/20-files.md.
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
# data must not live in a git tree we `git pull` into.
FILES_DATA_DIR=/srv/ulicraft-files
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
# mount flag rather than a boolean because compose has no conditionals — the
# value is substituted straight into the volume string.
FILES_MOUNT_MODE=rw
# Auth middleware (rendered into config.json by render-config.sh):
# htpasswd one shared user/pass — the only internet-safe value
# passthrough NO LOGIN. Anyone reaching the vhost is in.
# none alias of passthrough
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
# relay (malware/phishing hosted on your domain, under your cert, with your
# bandwidth — and a domain blocklisting would take auth. and the apex down
# with it). Only set it once this host is unreachable from the internet.
FILES_AUTH=htpasswd
# The shared player credential (FILES_AUTH=htpasswd).
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
# render-config.sh rejects the wrong format rather than shipping it.
FILES_USER=uli
FILES_PASSWORD_HASH=
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
# use a 20+ char generated password. It is the only credential guarding config.
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
FILES_ADMIN_PASSWORD_HASH=
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
FILES_SECRET_KEY=
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
# init unless the connection params carry this secret. config.json supplies it
# server-side; players never see it. openssl rand -hex 24.
FILES_LOCAL_SECRET=
# Only needed if using CurseForge-exclusive mods: # Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key # CF_API_KEY=your-curseforge-api-key
@@ -56,7 +102,7 @@ DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distri
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports. # Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included # space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth distribution www avatar status LE_SUBDOMAINS=auth distribution www avatar status files
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/ # OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it. # OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu OVH_END_POINT=ovh-eu

3
.gitignore vendored
View File

@@ -6,6 +6,9 @@ runtime/authlib-injector.jar
# rendered configs (from tooling/render-config.sh) # rendered configs (from tooling/render-config.sh)
drasl/config/config.toml drasl/config/config.toml
nmsr/config.toml nmsr/config.toml
# holds the admin bcrypt hash, the shared player hash, the session key and the
# local-backend secret — only the .tmpl is tracked
filestash/config.json
# server mod subset synced from the distribution repo by # server mod subset synced from the distribution repo by
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars) # tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)

View File

@@ -32,10 +32,11 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI) auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed) avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors) 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) distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
minecraft :25565 (+ 24454/udp Simple Voice Chat) minecraft :25565 (+ 24454/udp Simple Voice Chat)
└ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector └ 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 └ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only) mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
``` ```
@@ -67,6 +68,14 @@ via the `deploy` skill.
- Drasl: `SignPublicKeys = false` - Drasl: `SignPublicKeys = false`
- Linked. Drasl docs: *"Mixed authentication does not work with - Linked. Drasl docs: *"Mixed authentication does not work with
`SignPublicKeys = true` on Minecraft 1.21+."* `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 ### authlib-injector JVM agent
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>` - Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
@@ -97,6 +106,29 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
classify/sync time. See `plan/04-mods.md`. 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/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 ### Memory sizing
~50100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`, ~50100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
`USE_AIKAR_FLAGS: TRUE`. `USE_AIKAR_FLAGS: TRUE`.
@@ -155,6 +187,10 @@ Full design settled across WS1WS6; all tasks unchecked, ready to execute.
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
names, no categories/links; feeds the "50+" stat tile. names, no categories/links; feeds the "50+" stat tile.
- [ ] Server-side perf mods (C2ME, etc.). - [ ] Server-side perf mods (C2ME, etc.).
- [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).
- [ ] **Verify NeoForge version** against current stable before first deploy. - [ ] **Verify NeoForge version** against current stable before first deploy.
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is - [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip. `DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.

View File

@@ -15,24 +15,27 @@ One compose file, one `docker compose up -d`:
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) | | `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header | | `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` | | `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
| `mc-status` | built from source | live server-status JSON for the landing card |
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` | | `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` |
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON | | `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
Vhosts (all proxied through the host nginx → caddy): Vhosts (all proxied through the host nginx → caddy):
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads - apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
- `auth.` — Drasl - `auth.` — Drasl
- `avatar.` — NMSR - `avatar.` — NMSR
- `status.` — Uptime Kuma - `status.` — Uptime Kuma
- `files.` — Filestash player file share (one shared login, writable)
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`) - `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` / Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`. `CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
## DNS ## DNS
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
(`auth. avatar. status. distribution. www.`) at the host running nginx. (`auth. avatar. status. files. distribution. www.`) at the host running nginx.
## Prerequisites ## Prerequisites
@@ -121,6 +124,7 @@ persists in the `kuma_data` volume):
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` | | Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` | | Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` | | Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
@@ -153,6 +157,51 @@ curl -H "Host: ulicraft.net" \
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`. Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
## Player file share (Filestash)
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
schematics, maps, guides. **One shared username/password for everybody**
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
browser and *not* a mod mirror: world snapshots contain every player's inventory
and coordinates, and the modset already ships via `distribution.`.
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
- Config is `filestash/config.json`, **rendered from a template and mounted
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
re-rendering, and **recreating** the container (`up -d --force-recreate
filestash` — a single-file bind mount pins the inode, so `restart` reads the
stale file).
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
up** by mc-backup — treat the share as disposable.
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
read-only at the *kernel*, whatever the UI thinks.
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
Only valid once the host is off the public internet: an unauthenticated
*writable* share on a public domain is an open upload relay.
Four traps that cost real time (all enforced or documented in the tooling):
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
URL there produces `http://https://files...` and every client-side redirect
breaks. The server strips the scheme before its *own* Host check, so `curl`
and the uptime monitor stay **green while every browser is broken**.
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
reaching it through the connection params, every login dies with
`backend error - Not Allowed`, which looks like bad credentials and isn't.
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
rewrite `config.json` at boot, which fails against the `:ro` mount.
> Verifying this service: a 200 on `/` only proves the server is up. Check the
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
## Join (guests) ## Join (guests)
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS. 1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.

View File

@@ -0,0 +1,10 @@
# Files — Filestash player file share (filestash service). Shared login,
# writable. See plan/20-files.md.
#
# Filestash blocks any request whose Host header doesn't match its configured
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
# allowed". The host nginx forwards the original Host, and caddy passes it
# through untouched — do NOT rewrite it here or every request 403s.
http://files.{$BASE_DOMAIN} {
reverse_proxy filestash:8334
}

View File

@@ -58,8 +58,13 @@ services:
USE_AIKAR_FLAGS: "TRUE" USE_AIKAR_FLAGS: "TRUE"
JVM_XX_OPTS: "-XX:+UseG1GC" JVM_XX_OPTS: "-XX:+UseG1GC"
# ── Auth: offline mode + authlib-injector pointing at Drasl # ── Auth: ONLINE mode validated against Drasl via authlib-injector
ONLINE_MODE: "FALSE" # MUST be true: authlib-injector redirects the normal online-auth
# handshake from Mojang to Drasl. online-mode=false skips the handshake
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
# Secure-profile is the part that must stay off on 1.21+ (see
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
ONLINE_MODE: "TRUE"
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl. # Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector" JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
@@ -95,6 +100,17 @@ services:
- mc_data:/data - mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here - ./runtime:/extras:ro # authlib-injector.jar lives here
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods - ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
# 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/,
# 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
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
depends_on: depends_on:
- drasl # authlib (auth.) must resolve at boot - drasl # authlib (auth.) must resolve at boot
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a # Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
@@ -143,6 +159,7 @@ services:
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro - ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro - ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro - ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
- ./www:/srv/www:ro - ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro - ./launcher:/srv/launcher:ro
# Static site from ANOTHER repo (absolute host path in .env). # Static site from ANOTHER repo (absolute host path in .env).
@@ -197,6 +214,38 @@ services:
networks: networks:
- mcnet - mcnet
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
# plan/20-files.md — it documents every non-obvious bit of this service.
#
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
# no env-var config, and its admin console writes config.json back at runtime,
# so pinning it :ro is what keeps git authoritative. The console still loads —
# it just cannot save. That is intentional, not a bug.
filestash:
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
container_name: filestash
restart: unless-stopped
environment:
# The `local` storage backend is admin-gated in filestash: it refuses to
# init unless the connection params carry this secret (or the admin
# password). config.json's attribute_mapping supplies it server-side, so
# players never see it. Without this, every login dies on "Not Allowed".
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
# filestash rewrite config.json at boot, which fails on the :ro mount.
# Both live in the rendered config instead (general.host / auth.admin).
volumes:
# /app/data/state holds config + sqlite + search index + logs, so the
# DIRECTORY must stay writable. Only config.json is pinned read-only.
- filestash_state:/app/data/state
- ./filestash/config.json:/app/data/state/config/config.json:ro
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
networks:
- mcnet
# Status monitoring + public status page. caddy proxies status. -> :3001. # Status monitoring + public status page. caddy proxies status. -> :3001.
# Joins mcnet so monitors probe the stack by internal service name. # Joins mcnet so monitors probe the stack by internal service name.
uptime-kuma: uptime-kuma:
@@ -212,6 +261,7 @@ volumes:
drasl_state: drasl_state:
mc_data: mc_data:
kuma_data: kuma_data:
filestash_state:
networks: networks:
mcnet: mcnet:

View File

@@ -0,0 +1,31 @@
{
"general": {
"name": "Ulicraft Files",
"host": "files.${BASE_DOMAIN}",
"force_ssl": true,
"secret_key": "${FILES_SECRET_KEY}",
"editor": "base",
"display_hidden": false,
"upload_button": true,
"fork_button": false
},
"auth": {
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
},
"middleware": {
"identity_provider": {
"type": "${FILES_IDP_TYPE}",
"params": "${FILES_IDP_PARAMS}"
},
"attribute_mapping": {
"related_backend": "files",
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
}
},
"connections": [
{
"label": "files",
"type": "local"
}
]
}

View File

@@ -4,7 +4,7 @@
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never // loaded via ./data/mods.ts (resilient: missing file → empty list, build never
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) + // fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
// pretty name + version. description is exposed as a tooltip. No categories, no // pretty name + version. description is exposed as a tooltip. No categories, no
// external links. Tiles share the MC-GUI bevel treatment used by .feat/.tile. // external links. Tiles share the MC-GUI bevel treatment used by .feat.
import { MODS } from "../data/mods"; import { MODS } from "../data/mods";
interface Props { interface Props {

View File

@@ -29,7 +29,7 @@ const {
upTo, upTo,
onlineLabel = "Online", onlineLabel = "Online",
offlineLabel = "Offline", offlineLabel = "Offline",
emptyLabel = "Nobody online — be the first.", emptyLabel = "Nobody online",
} = Astro.props; } = Astro.props;
const { status, avatarUrl, avatarMode } = site; const { status, avatarUrl, avatarMode } = site;
@@ -98,7 +98,6 @@ const { status, avatarUrl, avatarMode } = site;
width: 72px; height: 72px; flex-shrink: 0; width: 72px; height: 72px; flex-shrink: 0;
display: grid; place-items: center; position: relative; display: grid; place-items: center; position: relative;
background: var(--bg-2); background: var(--bg-2);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
} }
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; } .sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
.sc-creeper img { width: 56px; height: 56px; } .sc-creeper img { width: 56px; height: 56px; }
@@ -207,7 +206,7 @@ const { status, avatarUrl, avatarMode } = site;
players.hidden = false; players.hidden = false;
if (real.length === 0) { if (real.length === 0) {
if (emptyEl) { if (emptyEl) {
emptyEl.textContent = empty ?? "Nobody online."; emptyEl.textContent = empty ?? "Nobody online";
emptyEl.hidden = false; emptyEl.hidden = false;
} }
} else { } else {

View File

@@ -5,15 +5,9 @@
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason // @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
// `process` below is untyped). Used only at build time to read the manifest. // `process` below is untyped). Used only at build time to read the manifest.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { MODS } from "./mods";
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local"; const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
// Installed-pack mod count, fed by tooling/build-modlist.py via mods.json (see
// ./mods.ts). Feeds the homepage "Mods" stat tile. When the generated catalogue
// is absent/empty (fresh checkout), fall back to the honest "50+" estimate.
const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+";
// NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row + // NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row +
// account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by // account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" | // default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
@@ -183,15 +177,6 @@ export const site = {
rosterApi: `/api/mcstatus/players`, rosterApi: `/api/mcstatus/players`,
}, },
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
// come from ui.ts (same order).
stats: [
{ key: MOD_COUNT_KEY },
{ key: MC_VERSION },
{ key: "10" },
{ key: "6h" },
],
// Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill. // Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
// Text (h/p) comes from ui.ts (same order). // Text (h/p) comes from ui.ts (same order).
features: [ features: [

View File

@@ -43,7 +43,6 @@ export interface UI {
copyIp: string; copyIp: string;
howToJoin: string; howToJoin: string;
metaModpack: string; metaModpack: string;
metaLan: string;
}; };
status: { status: {
eyebrow: string; eyebrow: string;
@@ -52,8 +51,8 @@ export interface UI {
modded: string; modded: string;
motd: string; motd: string;
upTo: string; upTo: string;
empty: string; // shown on the ServerCard when nobody is online
}; };
statLabels: [string, string, string, string];
members: { members: {
eyebrow: string; eyebrow: string;
title: string; title: string;
@@ -194,7 +193,6 @@ export const ui: Record<Lang, UI> = {
copyIp: "Copy IP", copyIp: "Copy IP",
howToJoin: "How to Join", howToJoin: "How to Join",
metaModpack: "NeoForge modpack", metaModpack: "NeoForge modpack",
metaLan: "LAN-only",
}, },
status: { status: {
eyebrow: "Server", eyebrow: "Server",
@@ -203,8 +201,8 @@ export const ui: Record<Lang, UI> = {
modded: "Modded", modded: "Modded",
motd: "NeoForge modpack · Simple Voice Chat · LAN-only", motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
upTo: "up to", upTo: "up to",
empty: "Nobody online",
}, },
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
members: { members: {
eyebrow: "El valle", eyebrow: "El valle",
title: "Who's on the server.", title: "Who's on the server.",
@@ -376,7 +374,6 @@ export const ui: Record<Lang, UI> = {
copyIp: "Copiar IP", copyIp: "Copiar IP",
howToJoin: "Cómo entrar", howToJoin: "Cómo entrar",
metaModpack: "Pack NeoForge", metaModpack: "Pack NeoForge",
metaLan: "Solo LAN",
}, },
status: { status: {
eyebrow: "Servidor", eyebrow: "Servidor",
@@ -385,8 +382,8 @@ export const ui: Record<Lang, UI> = {
modded: "Con mods", modded: "Con mods",
motd: "Pack NeoForge · Simple Voice Chat · Solo LAN", motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
upTo: "hasta", upTo: "hasta",
empty: "No hay nadie conectado",
}, },
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
members: { members: {
eyebrow: "El valle", eyebrow: "El valle",
title: "Quién está en el servidor.", title: "Quién está en el servidor.",
@@ -558,7 +555,6 @@ export const ui: Record<Lang, UI> = {
copyIp: "Kopiatu IPa", copyIp: "Kopiatu IPa",
howToJoin: "Nola sartu", howToJoin: "Nola sartu",
metaModpack: "NeoForge packa", metaModpack: "NeoForge packa",
metaLan: "LAN soilik",
}, },
status: { status: {
eyebrow: "Zerbitzaria", eyebrow: "Zerbitzaria",
@@ -567,8 +563,8 @@ export const ui: Record<Lang, UI> = {
modded: "Mod-duna", modded: "Mod-duna",
motd: "NeoForge packa · Simple Voice Chat · LAN soilik", motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
upTo: "gehienez", upTo: "gehienez",
empty: "Inor ez dago konektatuta",
}, },
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
members: { members: {
eyebrow: "El valle", eyebrow: "El valle",
title: "Nor dago zerbitzarian.", title: "Nor dago zerbitzarian.",

View File

@@ -111,8 +111,6 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<span>Java {site.mcVersion}</span> <span>Java {site.mcVersion}</span>
<span class="dot"></span> <span class="dot"></span>
<span>{t.hero.metaModpack}</span> <span>{t.hero.metaModpack}</span>
<span class="dot"></span>
<span>{t.hero.metaLan}</span>
</div> </div>
</div> </div>
<div class="hero-status"> <div class="hero-status">
@@ -120,6 +118,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
modded={t.status.modded} modded={t.status.modded}
motd={t.status.motd} motd={t.status.motd}
upTo={t.status.upTo} upTo={t.status.upTo}
emptyLabel={t.status.empty}
/> />
</div> </div>
</div> </div>
@@ -178,16 +177,9 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
modded={t.status.modded} modded={t.status.modded}
motd={t.status.motd} motd={t.status.motd}
upTo={t.status.upTo} upTo={t.status.upTo}
emptyLabel={t.status.empty}
/> />
</div> </div>
<div class="stat-grid">
{site.stats.map((s, i) => (
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
<div class="k">{s.key}</div>
<div class="l">{t.statLabels[i]}</div>
</div>
))}
</div>
</div> </div>
</section> </section>

View File

@@ -472,22 +472,6 @@ section { position: relative; z-index: 1; }
.bars i:nth-child(4){height:85%} .bars i:nth-child(4){height:85%}
.bars i:nth-child(5){height:100%} .bars i:nth-child(5){height:100%}
/* stat tiles */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 28px;
}
.tile {
background: var(--surface);
padding: 22px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
.tile .k .u { color: var(--accent); font-size: 22px; }
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
/* ============================================================ /* ============================================================
PLAYER ROSTER (shared avatar tiles) PLAYER ROSTER (shared avatar tiles)
Used by ServerCard.astro (online row) and PlayerRoster.astro Used by ServerCard.astro (online row) and PlayerRoster.astro
@@ -650,7 +634,6 @@ section { position: relative; z-index: 1; }
:root[data-head="8bit"] .step .num { font-size: 30px; } :root[data-head="8bit"] .step .num { font-size: 30px; }
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; } :root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
:root[data-head="8bit"] .serverlist .title { font-size: 16px; } :root[data-head="8bit"] .serverlist .title { font-size: 16px; }
:root[data-head="8bit"] .tile .k { font-size: 26px; }
:root[data-head="8bit"] .ip-chip button { font-size: 12px; } :root[data-head="8bit"] .ip-chip button { font-size: 12px; }
:root[data-head="8bit"] .live-pill { font-size: 12px; } :root[data-head="8bit"] .live-pill { font-size: 12px; }
@@ -733,7 +716,6 @@ section { position: relative; z-index: 1; }
@media (max-width: 880px) { @media (max-width: 880px) {
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; } :root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
:root[data-hero="split"] .hero-status { display: block; } :root[data-hero="split"] .hero-status { display: block; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.feat-grid { grid-template-columns: 1fr; } .feat-grid { grid-template-columns: 1fr; }
.nav-links { display: none; } .nav-links { display: none; }
.serverlist { flex-wrap: wrap; } .serverlist { flex-wrap: wrap; }
@@ -743,5 +725,4 @@ section { position: relative; z-index: 1; }
.step { grid-template-columns: 1fr; gap: 18px; } .step { grid-template-columns: 1fr; gap: 18px; }
.step .num { width: 64px; height: 64px; font-size: 32px; } .step .num { width: 64px; height: 64px; font-size: 32px; }
.ip-chip .ip-val { font-size: 22px; } .ip-chip .ip-val { font-size: 22px; }
.stat-grid { grid-template-columns: 1fr 1fr; }
} }

View File

@@ -11,31 +11,50 @@
"EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both", "EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both",
"EnderStorage-1.21.1-2.13.0.191.jar": "both", "EnderStorage-1.21.1-2.13.0.191.jar": "both",
"ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both", "ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both",
"ExtremeSoundMuffler-3.56_NeoForge-1.21.jar": "client",
"FarmersDelight-1.21.1-1.3.2.jar": "both", "FarmersDelight-1.21.1-1.3.2.jar": "both",
"FramedBlocks-10.6.0.jar": "both",
"GnKinetics-1.21.1-1.0m.jar": "both",
"HangGlider-v21.1.0-1.21.1-NeoForge.jar": "both", "HangGlider-v21.1.0-1.21.1-NeoForge.jar": "both",
"ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client", "ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client",
"Jade-1.21.1-NeoForge-15.10.5.jar": "both", "Jade-1.21.1-NeoForge-15.10.5.jar": "both",
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both", "JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both", "JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client", "JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client",
"MoogsEndStructures-1.21-2.0.3.jar": "both",
"MoogsMissingVillages-1.21-2.1.1.jar": "both",
"MoogsNetherStructures-1.21-3.0.0-alpha.2.jar": "both",
"MoogsVoyagerStructures-1.21-5.0.11.jar": "both",
"MouseTweaks-neoforge-mc1.21-2.26.1.jar": "client", "MouseTweaks-neoforge-mc1.21-2.26.1.jar": "client",
"Neat-1.21-47-NEOFORGE.jar": "both", "Neat-1.21-47-NEOFORGE.jar": "both",
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both", "Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
"Patchouli-1.21.1-93-NEOFORGE.jar": "both", "Patchouli-1.21.1-93-NEOFORGE.jar": "both",
"Ping-Wheel-1.12.2-neoforge-1.21.1.jar": "both", "Ping-Wheel-1.12.2-neoforge-1.21.1.jar": "both",
"Placebo-1.21.1-9.9.1.jar": "both",
"PuzzlesLib-v21.1.51-1.21.1-NeoForge.jar": "both", "PuzzlesLib-v21.1.51-1.21.1-NeoForge.jar": "both",
"Searchables-neoforge-1.21.1-1.0.2.jar": "client", "Searchables-neoforge-1.21.1-1.0.2.jar": "client",
"TerraBlender-neoforge-1.21.1-4.1.0.8.jar": "both",
"ToastControl-1.21.1-9.0.1.jar": "client",
"TravelersTitles-1.21.1-NeoForge-5.1.3.jar": "client", "TravelersTitles-1.21.1-NeoForge-5.1.3.jar": "client",
"UsefulSlime-neoforge-1.21-1.12.1.jar": "both", "UsefulSlime-neoforge-1.21-1.12.1.jar": "both",
"YungsApi-1.21.1-NeoForge-5.1.6.jar": "both", "YungsApi-1.21.1-NeoForge-5.1.6.jar": "both",
"YungsBetterCaves-1.21.1-NeoForge-3.1.4.jar": "both",
"YungsBetterDesertTemples-1.21.1-NeoForge-4.1.5.jar": "both",
"YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar": "both",
"YungsBetterEndIsland-1.21.1-NeoForge-3.1.2.jar": "both",
"YungsBetterJungleTemples-1.21.1-NeoForge-3.1.2.jar": "both",
"YungsBetterMineshafts-1.21.1-NeoForge-5.1.1.jar": "both",
"YungsBetterNetherFortresses-1.21.1-NeoForge-3.1.5.jar": "both",
"YungsBetterOceanMonuments-1.21.1-NeoForge-4.1.2.jar": "both",
"YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar": "both",
"YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar": "both",
"YungsBridges-1.21.1-NeoForge-5.1.1.jar": "both",
"YungsCaveBiomes-1.21.1-NeoForge-3.1.1.jar": "both",
"amendments-neoforge-1.21-2.1.5.jar": "both",
"appleskin-neoforge-mc1.21-3.0.9.jar": "client", "appleskin-neoforge-mc1.21-3.0.9.jar": "client",
"architectury-13.0.8-neoforge.jar": "both", "architectury-13.0.8-neoforge.jar": "both",
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both", "baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
"balm-neoforge-1.21.1-21.0.58.jar": "both", "balm-neoforge-1.21.1-21.0.58.jar": "both",
"bellsandwhistles-0.4.7-1.21.1.jar": "both",
"buildinggadgets2-1.3.9.jar": "both", "buildinggadgets2-1.3.9.jar": "both",
"caelus-neoforge-7.0.1+1.21.1.jar": "both",
"cardboardboxes-1.21.1-0.1.2.jar": "both", "cardboardboxes-1.21.1-0.1.2.jar": "both",
"charginggadgets-1.14.1.jar": "both", "charginggadgets-1.14.1.jar": "both",
"cloth-config-15.0.140-neoforge.jar": "both", "cloth-config-15.0.140-neoforge.jar": "both",
@@ -55,11 +74,10 @@
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both", "create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
"create_mechanical_extruder-1.21.1-2.2.0-6.0.8.jar": "both", "create_mechanical_extruder-1.21.1-2.2.0-6.0.8.jar": "both",
"create_mechanical_spawner-1.21.1-1.3.0-6.0.8.jar": "both", "create_mechanical_spawner-1.21.1-1.3.0-6.0.8.jar": "both",
"create_vibrant_vaults-0.3.2.jar": "both",
"createaddition-1.6.0.jar": "both", "createaddition-1.6.0.jar": "both",
"createdeco-2.1.3.jar": "both", "createdeco-2.1.3.jar": "both",
"createornithopterglider-1.2.0-1.21.1.jar": "both",
"createsifter-1.21.1-2.2.1.jar": "both", "createsifter-1.21.1-2.2.1.jar": "both",
"createtransmission-1.2.1+neoforge-create6-1.21.1.jar": "both",
"createultimine-1.21.1-neoforge-1.3.2.jar": "both", "createultimine-1.21.1-neoforge-1.3.2.jar": "both",
"curios-neoforge-9.5.1+1.21.1.jar": "both", "curios-neoforge-9.5.1+1.21.1.jar": "both",
"cwb-neoforge-3.0.0+mc1.21.jar": "client", "cwb-neoforge-3.0.0+mc1.21.jar": "client",
@@ -68,23 +86,29 @@
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both", "easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both", "easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
"easydisenchanting-neoforge-1.0.0-1.21.1.jar": "both", "easydisenchanting-neoforge-1.0.0-1.21.1.jar": "both",
"emotecraft-for-MC1.21.1-2.4.12-neoforge.jar": "both",
"enderrf-1.21.1-1.0.1.jar": "both", "enderrf-1.21.1-1.0.1.jar": "both",
"entity_model_features-3.2.4-1.21-neoforge.jar": "client", "entity_model_features-3.2.4-1.21-neoforge.jar": "client",
"entity_texture_features_1.21-neoforge-7.1.jar": "client", "entity_texture_features_1.21-neoforge-7.1.jar": "client",
"entityculling-neoforge-1.10.2-mc1.21.1.jar": "client", "entityculling-neoforge-1.10.2-mc1.21.1.jar": "client",
"etched-5.1.0.jar": "both",
"fancymenu_neoforge_3.9.1_MC_1.21.1.jar": "client", "fancymenu_neoforge_3.9.1_MC_1.21.1.jar": "client",
"fastleafdecay-35.jar": "both", "fastleafdecay-35.jar": "both",
"ferritecore-7.0.3-neoforge.jar": "both", "ferritecore-7.0.3-neoforge.jar": "both",
"fishingoverhaul-1.21.1-1.2.1.jar": "both",
"ftb-chunks-neoforge-2101.1.17.jar": "both", "ftb-chunks-neoforge-2101.1.17.jar": "both",
"ftb-essentials-neoforge-2101.1.9.jar": "both", "ftb-essentials-neoforge-2101.1.9.jar": "both",
"ftb-library-neoforge-2101.1.31.jar": "both", "ftb-library-neoforge-2101.1.31.jar": "both",
"ftb-teams-neoforge-2101.1.10.jar": "both", "ftb-teams-neoforge-2101.1.10.jar": "both",
"ftb-ultimine-neoforge-2101.1.14.jar": "both", "ftb-ultimine-neoforge-2101.1.14.jar": "both",
"functionalstorage-1.21.1-1.5.7.jar": "both", "functionalstorage-1.21.1-1.5.7.jar": "both",
"fusion-1.3.2b-neoforge-mc1.21.1.jar": "client",
"fzzy_config-0.7.6+1.21+neoforge.jar": "both", "fzzy_config-0.7.6+1.21+neoforge.jar": "both",
"geckolib-neoforge-1.21.1-4.8.4.jar": "both",
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both", "handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
"interiors-1.21.1-neoforge-0.6.1.jar": "both", "immersive_paintings-neoforge-1.21.1-0.7.8.jar": "both",
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both", "inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
"iris-neoforge-1.8.12+mc1.21.1.jar": "client",
"jamlib-neoforge-1.3.6+1.21.1.jar": "both", "jamlib-neoforge-1.3.6+1.21.1.jar": "both",
"jei-1.21.1-neoforge-19.27.0.340.jar": "both", "jei-1.21.1-neoforge-19.27.0.340.jar": "both",
"konkrete_neoforge_1.9.9_MC_1.21.jar": "client", "konkrete_neoforge_1.9.9_MC_1.21.jar": "client",
@@ -94,24 +118,30 @@
"kubejsdelight-1.1.6.jar": "both", "kubejsdelight-1.1.6.jar": "both",
"laserio-1.9.11.jar": "both", "laserio-1.9.11.jar": "both",
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both", "lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
"lootjs-neoforge-1.21.1-3.7.0.jar": "both",
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both", "lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both", "mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
"mechanicals-1.21.1-1.1.0.jar": "both", "mechanicals-1.21.1-1.1.0.jar": "both",
"melody_neoforge_1.0.10_MC_1.21.jar": "client", "melody_neoforge_1.0.10_MC_1.21.jar": "client",
"melter-1.21.1-1.8.2.jar": "both",
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both", "mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
"modernfix-neoforge-5.27.12+mc1.21.1.jar": "both", "modernfix-neoforge-5.27.12+mc1.21.1.jar": "both",
"moogs_structures-neoforge-1.21.1-3.0.0.jar": "both",
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
"morefunctionalstorage-1.2.3.jar": "both", "morefunctionalstorage-1.2.3.jar": "both",
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client", "moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
"picaxe-1.21.1-neoforge-1.0.9.jar": "both",
"polymorph-neoforge-1.1.0+1.21.1.jar": "both", "polymorph-neoforge-1.1.0+1.21.1.jar": "both",
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client", "ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
"resourcefullib-neoforge-1.21-3.0.12.jar": "both", "resourcefullib-neoforge-1.21-3.0.12.jar": "both",
"rhino-2101.2.7-build.85.jar": "both", "rhino-2101.2.7-build.85.jar": "both",
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both", "rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both", "simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
"sliceanddice-forge-4.2.4.jar": "both", "sliceanddice-forge-4.2.4.jar": "both",
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client", "sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client", "sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
"solcarrot-1.21.1-1.16.6.jar": "both",
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both", "sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both", "sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both", "sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
@@ -124,9 +154,9 @@
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both", "torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both", "trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
"txnilib-neoforge-1.0.24-1.21.1.jar": "both", "txnilib-neoforge-1.0.24-1.21.1.jar": "both",
"ulicraftmod-1.21.1-1.0.0-6.0.10.jar": "both",
"utilitarian-1.21.1-0.19.2.jar": "both", "utilitarian-1.21.1-0.19.2.jar": "both",
"voicechat-neoforge-1.21.1-2.6.18.jar": "both", "voicechat-neoforge-1.21.1-2.6.18.jar": "both",
"welcomescreen-neoforge-1.0.0-1.21.1.jar": "client",
"wm_binaries-3.0.0-rc.1.jar": "client", "wm_binaries-3.0.0-rc.1.jar": "client",
"yeetusexperimentus-neoforge-87.0.0.jar": "client", "yeetusexperimentus-neoforge-87.0.0.jar": "client",
"yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both" "yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both"

View File

@@ -26,11 +26,15 @@ upstream ulicraft_caddy {
server 127.0.0.1:${CADDY_HTTP_PORT}; server 127.0.0.1:${CADDY_HTTP_PORT};
} }
# Brute-force throttle for the publicly-exposed Filestash /admin console (it is
# the only credential on files.), applied in its location block below.
limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
# HTTP -> HTTPS for every name. # HTTP -> HTTPS for every name.
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN}; server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN};
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
@@ -125,6 +129,44 @@ server {
} }
} }
# Filestash player file share. Uploads need a real body limit (nginx defaults to
# 1m, which rejects almost anything worth sharing) and streaming rather than
# spooling — a 2g upload buffered to disk would write it twice on a host whose
# disk is already the shared failure domain for Minecraft + Drasl + backups.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name files.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 2g; # uploads; other vhosts stay at 4m
proxy_request_buffering off; # stream uploads, don't spool them to disk
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# /admin is reachable on purpose, so throttle it: it is the only password
# on this vhost. 6 req/min, small burst.
location /admin {
limit_req zone=files_admin burst=3 nodelay;
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;

View File

@@ -13,9 +13,10 @@ Everything is driven by one env var in `.env`:
BASE_DOMAIN=ulicraft.net # all services are subdomains of this BASE_DOMAIN=ulicraft.net # all services are subdomains of this
``` ```
`render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates. `render-config.sh` substitutes `${BASE_DOMAIN}` into the templates (plus the
derived `REGISTRATION_*` and `FILES_*` values — see `20-files.md`).
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running (`auth.` `avatar.` `status.` `files.` `distribution.` `www.`) at the host running
nginx; that is configured outside this repo. nginx; that is configured outside this repo.
## Subdomain / ingress map ## Subdomain / ingress map
@@ -23,9 +24,9 @@ nginx; that is configured outside this repo.
``` ```
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/ ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API) auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
pack.ulicraft.net → nginx → caddy → /srv/pack (packwiz pack metadata)
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer) avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page) status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
files.ulicraft.net → nginx → caddy → filestash:8334 (player file share, shared login)
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo) distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP) mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
``` ```
@@ -45,36 +46,35 @@ resolves its own subdomains internally:
caddy: caddy:
networks: networks:
mcnet: mcnet:
aliases: [ "auth.${BASE_DOMAIN}", "pack.${BASE_DOMAIN}" ] aliases: [ "auth.${BASE_DOMAIN}" ]
``` ```
Result: `http://auth.ulicraft.net/authlib-injector` and Result: `http://auth.ulicraft.net/authlib-injector` resolves identically inside
`http://pack.ulicraft.net/pack.toml` resolve identically inside the stack the stack (minecraft → caddy) as the public name does outside it (client → nginx
(minecraft → caddy) as the public names do outside it (client → nginx → caddy). → caddy).
#### HTTPS variant (`extra_hosts: host-gateway`) #### HTTPS variant (`extra_hosts: host-gateway`)
The caddy alias only serves **http** on `mcnet` (port 80). Once the stack moved The caddy alias only serves **http** on `mcnet` (port 80). Once the stack moved
to https URLs (drasl `BaseURL`, `PACKWIZ_URL`, the authlib-injector agent must to https URLs (drasl `BaseURL`; the authlib-injector agent must match the
match the client's https endpoint), the internal http alias is no longer enough — client's https endpoint), the internal http alias is no longer enough — the
the container needs to reach a **TLS** terminator holding a valid cert. container needs to reach a **TLS** terminator holding a valid cert.
Caddy does not terminate TLS internally; the host's nginx does (on `0.0.0.0:443`). Caddy does not terminate TLS internally; the host's nginx does (on `0.0.0.0:443`).
So pin the public names to the host in the service, not to caddy: So pin the public name to the host in the service, not to caddy:
```yaml ```yaml
minecraft: minecraft:
extra_hosts: extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway" - "auth.${BASE_DOMAIN}:host-gateway"
- "pack.${BASE_DOMAIN}:host-gateway"
``` ```
`/etc/hosts` (extra_hosts) wins over the Docker resolver, so the container reaches `/etc/hosts` (extra_hosts) wins over the Docker resolver, so the container reaches
the host's nginx (valid LE cert) → caddy → service. This is required because the host's nginx (valid LE cert) → caddy → service. This is required because
**containers do not inherit the host's `/etc/hosts`** — on cochi the host resolves **containers do not inherit the host's `/etc/hosts`** — on cochi the host resolves
`auth./pack.` to the public IP, but inside a container the LAN resolver returns a `auth.` to the public IP, but inside a container the LAN resolver returns a
dead address (`192.168.0.3:443`, connection refused), which crash-loops the dead address (`192.168.0.3:443`, connection refused), which crash-loops session
packwiz install on boot. `host-gateway` sidesteps DNS entirely. validation on boot. `host-gateway` sidesteps DNS entirely.
## Build / run flow ## Build / run flow
@@ -83,7 +83,7 @@ PREP (run once, online):
1. render configs → tooling/render-config.sh (08-tooling.md) 1. render configs → tooling/render-config.sh (08-tooling.md)
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md) 2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md) 3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
4. curate packwiz pack → see 04-packwiz.md 4. sync server mods → tooling/sync-server-mods.sh (04-mods.md)
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md) 5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md) 6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
@@ -92,15 +92,16 @@ UP:
``` ```
Bring-up is a single command. There are no run modes and no override compose Bring-up is a single command. There are no run modes and no override compose
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr,
and uptime-kuma. mc-status, uptime-kuma and filestash.
## Why each hard call was made ## Why each hard call was made
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of - **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
this stack's scope. Re-add later if desired. this stack's scope. Re-add later if desired.
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth/CF CDNs; - **Distribution-fed mods** — the modset's source of truth is the HeliosLauncher
server and clients fetch jars from there. The internet is assumed present. distribution repo; clients install the full set, the server loads a filtered
`both`/`server` subset from `./server-mods`. See `04-mods.md`.
- **Fjord launcher** has authlib-injector built in → no manual JVM agent on - **Fjord launcher** has authlib-injector built in → no manual JVM agent on
clients. clients.
- **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them - **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them
@@ -120,8 +121,11 @@ and uptime-kuma.
│ ├── config.toml.tmpl # render-config.sh source │ ├── config.toml.tmpl # render-config.sh source
│ └── config.toml # generated (gitignored) │ └── config.toml # generated (gitignored)
├── nmsr/ # avatar renderer config + Dockerfile context ├── nmsr/ # avatar renderer config + Dockerfile context
├── filestash/
│ ├── config.json.tmpl # render-config.sh source (files. share)
│ └── config.json # generated, mounted :ro (gitignored — holds hashes)
├── docker/nmsr/ # nmsr image build context ├── docker/nmsr/ # nmsr image build context
├── pack/ # packwiz source of truth ├── server-mods/ # filtered server modset (synced, gitignored)
├── launcher/ # vendored Fjord launcher releases (gitignored) ├── launcher/ # vendored Fjord launcher releases (gitignored)
├── runtime/ # authlib-injector.jar ├── runtime/ # authlib-injector.jar
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md) ├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
@@ -135,7 +139,7 @@ and uptime-kuma.
- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx - `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
- `03-drasl.md` — auth (password login) - `03-drasl.md` — auth (password login)
- `04-packwiz.md`modpack source - `04-mods.md`distribution-fed mod volume + server filtering (supersedes `04-packwiz.md`)
- `05-minecraft.md` — itzg NeoForge server - `05-minecraft.md` — itzg NeoForge server
- `06-launcher.md` — Fjord launcher fetch + distribution - `06-launcher.md` — Fjord launcher fetch + distribution
- `07-mc-backup.md` — world backups - `07-mc-backup.md` — world backups
@@ -146,9 +150,11 @@ and uptime-kuma.
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress - `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
- `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED) - `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED)
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED) - `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
- `19-routes.md` — HTTP route reference (nmsr / landing / auth + internal) - `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
- `20-files.md` — Filestash player file share (`files.`, one shared login)
## Boot/dependency order ## Boot/dependency order
`caddy` (aliases) → `drasl``minecraft` (depends on drasl + pack served by `caddy` (aliases) → `drasl``minecraft` (depends on drasl) → `mc-backup`.
caddy) → `mc-backup`. `nmsr` and `uptime-kuma` are always-on and join `mcnet`. `nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
(no ordering constraints — none of them are on the auth/boot path).

View File

@@ -7,9 +7,10 @@ nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published onl
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`); on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
nginx reverse-proxies every public vhost to it by Host header. Roles: nginx reverse-proxies every public vhost to it by Host header. Roles:
1. **Reverse proxy**`auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma. 1. **Reverse proxy**`auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
2. **Static**`pack.` serves packwiz metadata; apex serves the landing page + `files.` → filestash.
`/launcher/` downloads; `distribution.` serves a static site from another repo. 2. **Static** — apex serves the landing page + `/launcher/` downloads;
`distribution.` serves a static site from another repo.
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders — Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
no template render needed. no template render needed.
@@ -22,31 +23,35 @@ caddy:
mcnet: mcnet:
aliases: aliases:
- "auth.${BASE_DOMAIN}" - "auth.${BASE_DOMAIN}"
- "pack.${BASE_DOMAIN}"
``` ```
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` and Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` from inside
`http://pack.ulicraft.net/pack.toml` from inside the stack. the stack.
## conf.d snippets ## conf.d snippets
The Caddyfile imports per-vhost snippets from `caddy/conf.d/`: The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
- `00-core.caddy``auth.` → drasl, `pack.``/srv/pack` (browse). - `00-core.caddy``auth.` → drasl.
- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`). - `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
- `30-distribution.caddy``distribution.``/srv/distribution` (static, from - `30-distribution.caddy``distribution.``/srv/distribution` (static, from
`DISTRIBUTION_WEB_ROOT`, another repo). `DISTRIBUTION_WEB_ROOT`, another repo).
- `40-avatar.caddy``avatar.``nmsr:8080` (skin/avatar renderer). - `40-avatar.caddy``avatar.``nmsr:8080` (skin/avatar renderer).
- `50-status.caddy``status.``uptime-kuma:3001` (status page). - `50-status.caddy``status.``uptime-kuma:3001` (status page).
- `60-files.caddy``files.``filestash:8334` (player file share, `20-files.md`).
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`, All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
`./pack:/srv/pack:ro`, `./launcher:/srv/launcher:ro`, `./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
`${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
> **Don't rewrite the `Host` header for `files.`** — filestash rejects any
> request whose Host doesn't match its configured `general.host`, with *"only
> traffic from X is allowed"* (a 403 that reads like an auth failure). nginx
> forwards the original Host and caddy passes it through untouched. Keep it that
> way.
## Notes / risks ## Notes / risks
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is - drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
fine; nginx passes the original Host through to caddy → drasl. fine; nginx passes the original Host through to caddy → drasl.
- `file_server browse` gives a directory listing — handy for debugging the pack.
- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the - `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the
read-only `/srv/www`; `handle_path` strips the prefix. read-only `/srv/www`; `handle_path` strips the prefix.
@@ -55,8 +60,8 @@ All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:r
- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80` - [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env - [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
- [ ] Caddyfile imports `conf.d/*.caddy` - [ ] Caddyfile imports `conf.d/*.caddy`
- [ ] Network aliases for `auth.` and `pack.` subdomains - [ ] Network alias for `auth.` subdomain
- [ ] Mount `./www`, `./pack`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}` - [ ] Mount `./www`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}`
- [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds) - [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds)
- [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml) - [ ] Verify internal alias resolution from `minecraft` container (curl authlib-injector)
- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads) - [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)

View File

@@ -25,16 +25,95 @@ mirrors `/mods` into `/data/mods` and prunes removed ones (`REMOVE_OLD_MODS`).
## Auth / config ## Auth / config
``` ```
ONLINE_MODE=FALSE ONLINE_MODE=TRUE
ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false
JVM_OPTS=-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector JVM_OPTS=-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector
``` ```
authlib URL uses the `auth` subdomain; resolves internally via the caddy alias. authlib URL uses the `auth` subdomain; resolves internally via the caddy alias.
(Server still needs the agent even though Fjord clients have it built in.) (Server still needs the agent even though Fjord clients have it built in.)
**`ONLINE_MODE` MUST be true.** authlib-injector works by redirecting the
server's normal online-auth handshake from Mojang to Drasl. With
`ONLINE_MODE=FALSE` the server skips that handshake entirely → players get
offline-hash UUIDs, no session validation, and **no skins** (the server never
fetches textures from Drasl). The thing that must stay *off* for 1.21+ is the
**secure profile** (`ENFORCE_SECURE_PROFILE=FALSE` + Drasl `SignPublicKeys=false`),
not online-mode. Symptom of getting this wrong: everyone can join but everyone is
Steve, and `usercache.json` shows offline-hash UUIDs.
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`. Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
RCON enabled for mc-backup. RCON enabled for mc-backup.
## Server operators (OP)
**Do NOT use the itzg `OPS` env var.** It resolves usernames to UUIDs via a
Mojang lookup, but this server is `ONLINE_MODE=TRUE` validated against Drasl —
Drasl assigns its own UUIDs (not Mojang, not the offline-hash). A Mojang-resolved UUID
would not match the player's real session UUID, so the op entry would be inert
(and could clobber a correct `ops.json` on reboot).
**Op via RCON instead.** The player must have connected at least once so their
real Drasl UUID is cached in `/data/usercache.json`; then `op <name>` writes that
cached UUID to `ops.json`. On the host:
```
ssh cochi
docker exec minecraft rcon-cli list # confirm cache / who's on
docker exec minecraft sh -c 'cat /data/usercache.json' # verify name→UUID cached
docker exec minecraft rcon-cli op <name>
docker exec minecraft sh -c 'cat /data/ops.json' # verify level 4 + right UUID
```
`ops.json` lives in the `mc_data` volume → persists across restarts; no reboot
needed (takes effect live). Current op: `oier`
(`7fbc9adb-bcf4-3913-9a99-46d46acd1009`), level 4, set 2026-06-21.
## Simple Voice Chat — `voice_host` (NOT repo-tracked)
Voice chat is UDP `24454/udp`, published in compose. The mod auto-generates
`/data/config/voicechat/voicechat-server.properties` in the `mc_data` volume.
**Gotcha — `voice_host` must be set or remote clients get a red/crossed icon.**
Behind any NAT/Docker the server announces an address for clients' UDP
connection; if `voice_host=` is empty it auto-detects the internal Docker IP →
unreachable → the secret (TCP) is sent but the UDP handshake never completes
("Sent secret" with no following "successfully connected to voice chat").
Set on prod:
```
docker exec minecraft sed -i 's/^voice_host=.*/voice_host=ulicraft.net/' \
/data/config/voicechat/voicechat-server.properties
docker restart minecraft # mod reads props only at startup
```
**⚠️ This value lives in the `mc_data` volume, NOT auto-managed from this repo.**
It survives restarts and `compose down/up`, but a **world wipe / fresh `mc_data`**
regenerates the file with `voice_host=` empty → voice silently breaks again.
A known-good prod snapshot is committed at
`server-configs/voicechat-server.properties` (reference copy, `voice_host=ulicraft.net`).
After a world reset, either re-run the `sed` + `docker restart` above, or restore
the snapshot:
```
docker cp server-configs/voicechat-server.properties \
minecraft:/data/config/voicechat/voicechat-server.properties
docker restart minecraft
```
(Snapshot is not auto-mounted — to make it durable, add a bind mount of
`server-configs/voicechat-server.properties` into `/data/config/voicechat/` in
compose.)
Verify after a client joins:
`docker logs minecraft 2>&1 | grep -i "connected to voice"` → expect
`Player <name> ... successfully connected to voice chat`.
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.
## Tasks ## Tasks
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector` - [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
@@ -44,3 +123,4 @@ RCON enabled for mc-backup.
- [ ] `depends_on`: drasl (authlib must resolve at boot) - [ ] `depends_on`: drasl (authlib must resolve at boot)
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false` - [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
- [ ] Verify a client joins with their drasl skin - [ ] Verify a client joins with their drasl skin
- [ ] After any world wipe: re-set `voice_host` (see Simple Voice Chat section)

View File

@@ -4,7 +4,7 @@
`itzg/mc-backup` alongside the server, talking via RCON. Snapshots the world every `itzg/mc-backup` alongside the server, talking via RCON. Snapshots the world every
6h, prunes after 14 days. World-only (no mod jars — mods are reproducible from the 6h, prunes after 14 days. World-only (no mod jars — mods are reproducible from the
packwiz pack). Unchanged from the original design; lowest-priority service. distribution). Unchanged from the original design; lowest-priority service.
## Config (carry-over) ## Config (carry-over)

View File

@@ -1,4 +1,4 @@
# Tooling — config rendering, pack render, ingress, fetch scripts # Tooling — config rendering, mod sync, ingress, fetch scripts
## Summary ## Summary
@@ -10,11 +10,13 @@ half-broken output.
Surviving scripts: Surviving scripts:
- `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only) - `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only)
- `render-pack.sh` — render packwiz pack templates + index - `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)
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy) - `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) - `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) - `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md)
- `add-custom-mod.sh` — add a custom (non-CDN) mod to the pack - `fetch-authlib.sh` — vendor the authlib-injector jar → `runtime/`
- `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md) - `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md)
## render-config.sh ## render-config.sh
@@ -28,8 +30,8 @@ Targets:
- `drasl/config/config.toml.tmpl``drasl/config/config.toml` - `drasl/config/config.toml.tmpl``drasl/config/config.toml`
- `nmsr/config.toml.tmpl``nmsr/config.toml` - `nmsr/config.toml.tmpl``nmsr/config.toml`
It then delegates pack templates (`mods/*.pw.toml.tmpl`, CustomSkinLoader, …) + Mods are **not** rendered — they are synced from the distribution by
the packwiz index to `render-pack.sh` — the single source of pack render. `sync-server-mods.sh` (see 04-mods.md), no templating involved.
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -46,7 +48,6 @@ render() { # $1=tmpl $2=out
render drasl/config/config.toml.tmpl drasl/config/config.toml render drasl/config/config.toml.tmpl drasl/config/config.toml
render nmsr/config.toml.tmpl nmsr/config.toml render nmsr/config.toml.tmpl nmsr/config.toml
tooling/render-pack.sh
``` ```
## render-nginx.sh ## render-nginx.sh
@@ -81,7 +82,7 @@ certs/ # Let's Encrypt certs
## Tasks ## Tasks
- [ ] Write `tooling/render-config.sh` (above), `chmod +x` - [ ] Write `tooling/render-config.sh` (above), `chmod +x`
- [ ] Write `tooling/render-pack.sh` (pack templates + index) - [ ] Write `tooling/sync-server-mods.sh` (see 04-mods.md)
- [ ] Write `tooling/render-nginx.sh` (see 15) - [ ] Write `tooling/render-nginx.sh` (see 15)
- [ ] Write `tooling/issue-letsencrypt.sh` (see 15) - [ ] Write `tooling/issue-letsencrypt.sh` (see 15)
- [ ] Write `tooling/fetch-launcher.sh` (see 06) - [ ] Write `tooling/fetch-launcher.sh` (see 06)

View File

@@ -39,7 +39,7 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
> Internal `minecraft` resolves because uptime-kuma is on `mcnet` (the container > Internal `minecraft` resolves because uptime-kuma is on `mcnet` (the container
> name is the hostname). It is **not** routed through caddy — caddy only fronts > name is the hostname). It is **not** routed through caddy — caddy only fronts
> HTTP vhosts, and MC is raw TCP. Do not point the monitor at `caddy` or any > HTTP vhosts, and MC is raw TCP. Do not point the monitor at `caddy` or any
> `status.`/`pack.` alias. > `status.` alias.
## Notes ## Notes
@@ -50,6 +50,14 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
- Put both MC monitors on the public **status page** - Put both MC monitors on the public **status page**
(Settings → Status Pages) so guests can self-check before pinging you. (Settings → Status Pages) so guests can self-check before pinging you.
- Optional companion HTTP monitors for the web stack: - Optional companion HTTP monitors for the web stack:
`https://auth.${BASE_DOMAIN}`, `https://pack.${BASE_DOMAIN}/pack.toml`, `https://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
`https://${BASE_DOMAIN}` — a red `pack.toml` here explains client mod-mismatch `https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
join failures. `distribution.` here explains client mod-mismatch join failures.
> **An HTTP monitor cannot see an SPA break.** It asserts "the server returned
> 200", which is a much weaker claim than "the app works". `files.` (Filestash)
> proved this on 2026-07-14: a bad `general.host` made every client-side redirect
> resolve to `http://https://files…`, so the site was unusable in every browser
> while `/` kept returning 200 and the monitor stayed **green**. If you want a
> monitor with teeth on a JS app, use Kuma's **Keyword** type against a string
> the *rendered* page must contain, and don't read green as "users can log in".

View File

@@ -50,6 +50,15 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh` 17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block` 18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
## Phase 11 — file share (DONE, 2026-07-14)
19. `feat(files): add Filestash player file share at files.${BASE_DOMAIN}`
compose service (digest-pinned) + `60-files.caddy` + nginx vhost (2g uploads,
`/admin` rate limit) + `filestash/config.json.tmpl` rendered `:ro` +
`FILES_*` env block. One shared htpasswd login, writable. See `20-files.md`.
- [ops] `files` added to `LE_SUBDOMAINS``issue-letsencrypt.sh`, then
`render-nginx.sh --install` (a new subdomain needs BOTH; `update-all.sh`
does not touch host nginx).
## Phase 10 — operational (no commits) ## Phase 10 — operational (no commits)
- [ops] Prep online (once): `tooling/render-config.sh`, build landing - [ops] Prep online (once): `tooling/render-config.sh`, build landing
(`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources (`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources

View File

@@ -56,8 +56,19 @@ Everyday landing/config tweak (no world downtime):
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh' ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh'
``` ```
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr from `.env`; > **Same inode trap for `filestash/config.json`.** It is a single-file bind mount
halts on a bad `REGISTRATION_MODE`) → `sync-server-mods.sh` (mirror the > *and* it is re-rendered on every `update-all.sh` run, so a **rolling** update
> after any `FILES_*` change (rotating the shared password, flipping
> `FILES_AUTH`) leaves filestash reading the stale inode. Apply with
> `docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
> Also: never run `docker compose up` on the host without rendering first — a
> missing `filestash/config.json` makes Docker create a **directory** at that
> path. See `plan/20-files.md`.
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr/filestash
from `.env`; halts on a bad `REGISTRATION_MODE`, a bad `FILES_AUTH`, or a
`FILES_PASSWORD_HASH` in a format the htpasswd plugin can't verify) →
`sync-server-mods.sh` (mirror the
`both`/`server` subset from `mods-sides.json` out of `$DISTRIBUTION_WEB_ROOT` `both`/`server` subset from `mods-sides.json` out of `$DISTRIBUTION_WEB_ROOT`
into `./server-mods`) → `build-modlist.py` (regen landing `mods.json` + logos; into `./server-mods`) → `build-modlist.py` (regen landing `mods.json` + logos;
**run under `python>=3.11`** — cochi's default `python3` is 3.10, but **run under `python>=3.11`** — cochi's default `python3` is 3.10, but
@@ -75,30 +86,25 @@ pull` never updates them. `update-all.sh` regenerates both every run
`render-config.sh` changed its config (Drasl's `RequireInvite`/CORS/RateLimit) — `render-config.sh` changed its config (Drasl's `RequireInvite`/CORS/RateLimit) —
no world downtime. no world downtime.
## Known host-local divergence on `cochi` (uncommitted) ## Host-local divergence on `cochi` — RESOLVED (verified 2026-07-14)
`cochi` carries deliberate local edits that are **not in the repo**, so **There are no longer any tracked modifications on the host.** `git diff HEAD`
`git pull --ff-only` **aborts** ("local changes would be overwritten") whenever is empty; `git pull --ff-only` fast-forwards cleanly (confirmed on the filestash
an incoming commit also touches `docker-compose.yml`. As of 2026-06-09 the host deploy). The old divergence — `docker-compose.yml` forced `http`→`https`,
has, uncommitted: `landing/package.json`, `landing/pnpm-workspace.yaml` — has been converged into
the repo. The stashpullpop dance previously documented here is **no longer
needed**.
- `docker-compose.yml` — internal URLs forced `http`→`https` What remains on the host is **untracked only**, so it never blocks a pull:
(`JVM_OPTS` authlib-injector), for the in-container HTTPS path. (The old
`PACKWIZ_URL` divergence is moot — packwiz was removed; see `04-mods.md`.)
- `dnsmasq/` (new), `caddy/caddy-root-ca.crt`, `landing/pnpm-workspace.yaml` —
internal DNS + private CA so containers resolve/trust `https://auth.` etc.
- `landing/package.json` modified. (The former `pack/` divergence is resolved —
`pack/` and `custom/` were deleted with the packwiz removal.)
**Do not steamroll it.** Before a deploy that edits `docker-compose.yml`, either: - `dnsmasq/`, `caddy/caddy-root-ca.crt` — internal DNS + private CA so containers
1. **Converge** — commit the host edits to `main` + push, then a clean ff-pull resolve/trust `https://auth.` etc. Host-local infra, deliberately not in git.
deploys (preferred — ends the divergence). The https/dnsmasq/CA setup belongs - `pack/` — **stale leftover** from the packwiz era (packwiz was removed; see
in the repo. `04-mods.md`). Nothing reads it. Safe to delete.
2. **Stashpullpop** — `git stash` → `git pull --ff-only` → `git stash pop`.
Non-destructive; safe only when the incoming and host edits are in different
regions of the file. Stop on a pop conflict.
Never `git reset --hard` / discard on the host without confirming first. Still true, and worth keeping: never `git reset --hard` or discard on the host
without confirming first, and re-check `git status` there before any deploy that
touches tracked files — a *new* host-local edit would reintroduce the abort.
## What survives a hard restart ## What survives a hard restart
@@ -116,8 +122,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
- SSH access as the `cochi` alias. - SSH access as the `cochi` alias.
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`, - `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`, `CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
defaults `invite`), and the Let's Encrypt block (`render-config.sh` halts on an defaults `invite`), the `FILES_*` block (`plan/20-files.md`), and the Let's
unset `BASE_DOMAIN` or an invalid `REGISTRATION_MODE`). Encrypt block. `render-config.sh` halts on an unset `BASE_DOMAIN`, an invalid
`REGISTRATION_MODE`/`FILES_AUTH`, or a `FILES_PASSWORD_HASH` the htpasswd
plugin can't verify.
> **A missing `FILES_*` var takes the WHOLE stack down, not just filestash.**
> The compose service uses `${FILES_DATA_DIR:?}` / `${FILES_LOCAL_SECRET:?}`
> guards, and compose fails the entire file on an unset required var — so
> `up` starts *nothing*, and a `--hard` deploy has already run `down`. Check
> `.env` on the host **before** deploying a commit that adds a guarded service.
- Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`; - Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`;
falls back to `python3` if absent), `git`. falls back to `python3` if absent), `git`.
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`. - Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
@@ -127,11 +140,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
## Verify ## Verify
```bash ```bash
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, mc-status, uptime-kuma up docker compose ps # caddy drasl minecraft mc-backup nmsr mc-status uptime-kuma filestash
# self-hosted live status feed (plan/15-mc-status.md): # self-hosted live status feed (plan/15-mc-status.md):
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
docker compose logs -f minecraft # watch for "Done" # server ready — match the full marker, not a bare "Done" (that also hits mod-load lines)
docker compose logs -f minecraft | grep -m1 'Done ([0-9.]*s)! For help'
curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok" curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
# files. — 200 = live; anonymous API must be REFUSED (auth is on)
curl -s -o /dev/null -w 'files: %{http_code}\n' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/"
curl -s -H 'X-Requested-With: XmlHttpRequest' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/session" # want "Not authorised"
``` ```
## Rollback ## Rollback

View File

@@ -9,7 +9,7 @@ path — there are no compose overrides.
## Certs: one per name, via OVH DNS-01 ## Certs: one per name, via OVH DNS-01
`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each `tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each
subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`, plus the rest of subdomain (`${BASE_DOMAIN}`, `auth.…`, plus the rest of
`LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports `LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports
needed, so it works regardless of public reachability. needed, so it works regardless of public reachability.
@@ -55,7 +55,7 @@ the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
## DNS ## DNS
Point the public DNS for `${BASE_DOMAIN}` and every subdomain Point the public DNS for `${BASE_DOMAIN}` and every subdomain
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running (`auth.` `avatar.` `status.` `distribution.` `www.`) at the host running
nginx. DNS is configured **outside this repo**. The Minecraft container reaches nginx. DNS is configured **outside this repo**. The Minecraft container reaches
the stack's own names via caddy's `mcnet` aliases, not public DNS. the stack's own names via caddy's `mcnet` aliases, not public DNS.

View File

@@ -12,6 +12,7 @@
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` | | `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` | | `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` | | `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
| `files.${BASE_DOMAIN}` | `60-files.caddy` | `filestash:8334` |
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) | | `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
--- ---
@@ -58,6 +59,37 @@ base `https://auth.${BASE_DOMAIN}/drasl/api/v2` (`site.draslApiUrl`):
--- ---
## Files — `files.${BASE_DOMAIN}` (Filestash player share)
`reverse_proxy filestash:8334`. Screenshots / schematics / maps. **One shared
login** for every player (`FILES_AUTH=htpasswd`), writable. Full design:
`plan/20-files.md`.
- `/` — SPA. The login form renders **only** at
`GET /api/session/auth/?action=redirect&label=files`. Without `action=redirect`
the request falls through to the credential check, fails with empty creds and
303s back to `?action=redirect` — so a bare `/api/session/auth/?label=files`
returning a 303 is **normal, not a bug**. `POST` with `user`/`password`
authenticates.
- `GET /api/config``result.origin` must be `https://files.${BASE_DOMAIN}`.
It's built as `(force_ssl ? https:// : http://) + general.host`, so a scheme in
`general.host` yields `http://https://…` and silently breaks every browser
while the server keeps answering 200. Check this after any config change.
- `/api/*` — JSON API. Requires the header `X-Requested-With: XmlHttpRequest`
(the SPA sends it); without it Filestash's intrusion detection 403s the
request. Relevant when curling it by hand.
- `/admin` — admin console, own bcrypt password (`auth.admin`). Deliberately
public; nginx throttles it (`limit_req`, 6r/m). It **cannot save** — the
config is a read-only mount.
- **`?label=files` matters.** The attribute mapping is keyed by the connection
label; auth without it yields `backend error - Not Allowed`.
Filestash blocks any request whose `Host` doesn't match its `general.host`
(`https://files.${BASE_DOMAIN}`) — nginx forwards the original Host and caddy
passes it through, so don't rewrite it anywhere in the chain.
---
## Auth / Drasl — `auth.${BASE_DOMAIN}` ## Auth / Drasl — `auth.${BASE_DOMAIN}`
`reverse_proxy drasl:25585`. One service exposes three surfaces: `reverse_proxy drasl:25585`. One service exposes three surfaces:

203
plan/20-files.md Normal file
View File

@@ -0,0 +1,203 @@
# 20 — Filestash (`files.${BASE_DOMAIN}`)
> Web file share for player media (screenshots, schematics, maps, guides).
> One [Filestash](https://github.com/mickael-kerjean/filestash) container behind
> caddy, **one shared username/password for everybody**, **writable**.
> Config is rendered read-only from a template, like drasl/nmsr.
>
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
>
> Everything below was verified against the real image (digest pinned in
> compose) and against the upstream source, not inferred from the docs — the
> docs omit most of it.
## Shape
```
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334
${FILES_DATA_DIR} ──┘ (bind mount)
```
| Thing | Value |
|---|---|
| Vhost | `files.${BASE_DOMAIN}` |
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
| Access | read **+ write** for anyone with the password |
| Data | `${FILES_DATA_DIR}` on the host, **outside the repo** |
| State | named volume `filestash_state``/app/data/state` |
| Config | `filestash/config.json` rendered from `.tmpl`, mounted `:ro` |
| Backups | **none** — outside mc-backup's scope. Treat as disposable. |
| Quota | none — eyeballed |
## Decisions (settled — don't relitigate)
**Auth is htpasswd with one shared credential, not SSO.** Filestash's OIDC and
SAML middlewares are **enterprise-only**; the community build compiles in
`htpasswd`, `ldap`, `passthrough`, `wordpress`, `local` only (verified in
`server/plugin/index.go`). Keycloak was considered and dropped — it would need an
oauth2-proxy sidecar in front + `passthrough`. That path stays open but is not
built.
**One instance, everything behind the password.** No anonymous read tier.
Filestash's auth middleware is **global to the instance**, so "anonymous read +
authenticated write" is impossible in one container (that's the enterprise
RBAC). A two-instance split was designed and rejected as not worth it.
**`FILES_AUTH=none` is gated on the host being internal-only.** The off-switch
exists for when this server moves behind a LAN/VPN. **Do not set it while
`files.` is internet-reachable** — auth-off on a *writable* public share is an
open upload relay: someone parks malware or a phishing kit on your domain, under
your LE cert, and the abuse mail, the bandwidth, and a possible domain
blocklisting (which would take `auth.` and the apex down with it, breaking logins
and the launcher for every player) all land on you. `render-config.sh` prints a
loud warning when it renders this mode.
**Shared credential ⇒ no audit trail, no per-person revocation.** You will never
know who deleted a folder; rotating means re-telling everyone. Fine for a
friends' media share — but nothing here is backed up, so treat the share as
disposable and keep anything you'd miss elsewhere.
## What goes in it
| Content | Verdict |
|---|---|
| Screenshots, schematics, maps, guides | ✅ the point |
| World downloads / `./backups/*.tgz` | ❌ contains `playerdata/`, `stats/`, the full map — every player's inventory and coords, handed to anyone with the shared password |
| Mods / jars | ❌ redistribution; `distribution.${BASE_DOMAIN}` already serves the modset to launchers |
## `.env`
See `.env.example` for the annotated block. Summary:
| Var | What |
|---|---|
| `FILES_DATA_DIR` | host path of the share, outside the repo |
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
| `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` |
| `FILES_USER` | shared login name |
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'` |
| `FILES_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
| `LE_SUBDOMAINS` | must include `files` |
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
both secrets. Only the `.tmpl` is tracked.
## Deploy
```sh
tooling/render-config.sh # -> filestash/config.json (validates the JSON)
tooling/issue-letsencrypt.sh # picks up `files` from LE_SUBDOMAINS
tooling/render-nginx.sh --install # adds the files. vhost + reloads nginx
mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
docker compose up -d filestash caddy
```
Then add an Uptime Kuma HTTP monitor for `https://files.${BASE_DOMAIN}` (expect
`200` — the login page is a 200) and put it on the status page with the others.
## Gotchas (all of these were hit for real)
### The `local` backend is admin-gated — this is the one that wastes an evening
`plg_backend_local.Init()` **refuses to start** unless the connection params
carry `$LOCAL_BACKEND_SECRET` or the bcrypt admin password. Miss it and every
login dies at `backend error - Not Allowed`, which reads like a credentials
problem and isn't. `config.json`'s `attribute_mapping` injects the secret
server-side (Filestash calls this the "facade pattern"), so players never see it.
`FILES_LOCAL_SECRET` must be **identical** in the compose env and the rendered
config — `render-config.sh` renders both from the same var.
### `$2y$` bcrypt silently fails every login
The htpasswd plugin's bcrypt branch matches on the literal prefix **`$2a$`**.
`htpasswd -B` emits `$2y$`, which falls through to `return false` — so every
login fails with correct credentials and no useful log line. Use
**`openssl passwd -6`** (`$6$`, sha512) for `FILES_PASSWORD_HASH`.
`render-config.sh` hard-fails on a `$2y$`/`$2b$` hash rather than shipping a
config nobody can log into. (The *admin* password is checked by Go's bcrypt
directly, which does accept `$2y$` — but `caddy hash-password` gives `$2a$`
anyway, so just use that for both.)
### `general.host` is a BARE HOSTNAME — a scheme breaks the browser only
`"host": "files.ulicraft.net"`, **not** `"https://files.ulicraft.net"`, plus
`"force_ssl": true`. The SPA computes its redirect origin as
`(force_ssl ? "https://" : "http://") + host`, so a scheme in `host` yields
**`http://https://files.ulicraft.net`** and every client-side redirect dies —
the page loads, then silently fails to route anywhere.
The vicious part: the server-side Host check (`SecureOrigin`) **strips** the
scheme before comparing, so the server is perfectly happy. `curl /` returns 200,
the API answers, and the Uptime Kuma monitor stays green — **only real browsers
break**. Symptom in the log is a `POST /report?...msg=Redirecting to
http://https://...` line. Hit this on 2026-07-14; fixed in `config.json.tmpl`.
`force_ssl` only sets an HSTS header — it does **not** redirect, so it can't loop
behind the nginx→caddy (plain http) hop. It must be `true` or the SPA builds
`http://` origins on an https page.
### `general.host` must also match the incoming `Host` header
Filestash blocks every non-`/admin/` request whose `Host` differs from
`general.host`, with *"only traffic from X is allowed"* — a 403 that looks like
an auth failure. nginx forwards the original Host and caddy passes it through —
don't rewrite it anywhere.
### The login form only renders at `?action=redirect`
`GET /api/session/auth/` renders the htpasswd form **only** when
`action=redirect` is present; any other request falls through to the credential
check, fails with empty creds, and 303s back to `?action=redirect`. So the real
form URL is `/api/session/auth/?action=redirect&label=files`. A bare
`/api/session/auth/?label=files` returning a 303 is **normal**, not a bug.
### `config.json` is a single-file bind mount → stale inode
Same shape as the caddy `conf.d` gotcha. The mount pins the **inode**, so
re-rendering (or a `git pull`) writes a *new* file while the container keeps
reading the *old* one.
**After re-rendering: `docker compose up -d --force-recreate filestash`.**
`restart` is not enough. Rotating the shared password needs a recreate too.
### Don't set `APPLICATION_URL` or `ADMIN_PASSWORD` env
Either one makes Filestash **rewrite `config.json` at boot**, which fails against
the `:ro` mount. Both live in the rendered config instead (`general.host` /
`auth.admin`). `general.secret_key` must likewise be non-empty, or Filestash
generates one and tries to save that. With all three set in the file, Filestash
never attempts a boot write and the `:ro` mount is clean — you get one harmless
`cannot chmod config file` WARN in the log, and nothing else.
### The admin console cannot save
`/admin` loads and its login works, but `config.json` is `:ro`, so every save
silently fails. That's the deliberate trade for keeping git authoritative
(drasl/nmsr never write their configs; Filestash does, which is exactly why it
needs pinning). To change config: edit the `.tmpl`, re-render, recreate. To
re-derive the schema after an upstream upgrade: run the image locally against a
*writable* state volume, click it into shape, `docker cp` the result out.
### The API needs `X-Requested-With: XmlHttpRequest`
The SPA sends it; anything else (curl, a naive monitor) trips intrusion
detection and gets a 403 that looks like an auth bug. Only matters when probing
by hand — the Kuma monitor hits `/`, which is fine.
### Disk is the shared failure domain
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
full disk on `cochi` does not just break `files.` — it takes **Minecraft, Drasl,
and mc-backup** down with it. Eyeballing is the accepted plan. Revisit (quota,
separate partition, or a Kuma disk monitor) if the share sees real use.
### Pinned digest, not `latest`
`machines/filestash` publishes essentially only `latest`. Unpinned, any
`docker compose pull` silently ships a new upstream build of an internet-facing,
writable file manager. Pin the digest; upgrade deliberately — same discipline as
`NEOFORGE_VERSION`.
## Deferred
- **Keycloak SSO** — needs oauth2-proxy + `FILES_AUTH=passthrough`. Not built.
- **Anonymous access** (`FILES_AUTH=none`) — only once the host is internal-only.
- **Landing page link** — deliberately none on day one; `plan/18-landing-rework.md`
is mid-rework, so revisit with WS5 (footer) rather than merging into a moving
design.
- **Per-user accounts / roles** — htpasswd RBAC is enterprise-only.
- **Backups / quota** — none.

View File

@@ -0,0 +1,56 @@
# Simple Voice Chat server config v1.21.1-2.6.18
# The port number to use for the voice chat communication.
# Audio packets are always transmitted via the UDP protocol on the port number
# specified here, independently of other networking used for the game server.
# Set this to '-1' to use the same port number that is used by the Minecraft server.
# However, it is strongly recommended NOT to use the same port number because UDP on
# it is also used by default for the server query. Doing so may crash the server!
port=24454
# The server IP address to bind the voice chat to
# Leave blank to use the 'server-ip' property from the 'server.properties' config file
# To bind to the wildcard IP address, use '*'
bind_address=
# The distance to which the voice can be heard
max_voice_distance=48.0
# The distance to which the voice can be heard when whispering
whisper_distance=24.0
# The Opus codec
# Valid values are 'VOIP', 'AUDIO', and 'RESTRICTED_LOWDELAY'
codec=VOIP
# The maximum size that audio packets are allowed to have (in bytes)
# Setting this to lower values might cause issues
mtu_size=1275
# The maximum number of packets a player can send per second
# Set this to -1 to disable the rate limit - This must be greater than 0 in all other cases
# This only applies to voice chat packets that are sent through Minecrafts networking
# This affects actions like opening/joining/leaving voice chat groups or general state changes like disabling/enabling the voice chat
tcp_rate_limit=16
# The frequency at which keep-alive packets are sent (in milliseconds)
# Setting this to a higher value may result in timeouts
keep_alive=1000
# If group chats are allowed
enable_groups=true
# The hostname that clients should use to connect to the voice chat
# This may also include a port, e.g. 'example.com:24454' or just a port, e.g. '24454'
# Do NOT change this value if you don't know what you're doing
voice_host=ulicraft.net
# If players are allowed to record the voice chat audio
allow_recording=true
# If spectators are allowed to talk to other players
spectator_interaction=false
# If spectators can talk to players they are spectating
spectator_player_possession=false
# If players without the voice chat mod should be kicked from the server
force_voice_chat=false
# The amount of time the server should wait to check if a player has the mod installed (in milliseconds)
# Only relevant when 'force_voice_chat' is set to 'true'
login_timeout=10000
# The range in which the voice chat should broadcast audio
# A value less than 0 means 'max_voice_distance'
broadcast_range=-1.0
# If the voice chat server should reply to external pings
allow_pings=true
# If the mod should load native libraries on dedicated servers
# This is mostly relevant for voice chat addons
use_natives=true

View File

@@ -29,6 +29,67 @@ render() { # $1=template $2=output
render drasl/config/config.toml.tmpl drasl/config/config.toml render drasl/config/config.toml.tmpl drasl/config/config.toml
render nmsr/config.toml.tmpl nmsr/config.toml render nmsr/config.toml.tmpl nmsr/config.toml
# ── filestash (files.${BASE_DOMAIN}) ────────────────────────────────────
# Filestash has NO env-var config: config.json is the source of truth and the
# admin console writes it back at runtime. We render it and mount it read-only,
# so git stays authoritative (the console renders but cannot save — by design).
#
# general.host in the template is a BARE HOSTNAME (files.$BASE_DOMAIN, no
# scheme) and general.force_ssl is true. The SPA builds its redirect origin as
# (force_ssl ? "https://" : "http://") + host, so a scheme in `host` produces
# "http://https://files..." and every client-side redirect breaks. The
# server-side Host check strips the scheme first, so curl/monitors see a healthy
# 200 and ONLY browsers fail. Don't "fix" it by putting the URL back.
#
# FILES_AUTH picks the identity_provider block. The params fields are JSON
# *strings* holding escaped JSON (that's Filestash's schema, not a typo) —
# built here rather than in the template because the shape differs per mode.
: "${FILES_AUTH:=htpasswd}"
: "${FILES_SECRET_KEY:?FILES_SECRET_KEY unset (openssl rand -hex 16)}"
: "${FILES_ADMIN_PASSWORD_HASH:?FILES_ADMIN_PASSWORD_HASH unset (docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>')}"
: "${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset (openssl rand -hex 24) — unlocks the admin-gated local backend}"
case "$FILES_AUTH" in
htpasswd)
: "${FILES_USER:?FILES_USER unset (shared login for players)}"
: "${FILES_PASSWORD_HASH:?FILES_PASSWORD_HASH unset (openssl passwd -6 '<pw>')}"
# The htpasswd plugin's bcrypt branch only accepts $2a$ — a $2y$ hash (what
# `htpasswd -B` emits) silently fails every login. $6$ (openssl passwd -6)
# is the safe, explicitly-supported format. Halt rather than ship a config
# nobody can log into.
case "$FILES_PASSWORD_HASH" in
'$6$'*|'$5$'*|'$2a$'*|'$apr1$'*|'$1$'*) ;;
'$2y$'*|'$2b$'*)
echo "FILES_PASSWORD_HASH is bcrypt '${FILES_PASSWORD_HASH:0:4}' — the htpasswd plugin only accepts \$2a\$." >&2
echo "Use: openssl passwd -6 '<password>'" >&2
exit 1 ;;
*) echo "FILES_PASSWORD_HASH is not a recognised hash (want \$6\$… from: openssl passwd -6 '<password>')" >&2; exit 1 ;;
esac
FILES_IDP_TYPE="htpasswd"
FILES_IDP_PARAMS="{\\\"type\\\":\\\"htpasswd\\\",\\\"users\\\":\\\"${FILES_USER}:${FILES_PASSWORD_HASH}\\\"}"
;;
passthrough|none)
# NO LOGIN — anyone who reaches the vhost gets in. Only safe once this host
# is NOT reachable from the internet (see plan/20-files.md).
echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2
echo "!! Only valid if files.${BASE_DOMAIN} is unreachable from the internet." >&2
FILES_IDP_TYPE="passthrough"
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
;;
*)
echo "FILES_AUTH must be 'htpasswd', 'passthrough' or 'none' (got '$FILES_AUTH')" >&2; exit 1 ;;
esac
export FILES_IDP_TYPE FILES_IDP_PARAMS
envsubst '${BASE_DOMAIN} ${FILES_IDP_TYPE} ${FILES_IDP_PARAMS} ${FILES_ADMIN_PASSWORD_HASH} ${FILES_SECRET_KEY} ${FILES_LOCAL_SECRET}' \
< filestash/config.json.tmpl > filestash/config.json
# The hashes contain '$' and the params fields are nested JSON — one bad escape
# yields a config filestash silently ignores. Fail loudly instead.
python3 -c 'import json,sys; json.load(open("filestash/config.json"))' \
|| { echo "filestash/config.json is not valid JSON — refusing to ship it" >&2; exit 1; }
echo "rendered filestash/config.json (auth=$FILES_AUTH)"
# Server mods are no longer rendered here — they come from the distribution repo # Server mods are no longer rendered here — they come from the distribution repo
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See # via tooling/sync-server-mods.sh (run separately in the deploy flow). See
# plan/04-mods.md. # plan/04-mods.md.