Add an env-driven `user:`/`group_add` to the filestash service plus tooling/filestash-set-run-user.sh. Running the container as a real host account (oxps: oier=1002) makes every file it writes into the share owned by that account, so scp/ssh and the web UI share one owner — no chown dance. Defaults FILES_UID/FILES_GID=1000, leaving cochi (image's own uid) untouched. group_add 1000 keeps the 0730 image binary + 0770 /app/data usable under any uid; the helper also chowns the filestash_state volume (1000:1000) so a non-1000 uid doesn't crash-loop unable to write its own sqlite/config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
14 KiB
Markdown
270 lines
14 KiB
Markdown
# 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.
|
|
|
|
**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
|
|
|
|
```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.
|
|
|
|
### Share dir must be owned by uid 1000 or writes silently 403
|
|
`filestash` runs as **uid/gid 1000**. `FILES_MOUNT_MODE=rw` and `FILES_AUTH=none`
|
|
(or a valid login) are **not enough** — the kernel still enforces the share
|
|
directory's own perms. If `${FILES_DATA_DIR}` is owned by a different uid with
|
|
mode `0775`, uid 1000 lands in *other* (`r-x`) and every upload/mkdir/delete dies
|
|
with `Permission denied`, while the UI, `curl /`, and the Kuma monitor all stay
|
|
green (same false-green trap as the SPA gotchas). Hit on `oxps` 2026-07-24: the
|
|
share (`/home/oier/mc/distribution-ulicraft/files`) was `1002:1002 0775`.
|
|
|
|
Fix — give the share to filestash's uid:
|
|
```sh
|
|
sudo chown -R 1000:1000 "$FILES_DATA_DIR"
|
|
sudo chmod g+s "$FILES_DATA_DIR" # new files inherit the group
|
|
docker exec -u 1000 filestash sh -c 'touch /data/files/.wtest && echo OK && rm /data/files/.wtest'
|
|
```
|
|
Reversible (`chown -R <old> "$FILES_DATA_DIR"`). If another account also writes
|
|
the share directly, use a shared group + `g+rwx` instead of chowning it away.
|
|
|
|
#### Cleaner: run filestash *as* the owning uid (share files become `oier:oier`)
|
|
Chowning the share to filestash's 1000 works, but then everything filestash
|
|
writes is owned by an anonymous `1000` nobody logs in as. To make the share
|
|
files owned by a **real host account** (oxps `oier` = uid 1002) so you can manage
|
|
them over ssh, run the container as that uid. Compose exposes it:
|
|
```yaml
|
|
user: "${FILES_UID:-1000}:${FILES_GID:-1000}"
|
|
group_add: ["1000"]
|
|
```
|
|
Two things make a bare uid override fail, both handled above:
|
|
- The image binary `/app/filestash` is `0730 filestash:filestash` and `/app/data`
|
|
is `0770` — a non-1000 uid is *other* and can neither exec nor traverse. The
|
|
`group_add: ["1000"]` restores that via the filestash group.
|
|
- The `filestash_state` volume (config, sqlite, search index) is owned `1000:1000`.
|
|
Running as another uid without chowning it → filestash can't write its own
|
|
state and crash-loops. **Chown the state volume too.**
|
|
|
|
Do all of it with **`tooling/filestash-set-run-user.sh`** (reads `FILES_UID`/
|
|
`FILES_GID` from `.env`, chowns the state volume + `FILES_DATA_DIR`, recreates,
|
|
write-tests). oxps: set `FILES_UID=FILES_GID=1002` in `.env`, run it. Default
|
|
`1000:1000` leaves cochi untouched. Per-host, host-local — nothing but the
|
|
compose knob and this script is committed.
|
|
|
|
### Accessing the share files directly (bypassing the web UI)
|
|
`${FILES_DATA_DIR}` is a plain host directory — the web UI is just a view of it.
|
|
- **On the host:** `ls "$FILES_DATA_DIR"` (oxps: `/home/oier/mc/distribution-ulicraft/files`).
|
|
Write as uid 1000 to match filestash ownership: `sudo -u '#1000' cp foo "$FILES_DATA_DIR"/`.
|
|
- **From your machine (oxps is LAN-only):** `scp file oxps:/home/oier/mc/distribution-ulicraft/files/`
|
|
then `ssh oxps 'sudo chown 1000:1000 /home/oier/mc/distribution-ulicraft/files/file'`,
|
|
or mount over sshfs: `sshfs oxps:/home/oier/mc/distribution-ulicraft/files ~/mnt/ulifiles`.
|
|
- **Via the container:** `docker exec -u 1000 filestash ls /data/files`.
|
|
- **Web (LAN):** `http://files.${BASE_DOMAIN}` — no login on oxps (auth=none).
|
|
|
|
### 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.
|