Add repeatable preparation for dedicated Headscale hosts

This commit is contained in:
kaka
2026-11-04 18:01:23 +08:00
parent 9887d50f47
commit 3365c09c8d
2 changed files with 73 additions and 1 deletions

View File

@@ -77,7 +77,7 @@ resume/verify 可使用同一固定版本 URL 再次下载执行,或保存该
## 发布
Caddy 同时提供 /install.sh 稳定入口与 /releases/<40位小写Git提交>/install.sh 固定版本入口;生产接入命令固定使用后者。
发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`host-baseline.sh`、`ssh-access.sh`、`tailscale.sh`、`firewall.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。上述入口全部必需,缺少任一文件就拒绝发布,不兼容缺少基线或防火墙入口的不完整文件集。
发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`host-baseline.sh`、`ssh-access.sh`、`headscale-prepare.sh`、`tailscale.sh`、`firewall.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。上述入口全部必需,缺少任一文件就拒绝发布,不兼容缺少基线或防火墙入口的不完整文件集。
源代码与修复就在此仓库维护,无生成器、内嵌 Python 或第二份安装源码。
## 固定资源
@@ -87,3 +87,7 @@ Caddy 同时提供 /install.sh 稳定入口与 /releases/<40位小写Git提交>/
当前包含 Periphery 2.3.4 x86_64、Tailscale 1.104.4 amd64 DEB 和 PG 扩展构建资源,准确路径、摘要与来源见 `artifacts.lock`。控制端使用 platform 的 `control/vendor.sh --source <本仓库目录>` 准备资源,再用 `--check` 做完全离线校验。已有正确文件直接复用;摘要不符时停止,不静默覆盖。
新增版本时增加新路径和已核实摘要,准备二进制,同步修改 install.sh 的固定版本/摘要,检查后提交发布。不得改写已发布版本文件;固定提交及其 Caddy release 用于同版本回退。版本维护单独执行。
## Headscale 独立机准备
`headscale-prepare.sh` 在已登记并确认防火墙的 Debian 主机上校验主机名、端口及显式策略/DERP 文件,独占创建服务目录;相同输入重跑只复核,不清空 SQLite 或密钥。根目录的父目录须已存在。通过已发布固定 release 的 curl | bash 调用,参数见 `--help`。此入口不安装第二套控制面,不生成用户或授权 key;Headscale 与专用 Caddy 由 platform 的 `templates/headscale/standalone.toml` 部署。

68
headscale-prepare.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Prepare one dedicated Headscale host; native Komodo Stack owns service deployment.
set -euo pipefail
export LC_ALL=C
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
safe_path() {
local path=$1 part
local parts=()
[[ "$path" =~ ^/([a-zA-Z0-9_.-]+/){2,}[a-zA-Z0-9_.-]+$ ]] || { fail 'Use an explicit absolute service path'; return 1; }
IFS=/ read -ra parts <<< "$path"
for part in "${parts[@]}"; do [[ "$part" != . && "$part" != .. ]] || return 1; done
while [[ "$path" != / ]]; do
[[ ! -L "$path" ]] || { fail 'Symlink in service path'; return 1; }
path=${path%/*}; path=${path:-/}
done
}
prepare() {
local name=$1 root=$2 policy=$3 derp=$4 port=$5 path binding expected listeners
safe_path "$root"; safe_path "$policy"; safe_path "$derp"
[[ "$policy" != "$root" && "$policy" != "$root/"* && "$derp" != "$root" && "$derp" != "$root/"* ]] || { fail 'Candidate files must be outside the service directory'; return 1; }
[[ -f "$policy" && -s "$policy" && -f "$derp" && -s "$derp" ]] || { fail 'Existing policy JSON and native DERP YAML candidates required'; return 1; }
[[ $(hostname) == "$name" ]] || { fail 'Target hostname differs'; return 1; }
systemctl is-active --quiet platform-firewall.service
test ! -e /run/platform-firewall/pending.nft
nft list table inet platform_host >/dev/null
binding=$(printf '%s\n' "$name" "$root" "$port" "$(sha256sum "$policy" | cut -d ' ' -f 1)" "$(sha256sum "$derp" | cut -d ' ' -f 1)" | sha256sum | cut -d ' ' -f 1)
if [[ -e "$root" ]]; then
[[ -d "$root" && -f "$root/prepared.sha256" ]] || { fail 'Unregistered or interrupted preparation; inspect before recovery'; return 1; }
safe_path "$root/prepared.sha256"
[[ $(cat "$root/prepared.sha256") == "$binding" ]] || { fail 'Existing instance inputs differ; use explicit maintenance'; return 1; }
for path in data caddy-data caddy-config; do safe_path "$root/$path"; test -d "$root/$path"; done
for path in policy.json derp.yaml; do safe_path "$root/$path"; test -f "$root/$path"; done
cmp -s "$policy" "$root/policy.json" && cmp -s "$derp" "$root/derp.yaml" || { fail 'Published policy or DERP configuration changed'; return 1; }
printf '%s\n' 'Headscale preparation reused; existing identity/data preserved.'
return 0
fi
for expected in 80 443 2019 9090 50443 "$port"; do
listeners=$(ss -H -lnt "sport = :$expected") || { fail 'Cannot inspect listeners'; return 1; }
[[ -z "$listeners" ]] || { fail "Port $expected is occupied; dedicated host required"; return 1; }
done
# mkdir is exclusive: concurrent first preparations cannot both own the directory.
mkdir -m 0700 -- "$root"
for path in data caddy-data caddy-config; do install -d -m 0700 "$root/$path"; done
install -m 0600 "$policy" "$root/policy.json"
install -m 0600 "$derp" "$root/derp.yaml"
printf '%s\n' "$binding" > "$root/prepared.sha256"
printf '%s\n' 'Headscale directories prepared; configuration semantics and service deployment remain native checks.'
}
main() {
local name='' root='' policy='' derp='' port='' command
while (($#)); do
case "$1" in
--help|-h) echo 'Usage: headscale-prepare.sh --hostname NAME --root /srv/platform/headscale --policy-file ABSOLUTE_JSON --derp-map-file ABSOLUTE_YAML --port PORT'; return 0;;
--hostname|--root|--policy-file|--derp-map-file|--port)
(($# >= 2)) || { fail 'Missing option value'; return 1; }
case "$1" in --hostname) name=$2;; --root) root=$2;; --policy-file) policy=$2;; --derp-map-file) derp=$2;; --port) port=$2;; esac; shift 2;;
*) fail 'Unknown option'; return 1;;
esac
done
[[ "$name" =~ ^(dev|test|stage|prod)-[a-z0-9-]+-[0-9]{2}$ ]] || { fail 'Registered platform hostname required'; return 1; }
[[ "$port" =~ ^[1-9][0-9]{3,4}$ ]] && ((port >= 1024 && port <= 65535)) || { fail 'Explicit unprivileged HTTP port required'; return 1; }
case "$port" in 2019|9090|50443) fail 'HTTP port conflicts with management listeners'; return 1;; esac
[[ $EUID == 0 && $(uname -s) == Linux ]] || { fail 'Run on the Linux target as root'; return 1; }
for command in hostname systemctl nft ss sha256sum cut cmp install mkdir; do command -v "$command" >/dev/null || return 1; done
umask 077
prepare "$name" "$root" "$policy" "$derp" "$port"
}
main "$@"