#!/usr/bin/env bash # Prepare Headplane files or issue one protected login key; Komodo owns deployment. set -euo pipefail export LC_ALL=C fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } safe_path() { local path=$1 [[ "$path" =~ ^/([a-zA-Z0-9_-]+/){2,}[a-zA-Z0-9_-]+$ ]] || { fail 'Explicit service path required'; return 1; } while [[ "$path" != / ]]; do [[ ! -L "$path" ]] || { fail 'Symlink in service path'; return 1; } path=${path%/*}; path=${path:-/} done } prepare() { local root=$1 port=$2 binding=$3 listeners if [[ -e "$root/headplane-prepared" ]]; then [[ -f "$root/headplane-prepared" && $(cat "$root/headplane-prepared") == "$binding" ]] || { fail 'Headplane binding differs'; return 1; } [[ -d "$root/headplane-data" && -d "$root/headplane-secrets" && -f "$root/headplane-secrets/cookie" ]] || { fail 'Incomplete preparation'; return 1; } [[ $(stat -c '%u:%a:%h' "$root/headplane-secrets/cookie") == 0:600:1 && $(wc -c < "$root/headplane-secrets/cookie") == 32 ]] || { fail 'Cookie file ownership, mode or length differs'; return 1; } printf 'Headplane preparation reused; cookie and data preserved.\n' return 0 fi [[ ! -e "$root/headplane-data" && ! -e "$root/headplane-secrets" ]] || { fail 'Unregistered or interrupted Headplane preparation; inspect before recovery'; return 1; } listeners=$(ss -H -lnt "sport = :$port") || { fail 'Cannot inspect Headplane port'; return 1; } [[ -z "$listeners" ]] || { fail 'Headplane port occupied'; return 1; } mkdir -m 0700 "$root/headplane-data" "$root/headplane-secrets" openssl rand -hex 16 | tr -d '\r\n' > "$root/headplane-secrets/cookie" chmod 0600 "$root/headplane-secrets/cookie" [[ $(wc -c < "$root/headplane-secrets/cookie") == 32 ]] || return 1 printf '%s\n' "$binding" > "$root/headplane-prepared" printf 'Headplane directories and protected cookie prepared.\n' } issue_key() { local root=$1 project=$2 container actual output="$1/headplane-secrets/admin-api-key" [[ -f "$root/headplane-prepared" && -d "$root/headplane-secrets" ]] || { fail 'Run prepare first'; return 1; } container=$(docker ps --filter "label=com.docker.compose.project=$project" --filter label=com.docker.compose.service=headscale --format '{{.ID}}') || return 1 [[ "$container" =~ ^[a-f0-9]{12,64}$ ]] || { fail 'Exactly one running Headscale container required'; return 1; } actual=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}/{{index .Config.Labels "com.docker.compose.service"}}/{{.State.Health.Status}}{{range .Mounts}}{{if eq .Destination "/var/lib/headscale"}}/{{.Source}}{{end}}{{end}}' "$container") || return 1 [[ "$actual" == "$project/headscale/healthy/$root/data" ]] || { fail 'Container project, data directory or health differs'; return 1; } [[ ! -e "$output.pending" ]] || { fail 'Interrupted key issuance; inspect original operation'; return 1; } if [[ -e "$output" ]]; then [[ -f "$output" && -s "$output" && $(stat -c '%u:%a:%h' "$output") == 0:600:1 ]] || { fail 'Existing credential incomplete or unsafe; inspect original operation'; return 1; } printf 'Existing login key preserved; verify expiration separately.\n' return 0 fi # Reserve first: interruption leaves an empty file, never silently issues another key. (set -o noclobber; : > "$output") if ! docker exec "$container" headscale apikeys create --expiration 90d > "$output.pending"; then rm -f -- "$output.pending" fail 'Key creation failed; reserved empty file prevents duplicate issuance'; return 1 fi [[ -s "$output.pending" ]] || { fail 'Key creation result missing; inspect original operation'; return 1; } chmod 0600 "$output.pending" mv -T -- "$output.pending" "$output" printf '90-day login key written to protected file; retrieve over the verified management connection.\n' } main() { local mode='' name='' root='' port='' project='' command binding path while (($#)); do case "$1" in --help|-h) echo 'Usage: headplane-prepare.sh --mode prepare|key --hostname NAME --root ABSOLUTE_SERVICE_PATH --port PORT --project STACK_PROJECT'; return 0;; --mode|--hostname|--root|--port|--project) (($# >= 2)) || { fail 'Missing value'; return 1; } case "$1" in --mode) mode=$2;; --hostname) name=$2;; --root) root=$2;; --port) port=$2;; --project) project=$2;; esac; shift 2;; *) fail 'Unknown option'; return 1;; esac done [[ "$mode" == prepare || "$mode" == key ]] || { fail 'Explicit mode required'; return 1; } [[ "$name" =~ ^(dev|test|stage|prod)-[a-z0-9-]+-[0-9]{2}$ && "$project" =~ ^(dev|test|stage|prod)-[a-z0-9-]+$ && "${name%%-*}" == "${project%%-*}" ]] || { fail 'Registered hostname and matching project required'; return 1; } [[ "$port" =~ ^[1-9][0-9]{3,4}$ ]] && ((port <= 65535 && port >= 1024)) || { fail 'Unprivileged port required'; return 1; } case "$port" in 2019|9090|50443) fail 'Reserved management port'; return 1;; esac [[ $EUID == 0 && $(uname -s) == Linux && $(hostname) == "$name" ]] || { fail 'Run on the exact Linux host as root'; return 1; } for command in systemctl nft ss openssl flock stat sha256sum cut tr wc; do command -v "$command" >/dev/null; done safe_path "$root" [[ -d "$root" && -f "$root/prepared.sha256" ]] || { fail 'Headscale prepared root required'; return 1; } [[ $(stat -c '%u:%a' "$root") == 0:700 ]] || { fail 'Service root must be root-owned mode 0700'; return 1; } for path in prepared.sha256 headplane-prepared headplane-data headplane-secrets headplane-secrets/cookie headplane-secrets/admin-api-key headplane-secrets/admin-api-key.pending headplane-lock; do # File basenames contain dots; inspect every existing component separately. [[ ! -L "$root/$path" ]] || { fail 'Symlink in Headplane files'; return 1; } done systemctl is-active --quiet platform-firewall.service test ! -e /run/platform-firewall/pending.nft nft list table inet platform_host >/dev/null umask 077 exec 9>>"$root/headplane-lock"; flock -n 9 || { fail 'Another Headplane preparation is running'; return 1; } binding=$(printf '%s\n' "$name" "$root" "$port" "$project" | sha256sum | cut -d ' ' -f 1) if [[ "$mode" == prepare ]]; then prepare "$root" "$port" "$binding" else [[ -f "$root/headplane-prepared" && $(cat "$root/headplane-prepared") == "$binding" ]] || { fail 'Headplane binding differs'; return 1; } issue_key "$root" "$project" fi } if [[ ${BASH_SOURCE[0]:-} == "$0" || ${BASH_SOURCE[0]:-} == '' ]]; then main "$@"; fi