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>
This commit is contained in:
@@ -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:
|
||||
|
||||
180
plan/20-files.md
Normal file
180
plan/20-files.md
Normal file
@@ -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 '<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` 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.
|
||||
Reference in New Issue
Block a user