From d6542996fc0dc7f351d48e6aa1559c9019d64aa2 Mon Sep 17 00:00:00 2001 From: William Gill Date: Wed, 16 Sep 2026 13:43:58 -0500 Subject: [PATCH] fix(headscale): let a re-run's values reach .env instead of being reverted deploy.sh deliberately re-reads .env before rendering config.yaml and headplane.yaml ("so config.yaml substitution sees what's actually deployed"). That design is right, but .env was seeded only when absent, so "what's actually deployed" could never change: `set -a; . "$ENV_FILE"` overwrote every value passed to the run, and `set -a` re-exported the stale ones into compose as well. Worse, the prompts at :192-197 run 56 lines BEFORE ENV_FILE is even defined, so an interactive re-run asked for all six required values and then threw every answer away. Via automations.sh the same six arrive exported and meet the same fate. The validation only greps .env for non-empty values, which the stale ones satisfy, so the run printed DEPLOYED and exited 0. The case that matters is a rotated OIDC_CLIENT_SECRET: headscale keeps presenting the retired secret at pocket-id's token endpoint, and only_start_if_oidc_is_available probes issuer discovery, not the secret, so nothing fails at deploy time. Either the leaked credential is still live and the rotation is fiction, or tailnet OIDC login is broken and surfaces later at some user's `tailscale up`. A corrected HEADSCALE_DOMAIN leaves the LE cert hostname and the OIDC redirect URI on the typo; newly-supplied headplane OIDC creds silently leave /admin on API-key login, so the IdP-group gating the README recommends is never in force. Record which keys arrived in the environment BEFORE the ":=" defaults erase the distinction -- writing a blank over a live OIDC secret is exactly the wrong move. Pre-load .env into keys NOT passed, before prompting, so the prompts stop asking questions they will discard (and SKIP_PROMPTS=1 no longer dies demanding values .env already has). Then write only the passed keys through with a set_env upsert. Secret values are never echoed; a changed HEADSCALE_DOMAIN warns about the new cert and the redirect URI. The `. "$ENV_FILE"` stays: once .env carries this run's values it reads back what was passed, and .env, config.yaml, headplane.yaml and compose agree. Verified: rotated secret lands and is not echoed; changed domain lands and warns; new headplane creds land; a re-run passing nothing touches nothing; SKIP_PROMPTS=1 with a configured .env and no env vars no longer dies. Co-Authored-By: Claude Opus 5 --- deployments/headscale/deploy.sh | 74 ++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/deployments/headscale/deploy.sh b/deployments/headscale/deploy.sh index 4568248..67a22ce 100644 --- a/deployments/headscale/deploy.sh +++ b/deployments/headscale/deploy.sh @@ -8,7 +8,9 @@ # 1. Installs docker + docker-cli-compose if missing. # 2. Lays down docker-compose.yml, Caddyfile, and a substituted # config.yaml in $STACK_DIR. -# 3. Generates .env on first run; existing .env is never overwritten. +# 3. Generates .env on first run. On a re-run an existing .env is kept, +# except for values passed explicitly that run, which are written +# through (.env is the source of truth for every consumer below). # 4. Prompts for required values not preset (HEADSCALE_DOMAIN, # ACME_EMAIL, TAILNET_DOMAIN, POCKETID_DOMAIN, OIDC_CLIENT_ID, # OIDC_CLIENT_SECRET). @@ -19,8 +21,9 @@ # 7. Pulls images, brings the stack up, waits for healthchecks. # # Idempotent: re-run to apply config changes / pull new images. Re-running -# regenerates config.yaml from the current .env so edits to .env propagate -# (but secrets in .env are never touched once seeded). +# regenerates config.yaml from the current .env, so edits to .env propagate -- +# as do values passed to the re-run itself, including a rotated +# OIDC_CLIENT_SECRET. Values not passed are left exactly as they are. # # Self-contained: docker-compose.yml, Caddyfile, config.yaml, policy.hujson # and .env.example are embedded as a base64-encoded tar.gz at the bottom of @@ -44,6 +47,21 @@ set -euo pipefail : "${FORCE:=0}" : "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts [[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 +ENV_FILE="$STACK_DIR/.env" + +# Everything downstream reads .env (see the `. "$ENV_FILE"` below), so .env has +# to absorb what this run passed or the run silently deploys the old values. +# Which keys actually arrived in the ENVIRONMENT must be recorded BEFORE the +# ":=" defaults below, which make an unset variable look like an empty one -- +# and writing a blank over a live OIDC secret is exactly the wrong move. +RUNTIME_KEYS=(HEADSCALE_DOMAIN ACME_EMAIL TAILNET_DOMAIN POCKETID_DOMAIN + OIDC_CLIENT_ID OIDC_CLIENT_SECRET + HEADPLANE_OIDC_CLIENT_ID HEADPLANE_OIDC_CLIENT_SECRET) +KEYS_FROM_ENV=() +for _k in "${RUNTIME_KEYS[@]}"; do + if [[ -n "${!_k+x}" ]]; then KEYS_FROM_ENV+=("$_k"); fi +done + : "${HEADSCALE_DOMAIN:=}" : "${ACME_EMAIL:=}" : "${TAILNET_DOMAIN:=}" @@ -189,6 +207,25 @@ prompt() { fi } +# On a re-run the deployed values live in .env. Load them into any key NOT passed +# this run, so the prompts below do not ask six questions whose answers the +# `. "$ENV_FILE"` further down would discard -- and so SKIP_PROMPTS=1 does not +# die demanding values the .env already has. +if [[ -f "$ENV_FILE" ]]; then + _preloaded=() + for _k in "${RUNTIME_KEYS[@]}"; do + if [[ " ${KEYS_FROM_ENV[*]-} " == *" ${_k} "* ]]; then continue; fi + _v=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1) + if [[ -z "$_v" ]]; then continue; fi + printf -v "$_k" '%s' "$_v" + _preloaded+=("$_k") + done + if (( ${#_preloaded[@]} > 0 )); then + log "Reusing ${ENV_FILE} values for: ${_preloaded[*]}" + log " (pass VAR=... to change one; it is then written back to .env)" + fi +fi + prompt HEADSCALE_DOMAIN "Public hostname for headscale (e.g. hs.example.com)" prompt ACME_EMAIL "Let's Encrypt email" prompt TAILNET_DOMAIN "Tailnet base domain for MagicDNS (e.g. tail.example.com)" @@ -220,7 +257,17 @@ install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile" # .env -ENV_FILE="$STACK_DIR/.env" +set_env() { # : update KEY in .env, or append if absent + local key="$1" val="$2" esc + esc=${val//\/\\}; esc=${esc//|/\|}; esc=${esc//&/\&} + if grep -qE "^${key}=" "$ENV_FILE"; then + sed -i -e "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE" + else + printf '%s=%s +' "$key" "$val" >> "$ENV_FILE" + fi +} + if [[ ! -f "$ENV_FILE" ]]; then log "Seeding $ENV_FILE..." install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE" @@ -236,7 +283,24 @@ if [[ ! -f "$ENV_FILE" ]]; then -e "s|^HEADPLANE_OIDC_CLIENT_SECRET=.*|HEADPLANE_OIDC_CLIENT_SECRET=${HEADPLANE_OIDC_CLIENT_SECRET}|" \ "$ENV_FILE" else - log ".env exists; leaving it alone." + # Keep the file, but absorb what was passed this run -- otherwise the + # `. "$ENV_FILE"` below reverts it and the run deploys the old values while + # reporting success. A rotated OIDC_CLIENT_SECRET silently not taking effect + # is the case that matters. Keys not passed are left untouched. + log ".env exists; keeping it (only values passed this run are updated)." + for _k in ${KEYS_FROM_ENV[@]+"${KEYS_FROM_ENV[@]}"}; do + _cur=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1) + if [[ "$_cur" == "${!_k}" ]]; then continue; fi + set_env "$_k" "${!_k}" + case "$_k" in + *SECRET*) + log " ${_k}: updated (value changed; not echoed)" ;; + HEADSCALE_DOMAIN) + warn "HEADSCALE_DOMAIN ${_cur} -> ${!_k}: Caddy will request a new Let's Encrypt cert, and the pocket-id redirect URI must become https://${!_k}/oidc/callback" ;; + *) + log " ${_k}: ${_cur:-} -> ${!_k:-}" ;; + esac + done fi # Validate