Files
ulicraft-server-v1/plan/20-files.md
T
oierandClaude Opus 4.8 9322e39a8c docs(files): oxps runs filestash auth-off (LAN-only)
Two prod hosts now diverge on filestash auth: cochi stays htpasswd
(internet-facing), oxps runs FILES_AUTH=none/passthrough since it is
LAN-only, satisfying the internal-only gate. Document the per-host
status table and the flip-back rule for if oxps ever gains an internet
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:57:56 +02:00

11 KiB

20 — Filestash (files.${BASE_DOMAIN})

Web file share for player media (screenshots, schematics, maps, guides). One 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.

Per-host auth status (the two prod envs diverge):

Host files. reachability FILES_AUTH Notes
cochi internet-facing htpasswd shared uli login, $6$ hash
oxps LAN-only none (passthrough) auth off — gate satisfied (2026-07-23)

oxps runs auth-off deliberately: it is unreachable from the internet, so the open-upload-relay risk above does not apply. Applied host-locally (.env FILES_AUTH=none → render-config.sh → docker compose up -d --force-recreate filestash); nothing committed — .env and the rendered config.json are both gitignored per host. If oxps ever gains an internet path, flip it back to htpasswd (or set FILES_MOUNT_MODE=ro) before exposing it.

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

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. Done on oxps (LAN-only, 2026-07-23); still htpasswd on cochi.
  • 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.