From 8507ffd0204b9312125c734d956de451be6e2bb6 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Thu, 30 Jul 2026 17:08:33 +0200 Subject: [PATCH] feat(files): gate files. browse behind FILES_BROWSE (on|off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-host toggle for the read-only, no-auth browse of the shared upload dir, so it can stay on for the LAN-party host (oxps) and be off on the internet-facing one (cochi). Defaults to `on` — existing hosts unchanged. Neither layer has conditionals, so each gets the value substituted in: - caddy: docker-compose mounts 62-files.${FILES_BROWSE:-on}.caddy at the fixed conf.d target (62-files.caddy -> 62-files.on.caddy, plus a comments-only 62-files.off.caddy stub — caddy needs a parseable file there). Same trick as FILES_MOUNT_MODE. - nginx: render-nginx.sh splices nginx/files-browse.server.tmpl into ${FILES_BROWSE_SERVER} and adds/omits the name in the port-80 redirect. This is the gate that matters; caddy is bound to 127.0.0.1. Applying a change needs render-nginx.sh --install plus a caddy --force-recreate (single-file bind mount pins the inode). With `off`, drop `files` from LE_SUBDOMAINS on that host. upload. is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 11 +++++ CLAUDE.md | 3 +- caddy/conf.d/62-files.off.caddy | 9 ++++ .../{62-files.caddy => 62-files.on.caddy} | 0 docker-compose.yml | 9 +++- nginx/files-browse.server.tmpl | 24 ++++++++++ nginx/ulicraft-caddy.conf.tmpl | 25 +++-------- plan/20-files.md | 44 +++++++++++++++---- tooling/render-nginx.sh | 30 ++++++++++++- 9 files changed, 122 insertions(+), 33 deletions(-) create mode 100644 caddy/conf.d/62-files.off.caddy rename caddy/conf.d/{62-files.caddy => 62-files.on.caddy} (100%) create mode 100644 nginx/files-browse.server.tmpl diff --git a/.env.example b/.env.example index e873729..9c1654a 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,17 @@ FILES_DATA_DIR=/srv/ulicraft-files # value is substituted straight into the volume string. FILES_MOUNT_MODE=rw +# files.${BASE_DOMAIN} — the read-only, NO-AUTH browse of the same share. +# Per-host toggle, `on` | `off` (default on). It gates two layers, both reading +# this var: the caddy vhost (docker-compose.yml substitutes the value into the +# conf.d mount path — 62-files.on.caddy vs the 62-files.off.caddy stub) and the +# public nginx vhost (tooling/render-nginx.sh). `off` also drops the name from +# the port-80 redirect, so no cert is needed — drop `files` from LE_SUBDOMAINS +# too on that host. upload. (filestash, authenticated) is NOT affected. +# After changing it: tooling/render-nginx.sh --install +# docker compose up -d --force-recreate caddy +FILES_BROWSE=on + # Runtime uid:gid of the filestash process (docker `user:`). Leave at the image # default 1000:1000 UNLESS you want files it writes into the share owned by a # host account. To own the share as `oier` on oxps: set both to 1002 and run diff --git a/CLAUDE.md b/CLAUDE.md index 04c05fe..d1095be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,8 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880 avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed) status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors) 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) + files.${BASE_DOMAIN} ─► caddy (read-only, no-auth browse of the same share dir; + per-host on/off via FILES_BROWSE — off on cochi) distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT) minecraft :25565 (+ 24454/udp Simple Voice Chat) diff --git a/caddy/conf.d/62-files.off.caddy b/caddy/conf.d/62-files.off.caddy new file mode 100644 index 0000000..b2052d1 --- /dev/null +++ b/caddy/conf.d/62-files.off.caddy @@ -0,0 +1,9 @@ +# Files browse DISABLED (FILES_BROWSE=off in .env). +# +# This stub is mounted at /etc/caddy/conf.d/62-files.caddy instead of +# 62-files.on.caddy, so caddy defines NO vhost for files.${BASE_DOMAIN} and the +# share is not served read-only anywhere. Uploads via upload.${BASE_DOMAIN} +# (filestash) are unaffected — that is a separate service. +# +# Caddy needs a parseable file here, not an absent one: the Caddyfile imports +# conf.d/*.caddy and the mount target is fixed. Comments only, on purpose. diff --git a/caddy/conf.d/62-files.caddy b/caddy/conf.d/62-files.on.caddy similarity index 100% rename from caddy/conf.d/62-files.caddy rename to caddy/conf.d/62-files.on.caddy diff --git a/docker-compose.yml b/docker-compose.yml index d8160e7..6b37219 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -172,7 +172,14 @@ services: - ./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-upload.caddy:/etc/caddy/conf.d/60-upload.caddy:ro - - ./caddy/conf.d/62-files.caddy:/etc/caddy/conf.d/62-files.caddy:ro + # files.${BASE_DOMAIN} read-only browse, gated by FILES_BROWSE (on|off). + # The value is substituted straight into the source path — `on` mounts the + # real vhost, `off` a comment-only stub — because compose has no + # conditionals (same trick as FILES_MOUNT_MODE). Changing it needs + # `docker compose up -d --force-recreate caddy`: a single-file bind mount + # pins the inode. The public gate is the nginx vhost (render-nginx.sh + # reads the same var); this one keeps caddy itself from serving the share. + - ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.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 diff --git a/nginx/files-browse.server.tmpl b/nginx/files-browse.server.tmpl new file mode 100644 index 0000000..8c05116 --- /dev/null +++ b/nginx/files-browse.server.tmpl @@ -0,0 +1,24 @@ +# 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. +# +# Rendered into ulicraft-caddy.conf.tmpl at ${FILES_BROWSE_SERVER} by +# render-nginx.sh, ONLY when FILES_BROWSE=on. With `off` the placeholder +# collapses to a comment, so the name has no TLS vhost at all (and needs no +# cert — drop `files` from LE_SUBDOMAINS on that host). +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; + } +} diff --git a/nginx/ulicraft-caddy.conf.tmpl b/nginx/ulicraft-caddy.conf.tmpl index 75bbc2c..aeaa41c 100644 --- a/nginx/ulicraft-caddy.conf.tmpl +++ b/nginx/ulicraft-caddy.conf.tmpl @@ -34,7 +34,7 @@ limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m; server { listen 80; listen [::]:80; - 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}; + server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN}${FILES_BROWSE_NAME} generators.${BASE_DOMAIN}; return 301 https://$host$request_uri; } @@ -191,25 +191,10 @@ 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; - } -} +# files. (read-only browse) goes here — the whole server block, or a single +# comment line when FILES_BROWSE=off. Source: nginx/files-browse.server.tmpl, +# spliced in by render-nginx.sh. +${FILES_BROWSE_SERVER} server { listen 443 ssl http2; diff --git a/plan/20-files.md b/plan/20-files.md index 885fb95..fdb84b8 100644 --- a/plan/20-files.md +++ b/plan/20-files.md @@ -7,9 +7,9 @@ > > 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). +> publicly listable (`62-files.on.caddy`, toggled per host by `FILES_BROWSE`). +> 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". > @@ -30,7 +30,7 @@ Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro | Thing | Value | |---|---| | Vhost (filestash) | `upload.${BASE_DOMAIN}` | -| Vhost (static browse) | `files.${BASE_DOMAIN}` — read-only, no auth, `62-files.caddy` | +| Vhost (static browse) | `files.${BASE_DOMAIN}` — read-only, no auth, `62-files.on.caddy`, per-host `FILES_BROWSE=on\|off` | | 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` | @@ -64,13 +64,38 @@ blocklisting (which would take `auth.` and the apex down with it, breaking login 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 +**`files.` is public read wherever it is enabled.** Independent of `FILES_AUTH`, +the static browse serves the whole share with **no auth** — anyone who reaches +`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. +**`FILES_BROWSE=on|off` turns the whole `files.` vhost off per host** (added +2026-07-30) — the answer to "I want it on `oxps` (LAN party) but not on `cochi` +(internet-facing)". One var, two layers, because each has its own conditional +problem: + +| Layer | Mechanism | +|---|---| +| caddy | `docker-compose.yml` substitutes the value into the mount source: `./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy` → `/etc/caddy/conf.d/62-files.caddy`. `62-files.off.caddy` is a comments-only stub (compose has no conditionals, and caddy needs a parseable file at the fixed mount target — same trick as `FILES_MOUNT_MODE`). | +| nginx | `render-nginx.sh` splices `nginx/files-browse.server.tmpl` into `${FILES_BROWSE_SERVER}` when `on`, or a comment when `off`, and adds/omits the name in the port-80 redirect (nginx has no conditionals either). | + +nginx is the gate that matters — with no TLS vhost the name is unreachable from +the internet regardless of caddy (caddy is bound to `127.0.0.1`). The caddy stub +is the second layer, and it also matters on hosts reached **directly** on caddy's +port rather than through nginx (`oxps` on the LAN). Applying a change: + +```sh +tooling/render-nginx.sh --install # nginx vhost in/out + reload +docker compose up -d --force-recreate caddy # single-file bind mount pins the inode +``` + +`restart caddy` is NOT enough (the conf mount is a single file — see +`plan/14-deploy.md`). With `off`, `files` also needs dropping from +`LE_SUBDOMAINS` on that host: no vhost, no cert to renew. `upload.` (filestash, +authenticated, writable) is untouched by this var. + **Per-host auth status (the two prod envs diverge):** | Host | `upload.` reachability | `FILES_AUTH` | Notes | @@ -106,13 +131,14 @@ See `.env.example` for the annotated block. Summary: |---|---| | `FILES_DATA_DIR` | host path of the share, outside the repo | | `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` | +| `FILES_BROWSE` | `on` \| `off` — serve the `files.` read-only browse on this host | | `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` | | `FILES_USER` | shared login name | | `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 ''` | | `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 `upload` **and** `files` (both are vhosts) | +| `LE_SUBDOMAINS` | must include `upload`, plus `files` when `FILES_BROWSE=on` | `filestash/config.json` is **rendered and gitignored** — it holds every hash and both secrets. Only the `.tmpl` is tracked. diff --git a/tooling/render-nginx.sh b/tooling/render-nginx.sh index 591d55f..4ae4339 100755 --- a/tooling/render-nginx.sh +++ b/tooling/render-nginx.sh @@ -17,6 +17,11 @@ # APP_DIR host path to this repo (cert files live under APP_DIR/certs) # — defaults to the repo root (this checkout) # SITE_NAME installed filename (default ulicraft.conf) +# FILES_BROWSE on|off (default on) — emit the files.${BASE_DOMAIN} +# read-only browse vhost. `off` omits the server block AND +# the name from the port-80 redirect, so the host serves +# nothing for it (no cert needed either). Same var gates the +# caddy side in docker-compose.yml. set -euo pipefail cd "$(dirname "$0")/.." # repo root @@ -40,7 +45,28 @@ SITE_NAME="${SITE_NAME:-ulicraft.conf}" export BASE_DOMAIN APP_DIR export CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-80}" -rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT' < "$TEMPLATE")" +# files.${BASE_DOMAIN} — the no-auth read-only browse. Per-host toggle: on for +# the LAN-party box, off where the share must not be publicly listable. nginx +# has no conditionals, so the vhost is a separate template spliced in here (or +# replaced by a comment). Keep in sync with the caddy mount in docker-compose.yml, +# which reads the same var. +: "${FILES_BROWSE:=on}" +FILES_BROWSE_TEMPLATE="${FILES_BROWSE_TEMPLATE:-nginx/files-browse.server.tmpl}" +case "$FILES_BROWSE" in + on) + [ -f "$FILES_BROWSE_TEMPLATE" ] || die "template not found: $FILES_BROWSE_TEMPLATE" + FILES_BROWSE_NAME=" files.${BASE_DOMAIN}" + FILES_BROWSE_SERVER="$(envsubst '$BASE_DOMAIN $APP_DIR' < "$FILES_BROWSE_TEMPLATE")" + ;; + off) + FILES_BROWSE_NAME="" + FILES_BROWSE_SERVER="# files.${BASE_DOMAIN} disabled on this host (FILES_BROWSE=off in .env)." + ;; + *) die "FILES_BROWSE must be 'on' or 'off' (got '$FILES_BROWSE')" ;; +esac +export FILES_BROWSE_NAME FILES_BROWSE_SERVER + +rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT $FILES_BROWSE_NAME $FILES_BROWSE_SERVER' < "$TEMPLATE")" if [ "${1:-}" != "--install" ]; then printf '%s\n' "$rendered" @@ -52,7 +78,7 @@ command -v nginx >/dev/null 2>&1 || die "nginx not found on PATH" avail="/etc/nginx/sites-available/$SITE_NAME" enabled="/etc/nginx/sites-enabled/$SITE_NAME" -log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT)" +log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT, FILES_BROWSE=$FILES_BROWSE)" printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null sudo ln -sf "$avail" "$enabled" sudo nginx -t