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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:43:58 -05:00
co-authored by Claude Opus 5
parent 4643b77083
commit d6542996fc
+69 -5
View File
@@ -8,7 +8,9 @@
# 1. Installs docker + docker-cli-compose if missing. # 1. Installs docker + docker-cli-compose if missing.
# 2. Lays down docker-compose.yml, Caddyfile, and a substituted # 2. Lays down docker-compose.yml, Caddyfile, and a substituted
# config.yaml in $STACK_DIR. # 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, # 4. Prompts for required values not preset (HEADSCALE_DOMAIN,
# ACME_EMAIL, TAILNET_DOMAIN, POCKETID_DOMAIN, OIDC_CLIENT_ID, # ACME_EMAIL, TAILNET_DOMAIN, POCKETID_DOMAIN, OIDC_CLIENT_ID,
# OIDC_CLIENT_SECRET). # OIDC_CLIENT_SECRET).
@@ -19,8 +21,9 @@
# 7. Pulls images, brings the stack up, waits for healthchecks. # 7. Pulls images, brings the stack up, waits for healthchecks.
# #
# Idempotent: re-run to apply config changes / pull new images. Re-running # Idempotent: re-run to apply config changes / pull new images. Re-running
# regenerates config.yaml from the current .env so edits to .env propagate # regenerates config.yaml from the current .env, so edits to .env propagate --
# (but secrets in .env are never touched once seeded). # 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 # 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 # and .env.example are embedded as a base64-encoded tar.gz at the bottom of
@@ -44,6 +47,21 @@ set -euo pipefail
: "${FORCE:=0}" : "${FORCE:=0}"
: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts : "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts
[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 [[ "$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:=}" : "${HEADSCALE_DOMAIN:=}"
: "${ACME_EMAIL:=}" : "${ACME_EMAIL:=}"
: "${TAILNET_DOMAIN:=}" : "${TAILNET_DOMAIN:=}"
@@ -189,6 +207,25 @@ prompt() {
fi 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 HEADSCALE_DOMAIN "Public hostname for headscale (e.g. hs.example.com)"
prompt ACME_EMAIL "Let's Encrypt email" prompt ACME_EMAIL "Let's Encrypt email"
prompt TAILNET_DOMAIN "Tailnet base domain for MagicDNS (e.g. tail.example.com)" 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" install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile"
# .env # .env
ENV_FILE="$STACK_DIR/.env" set_env() { # <KEY> <value>: 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 if [[ ! -f "$ENV_FILE" ]]; then
log "Seeding $ENV_FILE..." log "Seeding $ENV_FILE..."
install -m 0600 "$SCRIPT_DIR/.env.example" "$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}|" \ -e "s|^HEADPLANE_OIDC_CLIENT_SECRET=.*|HEADPLANE_OIDC_CLIENT_SECRET=${HEADPLANE_OIDC_CLIENT_SECRET}|" \
"$ENV_FILE" "$ENV_FILE"
else 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:-<unset>} -> ${!_k:-<empty>}" ;;
esac
done
fi fi
# Validate # Validate