#!/usr/bin/env bash # capture-dsh-token.sh — refresh the dsh-web login token WITHOUT restarting dsh-web. # # Context (CT 112 / tankodhs.sysloggh.net) # ---------------------------------------- # The dsh-web UI (systemd unit `dsh-web.service`, 127.0.0.1:3080) prints a random # launch token to the journal on every start: # # dsh web: http://127.0.0.1:3080/?token= # # That token is the only way to bootstrap the authority-bound 30-day browser # cookie. It rotates on every dsh-web start, so the Authentik-gated # `location = /dsh-web-login` in /etc/nginx/sites-available/dsh must always # reference the token of the RUNNING process. # # This script: # 1. selects the launch token the RUNNING service actually accepts from the # current systemd invocation — it NEVER stops or starts dsh-web, # 2. records it in /etc/dsh-web/launch-token, # 3. regenerates the nginx include /etc/dsh-web/nginx-login.conf (the # `proxy_pass ...?token=` line consumed by /dsh-web-login), # 4. reloads nginx ONLY when the on-disk include differs from the generated # one or the applied-state stamp does not match the token (the stamp is # written only after a successful reload), rolling the include back on # failure so the next run retries, # 5. removes the legacy unauthenticated :8081 endpoint if it ever reappears. # # Idempotent and safe to run at any time (systemd ExecStartPost or timer). set -euo pipefail umask 077 PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" JOURNAL_UNIT="dsh-web.service" TOKEN_FILE="/etc/dsh-web/launch-token" INCLUDE_FILE="/etc/dsh-web/nginx-login.conf" STAMP_FILE="/etc/dsh-web/nginx-login.conf.applied" PENDING_FILE="/etc/dsh-web/nginx-reload.pending" SITE_ENABLED="/etc/nginx/sites-enabled/dsh" LEGACY_8081="/etc/nginx/sites-enabled/dsh.token" STASH_DIR="/etc/nginx/sites-available" LOCK_FILE="/run/capture-dsh-token.lock" LOGIN_HOST="tankodhs.sysloggh.net" LOGIN_UPSTREAM="http://127.0.0.1:3080" TOKEN_WAIT=120 log() { printf 'capture-dsh-token: %s\n' "$*" >&2; } die() { printf 'capture-dsh-token: ERROR: %s\n' "$*" >&2; exit 1; } [ "$(id -u)" -eq 0 ] || die "must run as root" # ── 0. Serialize runs so timer/ExecStartPost/manual runs cannot interleave ── exec 9>"$LOCK_FILE" flock -n 9 || { log "another capture-dsh-token run holds $LOCK_FILE; exiting"; exit 0; } mkdir -p "$(dirname "$PENDING_FILE")" # ── 0b. Guarantee the generated include exists before any `nginx -t` ────── # The :80 site includes /etc/dsh-web/nginx-login.conf by literal path, so a # missing include makes every `nginx -t` fail and can wedge recovery. Seed it # from the last known token (or a placeholder); step 4 replaces it. if [ ! -f "$INCLUDE_FILE" ]; then SEED="placeholder" if [ -f "$TOKEN_FILE" ]; then SEED="$(cat "$TOKEN_FILE" 2>/dev/null || true)" [ -n "$SEED" ] || SEED="placeholder" fi printf '%s' "$SEED" | grep -qE '^[A-Za-z0-9._~+/=:@-]+$' || SEED="placeholder" printf 'proxy_pass %s/?token=%s;\n' "$LOGIN_UPSTREAM" "$SEED" > "$INCLUDE_FILE" chmod 600 "$INCLUDE_FILE" log "created missing $INCLUDE_FILE" fi # ── 1. Remove the legacy unauthenticated :8081 endpoint, if present ───────── # It bypassed Authentik entirely (listened on 0.0.0.0:8081 with no auth_request) # and must never come back. Stash it rather than delete so it is auditable. if [ -e "$LEGACY_8081" ] || [ -L "$LEGACY_8081" ]; then TS="$(date -u +%Y%m%dT%H%M%SZ)" STASHED="$STASH_DIR/dsh.token.disabled-$TS" mv "$LEGACY_8081" "$STASHED" chmod 600 "$STASHED" 2>/dev/null || true touch "$PENDING_FILE" if ! NGINX_TEST_OUT="$(nginx -t 2>&1)"; then die "nginx config test failed after disabling $LEGACY_8081 (kept disabled at $STASHED): $NGINX_TEST_OUT; a pending reload is recorded so running nginx is reloaded once the config is fixed. The legacy :8081 endpoint will NOT be restored." fi if ! nginx -s reload; then die "nginx reload failed after disabling $LEGACY_8081 (kept disabled at $STASHED); a pending reload is recorded so running nginx is reloaded on the next run. The legacy :8081 endpoint will NOT be restored." fi rm -f "$PENDING_FILE" log "removed legacy :8081 endpoint -> $STASHED" fi # ── 1b. Honor a recorded pending reload regardless of token selection ─────── # A failed reload leaves PENDING_FILE set so a stashed legacy :8081 file can # never remain loaded in the running nginx while dsh-web is down or not yet # answering. Reconcile it before the token wait. if [ -e "$PENDING_FILE" ]; then if ! NGINX_TEST_OUT="$(nginx -t 2>&1)"; then log "WARNING: pending nginx reload recorded but 'nginx -t' fails: $NGINX_TEST_OUT; continuing so the include can be regenerated; will retry next run" elif ! nginx -s reload; then log "WARNING: pending nginx reload recorded but 'nginx -s reload' failed; will retry next run" else rm -f "$PENDING_FILE" log "completed pending nginx reload" fi fi # ── 2. Select the token the RUNNING service actually accepts ──────────────── # Re-sample the service's CURRENT systemd invocation on every pass and read # candidates only from it, so a restart that lands during the wait immediately # switches to the new invocation; there is no whole-journal or cross-invocation # fallback, and an empty/unknown invocation just waits. Each candidate is then # functionally verified against the local dsh-web using the public authority, # exactly as the /dsh-web-login proxy does, and the first that answers 303 is # the live token. Candidates are re-probed newest-first on each pass (connection # failures stay eligible) until one is accepted or the wait elapses. journal_tokens() { journalctl -u "$JOURNAL_UNIT" "_SYSTEMD_INVOCATION_ID=$1" --no-pager -o cat 2>/dev/null \ | grep -oE 'dsh web: https?://[^[:space:]]+[?&]token=[^[:space:]]+' \ | sed -E 's/.*[?&]token=//' \ | grep -E '^[A-Za-z0-9._~+/=:@-]+$' \ | tac | awk '!seen[$0]++' || true } TOKEN="" DEADLINE=$((SECONDS + TOKEN_WAIT)) NO_INVOCATION_WARNED=0 while [ -z "$TOKEN" ] && [ "$SECONDS" -lt "$DEADLINE" ]; do INVOCATION="$(systemctl show -p InvocationID --value "$JOURNAL_UNIT" 2>/dev/null || true)" if [ -z "$INVOCATION" ] || [ "$INVOCATION" = "n/a" ]; then if [ "$NO_INVOCATION_WARNED" -eq 0 ]; then log "WARNING: no invocation id for $JOURNAL_UNIT; waiting for a live invocation" NO_INVOCATION_WARNED=1 fi sleep 2 continue fi for cand in $(journal_tokens "$INVOCATION"); do code="$(curl -s -o /dev/null --max-time 5 -w '%{http_code}' \ -H "Host: $LOGIN_HOST" "$LOGIN_UPSTREAM/?token=$cand" || true)" if [ "$code" = "303" ]; then TOKEN="$cand" break fi done [ -n "$TOKEN" ] && break sleep 2 done if [ -z "$TOKEN" ]; then log "no accepted launch token in the current invocation within ${TOKEN_WAIT}s; leaving the include untouched for the next run" [ -e "$PENDING_FILE" ] && die "pending nginx reload could not be completed; will retry next run" exit 0 fi # ── 3. Record the token (atomic, private) ────────────────────────────────── mkdir -p "$(dirname "$TOKEN_FILE")" if ! printf '%s\n' "$TOKEN" | cmp -s - "$TOKEN_FILE" 2>/dev/null; then printf '%s\n' "$TOKEN" > "$TOKEN_FILE.tmp" chmod 600 "$TOKEN_FILE.tmp" mv "$TOKEN_FILE.tmp" "$TOKEN_FILE" log "recorded live launch token in $TOKEN_FILE" fi chmod 600 "$TOKEN_FILE" # ── 4. Regenerate the nginx login include (reload only when it changes) ──── NEW_INCLUDE="$(mktemp "$INCLUDE_FILE.XXXXXX")" printf 'proxy_pass %s/?token=%s;\n' "$LOGIN_UPSTREAM" "$TOKEN" > "$NEW_INCLUDE" chmod 600 "$NEW_INCLUDE" # The stamp records the token nginx actually loaded. It is written only after a # successful reload, so the early exit is safe only when both the stamp and the # on-disk include agree with the live token; anything else falls through to the # reload path so the include can never silently diverge from what nginx serves. APPLIED="" [ -f "$STAMP_FILE" ] && APPLIED="$(cat "$STAMP_FILE" 2>/dev/null || true)" [ -f "$INCLUDE_FILE" ] && chmod 600 "$INCLUDE_FILE" [ -f "$STAMP_FILE" ] && chmod 600 "$STAMP_FILE" if [ "$APPLIED" = "$TOKEN" ] && [ -f "$INCLUDE_FILE" ] && cmp -s "$NEW_INCLUDE" "$INCLUDE_FILE" \ && [ ! -e "$PENDING_FILE" ]; then rm -f "$NEW_INCLUDE" log "token unchanged; nginx not reloaded" exit 0 fi [ -e "$SITE_ENABLED" ] || { rm -f "$NEW_INCLUDE"; die "$SITE_ENABLED missing; refusing to reload"; } RESTORE="" if [ -f "$INCLUDE_FILE" ]; then RESTORE="$(mktemp "$INCLUDE_FILE.bak.XXXXXX")" cp -p "$INCLUDE_FILE" "$RESTORE" chmod 600 "$RESTORE" fi mv "$NEW_INCLUDE" "$INCLUDE_FILE" chmod 600 "$INCLUDE_FILE" if ! NGINX_TEST_OUT="$(nginx -t 2>&1)"; then if [ -n "$RESTORE" ]; then mv "$RESTORE" "$INCLUDE_FILE" else rm -f "$INCLUDE_FILE" fi die "nginx config test failed: $NGINX_TEST_OUT; previous include restored" fi if ! nginx -s reload; then if [ -n "$RESTORE" ]; then mv "$RESTORE" "$INCLUDE_FILE" else rm -f "$INCLUDE_FILE" fi touch "$PENDING_FILE" die "nginx reload failed; previous include restored; a pending reload is recorded so the next run retries" fi if [ -n "$RESTORE" ]; then rm -f "$RESTORE" fi rm -f "$PENDING_FILE" printf '%s\n' "$TOKEN" > "$STAMP_FILE.tmp" chmod 600 "$STAMP_FILE.tmp" mv "$STAMP_FILE.tmp" "$STAMP_FILE" log "token changed; nginx reloaded" log "login endpoint: https://$LOGIN_HOST/dsh-web-login (Authentik-gated)"