Files
ulicraft-server-v1/tooling/render-config.sh
T
oierandClaude Opus 4.8 16523e4f62 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>
2026-07-24 17:03:33 +02:00

96 lines
4.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# render-config.sh — turn *.tmpl files into real configs via envsubst.
# Substitutes ONLY a small whitelist so literal $-syntax that belongs to the
# target file (caddy, toml) is left untouched.
# Halts on any unset var or missing template (cautious: no half-rendered output).
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
[ -f .env ] && { set -a; . ./.env; set +a; }
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
# REGISTRATION_MODE (invite|open) -> Drasl RequireInvite boolean. drasl is
# TOML-only with no env support, so we derive the literal here and substitute it.
: "${REGISTRATION_MODE:=invite}"
case "$REGISTRATION_MODE" in
invite) REGISTRATION_REQUIRE_INVITE=true ;;
open) REGISTRATION_REQUIRE_INVITE=false ;;
*) echo "REGISTRATION_MODE must be 'invite' or 'open' (got '$REGISTRATION_MODE')" >&2; exit 1 ;;
esac
export REGISTRATION_REQUIRE_INVITE
render() { # $1=template $2=output
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
envsubst '${BASE_DOMAIN} ${REGISTRATION_REQUIRE_INVITE}' < "$1" > "$2"
echo "rendered $2"
}
render drasl/config/config.toml.tmpl drasl/config/config.toml
render nmsr/config.toml.tmpl nmsr/config.toml
# ── 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 (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://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.
#
# FILES_AUTH picks the identity_provider block. The params fields are JSON
# *strings* holding escaped JSON (that's Filestash's schema, not a typo) —
# built here rather than in the template because the shape differs per mode.
: "${FILES_AUTH:=htpasswd}"
: "${FILES_SECRET_KEY:?FILES_SECRET_KEY unset (openssl rand -hex 16)}"
: "${FILES_ADMIN_PASSWORD_HASH:?FILES_ADMIN_PASSWORD_HASH unset (docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>')}"
: "${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset (openssl rand -hex 24) — unlocks the admin-gated local backend}"
case "$FILES_AUTH" in
htpasswd)
: "${FILES_USER:?FILES_USER unset (shared login for players)}"
: "${FILES_PASSWORD_HASH:?FILES_PASSWORD_HASH unset (openssl passwd -6 '<pw>')}"
# The htpasswd plugin's bcrypt branch only accepts $2a$ — a $2y$ hash (what
# `htpasswd -B` emits) silently fails every login. $6$ (openssl passwd -6)
# is the safe, explicitly-supported format. Halt rather than ship a config
# nobody can log into.
case "$FILES_PASSWORD_HASH" in
'$6$'*|'$5$'*|'$2a$'*|'$apr1$'*|'$1$'*) ;;
'$2y$'*|'$2b$'*)
echo "FILES_PASSWORD_HASH is bcrypt '${FILES_PASSWORD_HASH:0:4}' — the htpasswd plugin only accepts \$2a\$." >&2
echo "Use: openssl passwd -6 '<password>'" >&2
exit 1 ;;
*) echo "FILES_PASSWORD_HASH is not a recognised hash (want \$6\$… from: openssl passwd -6 '<password>')" >&2; exit 1 ;;
esac
FILES_IDP_TYPE="htpasswd"
FILES_IDP_PARAMS="{\\\"type\\\":\\\"htpasswd\\\",\\\"users\\\":\\\"${FILES_USER}:${FILES_PASSWORD_HASH}\\\"}"
;;
passthrough|none)
# 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 upload.${BASE_DOMAIN} is unreachable from the internet." >&2
FILES_IDP_TYPE="passthrough"
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
;;
*)
echo "FILES_AUTH must be 'htpasswd', 'passthrough' or 'none' (got '$FILES_AUTH')" >&2; exit 1 ;;
esac
export FILES_IDP_TYPE FILES_IDP_PARAMS
envsubst '${BASE_DOMAIN} ${FILES_IDP_TYPE} ${FILES_IDP_PARAMS} ${FILES_ADMIN_PASSWORD_HASH} ${FILES_SECRET_KEY} ${FILES_LOCAL_SECRET}' \
< filestash/config.json.tmpl > filestash/config.json
# The hashes contain '$' and the params fields are nested JSON — one bad escape
# yields a config filestash silently ignores. Fail loudly instead.
python3 -c 'import json,sys; json.load(open("filestash/config.json"))' \
|| { echo "filestash/config.json is not valid JSON — refusing to ship it" >&2; exit 1; }
echo "rendered filestash/config.json (auth=$FILES_AUTH)"
# Server mods are no longer rendered here — they come from the distribution repo
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
# plan/04-mods.md.