From 2a16c1bf7b38f56d5605834e116119f892e248dc Mon Sep 17 00:00:00 2001 From: kaka Date: Wed, 4 Nov 2026 21:20:24 +0800 Subject: [PATCH] Add reusable protected Headplane preparation and login key issuance --- README.md | 5 +++ headplane-prepare.sh | 86 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 headplane-prepare.sh diff --git a/README.md b/README.md index 6f4d993..75ec362 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,8 @@ Caddy 同时提供 /install.sh 稳定入口与 /releases/<40位小写Git提交>/ ## Headscale 独立机准备 `headscale-prepare.sh` 在已登记并确认防火墙的 Debian 主机上校验主机名、端口及显式策略/DERP 文件,独占创建服务目录;相同输入重跑只复核,不清空 SQLite 或密钥。根目录的父目录须已存在。通过已发布固定 release 的 curl | bash 调用,参数见 `--help`。此入口不安装第二套控制面,不生成用户或授权 key;Headscale 与专用 Caddy 由 platform 的 `templates/headscale/standalone.toml` 部署。 + + +## Headplane 准备与凭据 + +`headplane-prepare.sh --mode prepare|key --hostname NAME --root /srv/platform/headscale --port 3000 --project PROJECT [--container CONTAINER]` 在已有 Headscale 准备根目录内创建持久目录和独立 cookie,或在精确项目的健康 Headscale 容器内签发 90 天管理 key。凭据仅写 root 0600 文件,不输出值;重跑保留原值,未知或中断状态停止检查。Komodo Stack 负责服务启动,控制端回收凭据并验证登录。完整顺序见 platform 的 `docs/headscale-repeatable-install.md`。 diff --git a/headplane-prepare.sh b/headplane-prepare.sh new file mode 100644 index 0000000..2b4e2e9 --- /dev/null +++ b/headplane-prepare.sh @@ -0,0 +1,86 @@ +#!/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 + 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; } + [[ -z $(ss -H -lnt "sport = :$port") ]] || { 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=$3 actual output="$1/headplane-secrets/admin-api-key" + [[ -f "$root/headplane-prepared" && -d "$root/headplane-secrets" ]] || { fail 'Run prepare first'; return 1; } + actual=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}/{{index .Config.Labels "com.docker.compose.service"}}/{{.State.Health.Status}}' "$container") + [[ "$actual" == "$project/headscale/healthy" ]] || { fail 'Container project, service or health differs'; 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") + docker exec "$container" headscale apikeys create --expiration 90d > "$output" + [[ -s "$output" ]] || { fail 'Key creation result missing; inspect original operation'; return 1; } + chmod 0600 "$output" + printf '90-day login key written to protected file; retrieve over the verified management connection.\n' +} +main() { + local mode='' name='' root='' port='' project='' container='' command binding path + while (($#)); do + case "$1" in + --help|-h) echo 'Usage: headplane-prepare.sh --mode prepare|key --hostname NAME --root /srv/platform/headscale --port PORT --project STACK_PROJECT [--container HEADSCALE_CONTAINER]'; return 0;; + --mode|--hostname|--root|--port|--project|--container) + (($# >= 2)) || { fail 'Missing value'; return 1; } + case "$1" in --mode) mode=$2;; --hostname) name=$2;; --root) root=$2;; --port) port=$2;; --project) project=$2;; --container) container=$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-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; } + [[ "$container" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]+$ ]] || { fail 'Exact Headscale container required'; return 1; } + issue_key "$root" "$project" "$container" + fi +} +if [[ ${BASH_SOURCE[0]:-} == "$0" || ${BASH_SOURCE[0]:-} == '' ]]; then main "$@"; fi