Compare commits

...

6 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
20 changed files with 787 additions and 45 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,6 +32,7 @@ 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)
@@ -105,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`.
@@ -163,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

@@ -159,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).
@@ -213,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:
@@ -228,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

@@ -11,6 +11,7 @@
"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",
"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",
@@ -27,9 +28,11 @@
"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", "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",
@@ -45,11 +48,13 @@
"YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar": "both", "YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar": "both",
"YungsBridges-1.21.1-NeoForge-5.1.1.jar": "both", "YungsBridges-1.21.1-NeoForge-5.1.1.jar": "both",
"YungsCaveBiomes-1.21.1-NeoForge-3.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",
"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",
@@ -71,6 +76,7 @@
"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",
"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",
"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",
@@ -80,13 +86,16 @@
"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",
@@ -97,7 +106,9 @@
"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", "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",
"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",
@@ -116,6 +127,7 @@
"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", "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",
"polymorph-neoforge-1.1.0+1.21.1.jar": "both", "polymorph-neoforge-1.1.0+1.21.1.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.` `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
@@ -25,6 +26,7 @@ ulicraft.net → nginx → caddy → landing page (/srv/www) + /la
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API) auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
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)
``` ```
@@ -90,8 +92,8 @@ 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
@@ -119,6 +121,9 @@ 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
├── server-mods/ # filtered server modset (synced, gitignored) ├── server-mods/ # filtered server modset (synced, gitignored)
├── launcher/ # vendored Fjord launcher releases (gitignored) ├── launcher/ # vendored Fjord launcher releases (gitignored)
@@ -145,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) → `mc-backup`. `caddy` (aliases) → `drasl``minecraft` (depends on drasl) → `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,7 +7,8 @@ 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,
`files.` → filestash.
2. **Static** — apex serves the landing page + `/launcher/` downloads; 2. **Static** — apex serves the landing page + `/launcher/` downloads;
`distribution.` serves a static site from another repo. `distribution.` serves a static site from another repo.
@@ -36,10 +37,17 @@ The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
`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`,
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`. `./launcher:/srv/launcher: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

View File

@@ -68,6 +68,52 @@ docker exec minecraft sh -c 'cat /data/ops.json' # verify level 4 + right UUI
needed (takes effect live). Current op: `oier` needed (takes effect live). Current op: `oier`
(`7fbc9adb-bcf4-3913-9a99-46d46acd1009`), level 4, set 2026-06-21. (`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`
@@ -77,3 +123,4 @@ needed (takes effect live). Current op: `oier`
- [ ] `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

@@ -51,5 +51,13 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
(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://distribution.${BASE_DOMAIN}`, `https://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
`https://${BASE_DOMAIN}` — a red `distribution.` here explains client `https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
mod-mismatch 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

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