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