From 25df9f9398b4f8ef90a51e9ee0f9899c36797289 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 14 Jul 2026 18:08:46 +0200 Subject: [PATCH] feat(files): add Filestash player file share at files.${BASE_DOMAIN} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 48 ++++++++- .gitignore | 3 + CLAUDE.md | 12 +++ caddy/conf.d/60-files.caddy | 10 ++ docker-compose.yml | 34 +++++++ filestash/config.json.tmpl | 30 ++++++ nginx/ulicraft-caddy.conf.tmpl | 44 +++++++- plan/19-routes.md | 24 +++++ plan/20-files.md | 180 +++++++++++++++++++++++++++++++++ tooling/render-config.sh | 54 ++++++++++ 10 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 caddy/conf.d/60-files.caddy create mode 100644 filestash/config.json.tmpl create mode 100644 plan/20-files.md diff --git a/.env.example b/.env.example index bb91e64..ffec8c0 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,52 @@ CADDY_HTTP_BIND=127.0.0.1 # 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 +# ── 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 '' +# 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 '' +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: # 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. LE_EMAIL=you@example.com # 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_CK can be blank on first run — acme.sh prints an auth URL, then paste it. OVH_END_POINT=ovh-eu diff --git a/.gitignore b/.gitignore index 4a13d41..f1a66d9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ runtime/authlib-injector.jar # rendered configs (from tooling/render-config.sh) drasl/config/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 # tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars) diff --git a/CLAUDE.md b/CLAUDE.md index 1518eea..cf91a8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed) status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors) + files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable) distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT) minecraft :25565 (+ 24454/udp Simple Voice Chat) @@ -105,6 +106,17 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at classify/sync time. See `plan/04-mods.md`. +### Filestash (files.) — three traps +Full list in `plan/20-files.md`; the ones that cost real time: +- 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`. + ### Memory sizing ~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`, `USE_AIKAR_FLAGS: TRUE`. diff --git a/caddy/conf.d/60-files.caddy b/caddy/conf.d/60-files.caddy new file mode 100644 index 0000000..fb23adb --- /dev/null +++ b/caddy/conf.d/60-files.caddy @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 09dcbfc..c529fb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -159,6 +159,7 @@ services: - ./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/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 - ./launcher:/srv/launcher:ro # Static site from ANOTHER repo (absolute host path in .env). @@ -213,6 +214,38 @@ services: networks: - 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. # Joins mcnet so monitors probe the stack by internal service name. uptime-kuma: @@ -228,6 +261,7 @@ volumes: drasl_state: mc_data: kuma_data: + filestash_state: networks: mcnet: diff --git a/filestash/config.json.tmpl b/filestash/config.json.tmpl new file mode 100644 index 0000000..fdce85d --- /dev/null +++ b/filestash/config.json.tmpl @@ -0,0 +1,30 @@ +{ + "general": { + "name": "Ulicraft Files", + "host": "https://files.${BASE_DOMAIN}", + "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" + } + ] +} diff --git a/nginx/ulicraft-caddy.conf.tmpl b/nginx/ulicraft-caddy.conf.tmpl index e88949c..21eb94d 100644 --- a/nginx/ulicraft-caddy.conf.tmpl +++ b/nginx/ulicraft-caddy.conf.tmpl @@ -26,11 +26,15 @@ upstream ulicraft_caddy { 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. server { 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; } @@ -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 { listen 443 ssl http2; listen [::]:443 ssl http2; diff --git a/plan/19-routes.md b/plan/19-routes.md index 103b6b2..3ad5497 100644 --- a/plan/19-routes.md +++ b/plan/19-routes.md @@ -12,6 +12,7 @@ | `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` | | `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` | | `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) | --- @@ -58,6 +59,29 @@ 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. `GET /api/session/auth/?label=files` renders the login form; + `POST` the same URL with `user`/`password` authenticates. +- `/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}` `reverse_proxy drasl:25585`. One service exposes three surfaces: diff --git a/plan/20-files.md b/plan/20-files.md new file mode 100644 index 0000000..bc0c303 --- /dev/null +++ b/plan/20-files.md @@ -0,0 +1,180 @@ +# 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 ''` | +| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext ''` | +| `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` must match the incoming `Host` header +Filestash blocks every non-`/admin/` request whose `Host` differs from its +configured `general.host`, with *"only traffic from X is allowed"*. It's a 403 +that looks like an auth failure. nginx forwards the original Host and caddy +passes it through — don't rewrite it anywhere. If `files.` starts 403ing after a +domain change, this is why. + +### `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. diff --git a/tooling/render-config.sh b/tooling/render-config.sh index c913321..de784a2 100755 --- a/tooling/render-config.sh +++ b/tooling/render-config.sh @@ -29,6 +29,60 @@ render() { # $1=template $2=output render drasl/config/config.toml.tmpl drasl/config/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). +# +# 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 '')}" +: "${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 '')}" + # 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 ''" >&2 + exit 1 ;; + *) echo "FILES_PASSWORD_HASH is not a recognised hash (want \$6\$… from: openssl passwd -6 '')" >&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 # via tooling/sync-server-mods.sh (run separately in the deploy flow). See # plan/04-mods.md.