diff --git a/automations.sh b/automations.sh index 1da8340..64ca819 100644 --- a/automations.sh +++ b/automations.sh @@ -86,7 +86,9 @@ ask_deployment_vars() { ask TAILNET_DOMAIN "Tailnet MagicDNS base (e.g. tail.example.com)" ask POCKETID_DOMAIN "OIDC issuer hostname (your pocket-id)" ask OIDC_CLIENT_ID "OIDC client_id (from pocket-id)" - ask OIDC_CLIENT_SECRET "OIDC client_secret (from pocket-id)" password ;; + ask OIDC_CLIENT_SECRET "OIDC client_secret (from pocket-id)" password + ask HEADPLANE_OIDC_CLIENT_ID "headplane UI OIDC client_id (blank = API-key login)" optional + if [[ -n "${HEADPLANE_OIDC_CLIENT_ID:-}" ]]; then ask HEADPLANE_OIDC_CLIENT_SECRET "headplane UI OIDC client_secret" password; fi ;; webfinger) ask BASE_DOMAIN "Apex domain to serve (e.g. example.com)" ask ISSUER_URL "OIDC issuer URL (e.g. https://auth.example.com)" diff --git a/deployments/headscale/.env.example b/deployments/headscale/.env.example index 452babe..cc37ded 100644 --- a/deployments/headscale/.env.example +++ b/deployments/headscale/.env.example @@ -25,6 +25,19 @@ POCKETID_DOMAIN=auth.example.com OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= +# ─── Headplane (web UI at /admin) ─────────────────────────────────────────── +# Generated on first deploy; leave blank. +HEADPLANE_COOKIE_SECRET= +# Auto-created from headscale on first deploy (the UI's API access); leave blank. +HEADPLANE_HS_API_KEY= +# Optional: OIDC login for headplane via pocket-id. Register a SECOND OIDC +# client in pocket-id with redirect URI: +# https://${HEADSCALE_DOMAIN}/admin/oidc/callback +# Leave both blank to use headplane's API-key login instead. +HEADPLANE_OIDC_CLIENT_ID= +HEADPLANE_OIDC_CLIENT_SECRET= + # ─── Image tags ───────────────────────────────────────────────────────────── HEADSCALE_TAG=0.28.0 +HEADPLANE_TAG=latest CADDY_TAG=2-alpine diff --git a/deployments/headscale/Caddyfile b/deployments/headscale/Caddyfile index 999e9d0..58a6b40 100644 --- a/deployments/headscale/Caddyfile +++ b/deployments/headscale/Caddyfile @@ -11,14 +11,24 @@ {$HEADSCALE_DOMAIN} { encode zstd gzip - reverse_proxy headscale:8080 { - header_up X-Real-IP {http.request.remote.host} - # Long-lived noise/wireguard control streams; lift the default - # idle limit so they don't get torn down. - flush_interval -1 - transport http { - read_timeout 10m - write_timeout 10m + # Headplane web UI lives at /admin. Pass the path through unchanged + # (headplane serves itself under /admin). Everything else is headscale's + # control protocol. + @admin path /admin /admin/* + handle @admin { + reverse_proxy headplane:3000 + } + + handle { + reverse_proxy headscale:8080 { + header_up X-Real-IP {http.request.remote.host} + # Long-lived noise/wireguard control streams; lift the default + # idle limit so they don't get torn down. + flush_interval -1 + transport http { + read_timeout 10m + write_timeout 10m + } } } diff --git a/deployments/headscale/README.md b/deployments/headscale/README.md index 5b32fca..cc10968 100644 --- a/deployments/headscale/README.md +++ b/deployments/headscale/README.md @@ -47,6 +47,30 @@ Unattended provisioning: [`cloud-init.yml`](cloud-init.yml). DNS for `HEADSCALE_DOMAIN` must resolve to the host and 80/443 be reachable before deploy. +## Headplane (web UI) + +[Headplane](https://github.com/tale/headplane) ships in the stack, served by +Caddy at **`https://HEADSCALE_DOMAIN/admin`**. It runs **API-only** — it talks +to the headscale API with a key and does *not* get the Docker socket, so it +can't control the host. `deploy.sh` mints the headscale API key automatically +on first deploy (stored in `.env`) and generates `headplane.yaml`. + +**Login:** to use OIDC via pocket-id, register a **second** OIDC client in +pocket-id with redirect URI: + +``` +https://HEADSCALE_DOMAIN/admin/oidc/callback +``` + +and pass `HEADPLANE_OIDC_CLIENT_ID` / `HEADPLANE_OIDC_CLIENT_SECRET` (prompted +by the launcher, or set in `.env`). Leave them blank to use headplane's +API-key login instead. Pin the image with `HEADPLANE_TAG` (defaults to +`latest`). + +> headplane integration is version-sensitive; if the UI doesn't come up, +> check `docker compose logs headplane` and verify `headplane.yaml` / +> the minted `HEADPLANE_HS_API_KEY` in `.env`. + ## ACL policy A starter ACL ships in [`policy.hujson`](policy.hujson) (installed to diff --git a/deployments/headscale/deploy.sh b/deployments/headscale/deploy.sh index 8f4200e..8c6432c 100644 --- a/deployments/headscale/deploy.sh +++ b/deployments/headscale/deploy.sh @@ -50,6 +50,8 @@ set -euo pipefail : "${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; } @@ -110,6 +112,35 @@ open_web_ports() { 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" </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 # ---------------------------------------------------------------------------- @@ -304,12 +375,14 @@ cat <