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>
572 lines
26 KiB
Bash
572 lines
26 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# deploy.sh -- deploy the headscale stack (caddy + headscale) on Alpine.
|
|
#
|
|
# Single-node, dedicated host: runs everything as root.
|
|
#
|
|
# What this does:
|
|
# 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. 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).
|
|
# 5. Substitutes those values into config.yaml from the embedded
|
|
# template.
|
|
# 6. Opens TCP 80/443 in UFW if it's installed and active (Tailscale
|
|
# clients connect on 443; LE HTTP-01 needs 80).
|
|
# 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 --
|
|
# 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
|
|
# this file. Rebuild with build.sh after editing the loose source files.
|
|
#
|
|
# Usage:
|
|
# bash deploy.sh # interactive prompts
|
|
# HEADSCALE_DOMAIN=hs.example.com \
|
|
# ACME_EMAIL=admin@example.com \
|
|
# TAILNET_DOMAIN=tail.example.com \
|
|
# POCKETID_DOMAIN=auth.example.com \
|
|
# OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... FORCE=1 bash deploy.sh
|
|
#
|
|
# STACK_DIR=/opt/headscale bash deploy.sh
|
|
# SKIP_DOCKER_INSTALL=1 bash deploy.sh
|
|
|
|
set -euo pipefail
|
|
|
|
: "${STACK_DIR:=/srv/headscale}"
|
|
: "${SKIP_DOCKER_INSTALL:=0}"
|
|
: "${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:=}"
|
|
: "${POCKETID_DOMAIN:=}"
|
|
: "${OIDC_CLIENT_ID:=}"
|
|
: "${OIDC_CLIENT_SECRET:=}"
|
|
: "${HEADPLANE_OIDC_CLIENT_ID:=}" # optional: headplane UI OIDC login
|
|
: "${HEADPLANE_OIDC_CLIENT_SECRET:=}"
|
|
|
|
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
[[ $EUID -eq 0 ]] || die "Run as root."
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS detection + Docker install (Alpine / Debian / Alma). This deploy.sh is
|
|
# self-contained (scp'd standalone), so the OS logic is inlined here instead
|
|
# of sourced from scripts/oslib.sh.
|
|
# ---------------------------------------------------------------------------
|
|
osfam() {
|
|
local id="" like=""
|
|
if [[ -r /etc/os-release ]]; then
|
|
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
|
|
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
|
|
fi
|
|
case " $id $like " in
|
|
*" alpine "*) echo alpine ;;
|
|
*" debian "*|*" ubuntu "*) echo debian ;;
|
|
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
|
|
*) echo "${id:-unknown}" ;;
|
|
esac
|
|
}
|
|
|
|
install_docker() {
|
|
if command -v docker >/dev/null 2>&1; then
|
|
log "Docker already installed: $(docker --version)"
|
|
else
|
|
log "Installing Docker (OS: $(osfam))..."
|
|
case "$(osfam)" in
|
|
alpine) apk add -q docker docker-cli-compose openrc ;;
|
|
debian|rhel) command -v curl >/dev/null 2>&1 || \
|
|
{ command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \
|
|
{ command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; }
|
|
curl -fsSL https://get.docker.com | sh ;;
|
|
*) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;;
|
|
esac
|
|
fi
|
|
if command -v rc-update >/dev/null 2>&1; then
|
|
rc-update add docker default >/dev/null 2>&1 || true
|
|
rc-service docker status >/dev/null 2>&1 || rc-service docker start
|
|
elif command -v systemctl >/dev/null 2>&1; then
|
|
systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true
|
|
fi
|
|
}
|
|
|
|
open_web_ports() {
|
|
# Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh)
|
|
# when present: drop in a rule file and re-apply. Else fall back to ufw/
|
|
# firewalld if active (no-op when neither is).
|
|
#
|
|
# NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the
|
|
# nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless
|
|
# belt-and-braces for any host-bound bind and self-documents the stack ports.
|
|
if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then
|
|
log "Registering 80,443/tcp with host firewall..."
|
|
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/headscale.rule
|
|
/usr/local/sbin/firewall-apply
|
|
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
|
|
log "ufw active -- allowing 80,443/tcp..."
|
|
ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null
|
|
elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
log "firewalld active -- allowing http,https..."
|
|
firewall-cmd -q --add-service=http --permanent
|
|
firewall-cmd -q --add-service=https --permanent
|
|
firewall-cmd -q --reload
|
|
fi
|
|
}
|
|
|
|
# Write headplane's config to $STACK_DIR/headplane.yaml from the current .env
|
|
# values. OIDC is enabled only when a client id + secret are present; otherwise
|
|
# headplane falls back to API-key login. Mode is API-only (no Docker socket).
|
|
render_headplane_config() {
|
|
local oidc_enabled=false
|
|
[[ -n "${HEADPLANE_OIDC_CLIENT_ID:-}" && -n "${HEADPLANE_OIDC_CLIENT_SECRET:-}" ]] && oidc_enabled=true
|
|
( umask 077
|
|
cat > "$STACK_DIR/headplane.yaml" <<EOF
|
|
# Generated by deploy.sh -- do not edit by hand (regenerated each run from .env).
|
|
server:
|
|
host: "0.0.0.0"
|
|
port: 3000
|
|
base_url: "https://${HEADSCALE_DOMAIN}"
|
|
cookie_secret: "${HEADPLANE_COOKIE_SECRET}"
|
|
cookie_secure: true
|
|
headscale:
|
|
url: "http://headscale:8080"
|
|
api_key: "${HEADPLANE_HS_API_KEY:-}"
|
|
oidc:
|
|
enabled: ${oidc_enabled}
|
|
issuer: "https://${POCKETID_DOMAIN}"
|
|
client_id: "${HEADPLANE_OIDC_CLIENT_ID:-}"
|
|
client_secret: "${HEADPLANE_OIDC_CLIENT_SECRET:-}"
|
|
use_pkce: true
|
|
EOF
|
|
)
|
|
chmod 600 "$STACK_DIR/headplane.yaml"
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Extract embedded archive
|
|
# ----------------------------------------------------------------------------
|
|
SCRIPT_DIR=$(mktemp -d -t headscale-deploy.XXXXXX)
|
|
trap 'rm -rf "$SCRIPT_DIR"' EXIT
|
|
|
|
extract_archive() {
|
|
grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \
|
|
| tail -n +2 \
|
|
| base64 -d \
|
|
| tar -xz -C "$SCRIPT_DIR"
|
|
}
|
|
|
|
if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then
|
|
log "Extracting embedded deployment files..."
|
|
extract_archive
|
|
else
|
|
die "No embedded archive found. Run build.sh to embed deployment files."
|
|
fi
|
|
|
|
for f in docker-compose.yml Caddyfile config.yaml policy.hujson .env.example; do
|
|
[[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f"
|
|
done
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Prompt for required vars
|
|
# ----------------------------------------------------------------------------
|
|
prompt() {
|
|
local varname="$1" message="$2"
|
|
local -n ref="$varname"
|
|
if [[ -z "${ref:-}" ]]; then
|
|
[[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)."
|
|
read -r -p "$message: " ref
|
|
[[ -n "$ref" ]] || die "$varname required."
|
|
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)"
|
|
prompt POCKETID_DOMAIN "OIDC issuer hostname (your pocket-id, e.g. auth.example.com)"
|
|
prompt OIDC_CLIENT_ID "OIDC client_id (from pocket-id)"
|
|
prompt OIDC_CLIENT_SECRET "OIDC client_secret (from pocket-id)"
|
|
|
|
[[ "$HEADSCALE_DOMAIN" != "$TAILNET_DOMAIN" ]] \
|
|
|| die "HEADSCALE_DOMAIN and TAILNET_DOMAIN must differ."
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Docker
|
|
# ----------------------------------------------------------------------------
|
|
if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then
|
|
install_docker
|
|
fi
|
|
|
|
# Open 80/443 on whichever host firewall is active. (Tailscale clients connect
|
|
# on 443; LE HTTP-01 needs 80. Note the IPv6-to-docker-proxy default-deny
|
|
# gotcha on ufw -- opening the ports explicitly covers it.)
|
|
open_web_ports
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stack directory + files
|
|
# ----------------------------------------------------------------------------
|
|
log "Setting up $STACK_DIR..."
|
|
install -d -m 0750 "$STACK_DIR"
|
|
install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml"
|
|
install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile"
|
|
|
|
# .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
|
|
log "Seeding $ENV_FILE..."
|
|
install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE"
|
|
sed -i \
|
|
-e "s|^HEADSCALE_DOMAIN=.*|HEADSCALE_DOMAIN=${HEADSCALE_DOMAIN}|" \
|
|
-e "s|^ACME_EMAIL=.*|ACME_EMAIL=${ACME_EMAIL}|" \
|
|
-e "s|^TAILNET_DOMAIN=.*|TAILNET_DOMAIN=${TAILNET_DOMAIN}|" \
|
|
-e "s|^POCKETID_DOMAIN=.*|POCKETID_DOMAIN=${POCKETID_DOMAIN}|" \
|
|
-e "s|^OIDC_CLIENT_ID=.*|OIDC_CLIENT_ID=${OIDC_CLIENT_ID}|" \
|
|
-e "s|^OIDC_CLIENT_SECRET=.*|OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}|" \
|
|
-e "s|^HEADPLANE_COOKIE_SECRET=.*|HEADPLANE_COOKIE_SECRET=$(openssl rand -hex 16)|" \
|
|
-e "s|^HEADPLANE_OIDC_CLIENT_ID=.*|HEADPLANE_OIDC_CLIENT_ID=${HEADPLANE_OIDC_CLIENT_ID}|" \
|
|
-e "s|^HEADPLANE_OIDC_CLIENT_SECRET=.*|HEADPLANE_OIDC_CLIENT_SECRET=${HEADPLANE_OIDC_CLIENT_SECRET}|" \
|
|
"$ENV_FILE"
|
|
else
|
|
# 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
|
|
|
|
# Validate
|
|
missing=()
|
|
for var in HEADSCALE_DOMAIN ACME_EMAIL TAILNET_DOMAIN POCKETID_DOMAIN \
|
|
OIDC_CLIENT_ID OIDC_CLIENT_SECRET; do
|
|
grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var")
|
|
done
|
|
(( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}"
|
|
|
|
# Re-read final values from .env so config.yaml substitution sees what's
|
|
# actually deployed (not whatever was passed in the environment).
|
|
# shellcheck disable=SC1090
|
|
set -a; . "$ENV_FILE"; set +a
|
|
|
|
# config.yaml: regenerate every run from the template so changes to .env
|
|
# propagate. Secrets in config.yaml are derived from .env, not stored
|
|
# independently, so this is safe.
|
|
log "Generating config.yaml from template..."
|
|
install -m 0600 "$SCRIPT_DIR/config.yaml" "$STACK_DIR/config.yaml"
|
|
|
|
# ACL policy: install the default on first run only, so your edits survive
|
|
# re-deploys. headscale reads it on start (policy mode = file); after editing,
|
|
# validate with `headscale policy check` and `docker compose restart headscale`.
|
|
if [[ ! -f "$STACK_DIR/policy.hujson" ]]; then
|
|
log "Installing default ACL policy -> $STACK_DIR/policy.hujson"
|
|
install -m 0640 "$SCRIPT_DIR/policy.hujson" "$STACK_DIR/policy.hujson"
|
|
else
|
|
log "ACL policy exists; leaving $STACK_DIR/policy.hujson alone."
|
|
fi
|
|
|
|
# Render headplane.yaml now so the bind mount exists (api_key may still be
|
|
# empty on first run; it's re-rendered after the key is minted below).
|
|
render_headplane_config
|
|
|
|
# Escape replacement values for sed (handle slashes/&). Headscale URLs and
|
|
# OIDC secrets can contain those.
|
|
sed_escape() { printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'; }
|
|
|
|
sed -i \
|
|
-e "s/__HEADSCALE_DOMAIN__/$(sed_escape "$HEADSCALE_DOMAIN")/g" \
|
|
-e "s/__TAILNET_DOMAIN__/$(sed_escape "$TAILNET_DOMAIN")/g" \
|
|
-e "s/__POCKETID_DOMAIN__/$(sed_escape "$POCKETID_DOMAIN")/g" \
|
|
-e "s/__OIDC_CLIENT_ID__/$(sed_escape "$OIDC_CLIENT_ID")/g" \
|
|
-e "s/__OIDC_CLIENT_SECRET__/$(sed_escape "$OIDC_CLIENT_SECRET")/g" \
|
|
"$STACK_DIR/config.yaml"
|
|
|
|
# Final sanity check (ignore comment lines so documentation tokens don't
|
|
# trip a false positive).
|
|
grep -v '^[[:space:]]*#' "$STACK_DIR/config.yaml" | grep -q '__[A-Za-z_]*__' \
|
|
&& warn "config.yaml still has un-substituted __PLACEHOLDER__ markers!" \
|
|
|| true
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Bring up the stack
|
|
# ----------------------------------------------------------------------------
|
|
if [[ "$FORCE" != "1" ]]; then
|
|
cat <<EOF
|
|
|
|
About to pull images and start the stack from $STACK_DIR.
|
|
|
|
Caddy will request a Let's Encrypt cert for ${HEADSCALE_DOMAIN}. DNS for
|
|
that name must already point at this host, and ports 80/443 must be
|
|
reachable from the internet.
|
|
|
|
In pocket-id, the OIDC client must have this redirect URI registered:
|
|
https://${HEADSCALE_DOMAIN}/oidc/callback
|
|
|
|
Continue? [y/N]
|
|
EOF
|
|
read -r ans
|
|
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { warn "Aborted."; exit 0; }
|
|
fi
|
|
|
|
cd "$STACK_DIR"
|
|
log "Pulling images..."
|
|
docker compose pull
|
|
|
|
# Start headscale + caddy first; headplane needs a headscale API key, which we
|
|
# can only mint once headscale is up.
|
|
log "Starting headscale + caddy..."
|
|
docker compose up -d --remove-orphans headscale caddy
|
|
|
|
log "Waiting for headscale to become healthy (up to 120s)..."
|
|
deadline=$(( $(date +%s) + 120 ))
|
|
while (( $(date +%s) < deadline )); do
|
|
h=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null | awk '$1=="headscale"{print $2}')
|
|
[[ "$h" == "healthy" ]] && { log "headscale healthy."; break; }
|
|
sleep 5
|
|
done
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Headplane: mint a headscale API key on first deploy (idempotent -- stored in
|
|
# .env), render its config, then bring the UI up.
|
|
# ----------------------------------------------------------------------------
|
|
if ! grep -qE '^HEADPLANE_HS_API_KEY=.+' "$ENV_FILE"; then
|
|
log "Creating a headscale API key for headplane..."
|
|
HP_KEY=$(docker compose exec -T headscale headscale apikeys create --expiration 999d 2>/dev/null \
|
|
| tr -d '\r' | tail -n1)
|
|
if [[ -n "$HP_KEY" ]]; then
|
|
sed -i "s|^HEADPLANE_HS_API_KEY=.*|HEADPLANE_HS_API_KEY=${HP_KEY}|" "$ENV_FILE"
|
|
log "Stored headplane API key in .env."
|
|
else
|
|
warn "Could not mint a headscale API key; set HEADPLANE_HS_API_KEY in $ENV_FILE and re-run."
|
|
fi
|
|
fi
|
|
|
|
# Re-read .env (now with the API key) and render headplane's config.
|
|
set -a; . "$ENV_FILE"; set +a
|
|
render_headplane_config
|
|
|
|
log "Starting headplane + remaining services..."
|
|
docker compose up -d --remove-orphans
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Wait for health
|
|
# ----------------------------------------------------------------------------
|
|
log "Waiting for services to become healthy (up to 120s)..."
|
|
deadline=$(( $(date +%s) + 120 ))
|
|
while (( $(date +%s) < deadline )); do
|
|
status=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null || true)
|
|
unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}')
|
|
if [[ -z "$unhealthy" && -n "$status" ]]; then
|
|
log "All services healthy."
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
# Convenience: a `headscale` command on the host that runs the CLI inside the
|
|
# container from ANY directory (plain `docker compose` needs to be run from
|
|
# $STACK_DIR). STACK_DIR is baked in; "$@" is passed through.
|
|
cat > /usr/local/bin/headscale <<EOF
|
|
#!/bin/sh
|
|
# Auto-generated by headscale deploy.sh -- runs the headscale CLI in-container.
|
|
exec docker compose -f "$STACK_DIR/docker-compose.yml" exec headscale headscale "\$@"
|
|
EOF
|
|
chmod +x /usr/local/bin/headscale
|
|
log "Installed /usr/local/bin/headscale (runs the CLI inside the container)."
|
|
|
|
echo
|
|
log "Stack status:"
|
|
docker compose ps
|
|
echo
|
|
cat <<EOF
|
|
================================================================
|
|
DEPLOYED
|
|
|
|
Headscale: https://${HEADSCALE_DOMAIN}
|
|
Headplane: https://${HEADSCALE_DOMAIN}/admin (web UI)
|
|
Tailnet: ${TAILNET_DOMAIN} (MagicDNS suffix)
|
|
OIDC: https://${POCKETID_DOMAIN}
|
|
Stack dir: ${STACK_DIR}
|
|
|
|
OIDC redirect/callback URIs -- register THESE in pocket-id:
|
|
headscale: https://${HEADSCALE_DOMAIN}/oidc/callback
|
|
headplane: https://${HEADSCALE_DOMAIN}/admin/oidc/callback (2nd OIDC client)
|
|
|
|
ACL policy: ${STACK_DIR}/policy.hujson
|
|
headscale policy check # validate after editing
|
|
cd ${STACK_DIR} && docker compose restart headscale # apply changes
|
|
|
|
Headscale CLI -- use the installed wrapper from anywhere:
|
|
headscale users create alice
|
|
headscale users list
|
|
headscale nodes list
|
|
headscale --help
|
|
# (equivalent: cd $STACK_DIR && docker compose exec headscale headscale ...)
|
|
|
|
Tailscale client login:
|
|
tailscale up --login-server https://${HEADSCALE_DOMAIN}
|
|
# Browser opens, OIDC flow via pocket-id, node registers.
|
|
|
|
Manage (run from $STACK_DIR):
|
|
cd $STACK_DIR
|
|
docker compose logs -f
|
|
docker compose pull && docker compose up -d # update
|
|
docker compose down # stop, keep volumes
|
|
docker compose down -v # stop, WIPE data
|
|
|
|
Or just re-run this script -- it's idempotent.
|
|
================================================================
|
|
EOF
|
|
|
|
# IMPORTANT: do not put any code below this exit. Everything after the
|
|
# __ARCHIVE_BELOW__ marker is the embedded tar.gz payload (base64).
|
|
exit 0
|
|
__ARCHIVE_BELOW__
|
|
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
|
|
dVmXdVmX/0X5H8de64YAUAAA
|