From 16523e4f62b37cdd9942d084795324a2a1743c28 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Fri, 24 Jul 2026 17:03:33 +0200 Subject: [PATCH] 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) --- .env.example | 5 ++-- CLAUDE.md | 13 ++++++--- README.md | 20 ++++++++------ caddy/conf.d/60-files.caddy | 10 ------- caddy/conf.d/60-upload.caddy | 14 ++++++++++ caddy/conf.d/62-files.caddy | 8 ++++++ docker-compose.yml | 11 +++++--- filestash/config.json.tmpl | 2 +- nginx/ulicraft-caddy.conf.tmpl | 39 ++++++++++++++++++++------- plan/02-caddy.md | 14 ++++++---- plan/19-routes.md | 3 ++- plan/20-files.md | 49 +++++++++++++++++++++++----------- tooling/render-config.sh | 8 +++--- 13 files changed, 133 insertions(+), 63 deletions(-) delete mode 100644 caddy/conf.d/60-files.caddy create mode 100644 caddy/conf.d/60-upload.caddy create mode 100644 caddy/conf.d/62-files.caddy diff --git a/.env.example b/.env.example index cb4fc2d..e873729 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,8 @@ CADDY_HTTP_BIND=127.0.0.1 # 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 -# ── 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. # 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. LE_EMAIL=you@example.com # 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_CK can be blank on first run — acme.sh prints an auth URL, then paste it. OVH_END_POINT=ovh-eu diff --git a/CLAUDE.md b/CLAUDE.md index 06ee4f4..e35f3df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed) 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) 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 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: -- **`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 - 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 monitor stay green while every browser is broken**. Cost a debugging round on 2026-07-14. diff --git a/README.md b/README.md index 430d873..25e2c67 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ One compose file, one `docker compose up -d`: | `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` | | `mc-status` | built from source | live server-status JSON for the landing card | | `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 | 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}` | | Distribution | HTTP(s) | `https://distribution.${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 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) -`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** -(`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 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): -- **`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 - 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` and the uptime monitor stay **green while every browser is broken**. - **`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 > browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest` -> `https://files.${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}/api/config | jq -r .result.origin` must print +> `https://upload.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`. ## Join (guests) diff --git a/caddy/conf.d/60-files.caddy b/caddy/conf.d/60-files.caddy deleted file mode 100644 index fb23adb..0000000 --- a/caddy/conf.d/60-files.caddy +++ /dev/null @@ -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 -} diff --git a/caddy/conf.d/60-upload.caddy b/caddy/conf.d/60-upload.caddy new file mode 100644 index 0000000..ee3463c --- /dev/null +++ b/caddy/conf.d/60-upload.caddy @@ -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 +} diff --git a/caddy/conf.d/62-files.caddy b/caddy/conf.d/62-files.caddy new file mode 100644 index 0000000..cdb97fb --- /dev/null +++ b/caddy/conf.d/62-files.caddy @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 2b1d5ca..fd2a27e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -159,10 +159,14 @@ services: - ./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/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 - ./www:/srv/www: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). - ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro networks: @@ -245,8 +249,9 @@ services: networks: - mcnet - # Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}. - # ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See + # Player file share (screenshots, schematics, maps) at upload.${BASE_DOMAIN}. + # 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. # # Config is rendered read-only from filestash/config.json.tmpl: filestash has diff --git a/filestash/config.json.tmpl b/filestash/config.json.tmpl index 43518ed..f4456ef 100644 --- a/filestash/config.json.tmpl +++ b/filestash/config.json.tmpl @@ -1,7 +1,7 @@ { "general": { "name": "Ulicraft Files", - "host": "files.${BASE_DOMAIN}", + "host": "upload.${BASE_DOMAIN}", "force_ssl": true, "secret_key": "${FILES_SECRET_KEY}", "editor": "base", diff --git a/nginx/ulicraft-caddy.conf.tmpl b/nginx/ulicraft-caddy.conf.tmpl index e01b2c3..75bbc2c 100644 --- a/nginx/ulicraft-caddy.conf.tmpl +++ b/nginx/ulicraft-caddy.conf.tmpl @@ -27,14 +27,14 @@ upstream ulicraft_caddy { } # 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; # HTTP -> HTTPS for every name. server { 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; } @@ -152,17 +152,18 @@ server { } } -# Filestash player file share. Uploads need a real body limit (nginx defaults to -# 1m, which rejects almost anything worth sharing) and streaming rather than -# spooling — a 2g upload buffered to disk would write it twice on a host whose -# disk is already the shared failure domain for Minecraft + Drasl + backups. +# Filestash player file share (upload.). Uploads need a real body limit (nginx +# defaults to 1m, which rejects almost anything worth sharing) and streaming +# rather than spooling — a 2g upload buffered to disk would write it twice on a +# host whose disk is already the shared failure domain for Minecraft + Drasl + +# backups. server { 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_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem; + ssl_certificate ${APP_DIR}/certs/upload.${BASE_DOMAIN}/cert.pem; + ssl_certificate_key ${APP_DIR}/certs/upload.${BASE_DOMAIN}/key.pem; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; 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 { listen 443 ssl http2; listen [::]:443 ssl http2; diff --git a/plan/02-caddy.md b/plan/02-caddy.md index 7c3567b..c155b1a 100644 --- a/plan/02-caddy.md +++ b/plan/02-caddy.md @@ -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 diff --git a/plan/19-routes.md b/plan/19-routes.md index d0b6f3c..93eab51 100644 --- a/plan/19-routes.md +++ b/plan/19-routes.md @@ -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) | diff --git a/plan/20-files.md b/plan/20-files.md index f245fe9..885fb95 100644 --- a/plan/20-files.md +++ b/plan/20-files.md @@ -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 ''` | | `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 diff --git a/tooling/render-config.sh b/tooling/render-config.sh index de17bac..5685860 100755 --- a/tooling/render-config.sh +++ b/tooling/render-config.sh @@ -29,15 +29,15 @@ render() { # $1=template $2=output render drasl/config/config.toml.tmpl drasl/config/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 # 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). # -# 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 # (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 # 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 # is NOT reachable from the internet (see plan/20-files.md). 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_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}" ;;