The set_env/env_set helper escaped its value with
`esc=${val//\/\\}; esc=${esc//|/\|}; esc=${esc//&/\&}` and then
interpolated it into `sed -i -e "s|^KEY=.*|KEY=${esc}|"`. The escaping does
not do what it looks like. Tested on bash 5.2:
set_env K 'a&b' -> K=aK=seedb (sed expanded & to the whole match)
set_env K 'a|b' -> sed: unknown option to `s' (rc!=0, aborts under set -e)
So any value containing & is silently corrupted and any value containing the
s||| delimiter kills the run. That is reachable: headscale writes
OIDC_CLIENT_SECRET through this, pocket-id writes REDIRECT_URL, copyparty
writes DATA_DIR. A generated secret or a URL query string hits both cases.
The copies in headscale and pocket-id were additionally mangled when they
were introduced -- `${val//\/\}` (pattern `\/`, a literal SLASH) and a raw
newline inside `printf '%s=%s\n'`. The mangled form is a no-op rather than a
corrupter, so the practical failure mode was the same as the original.
Replace all of them with an awk rewrite that passes the key and value through
the ENVIRONMENT, so the value is never parsed as part of a script and needs no
escaping at all. ENVIRON and index() are POSIX, so busybox awk handles them.
Output goes to a temp file and is copied back with `cat >`, which preserves the
original mode and owner -- a .env holding secrets stays 0600. If awk fails,
set -e aborts before .env is touched, which `sed -i` could not promise.
Verified against plain, a&b, a|b, a\b, p@ss&w|rd\x, R&D, a URL with a query
string, s/foo/bar/, a trailing space and the empty string; plus the
append-when-key-absent path, the file-does-not-exist path, non-target lines
left intact, no line-count drift, and mode preservation.
copyparty/update.sh and the rebuilt copyparty payload are included because
update.sh is embedded; regenerated with build.sh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
583 lines
27 KiB
Bash
583 lines
27 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
|
|
# The value goes through the ENVIRONMENT, never interpolated into a sed
|
|
# script. Interpolating it corrupts any value containing & (sed expands it to
|
|
# the whole match) and aborts the run on one containing the s||| delimiter --
|
|
# which is reachable for an OIDC secret, a password or a URL query string.
|
|
local key="$1" val="$2" tmp
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
printf '%s=%s
|
|
' "$key" "$val" >> "$ENV_FILE"
|
|
return 0
|
|
fi
|
|
tmp="$(mktemp)"
|
|
_SE_KEY="$key" _SE_VAL="$val" awk '
|
|
BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 }
|
|
!seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next }
|
|
{ print }
|
|
END { if (!seen) print k "=" v }
|
|
' "$ENV_FILE" > "$tmp"
|
|
cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
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
|