Files
automations/deployments/headscale/deploy.sh
T
57_Wolve 8fbeb8f6b0 feat: unified launcher, multi-OS hardening, login alerts & auto-updates
Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.
- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates: AUTO_REBOOT=idle reboots only when no SSH active; opt-in
  Alpine stable-branch upgrades (ALLOW_RELEASE_UPGRADE)
- cloud-init: generic base/jumphost + per-deployment, which harden SSH by
  default on fresh VMs
- pocket-id: optional WebFinger block (BASE_DOMAIN), tag v2.8.0
- headscale: fix oidc.expiry schema for 0.28 so the container starts
- Gitea release workflow on tag (TOKEN_GITEA); repo URLs -> Gitea
- README/LICENSE/.gitignore/.gitattributes (force LF)
2026-06-12 15:24:30 -05:00

370 lines
16 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; existing .env is never overwritten.
# 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
# (but secrets in .env are never touched once seeded).
#
# Self-contained: docker-compose.yml, Caddyfile, config.yaml, .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
: "${HEADSCALE_DOMAIN:=}"
: "${ACME_EMAIL:=}"
: "${TAILNET_DOMAIN:=}"
: "${POCKETID_DOMAIN:=}"
: "${OIDC_CLIENT_ID:=}"
: "${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() {
if 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
}
# ----------------------------------------------------------------------------
# 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 .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
}
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
ENV_FILE="$STACK_DIR/.env"
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}|" \
"$ENV_FILE"
else
log ".env exists; leaving it alone."
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"
# 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
log "Starting stack..."
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
echo
log "Stack status:"
docker compose ps
echo
cat <<EOF
================================================================
DEPLOYED
Headscale: https://${HEADSCALE_DOMAIN}
Tailnet: ${TAILNET_DOMAIN} (MagicDNS suffix)
OIDC: https://${POCKETID_DOMAIN}
Stack dir: ${STACK_DIR}
Bootstrap a user (run from $STACK_DIR):
docker compose exec headscale headscale users create alice
docker compose exec headscale headscale users list
Tailscale client login:
tailscale up --login-server https://${HEADSCALE_DOMAIN}
# Browser opens, OIDC flow via pocket-id, node registers.
CLI inside the container:
docker compose exec headscale headscale --help
docker compose exec headscale headscale nodes list
Manage:
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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