feat(files): move filestash to upload., serve files. as read-only static browse
Filestash cannot be served under a subpath (its SPA hardcodes /api, /assets,
/login at root), so it moves to its own subdomain upload.${BASE_DOMAIN}. The
apex-level ask was a static, no-auth file browser at files.${BASE_DOMAIN}: caddy
now file_server browse's the same ${FILES_DATA_DIR} share read-only there.
- filestash general.host -> upload.; nginx upload. vhost keeps the 2g body +
upload streaming + /admin throttle; new files. vhost is a plain static proxy.
- caddy: 60-files.caddy split into 60-upload.caddy (filestash) + 62-files.caddy
(file_server browse of /srv/files); caddy mounts FILES_DATA_DIR :ro.
- LE_SUBDOMAINS += upload (both upload. and files. are vhosts needing certs).
Security: files. is unconditionally public read (no auth), independent of
FILES_AUTH. The htpasswd login only gates writes via upload. Documented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
243787c530
commit
16523e4f62
13 files changed
+133
-63
No files matched your search
+9
-5
@@ -8,9 +8,10 @@ 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:
|
||||
|
||||
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
|
||||
`files.` → filestash.
|
||||
`upload.` → filestash.
|
||||
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; `files.` serves a
|
||||
read-only browse of the upload share.
|
||||
|
||||
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
|
||||
no template render needed.
|
||||
@@ -37,12 +38,15 @@ The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
||||
`DISTRIBUTION_WEB_ROOT`, another repo).
|
||||
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
||||
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
||||
- `60-files.caddy` — `files.` → `filestash:8334` (player file share, `20-files.md`).
|
||||
- `60-upload.caddy` — `upload.` → `filestash:8334` (player file share, `20-files.md`).
|
||||
- `62-files.caddy` — `files.` → static `/srv/files` (read-only, no-auth `file_server
|
||||
browse` of the same `FILES_DATA_DIR` filestash writes to).
|
||||
|
||||
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`,
|
||||
`${FILES_DATA_DIR}:/srv/files:ro`.
|
||||
|
||||
> **Don't rewrite the `Host` header for `files.`** — filestash rejects any
|
||||
> **Don't rewrite the `Host` header for `upload.`** — 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
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
| `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` |
|
||||
| `upload.${BASE_DOMAIN}` | `60-upload.caddy` | `filestash:8334` |
|
||||
| `files.${BASE_DOMAIN}` | `62-files.caddy` | static `/srv/files` (read-only, no-auth browse of the share) |
|
||||
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||
| `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) |
|
||||
|
||||
|
||||
+33
-16
@@ -1,9 +1,15 @@
|
||||
# 20 — Filestash (`files.${BASE_DOMAIN}`)
|
||||
# 20 — Filestash (`upload.${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.
|
||||
> caddy at **`upload.`**, **one shared username/password for everybody**,
|
||||
> **writable**. Config is rendered read-only from a template, like drasl/nmsr.
|
||||
>
|
||||
> The same `${FILES_DATA_DIR}` is also exposed at **`files.${BASE_DOMAIN}`** as a
|
||||
> plain caddy `file_server browse` — **read-only, no auth**, every uploaded file
|
||||
> publicly listable (`62-files.caddy`). Filestash gets its own subdomain because
|
||||
> it cannot be served under a subpath (SPA hardcodes `/api`, `/assets`, `/login`
|
||||
> at root).
|
||||
>
|
||||
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
||||
>
|
||||
@@ -14,14 +20,17 @@
|
||||
## Shape
|
||||
|
||||
```
|
||||
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334
|
||||
│
|
||||
${FILES_DATA_DIR} ──┘ (bind mount)
|
||||
Internet ── host nginx (TLS, LE cert upload.) ──► caddy ──► filestash:8334 (rw)
|
||||
│
|
||||
${FILES_DATA_DIR} ──┤ (bind mount)
|
||||
│
|
||||
Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro, no auth)
|
||||
```
|
||||
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Vhost | `files.${BASE_DOMAIN}` |
|
||||
| Vhost (filestash) | `upload.${BASE_DOMAIN}` |
|
||||
| Vhost (static browse) | `files.${BASE_DOMAIN}` — read-only, no auth, `62-files.caddy` |
|
||||
| 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` |
|
||||
@@ -48,16 +57,23 @@ 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
|
||||
`upload.` 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.
|
||||
|
||||
**`files.` is unconditionally public read.** Independent of `FILES_AUTH`, the
|
||||
`62-files.caddy` static browse serves the whole share with **no auth** — anyone
|
||||
who knows `files.${BASE_DOMAIN}` can list and download every uploaded file. This
|
||||
is deliberate (chosen 2026-07-24). The htpasswd login only gates *writes* (via
|
||||
`upload.`); it never gated reads at this URL. Don't put anything private in the
|
||||
share.
|
||||
|
||||
**Per-host auth status (the two prod envs diverge):**
|
||||
|
||||
| Host | `files.` reachability | `FILES_AUTH` | Notes |
|
||||
| Host | `upload.` 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) |
|
||||
@@ -96,7 +112,7 @@ See `.env.example` for the annotated block. Summary:
|
||||
| `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` |
|
||||
| `LE_SUBDOMAINS` | must include `upload` **and** `files` (both are vhosts) |
|
||||
|
||||
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||
both secrets. Only the `.tmpl` is tracked.
|
||||
@@ -105,13 +121,13 @@ both secrets. Only the `.tmpl` is tracked.
|
||||
|
||||
```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
|
||||
tooling/issue-letsencrypt.sh # picks up `upload`+`files` from LE_SUBDOMAINS
|
||||
tooling/render-nginx.sh --install # adds the upload.+files. vhosts + 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
|
||||
Then add an Uptime Kuma HTTP monitor for `https://upload.${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)
|
||||
@@ -136,10 +152,10 @@ 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
|
||||
`"host": "upload.ulicraft.net"`, **not** `"https://upload.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 —
|
||||
**`http://https://upload.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
|
||||
@@ -243,7 +259,8 @@ compose knob and this script is committed.
|
||||
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).
|
||||
- **Web (LAN):** `http://upload.${BASE_DOMAIN}` (filestash, no login on oxps,
|
||||
auth=none) or `http://files.${BASE_DOMAIN}` (read-only static browse, no auth).
|
||||
|
||||
### Disk is the shared failure domain
|
||||
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
|
||||
|
||||
Reference in new issue
Block a user