Files
ulicraft-server-v1/tooling/render-config.sh
Oier Bravo Urtasun 10b86ae23e fix(files): general.host must be a bare hostname, not a URL
The SPA computes its redirect origin as (force_ssl ? "https://" : "http://")
+ general.host. With host set to "https://files.${BASE_DOMAIN}" that produced
"http://https://files.ulicraft.net", so every client-side redirect broke and
the page never routed anywhere.

Server-side this was invisible: SecureOrigin strips the scheme before the
Host check, so `curl /` returned 200, the API answered, and the uptime
monitor stayed green — only real browsers failed. The tell is a
`POST /report?...msg=Redirecting to http://https://...` line in the log.

Set host to the bare hostname and force_ssl=true (which the origin needs to
build https://, and which only emits an HSTS header — it does not redirect,
so it can't loop behind the plain-http nginx→caddy hop).

Verified: origin is now "https://files.ulicraft.net"; login + ls still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:27:42 +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 (files.${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
# 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
# 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 files.${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.