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:
oierandClaude Opus 4.8 committed 2026-07-24 17:03:33 +02:00
1 parent 243787c530
commit 16523e4f62
13 files changed
+133 -63

No files matched your search

+3 -2
View File
@@ -49,7 +49,8 @@ CADDY_HTTP_BIND=127.0.0.1
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy. # 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 DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
# ── files.${BASE_DOMAIN} — Filestash player file share ──────────────── # ── upload.${BASE_DOMAIN} — Filestash player file share (write) ───────
# (files.${BASE_DOMAIN} = read-only, no-auth caddy browse of the same dir)
# Screenshots / schematics / maps. ONE shared login for all players, writable. # Screenshots / schematics / maps. ONE shared login for all players, writable.
# Full design + gotchas: plan/20-files.md. # Full design + gotchas: plan/20-files.md.
@@ -126,7 +127,7 @@ GENERATORS_DATA_DIR=./generators-data
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports. # Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included # space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth distribution www avatar status files generators LE_SUBDOMAINS=auth distribution www avatar status upload files generators
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/ # 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_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu OVH_END_POINT=ovh-eu
+9 -4
View File
@@ -34,7 +34,8 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI) auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed) avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors) status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable) upload.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
files.${BASE_DOMAIN} ─► caddy (read-only, no-auth browse of the same share dir)
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT) distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
minecraft :25565 (+ 24454/udp Simple Voice Chat) minecraft :25565 (+ 24454/udp Simple Voice Chat)
@@ -108,11 +109,15 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
classify/sync time. See `plan/04-mods.md`. classify/sync time. See `plan/04-mods.md`.
### Filestash (files.) — four traps ### Filestash (upload.) — four traps
Filestash serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA
hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a
separate caddy `file_server browse` of the same `FILES_DATA_DIR` — read-only,
**no auth**, every uploaded file publicly listable.
Full list in `plan/20-files.md`; the ones that cost real time: Full list in `plan/20-files.md`; the ones that cost real time:
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) + - **`general.host` is a BARE HOSTNAME** (`upload.${BASE_DOMAIN}`, no scheme) +
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there `force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
yields `http://https://files...` and every client-side redirect dies. The yields `http://https://upload...` and every client-side redirect dies. The
server strips the scheme before its own Host check, so **curl and the uptime server strips the scheme before its own Host check, so **curl and the uptime
monitor stay green while every browser is broken**. Cost a debugging round on monitor stay green while every browser is broken**. Cost a debugging round on
2026-07-14. 2026-07-14.
+12 -8
View File
@@ -17,7 +17,7 @@ One compose file, one `docker compose up -d`:
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` | | `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
| `mc-status` | built from source | live server-status JSON for the landing card | | `mc-status` | built from source | live server-status JSON for the landing card |
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` | | `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` | | `filestash` | machines/filestash (digest-pinned) | player file share (upload) at `upload.${BASE_DOMAIN}` |
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON | | `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
Vhosts (all proxied through the host nginx → caddy): Vhosts (all proxied through the host nginx → caddy):
@@ -124,7 +124,8 @@ persists in the `kuma_data` volume):
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` | | Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` | | Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` | | Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) | | Upload (Filestash) | HTTP(s) | `https://upload.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
| Files (static browse) | HTTP(s) | `https://files.${BASE_DOMAIN}` (caddy dir listing of the share, no auth) |
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
@@ -159,9 +160,12 @@ Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.ca
## Player file share (Filestash) ## Player file share (Filestash)
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots, `upload.${BASE_DOMAIN}` is a web file share for player media — screenshots,
schematics, maps, guides. **One shared username/password for everybody** schematics, maps, guides. **One shared username/password for everybody**
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup (`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. The same share is
also exposed **read-only, no-auth** as a plain caddy directory listing at
`files.${BASE_DOMAIN}` — every uploaded file is publicly downloadable there.
It is *not* a backup
browser and *not* a mod mirror: world snapshots contain every player's inventory browser and *not* a mod mirror: world snapshots contain every player's inventory
and coordinates, and the modset already ships via `distribution.`. and coordinates, and the modset already ships via `distribution.`.
@@ -183,9 +187,9 @@ Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version
Four traps that cost real time (all enforced or documented in the tooling): Four traps that cost real time (all enforced or documented in the tooling):
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with - **`general.host` is a bare hostname**, no scheme (`upload.${BASE_DOMAIN}`), with
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a `force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
URL there produces `http://https://files...` and every client-side redirect URL there produces `http://https://upload...` and every client-side redirect
breaks. The server strips the scheme before its *own* Host check, so `curl` breaks. The server strips the scheme before its *own* Host check, so `curl`
and the uptime monitor stay **green while every browser is broken**. and the uptime monitor stay **green while every browser is broken**.
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd - **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
@@ -199,8 +203,8 @@ Four traps that cost real time (all enforced or documented in the tooling):
> Verifying this service: a 200 on `/` only proves the server is up. Check the > Verifying this service: a 200 on `/` only proves the server is up. Check the
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest` > browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print > `https://upload.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`. > `https://upload.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
## Join (guests) ## Join (guests)
-10
View File
@@ -1,10 +0,0 @@
# 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
}
+14
View File
@@ -0,0 +1,14 @@
# Upload — 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://upload.${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.
#
# Filestash CANNOT be served under a subpath (SPA hardcodes /api, /assets, /login
# at root), so it gets its own subdomain. The read-only browse of the same share
# lives at files.${BASE_DOMAIN} (62-files.caddy).
http://upload.{$BASE_DOMAIN} {
reverse_proxy filestash:8334
}
+8
View File
@@ -0,0 +1,8 @@
# Files — read-only static browse of the shared upload dir (FILES_DATA_DIR,
# mounted at /srv/files). NO AUTH: every uploaded file is publicly listable and
# downloadable here. Writes happen through filestash at upload.${BASE_DOMAIN}
# (60-upload.caddy); this vhost is browse-only.
http://files.{$BASE_DOMAIN} {
root * /srv/files
file_server browse
}
+8 -3
View File
@@ -159,10 +159,14 @@ services:
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro - ./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/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/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 - ./caddy/conf.d/60-upload.caddy:/etc/caddy/conf.d/60-upload.caddy:ro
- ./caddy/conf.d/62-files.caddy:/etc/caddy/conf.d/62-files.caddy:ro
- ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro - ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro
- ./www:/srv/www:ro - ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro - ./launcher:/srv/launcher:ro
# Read-only browse of the shared upload dir at files.${BASE_DOMAIN}
# (62-files.caddy). Same host path filestash writes to via /data/files.
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/srv/files:ro
# Static site from ANOTHER repo (absolute host path in .env). # Static site from ANOTHER repo (absolute host path in .env).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro - ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
networks: networks:
@@ -245,8 +249,9 @@ services:
networks: networks:
- mcnet - mcnet
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}. # Player file share (screenshots, schematics, maps) at upload.${BASE_DOMAIN}.
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See # ONE shared login for everybody (FILES_AUTH=htpasswd), writable. The same
# share is browsable read-only + no-auth at files.${BASE_DOMAIN} (via caddy). See
# plan/20-files.md — it documents every non-obvious bit of this service. # 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 # Config is rendered read-only from filestash/config.json.tmpl: filestash has
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"general": { "general": {
"name": "Ulicraft Files", "name": "Ulicraft Files",
"host": "files.${BASE_DOMAIN}", "host": "upload.${BASE_DOMAIN}",
"force_ssl": true, "force_ssl": true,
"secret_key": "${FILES_SECRET_KEY}", "secret_key": "${FILES_SECRET_KEY}",
"editor": "base", "editor": "base",
+30 -9
View File
@@ -27,14 +27,14 @@ upstream ulicraft_caddy {
} }
# Brute-force throttle for the publicly-exposed Filestash /admin console (it is # Brute-force throttle for the publicly-exposed Filestash /admin console (it is
# the only credential on files.), applied in its location block below. # the only credential on upload.), applied in its location block below.
limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m; limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
# HTTP -> HTTPS for every name. # HTTP -> HTTPS for every name.
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN} generators.${BASE_DOMAIN}; server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN} files.${BASE_DOMAIN} generators.${BASE_DOMAIN};
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
@@ -152,17 +152,18 @@ server {
} }
} }
# Filestash player file share. Uploads need a real body limit (nginx defaults to # Filestash player file share (upload.). Uploads need a real body limit (nginx
# 1m, which rejects almost anything worth sharing) and streaming rather than # defaults to 1m, which rejects almost anything worth sharing) and streaming
# spooling — a 2g upload buffered to disk would write it twice on a host whose # rather than spooling — a 2g upload buffered to disk would write it twice on a
# disk is already the shared failure domain for Minecraft + Drasl + backups. # host whose disk is already the shared failure domain for Minecraft + Drasl +
# backups.
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name files.${BASE_DOMAIN}; server_name upload.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem; ssl_certificate ${APP_DIR}/certs/upload.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem; ssl_certificate_key ${APP_DIR}/certs/upload.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 2g; # uploads; other vhosts stay at 4m client_max_body_size 2g; # uploads; other vhosts stay at 4m
@@ -190,6 +191,26 @@ server {
} }
} }
# Read-only static browse of the same share (files.) — caddy serves the dir
# listing, no auth. Read-only, so no upload tuning: standard 4m body is fine.
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;
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 { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
+9 -5
View File
@@ -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: nginx reverse-proxies every public vhost to it by Host header. Roles:
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma, 1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
`files.` → filestash. `upload.` → filestash.
2. **Static** — apex serves the landing page + `/launcher/` downloads; 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 — Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
no template render needed. no template render needed.
@@ -37,12 +38,15 @@ The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
`DISTRIBUTION_WEB_ROOT`, another repo). `DISTRIBUTION_WEB_ROOT`, another repo).
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer). - `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page). - `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`, 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 > 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 > 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 > forwards the original Host and caddy passes it through untouched. Keep it that
+2 -1
View File
@@ -12,7 +12,8 @@
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` | | `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` | | `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` | | `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) | | `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
| `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) | | `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) |
+33 -16
View File
@@ -1,9 +1,15 @@
# 20 — Filestash (`files.${BASE_DOMAIN}`) # 20 — Filestash (`upload.${BASE_DOMAIN}`)
> Web file share for player media (screenshots, schematics, maps, guides). > Web file share for player media (screenshots, schematics, maps, guides).
> One [Filestash](https://github.com/mickael-kerjean/filestash) container behind > One [Filestash](https://github.com/mickael-kerjean/filestash) container behind
> caddy, **one shared username/password for everybody**, **writable**. > caddy at **`upload.`**, **one shared username/password for everybody**,
> Config is rendered read-only from a template, like drasl/nmsr. > **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". > **Not** a backup browser, **not** a mod mirror. See "What goes in it".
> >
@@ -14,14 +20,17 @@
## Shape ## Shape
``` ```
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334 Internet ── host nginx (TLS, LE cert upload.) ──► caddy ──► filestash:8334 (rw)
│ │
${FILES_DATA_DIR} ──┘ (bind mount) ${FILES_DATA_DIR} ──┤ (bind mount)
│
Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro, no auth)
``` ```
| Thing | Value | | 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`) | | Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) | | Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` | | 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 **`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 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 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 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 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 and the launcher for every player) all land on you. `render-config.sh` prints a
loud warning when it renders this mode. 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):** **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 | | `cochi` | internet-facing | `htpasswd` | shared `uli` login, `$6$` hash |
| `oxps` | **LAN-only** | **`none`** (passthrough) | auth off — gate satisfied (2026-07-23) | | `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_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_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend | | `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 `filestash/config.json` is **rendered and gitignored** — it holds every hash and
both secrets. Only the `.tmpl` is tracked. both secrets. Only the `.tmpl` is tracked.
@@ -105,13 +121,13 @@ both secrets. Only the `.tmpl` is tracked.
```sh ```sh
tooling/render-config.sh # -> filestash/config.json (validates the JSON) tooling/render-config.sh # -> filestash/config.json (validates the JSON)
tooling/issue-letsencrypt.sh # picks up `files` from LE_SUBDOMAINS tooling/issue-letsencrypt.sh # picks up `upload`+`files` from LE_SUBDOMAINS
tooling/render-nginx.sh --install # adds the files. vhost + reloads nginx 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 mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
docker compose up -d filestash caddy 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. `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) ## 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.) anyway, so just use that for both.)
### `general.host` is a BARE HOSTNAME — a scheme breaks the browser only ### `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": true`. The SPA computes its redirect origin as
`(force_ssl ? "https://" : "http://") + host`, so a scheme in `host` yields `(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 page loads, then silently fails to route anywhere.
The vicious part: the server-side Host check (`SecureOrigin`) **strips** the 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'`, 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`. 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`. - **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 ### Disk is the shared failure domain
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A `FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
+4 -4
View File
@@ -29,15 +29,15 @@ render() { # $1=template $2=output
render drasl/config/config.toml.tmpl drasl/config/config.toml render drasl/config/config.toml.tmpl drasl/config/config.toml
render nmsr/config.toml.tmpl nmsr/config.toml render nmsr/config.toml.tmpl nmsr/config.toml
# ── filestash (files.${BASE_DOMAIN}) ──────────────────────────────────── # ── filestash (upload.${BASE_DOMAIN}) ───────────────────────────────────
# Filestash has NO env-var config: config.json is the source of truth and the # 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, # 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). # so git stays authoritative (the console renders but cannot save — by design).
# #
# general.host in the template is a BARE HOSTNAME (files.$BASE_DOMAIN, no # general.host in the template is a BARE HOSTNAME (upload.$BASE_DOMAIN, no
# scheme) and general.force_ssl is true. The SPA builds its redirect origin as # scheme) and general.force_ssl is true. The SPA builds its redirect origin as
# (force_ssl ? "https://" : "http://") + host, so a scheme in `host` produces # (force_ssl ? "https://" : "http://") + host, so a scheme in `host` produces
# "http://https://files..." and every client-side redirect breaks. The # "http://https://upload..." and every client-side redirect breaks. The
# server-side Host check strips the scheme first, so curl/monitors see a healthy # server-side Host check strips the scheme first, so curl/monitors see a healthy
# 200 and ONLY browsers fail. Don't "fix" it by putting the URL back. # 200 and ONLY browsers fail. Don't "fix" it by putting the URL back.
# #
@@ -72,7 +72,7 @@ case "$FILES_AUTH" in
# NO LOGIN — anyone who reaches the vhost gets in. Only safe once this host # 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). # is NOT reachable from the internet (see plan/20-files.md).
echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2 echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2
echo "!! Only valid if files.${BASE_DOMAIN} is unreachable from the internet." >&2 echo "!! Only valid if upload.${BASE_DOMAIN} is unreachable from the internet." >&2
FILES_IDP_TYPE="passthrough" FILES_IDP_TYPE="passthrough"
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}" FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
;; ;;