Files
ulicraft-server-v1/tooling/render-nginx.sh
T
oierandClaude Opus 5 fca6747bcc feat(monitor): admin-only latest.log stream at monitor.
Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft
server's latest.log into an in-memory ring buffer and serves it to an embedded
single-page UI (autoscroll, substring/regex filter, level chips).

Read-only by construction — /data is mounted :ro, there is no mutating route,
and the container holds no credentials. It publishes NO port: caddy's
basic_auth vhost is the only way in, which is the point, since the service has
no auth of its own and the log carries player IPs, chat and admin commands.

Two non-obvious choices, both to avoid prod-only failures:

- Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers
  proxied responses by default, so an EventSource would stall on cochi (whose
  vhost is an untracked per-host file) and not reproduce locally. Delay was
  accepted at design time, so polling is strictly better.

- The cursor is a sequence number, not a byte offset, and the tailer reopens on
  inode change or size shrink. itzg rotates latest.log on every server restart,
  i.e. every deploy — a naive tailer goes silent afterwards while still
  answering HTTP 200. Verified against rotate, truncate, delete/recreate,
  partial-line and ring-overflow cases.

MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE.

MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose
interpolates $-sequences in .env values, so a bare hash is silently mangled
($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why.
Confirmed empirically, and it is the opposite of filestash, which needs $6$.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:51:39 +02:00

110 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# render-nginx.sh — render the nginx vhost template with the .env values.
#
# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into
# nginx/ulicraft-caddy.conf.tmpl (the host nginx that terminates TLS in front of
# caddy) and prints the result to stdout, or installs it with --install (writes
# to sites-available, symlinks sites-enabled, tests, reloads nginx).
#
# Usage:
# tooling/render-nginx.sh # render -> stdout
# tooling/render-nginx.sh --install # render + install + reload nginx
#
# .env vars used:
# BASE_DOMAIN required
# CADDY_HTTP_PORT localhost port nginx reverse-proxies to (caddy)
# optional:
# 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.
# MONITOR_ENABLED on|off (default on) — emit the monitor.${BASE_DOMAIN}
# admin log-stream vhost. Same on/off semantics as
# FILES_BROWSE, same var gates the caddy side.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
TEMPLATE="${TEMPLATE:-nginx/ulicraft-caddy.conf.tmpl}"
[ -f "$TEMPLATE" ] || die "template not found: $TEMPLATE"
APP_DIR="${APP_DIR:-$PWD}"
SITE_NAME="${SITE_NAME:-ulicraft.conf}"
# CADDY_HTTP_PORT only matters for the caddy-front template; default 80 so the
# static template (which never references it) still renders cleanly.
export BASE_DOMAIN APP_DIR
export CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-80}"
# 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
# monitor.${BASE_DOMAIN} — admin-only log stream (plan/23-monitor.md). Same
# splice-or-comment trick as FILES_BROWSE above, and the same var gates the
# caddy conf mount in docker-compose.yml. Turning it off here is what removes
# the public name entirely; the container itself publishes no port either way.
: "${MONITOR_ENABLED:=on}"
MONITOR_TEMPLATE="${MONITOR_TEMPLATE:-nginx/monitor.server.tmpl}"
case "$MONITOR_ENABLED" in
on)
[ -f "$MONITOR_TEMPLATE" ] || die "template not found: $MONITOR_TEMPLATE"
MONITOR_NAME=" monitor.${BASE_DOMAIN}"
MONITOR_SERVER="$(envsubst '$BASE_DOMAIN $APP_DIR' < "$MONITOR_TEMPLATE")"
;;
off)
MONITOR_NAME=""
MONITOR_SERVER="# monitor.${BASE_DOMAIN} disabled on this host (MONITOR_ENABLED=off in .env)."
;;
*) die "MONITOR_ENABLED must be 'on' or 'off' (got '$MONITOR_ENABLED')" ;;
esac
export MONITOR_NAME MONITOR_SERVER
rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT $FILES_BROWSE_NAME $FILES_BROWSE_SERVER $MONITOR_NAME $MONITOR_SERVER' < "$TEMPLATE")"
if [ "${1:-}" != "--install" ]; then
printf '%s\n' "$rendered"
warn "dry run — pipe to a file or pass --install to deploy"
exit 0
fi
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, FILES_BROWSE=$FILES_BROWSE, MONITOR_ENABLED=$MONITOR_ENABLED)"
printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null
sudo ln -sf "$avail" "$enabled"
sudo nginx -t
sudo systemctl reload nginx
log "done — nginx reloaded"