Initial native bootstrap release
This commit is contained in:
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
* text=auto
|
||||||
|
*.md text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.lock text eol=lf
|
||||||
|
vendor/** -text
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Private material and local execution state
|
||||||
|
.secrets/
|
||||||
|
.state/
|
||||||
|
.backups/
|
||||||
|
.cache/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
!*.env.example
|
||||||
|
!*.env.template
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
id_rsa*
|
||||||
|
id_ed25519*
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vendor-prepare.lock/
|
||||||
|
vendor/**/.download-*
|
||||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 原生 Shell 仓库
|
||||||
|
|
||||||
|
- 直接在本仓库维护原生 Bash 源码;目标机不需要 Python 载荷或私有仓库。
|
||||||
|
- install.sh 是 Debian 13 的最小 Komodo 接入;独立 upgrade-debian12-13.sh 由人执行。
|
||||||
|
- 支持 curl -fsSL URL | bash -s -- 参数;管道模式不能依赖脚本所在目录。交互升级读取 /dev/tty。
|
||||||
|
- 数据参数按字符串处理,不 eval 或 source 用户配置;不把 API/DNS token、私钥写入公开文件。
|
||||||
|
- 重跑保留身份。信任、公钥、现有配置冲突先停止;不重置网络、防火墙或已有数据库。
|
||||||
|
- 所有部署动作在 main 内执行,最后再调用,以免流式下载到一半提前修改机器。
|
||||||
|
- Caddy 发布由 platform/control/publish.sh 从完整 Git 提交选取白名单文件,生成摘要并切换 current。
|
||||||
|
- 保留固定提交 URL,日常可用稳定 /install.sh 入口。没有真实域名时只给参数示例。
|
||||||
|
- 修改脚本运行 Bash 语法和相关隔离测试,真机验证独立记录。
|
||||||
|
|
||||||
|
- 固定资源由 artifacts.lock 登记并存入 vendor/,与源码共同提交;目标机仅从显式 Caddy --assets-url 下载,不回退上游。版本变更同时修改安装器固定版本/摘要并检查;发布从同一 Git 提交提取源码与资源。
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 原生 Shell 安装入口
|
||||||
|
|
||||||
|
这里直接维护 Bash 源码,由 Gitea 保存版本,Caddy 提供 HTTPS 下载。无需 Python 安装载荷。
|
||||||
|
当前已完成本地实现,尚未在真实 Debian 主机和实际 Caddy 域名验收。
|
||||||
|
|
||||||
|
## Debian 13 接入 Komodo
|
||||||
|
|
||||||
|
以下命令以 root 执行。如果系统尚未安装 curl,或缺少 HTTPS 根证书,先安装下载所需的基础工具:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update && apt install -y curl ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
以下为域名占位示例;正式域名在控制机部署后替换:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL 'https://scripts.example.com/releases/<40位小写Git提交>/install.sh' | bash -s -- \
|
||||||
|
--name prod-app-web-01 \
|
||||||
|
--core-url wss://komodo.example.com \
|
||||||
|
--core-public-key 'MCowBQYDK2VuAyEA<43个Base64字符>=' \
|
||||||
|
--assets-url 'https://scripts.example.com/releases/<40位小写Git提交>'
|
||||||
|
```
|
||||||
|
|
||||||
|
支持原生 systemd Debian 13 x86_64、Periphery/Core 2.3.4。Periphery 固定版本和 SHA-256;二进制只从 --assets-url 指定的 Caddy 下载,没有上游下载回退。
|
||||||
|
`--core-public-key` 是单行 X25519 SPKI 公钥的 Base64(固定前缀 `MCowBQYDK2VuAyEA`,结尾一个 `=`),不是 PEM,也不能带空格或换行。`--assets-url` 必须是 HTTPS `/releases/` 加 40 位小写提交;稳定入口不能直接作为安装资源基址。
|
||||||
|
脚本通过 apt 补齐 curl、jq、git、CA,配置 systemd,复用或生成节点身份,输出公开回执。
|
||||||
|
--check 做本地输入/身份检查,使用临时文件但不安装软件或修改主机配置;它不证明网络或 Core 状态。
|
||||||
|
回执在 /var/lib/platform-bootstrap/receipt.json,明确标记 Core 注册未验证、主机初始化未开始。
|
||||||
|
Docker、Headscale、公钥注入和安全配置后续由 Komodo 任务处理。没有 Core API 凭据下发。
|
||||||
|
已有身份、配置或二进制不符时停止,不自动替换身份。源文件可直接 bash install.sh --help。
|
||||||
|
|
||||||
|
## 独立手工升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://scripts.example.com/upgrade-debian12-13.sh | bash -s -- --check
|
||||||
|
curl -fsSL https://scripts.example.com/upgrade-debian12-13.sh | bash -s -- --upgrade
|
||||||
|
# 人工重启并重新登录后,继续原任务或验收:
|
||||||
|
bash upgrade-debian12-13.sh --resume
|
||||||
|
bash upgrade-debian12-13.sh --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
resume/verify 可使用同一固定版本 URL 再次下载执行,或保存该版本的文件后执行。
|
||||||
|
升级使用真实终端 /dev/tty,APT 配置冲突由人判断,不自动重启、不自动 autoremove。
|
||||||
|
仅接受显式官方 Debian 源的受支持格式;第三方源、混合版本、复杂 Deb822 需先人工处理。
|
||||||
|
检查 APT、磁盘、UEFI 挂载;实际引导布局、救援入口和应用备份由操作者在升级前确认。
|
||||||
|
状态与系统归档在 /var/lib/platform-debian-upgrade;归档不替代异机或应用一致性备份。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
Caddy 同时提供 /install.sh 稳定入口与 /releases/<40位小写Git提交>/install.sh 固定版本入口;生产接入命令固定使用后者。
|
||||||
|
发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`tailscale.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。
|
||||||
|
源代码与修复就在此仓库维护,无生成器、内嵌 Python 或第二份安装源码。
|
||||||
|
|
||||||
|
## 固定资源
|
||||||
|
|
||||||
|
`artifacts.lock` 是数据清单,每行三个制表符分隔字段:SHA-256、仓库内路径、准备时使用的上游 HTTPS URL。文件实际保存在 `vendor/<组件>/<版本>/<文件>`,与清单一同提交到 Gitea。目标机不读取上游 URL。
|
||||||
|
|
||||||
|
当前包含 `vendor/komodo/2.3.4/periphery-x86_64`,不是空目录或下载占位符。控制端使用 platform 的 `control/vendor.sh --source <本仓库目录>` 准备资源,再用 `--check` 做完全离线校验。已有正确文件直接复用;摘要不符时停止,不静默覆盖。
|
||||||
|
|
||||||
|
新增版本时增加新路径和已核实摘要,准备二进制,同步修改 install.sh 的固定版本/摘要,检查后提交发布。不得改写已发布版本文件;固定提交及其 Caddy release 用于同版本回退。版本维护单独执行。
|
||||||
3
artifacts.lock
Normal file
3
artifacts.lock
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
46d50313970aea59d293bbe237648f4becea74e8abc2f30f1bb62b73456fc72e vendor/komodo/2.3.4/periphery-x86_64 https://github.com/moghtech/komodo/releases/download/v2.3.4/periphery-x86_64
|
||||||
|
f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f vendor/redis/6.4.0/redis-6.4.0-py3-none-any.whl https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl
|
||||||
|
67221f288f42f95c002a64df40f7d660e765e32df8ada0f2672ba915a14827f7 vendor/pgmq/1.13.1/pgmq.tar.gz https://codeload.github.com/pgmq/pgmq/tar.gz/refs/tags/v1.13.1
|
||||||
77
host-init.sh
Normal file
77
host-init.sh
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Native Debian baseline; Docker packages are prepared and pinned by our controller.
|
||||||
|
set -euo pipefail
|
||||||
|
export LC_ALL=C
|
||||||
|
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||||
|
check_existing_binding() {
|
||||||
|
local state_dir=$1 expected=$2 bound=''
|
||||||
|
local binding="$state_dir/binding"
|
||||||
|
[[ ! -L "$state_dir" && ! -L "$binding" ]] || { fail 'Symlink in bootstrap binding'; return 1; }
|
||||||
|
[[ ! -e "$binding" ]] && return 0
|
||||||
|
[[ -f "$binding" && $(stat -c %u "$binding") == 0 && $(stat -c %a "$binding") == 600 && $(stat -c %h "$binding") == 1 ]] || { fail 'Existing bootstrap binding is not a trusted 0600 regular file'; return 1; }
|
||||||
|
IFS= read -r bound < "$binding" || { fail 'Cannot read bootstrap binding'; return 1; }
|
||||||
|
[[ "$bound" == "$expected" ]] || { fail 'Existing bootstrap binding belongs to another hostname'; return 1; }
|
||||||
|
}
|
||||||
|
main() {
|
||||||
|
local action='' name='' packages='' public_key='' argument
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--help|-h) echo 'Usage: bash host-init.sh --check|--apply --hostname NAME [--docker-dir VERIFIED_DEB_DIRECTORY] [--ssh-key-file SHARED_PUBLIC_KEY]'; return 0;;
|
||||||
|
--check|--apply) [[ -z "$action" ]] || return 1; action=$1; shift;;
|
||||||
|
--hostname|--docker-dir|--ssh-key-file)
|
||||||
|
(($# >= 2)) || return 1
|
||||||
|
case "$1" in --hostname) name=$2;; --docker-dir) packages=$2;; --ssh-key-file) public_key=$2;; esac; shift 2;;
|
||||||
|
*) fail "Unknown argument: $1"; return 1;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ "$action" == --check || "$action" == --apply ]] || { fail 'Choose check or apply'; return 1; }
|
||||||
|
local word='[a-z][a-z0-9]{0,15}'
|
||||||
|
[[ "$name" =~ ^(dev|test|stage|prod)-$word-$word(-$word)?-(0[1-9]|[1-9][0-9])$ && ${#name} -le 63 ]] || { fail 'Explicit short hostname required'; return 1; }
|
||||||
|
[[ $EUID == 0 && -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Native root systemd host required'; return 1; }
|
||||||
|
[[ $(uname -m) == x86_64 ]] || { fail 'The published Periphery and Docker package set currently supports x86_64 only'; return 1; }
|
||||||
|
grep -Eq '^ID="?debian"?$' /etc/os-release && grep -Eq '^VERSION_ID="?13"?$' /etc/os-release || { fail 'Debian 13 required; upgrade is a separate human-run task'; return 1; }
|
||||||
|
check_existing_binding /var/lib/platform-bootstrap "$name"
|
||||||
|
local debs=() file package version architecture installed seen=' ' sha extra
|
||||||
|
if [[ -n "$packages" ]]; then
|
||||||
|
[[ "$packages" == /* && -d "$packages" && ! -L "$packages" && -f "$packages/SHA256SUMS" && ! -L "$packages/SHA256SUMS" ]] || { fail 'Prepared package directory and manifest required'; return 1; }
|
||||||
|
while read -r sha file extra; do
|
||||||
|
[[ "$sha" =~ ^[a-f0-9]{64}$ && "$file" =~ ^[A-Za-z0-9][A-Za-z0-9._+%:~-]*\.deb$ && -z "$extra" && -f "$packages/$file" && ! -L "$packages/$file" ]] || { fail 'Invalid package manifest'; return 1; }
|
||||||
|
printf '%s %s\n' "$sha" "$packages/$file" | sha256sum -c - >/dev/null
|
||||||
|
package=$(dpkg-deb -f "$packages/$file" Package); version=$(dpkg-deb -f "$packages/$file" Version); architecture=$(dpkg-deb -f "$packages/$file" Architecture)
|
||||||
|
[[ "$architecture" == amd64 ]] || { fail 'Prepared Docker packages must be amd64'; return 1; }
|
||||||
|
case "$package" in docker-ce|docker-ce-cli|containerd.io|docker-buildx-plugin|docker-compose-plugin) :;; *) fail 'Unexpected Docker package'; return 1;; esac
|
||||||
|
[[ "$seen" != *" $package "* ]] || { fail 'Duplicate package'; return 1; }; seen+="$package "
|
||||||
|
installed=$(dpkg-query -W -f='${Version}' "$package" 2>/dev/null) || installed=''
|
||||||
|
[[ -z "$installed" || "$installed" == "$version" ]] || { fail 'Existing Docker version differs; plan explicit maintenance'; return 1; }
|
||||||
|
debs+=("$packages/$file")
|
||||||
|
done < "$packages/SHA256SUMS"
|
||||||
|
[[ ${#debs[@]} == 5 ]] || { fail 'All five pinned official Docker packages required'; return 1; }
|
||||||
|
else
|
||||||
|
command -v docker >/dev/null && docker compose version >/dev/null || { fail 'Prepare the five pinned Docker DEBs through our resource publication first'; return 1; }
|
||||||
|
fi
|
||||||
|
if [[ -n "$public_key" ]]; then
|
||||||
|
[[ -f "$public_key" && ! -L "$public_key" && $(wc -l < "$public_key") == 1 ]] || { fail 'One shared public key line required'; return 1; }
|
||||||
|
grep -Eq '^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)) [A-Za-z0-9+/=]+( .*)?$' "$public_key" || { fail 'Public key format invalid'; return 1; }
|
||||||
|
ssh-keygen -l -f "$public_key" >/dev/null
|
||||||
|
[[ ! -L /root/.ssh && ! -L /root/.ssh/authorized_keys ]] || { fail 'Symlink in SSH destination'; return 1; }
|
||||||
|
fi
|
||||||
|
[[ "$action" == --apply ]] || { echo 'Baseline inputs checked; no packages, hostname or SSH configuration changed.'; return 0; }
|
||||||
|
umask 077
|
||||||
|
exec 9>>/run/lock/platform-host-init.lock; flock -n 9 || { fail 'Host initialization is already running'; return 1; }
|
||||||
|
check_existing_binding /var/lib/platform-bootstrap "$name"
|
||||||
|
apt-get -o DPkg::Lock::Timeout=60 update
|
||||||
|
apt-get -o DPkg::Lock::Timeout=60 install -y --no-install-recommends ca-certificates curl jq git openssl openssh-client iproute2
|
||||||
|
if ((${#debs[@]})); then apt-get -o DPkg::Lock::Timeout=60 install -y "${debs[@]}"; fi
|
||||||
|
hostnamectl set-hostname "$name"
|
||||||
|
if [[ -n "$public_key" ]]; then
|
||||||
|
install -d -m 0700 /root/.ssh
|
||||||
|
touch /root/.ssh/authorized_keys; chmod 0600 /root/.ssh/authorized_keys
|
||||||
|
if ! grep -Fqx -- "$(cat "$public_key")" /root/.ssh/authorized_keys; then printf '\n' >> /root/.ssh/authorized_keys; cat "$public_key" >> /root/.ssh/authorized_keys; fi
|
||||||
|
fi
|
||||||
|
systemctl enable --now docker
|
||||||
|
docker --host unix:///var/run/docker.sock info >/dev/null
|
||||||
|
docker --host unix:///var/run/docker.sock compose version
|
||||||
|
[[ $(hostname) == "$name" ]]
|
||||||
|
echo 'Baseline and local Docker checked. Verify a fresh SSH connection; firewall/SSH port and network acceptance are separate tasks.'
|
||||||
|
}
|
||||||
|
if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi
|
||||||
287
install.sh
Normal file
287
install.sh
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Native Bash entry. Source of truth: bootstrap-public Git repository.
|
||||||
|
set -euo pipefail
|
||||||
|
export LC_ALL=C
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: bash install.sh --name NAME --core-url wss://CORE --core-public-key KEY --assets-url HTTPS_BASE [--check]
|
||||||
|
Debian 13 x86_64; native Periphery 2.3.4. No Python payload.
|
||||||
|
--check performs local checks only. Core registration is verified by the controller.
|
||||||
|
Docker, Headscale and SSH hardening run afterwards through Komodo.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||||
|
validate_host() {
|
||||||
|
local host=$1 label octet
|
||||||
|
[[ -n "$host" && ${#host} -le 253 && "$host" != .* && "$host" != *. && "$host" != *..* ]] || { fail 'Invalid URL host'; return 1; }
|
||||||
|
if [[ "$host" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
|
||||||
|
local octets=()
|
||||||
|
IFS=. read -r -a octets <<< "$host"
|
||||||
|
for octet in "${octets[@]}"; do
|
||||||
|
[[ "$octet" =~ ^(0|[1-9][0-9]{0,2})$ ]] && ((10#$octet <= 255)) || { fail 'Invalid IPv4 URL host'; return 1; }
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || { fail 'Invalid URL host'; return 1; }
|
||||||
|
local labels=()
|
||||||
|
IFS=. read -r -a labels <<< "$host"
|
||||||
|
for label in "${labels[@]}"; do
|
||||||
|
[[ "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || { fail 'Invalid URL host label'; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
validate_port() {
|
||||||
|
local port=$1
|
||||||
|
[[ -z "$port" ]] && return 0
|
||||||
|
[[ "$port" =~ ^[0-9]{1,5}$ ]] && ((10#$port >= 1 && 10#$port <= 65535)) || { fail 'Invalid URL port'; return 1; }
|
||||||
|
}
|
||||||
|
validate_inputs() {
|
||||||
|
local word='[a-z][a-z0-9]{0,15}'
|
||||||
|
local host='' port=''
|
||||||
|
[[ ${#node_name} -le 63 && "$node_name" =~ ^(dev|test|stage|prod)-$word-$word(-$word)?-(0[1-9]|[1-9][0-9])$ ]] || { fail 'Invalid short hostname'; return 1; }
|
||||||
|
if [[ "$core_url" =~ ^wss://([^/:?#]+)(:([0-9]{1,5}))?/?$ ]]; then
|
||||||
|
host=${BASH_REMATCH[1]}; port=${BASH_REMATCH[3]}
|
||||||
|
validate_host "$host" || return 1
|
||||||
|
validate_port "$port" || return 1
|
||||||
|
else
|
||||||
|
fail 'Expected a wss Core base URL'; return 1
|
||||||
|
fi
|
||||||
|
if [[ "$assets_url" =~ ^https://([^/:?#]+)(:([0-9]{1,5}))?/releases/[0-9a-f]{40}$ ]]; then
|
||||||
|
host=${BASH_REMATCH[1]}; port=${BASH_REMATCH[3]}
|
||||||
|
validate_host "$host" || return 1
|
||||||
|
validate_port "$port" || return 1
|
||||||
|
else
|
||||||
|
fail 'Expected the fixed Caddy release URL https://HOST/releases/COMMIT'; return 1
|
||||||
|
fi
|
||||||
|
[[ "$core_key" =~ ^MCowBQYDK2VuAyEA[A-Za-z0-9+/]{43}=$ ]] || { fail 'Expected a single-line X25519 SPKI Core public key'; return 1; }
|
||||||
|
}
|
||||||
|
protected() {
|
||||||
|
local path=$1 parent=$1
|
||||||
|
[[ "$path" == /* ]] || { fail 'Absolute installation path required'; return 1; }
|
||||||
|
while [[ "$parent" != / ]]; do
|
||||||
|
[[ ! -L "$parent" ]] || { fail "Symlink in installation path: $parent"; return 1; }
|
||||||
|
if [[ -e "$parent" ]]; then
|
||||||
|
[[ $(stat -c %u "$parent") == 0 ]] || { fail "Untrusted owner: $parent"; return 1; }
|
||||||
|
(( (8#$(stat -c %a "$parent") & 0022) == 0 )) || { fail "Writable installation path: $parent"; return 1; }
|
||||||
|
fi
|
||||||
|
parent=$(dirname -- "$parent")
|
||||||
|
done
|
||||||
|
[[ ! -e "$path" || ( -f "$path" && $(stat -c %h "$path") == 1 ) ]] || { fail "Not a regular file: $path"; return 1; }
|
||||||
|
}
|
||||||
|
write_new() {
|
||||||
|
local target=$1 source=$2 mode=$3
|
||||||
|
protected "$target"
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
cmp -s "$source" "$target" || { fail "Existing configuration differs: $target"; return 1; }
|
||||||
|
else
|
||||||
|
atomic_install "$source" "$target" "$mode"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
atomic_install() {
|
||||||
|
local source=$1 target=$2 mode=$3 temporary
|
||||||
|
protected "$target"
|
||||||
|
temporary=$(mktemp "$(dirname -- "$target")/.platform-file-XXXXXXXX")
|
||||||
|
if ! install -m "$mode" "$source" "$temporary" || ! mv -fT -- "$temporary" "$target"; then
|
||||||
|
rm -f -- "$temporary"; return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
check_identity() {
|
||||||
|
local binary=$1 keys=$2 sha=$3 item
|
||||||
|
for item in "$binary" "$keys/periphery.key" "$keys/periphery.pub" "$keys/core.pub"; do protected "$item" || return 1; done
|
||||||
|
if [[ -e "$binary" ]]; then printf '%s %s\n' "$sha" "$binary" | sha256sum -c - >/dev/null || return 1; fi
|
||||||
|
if [[ -e "$keys/periphery.key" || -e "$keys/periphery.pub" ]]; then
|
||||||
|
[[ -s "$keys/periphery.key" && -s "$keys/periphery.pub" ]] || { fail 'Incomplete identity; restore the original key pair'; return 1; }
|
||||||
|
local private_mode=$(stat -c %a "$keys/periphery.key")
|
||||||
|
[[ "$private_mode" == 400 || "$private_mode" == 600 ]] || { fail 'Private key must be owner-readable only (0400 or 0600)'; return 1; }
|
||||||
|
verify_identity_pair "$keys" || return 1
|
||||||
|
elif [[ -e "$keys/core.pub" ]]; then fail 'Trust exists but node identity is missing'; return 1; fi
|
||||||
|
}
|
||||||
|
key_pem() {
|
||||||
|
local file=$1 kind=$2
|
||||||
|
if head -n 1 "$file" | grep -q '^-----BEGIN '; then cat "$file"; else
|
||||||
|
printf -- '-----BEGIN %s KEY-----\n' "$kind"
|
||||||
|
tr -d '\r\n' < "$file"
|
||||||
|
printf '\n-----END %s KEY-----\n' "$kind"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
public_key_base64() {
|
||||||
|
local source=$1 temp result
|
||||||
|
command -v openssl >/dev/null || return 1
|
||||||
|
temp=$(mktemp) || return 1
|
||||||
|
chmod 600 "$temp"
|
||||||
|
if ! key_pem "$source" PUBLIC > "$temp"; then
|
||||||
|
rm -f -- "$temp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! result=$(openssl pkey -pubin -in "$temp" -pubout -outform DER 2>/dev/null | openssl base64 -A); then
|
||||||
|
rm -f -- "$temp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f -- "$temp"
|
||||||
|
printf '%s' "$result"
|
||||||
|
}
|
||||||
|
validate_core_key() {
|
||||||
|
local temp canonical
|
||||||
|
command -v openssl >/dev/null || { fail 'OpenSSL is required to validate the Core public key'; return 1; }
|
||||||
|
temp=$(mktemp) || { fail 'Cannot create a temporary file for Core key validation'; return 1; }
|
||||||
|
chmod 600 "$temp"
|
||||||
|
printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$core_key" > "$temp"
|
||||||
|
if ! canonical=$(openssl pkey -pubin -in "$temp" -pubout -outform DER 2>/dev/null | openssl base64 -A); then
|
||||||
|
rm -f -- "$temp"
|
||||||
|
fail 'Core public key is not a valid X25519 SPKI key'; return 1
|
||||||
|
fi
|
||||||
|
rm -f -- "$temp"
|
||||||
|
[[ "$canonical" == "$core_key" ]] || { fail 'Core public key is not canonical X25519 SPKI Base64'; return 1; }
|
||||||
|
}
|
||||||
|
check_core_trust() {
|
||||||
|
[[ ! -e "$1" ]] && return 0
|
||||||
|
command -v openssl >/dev/null || { fail 'OpenSSL is required to verify the existing Core public key'; return 1; }
|
||||||
|
[[ $(public_key_base64 "$1") == "$2" ]] || { fail 'Existing Core public key differs'; return 1; }
|
||||||
|
}
|
||||||
|
verify_identity_pair() {
|
||||||
|
local keys=$1 expected computed temp_dir
|
||||||
|
[[ -s "$keys/periphery.key" && -s "$keys/periphery.pub" ]] || { fail 'Incomplete Periphery identity'; return 1; }
|
||||||
|
[[ $(head -n 1 "$keys/periphery.key") == '-----BEGIN PRIVATE KEY-----' && $(tail -n 1 "$keys/periphery.key") == '-----END PRIVATE KEY-----' && $(head -n 1 "$keys/periphery.pub") == '-----BEGIN PUBLIC KEY-----' && $(tail -n 1 "$keys/periphery.pub") == '-----END PUBLIC KEY-----' ]] || { fail 'Periphery identity must use the new PEM file format'; return 1; }
|
||||||
|
command -v openssl >/dev/null || { fail 'OpenSSL is required to verify the existing key pair'; return 1; }
|
||||||
|
expected=$(public_key_base64 "$keys/periphery.pub") || { fail 'Invalid Periphery public key'; return 1; }
|
||||||
|
temp_dir=$(mktemp -d) || { fail 'Cannot create a temporary directory for key verification'; return 1; }
|
||||||
|
chmod 700 "$temp_dir"
|
||||||
|
if ! key_pem "$keys/periphery.key" PRIVATE > "$temp_dir/private.pem" || ! key_pem "$keys/periphery.pub" PUBLIC > "$temp_dir/public.pem"; then
|
||||||
|
rm -rf -- "$temp_dir"
|
||||||
|
fail 'Invalid Periphery key encoding'; return 1
|
||||||
|
fi
|
||||||
|
chmod 600 "$temp_dir/private.pem" "$temp_dir/public.pem"
|
||||||
|
computed=$(openssl pkey -in "$temp_dir/private.pem" -pubout -outform DER 2>/dev/null | openssl base64 -A) || {
|
||||||
|
rm -rf -- "$temp_dir"
|
||||||
|
fail 'Invalid Periphery private key'; return 1;
|
||||||
|
}
|
||||||
|
rm -rf -- "$temp_dir"
|
||||||
|
[[ "$computed" =~ ^MCowBQYDK2VuAyEA[A-Za-z0-9+/]{43}=$ && "$computed" == "$expected" ]] || { fail 'Periphery X25519 private/public key pair does not match'; return 1; }
|
||||||
|
}
|
||||||
|
check_service() {
|
||||||
|
local unit=$1 unit_name=${1##*/} report status=0 key value loaded='' fragment='' dropins='' count=0
|
||||||
|
report=$(systemctl show "$unit_name" -p LoadState -p FragmentPath -p DropInPaths) || status=$?
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
LoadState) loaded=$value; count=$((count + 1));;
|
||||||
|
FragmentPath) fragment=$value; count=$((count + 1));;
|
||||||
|
DropInPaths) dropins=$value; count=$((count + 1));;
|
||||||
|
esac
|
||||||
|
done <<< "$report"
|
||||||
|
[[ "$count" == 3 && ( "$loaded" == loaded || "$loaded" == not-found ) ]] || { fail 'Cannot inspect systemd service'; return 1; }
|
||||||
|
[[ "$status" == 0 || ( "$loaded" == not-found && -z "$fragment" && -z "$dropins" ) ]] || { fail 'systemctl inspection failed'; return 1; }
|
||||||
|
[[ -z "$dropins" && ( -z "$fragment" || "$fragment" == "$unit" ) ]] || { fail 'Existing service override requires review'; return 1; }
|
||||||
|
}
|
||||||
|
main() {
|
||||||
|
local node_name='' core_url='' core_key='' assets_url='' check=false
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--help|-h) usage; return 0 ;;
|
||||||
|
--name|--core-url|--core-public-key|--assets-url)
|
||||||
|
(($# >= 2)) || { fail "Missing value: $1"; return 1; }
|
||||||
|
case "$1" in --name) node_name=$2;; --core-url) core_url=$2;; --core-public-key) core_key=$2;; --assets-url) assets_url=$2;; esac
|
||||||
|
shift 2 ;;
|
||||||
|
--check) check=true; shift ;;
|
||||||
|
*) fail "Unknown argument: $1"; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
validate_inputs
|
||||||
|
[[ $EUID == 0 && $(uname -s) == Linux && $(uname -m) == x86_64 ]] || { fail 'Run as root on Linux x86_64'; return 1; }
|
||||||
|
grep -Eq '^ID="?debian"?$' /etc/os-release && grep -Eq '^VERSION_ID="?13"?$' /etc/os-release || { fail 'Debian 13 required; use the separate upgrade entry for Debian 12'; return 1; }
|
||||||
|
[[ -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Native systemd host required'; return 1; }
|
||||||
|
local version=2.3.4
|
||||||
|
local sha=46d50313970aea59d293bbe237648f4becea74e8abc2f30f1bb62b73456fc72e
|
||||||
|
local binary=/usr/local/bin/periphery keys=/etc/komodo/keys state=/var/lib/platform-bootstrap
|
||||||
|
local config=/etc/komodo/periphery.config.toml unit=/etc/systemd/system/periphery.service item
|
||||||
|
for item in "$binary" "$config" "$unit" "$keys/periphery.key" "$keys/periphery.pub" "$keys/core.pub" "$state/binding" "$state/receipt.json" "$state/lock"; do protected "$item" || return 1; done
|
||||||
|
for item in "$keys" "$state"; do
|
||||||
|
[[ ! -e "$item" ]] || (( (8#$(stat -c %a "$item") & 0077) == 0 )) || { fail "Private directory must be 0700: $item"; return 1; }
|
||||||
|
done
|
||||||
|
check_identity "$binary" "$keys" "$sha"
|
||||||
|
check_service "$unit"
|
||||||
|
umask 077
|
||||||
|
local task_dir cleanup
|
||||||
|
task_dir=$(mktemp -d)
|
||||||
|
printf -v cleanup 'rm -rf -- %q' "$task_dir"; trap "$cleanup" EXIT
|
||||||
|
core_url=${core_url%/}
|
||||||
|
printf '%s\n%s\n%s\n%s\n' "$node_name" "$core_url" "$core_key" "$sha" > "$task_dir/binding"
|
||||||
|
printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$core_key" > "$task_dir/core.pub"
|
||||||
|
cat > "$task_dir/config" <<EOF
|
||||||
|
root_directory = "/etc/komodo"
|
||||||
|
core_address = "$core_url"
|
||||||
|
connect_as = "$node_name"
|
||||||
|
core_public_keys = "file:/etc/komodo/keys/core.pub"
|
||||||
|
private_key = "file:/etc/komodo/keys/periphery.key"
|
||||||
|
server_enabled = false
|
||||||
|
EOF
|
||||||
|
cat > "$task_dir/unit" <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Komodo native Periphery
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
Environment=HOME=/root
|
||||||
|
UMask=0077
|
||||||
|
ExecStart=/usr/local/bin/periphery --config-path /etc/komodo/periphery.config.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
check_core_trust "$keys/core.pub" "$core_key"
|
||||||
|
for item in "binding:$state/binding" "config:$config" "unit:$unit"; do
|
||||||
|
[[ ! -e "${item#*:}" ]] || cmp -s "$task_dir/${item%%:*}" "${item#*:}" || { fail "Existing identity/configuration differs: ${item#*:}"; return 1; }
|
||||||
|
done
|
||||||
|
if "$check"; then
|
||||||
|
printf 'Local checks passed. Expected Core version: %s. No installation; Core connection not checked.\n' "$version"
|
||||||
|
rm -rf -- "$task_dir"; trap - EXIT; return 0
|
||||||
|
fi
|
||||||
|
install -d -m 0700 "$state" "$keys"
|
||||||
|
exec 9>>"$state/lock"
|
||||||
|
flock -n 9 || { fail 'Another installation is running'; return 1; }
|
||||||
|
check_identity "$binary" "$keys" "$sha"
|
||||||
|
check_core_trust "$keys/core.pub" "$core_key"
|
||||||
|
check_service "$unit"
|
||||||
|
if ! command -v curl >/dev/null || ! command -v jq >/dev/null || ! command -v git >/dev/null || ! command -v openssl >/dev/null || [[ ! -s /etc/ssl/certs/ca-certificates.crt ]]; then
|
||||||
|
apt-get -o DPkg::Lock::Timeout=60 update
|
||||||
|
apt-get -o DPkg::Lock::Timeout=60 install -y --no-install-recommends ca-certificates curl jq git openssl
|
||||||
|
fi
|
||||||
|
validate_core_key
|
||||||
|
write_new "$state/binding" "$task_dir/binding" 0600
|
||||||
|
if [[ ! -e "$binary" ]]; then
|
||||||
|
curl --proto '=https' --proto-redir '=https' -fSL --retry 2 --connect-timeout 15 --max-time 300 "$assets_url/vendor/komodo/$version/periphery-x86_64" -o "$task_dir/periphery"
|
||||||
|
printf '%s %s\n' "$sha" "$task_dir/periphery" | sha256sum -c -
|
||||||
|
atomic_install "$task_dir/periphery" "$binary" 0755
|
||||||
|
fi
|
||||||
|
if [[ ! -e "$keys/periphery.key" ]]; then
|
||||||
|
if ! "$binary" key generate --format json > "$task_dir/pair.json" 2> "$task_dir/key-error"; then
|
||||||
|
fail 'Native identity generation failed; output suppressed'; return 1
|
||||||
|
fi
|
||||||
|
jq -er '.private_key | select(type == "string" and length > 0)' "$task_dir/pair.json" > "$task_dir/private.raw"
|
||||||
|
jq -er '.public_key | select(type == "string" and length > 0)' "$task_dir/pair.json" > "$task_dir/public.raw"
|
||||||
|
key_pem "$task_dir/private.raw" PRIVATE > "$task_dir/private"
|
||||||
|
key_pem "$task_dir/public.raw" PUBLIC > "$task_dir/public"
|
||||||
|
atomic_install "$task_dir/private" "$keys/periphery.key" 0600
|
||||||
|
atomic_install "$task_dir/public" "$keys/periphery.pub" 0600
|
||||||
|
fi
|
||||||
|
verify_identity_pair "$keys"
|
||||||
|
local changed=false
|
||||||
|
[[ -e "$config" && -e "$unit" && -e "$keys/core.pub" ]] || changed=true
|
||||||
|
atomic_install "$task_dir/core.pub" "$keys/core.pub" 0600
|
||||||
|
write_new "$config" "$task_dir/config" 0600
|
||||||
|
write_new "$unit" "$task_dir/unit" 0644
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable periphery.service
|
||||||
|
if "$changed" || ! systemctl is-active --quiet periphery.service; then systemctl restart periphery.service; fi
|
||||||
|
sleep 2
|
||||||
|
systemctl is-active --quiet periphery.service
|
||||||
|
jq -n --arg hostname "$node_name" --arg core_url "$core_url" --arg version "$version" --arg key "$(public_key_base64 "$keys/periphery.pub")" \
|
||||||
|
'{hostname:$hostname,core_url:$core_url,periphery_version:$version,periphery_public_key:($key|rtrimstr("\n")),native_service:"active",core_registration:"not_verified",initialization:"not_started"}' > "$task_dir/receipt.json"
|
||||||
|
atomic_install "$task_dir/receipt.json" "$state/receipt.json" 0600
|
||||||
|
cat "$state/receipt.json"
|
||||||
|
printf 'Return this public receipt to the controller to register the exact Server key.\n'
|
||||||
|
rm -rf -- "$task_dir"; trap - EXIT
|
||||||
|
}
|
||||||
|
# Calling main only after the full function is received also supports curl | bash.
|
||||||
|
if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi
|
||||||
97
tailscale.sh
Normal file
97
tailscale.sh
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Enroll using a locally prepared package and a short-lived root-only key file.
|
||||||
|
set -euo pipefail
|
||||||
|
export LC_ALL=C
|
||||||
|
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||||
|
validate_host() {
|
||||||
|
local host=$1 label octet
|
||||||
|
[[ -n "$host" && ${#host} -le 253 && "$host" != .* && "$host" != *. && "$host" != *..* ]] || { fail 'Invalid Headscale URL host'; return 1; }
|
||||||
|
if [[ "$host" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
|
||||||
|
local octets=()
|
||||||
|
IFS=. read -r -a octets <<< "$host"
|
||||||
|
for octet in "${octets[@]}"; do
|
||||||
|
[[ "$octet" =~ ^(0|[1-9][0-9]{0,2})$ ]] && ((10#$octet <= 255)) || { fail 'Invalid Headscale IPv4 host'; return 1; }
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || { fail 'Invalid Headscale URL host'; return 1; }
|
||||||
|
local labels=()
|
||||||
|
IFS=. read -r -a labels <<< "$host"
|
||||||
|
for label in "${labels[@]}"; do
|
||||||
|
[[ "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || { fail 'Invalid Headscale URL host label'; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
validate_tags() {
|
||||||
|
# Tailscale tags are not DNS labels: letters, digits and dashes are valid
|
||||||
|
# after the first letter, with no DNS length or final-character limit.
|
||||||
|
[[ -z "$1" || "$1" =~ ^tag:[A-Za-z][A-Za-z0-9-]*(,tag:[A-Za-z][A-Za-z0-9-]*)*$ ]] || { fail 'Invalid authorized tags'; return 1; }
|
||||||
|
}
|
||||||
|
main() {
|
||||||
|
local version='' package='' sha='' login='' login_port='' name='' auth='' tags='' action=''
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--help|-h) echo 'Usage: bash tailscale.sh --check|--apply --version VERSION --package LOCAL_DEB --sha256 SHA --login-server HTTPS_URL --hostname NAME [--auth-key-file /run/platform-secrets/KEY] [--tags tag:NAME,...]'; return 0;;
|
||||||
|
--check|--apply) [[ -z "$action" ]] || return 1; action=$1; shift;;
|
||||||
|
--version|--package|--sha256|--login-server|--hostname|--auth-key-file|--tags)
|
||||||
|
(($# >= 2)) || return 1
|
||||||
|
case "$1" in --version) version=$2;; --package) package=$2;; --sha256) sha=$2;; --login-server) login=$2;; --hostname) name=$2;; --auth-key-file) auth=$2;; --tags) tags=$2;; esac; shift 2;;
|
||||||
|
*) fail "Unknown argument: $1"; return 1;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
local word='[a-z][a-z0-9]{0,15}'
|
||||||
|
[[ $EUID == 0 && ( "$action" == --check || "$action" == --apply ) && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$sha" =~ ^[a-f0-9]{64}$ && "$name" =~ ^(dev|test|stage|prod)-$word-$word(-$word)?-(0[1-9]|[1-9][0-9])$ ]] || { fail 'Explicit root/action/version/Headscale/hostname inputs required'; return 1; }
|
||||||
|
if [[ "$login" =~ ^https://([^/:?#]+)(:([0-9]{1,5}))?/?$ ]]; then
|
||||||
|
local login_host=${BASH_REMATCH[1]}
|
||||||
|
login_port=${BASH_REMATCH[3]}
|
||||||
|
validate_host "$login_host"
|
||||||
|
if [[ -n "$login_port" ]]; then
|
||||||
|
[[ "$login_port" =~ ^[0-9]{1,5}$ ]] && ((10#$login_port >= 1 && 10#$login_port <= 65535)) || { fail 'Invalid Headscale port'; return 1; }
|
||||||
|
fi
|
||||||
|
login=${login%/}
|
||||||
|
else
|
||||||
|
fail 'Explicit HTTPS Headscale URL required'; return 1
|
||||||
|
fi
|
||||||
|
validate_tags "$tags"
|
||||||
|
[[ -f "$package" && ! -L "$package" ]] || { fail 'Prepared Tailscale DEB required'; return 1; }
|
||||||
|
printf '%s %s\n' "$sha" "$package" | sha256sum -c - >/dev/null
|
||||||
|
[[ $(dpkg-deb -f "$package" Package) == tailscale && $(dpkg-deb -f "$package" Version) == "$version" && $(dpkg-deb -f "$package" Architecture) == amd64 ]] || { fail 'Package identity or architecture differs'; return 1; }
|
||||||
|
if command -v tailscale >/dev/null; then [[ $(tailscale version | head -n 1) == "$version" ]] || { fail 'Existing version differs; use explicit maintenance'; return 1; }; fi
|
||||||
|
if [[ -n "$auth" ]]; then
|
||||||
|
[[ "$auth" == /run/platform-secrets/* && "$auth" != */../* && "$auth" != */./* && -f "$auth" && ! -L "$auth" && $(stat -c %u "$auth") == 0 && $(stat -c %a "$auth") == 600 && ! -L /run/platform-secrets ]] || { fail 'Use a root-owned 0600 temporary enrollment key file'; return 1; }
|
||||||
|
fi
|
||||||
|
[[ "$action" == --apply ]] || { echo 'Local inputs verified; network enrollment not checked.'; return 0; }
|
||||||
|
umask 077
|
||||||
|
exec 9>>/run/lock/platform-tailscale.lock; flock -n 9 || { fail 'Enrollment is already running'; return 1; }
|
||||||
|
local task cleanup status='' state
|
||||||
|
task=$(mktemp -d)
|
||||||
|
printf -v cleanup 'rm -rf -- %q' "$task"
|
||||||
|
if [[ -n "$auth" ]]; then printf -v cleanup '%s; rm -f -- %q' "$cleanup" "$auth"; fi
|
||||||
|
trap "$cleanup" EXIT
|
||||||
|
if ! command -v tailscale >/dev/null; then apt-get -o DPkg::Lock::Timeout=60 install -y "$package"; fi
|
||||||
|
systemctl enable --now tailscaled
|
||||||
|
if ! tailscale status --json > "$task/status.json" 2> "$task/error"; then
|
||||||
|
fail 'Cannot inspect the existing Tailscale identity; do not reset it'; return 1
|
||||||
|
fi
|
||||||
|
state=$(jq -er .BackendState "$task/status.json") || { fail 'Tailscale returned an invalid status document'; return 1; }
|
||||||
|
if [[ "$state" == Running ]]; then
|
||||||
|
tailscale debug prefs > "$task/prefs.json"
|
||||||
|
jq -e --arg url "$login" --arg name "$name" '(.ControlURL | if type == "string" then rtrimstr("/") else "" end) == $url and .Hostname == $name' "$task/prefs.json" >/dev/null || { fail 'Existing node belongs to another controller or name'; return 1; }
|
||||||
|
echo 'Existing connected identity reused; enrollment key not consumed.'
|
||||||
|
elif [[ "$state" == NeedsLogin ]]; then
|
||||||
|
[[ -n "$auth" ]] || { fail 'Provide a short-lived single-use Headscale enrollment key'; return 1; }
|
||||||
|
local up_args=(up --login-server="$login" --hostname="$name" --accept-routes=true --auth-key="file:$auth")
|
||||||
|
[[ -z "$tags" ]] || up_args+=(--advertise-tags="$tags")
|
||||||
|
if ! tailscale "${up_args[@]}" > "$task/enroll-output" 2>&1; then
|
||||||
|
fail 'Enrollment failed; inspect Headscale and the original node identity before retrying'; return 1
|
||||||
|
fi
|
||||||
|
else fail 'Existing backend is not ready for first enrollment; inspect it without resetting identity'; return 1; fi
|
||||||
|
tailscale status --json > "$task/status.json" || { fail 'Tailscale enrollment status is unavailable'; return 1; }
|
||||||
|
jq -e '.BackendState == "Running" and (.Self.TailscaleIPs | length > 0)' "$task/status.json" >/dev/null || { fail 'Tailscale did not reach Running with an address'; return 1; }
|
||||||
|
tailscale debug prefs > "$task/prefs.json" || { fail 'Tailscale preferences are unavailable'; return 1; }
|
||||||
|
jq -e --arg url "$login" --arg name "$name" '(.ControlURL | if type == "string" then rtrimstr("/") else "" end) == $url and .Hostname == $name' "$task/prefs.json" >/dev/null || { fail 'Enrolled node belongs to another controller or name'; return 1; }
|
||||||
|
echo 'Node enrollment checked. Service /32 addressing, exact route approval and cross-host access still require separate verification.'
|
||||||
|
rm -rf -- "$task"
|
||||||
|
[[ -z "$auth" ]] || rm -f -- "$auth"
|
||||||
|
trap - EXIT
|
||||||
|
}
|
||||||
|
if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi
|
||||||
238
upgrade-debian12-13.sh
Normal file
238
upgrade-debian12-13.sh
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Independent manual upgrade. Never invoked by install.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
export LC_ALL=C
|
||||||
|
usage() { echo 'Usage: bash upgrade-debian12-13.sh --check|--upgrade|--resume|--verify'; }
|
||||||
|
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||||
|
# Validate only explicitly supported official source syntax. Unknown layouts stop.
|
||||||
|
validate_source() {
|
||||||
|
local file=$1 suite=$2
|
||||||
|
awk -v suite="$suite" -v deb822="$([[ "$file" == *.sources || "$file" == *.sources.target ]] && echo 1 || echo 0)" '
|
||||||
|
function reject(){bad=1}
|
||||||
|
function valid_uri(u){return u ~ /^https?:\/\/(deb\.debian\.org|security\.debian\.org)\/debian(-security)?\/?$/}
|
||||||
|
function valid_suite(s){if(s==suite)base=1;if(s==suite "-security")security=1;return s==suite || s==suite "-updates" || s==suite "-security"}
|
||||||
|
function reset(){have=0;types=uri=suites=enabled=components="";delete seen}
|
||||||
|
function block( n,a,i){
|
||||||
|
if(!have)return
|
||||||
|
if(enabled!="" && enabled!="yes" && enabled!="no")reject()
|
||||||
|
if(enabled=="no"){reset();return}
|
||||||
|
if(types!="deb" || !valid_uri(uri) || suites=="" || components=="")reject()
|
||||||
|
n=split(suites,a,/[ \t]+/);for(i=1;i<=n;i++)if(!valid_suite(a[i]))reject()
|
||||||
|
if(uri ~ /debian-security\/?$/ && suites!=suite "-security")reject()
|
||||||
|
if(uri !~ /debian-security\/?$/ && suites ~ /-security/)reject()
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
/^[ \t]*#/ {next}
|
||||||
|
/^[ \t]*$/ {if(deb822)block();next}
|
||||||
|
!deb822 {
|
||||||
|
line=$0;sub(/[ \t]+#.*/,"",line)
|
||||||
|
if(line !~ /^[ \t]*deb[ \t]+/) {reject();next}
|
||||||
|
sub(/^[ \t]*deb[ \t]+/,"",line)
|
||||||
|
if(line ~ /^\[/){
|
||||||
|
if(line !~ /^\[[^]]+\][ \t]+/){reject();next}
|
||||||
|
options=line;sub(/\].*$/,"",options);sub(/^\[/,"",options)
|
||||||
|
n=split(options,a,/[ \t]+/);delete options_seen
|
||||||
|
for(i=1;i<=n;i++){
|
||||||
|
if(a[i]!~/^(arch|signed-by)=[A-Za-z0-9_\/.+,-]+$/)reject()
|
||||||
|
option=a[i];sub(/=.*/,"",option);if(options_seen[option]++)reject()
|
||||||
|
}
|
||||||
|
sub(/^\[[^]]+\][ \t]+/,"",line)
|
||||||
|
}
|
||||||
|
n=split(line,a,/[ \t]+/)
|
||||||
|
if(n<3 || !valid_uri(a[1]) || !valid_suite(a[2]))reject()
|
||||||
|
if((a[1] ~ /debian-security\/?$/)!=(a[2]==suite "-security"))reject()
|
||||||
|
next
|
||||||
|
}
|
||||||
|
deb822 {
|
||||||
|
if($0 !~ /^[A-Za-z][A-Za-z-]*:/){reject();next}
|
||||||
|
field=tolower($0);sub(/:.*/,"",field)
|
||||||
|
if(seen[field]++)reject()
|
||||||
|
value=$0;sub(/^[^:]+:[ \t]*/,"",value);sub(/[ \t]+$/,"",value)
|
||||||
|
if(field=="types")types=value
|
||||||
|
else if(field=="uris")uri=value
|
||||||
|
else if(field=="suites")suites=value
|
||||||
|
else if(field=="enabled")enabled=tolower(value)
|
||||||
|
else if(field=="components")components=value
|
||||||
|
else if(field!="signed-by" && field!="architectures")reject()
|
||||||
|
have=1
|
||||||
|
}
|
||||||
|
END{if(deb822)block();if(bad)exit 1;if(base)print "base";if(security)print "security"}' "$file"
|
||||||
|
}
|
||||||
|
# Change only active suite fields, including every suite in a Deb822 list.
|
||||||
|
render_source() {
|
||||||
|
local file=$1
|
||||||
|
validate_source "$file" bookworm >/dev/null || return 1
|
||||||
|
awk -v deb822="$([[ "$file" == *.sources || "$file" == *.sources.target ]] && echo 1 || echo 0)" '
|
||||||
|
function flush( i,disabled){
|
||||||
|
disabled=0
|
||||||
|
for(i=1;i<=count;i++)if(tolower(lines[i]) ~ /^enabled:[ \t]*no[ \t]*$/)disabled=1
|
||||||
|
for(i=1;i<=count;i++){
|
||||||
|
if(!disabled && tolower(lines[i]) ~ /^suites:/)gsub(/bookworm/,"trixie",lines[i])
|
||||||
|
print lines[i]
|
||||||
|
}
|
||||||
|
count=0;delete lines
|
||||||
|
}
|
||||||
|
!deb822 {
|
||||||
|
line=$0
|
||||||
|
if(match(line,/^[ \t]*deb[ \t]+(\[[^]]+\][ \t]+)?[^ \t]+[ \t]+/)){
|
||||||
|
prefix=substr(line,1,RLENGTH);tail=substr(line,RLENGTH+1)
|
||||||
|
sub(/^bookworm/,"trixie",tail);line=prefix tail
|
||||||
|
}
|
||||||
|
print line;next
|
||||||
|
}
|
||||||
|
/^[ \t]*$/ {flush();print;next}
|
||||||
|
{lines[++count]=$0}
|
||||||
|
END{if(deb822)flush()}' "$file"
|
||||||
|
}
|
||||||
|
package_checks() {
|
||||||
|
local report preference
|
||||||
|
report=$(dpkg --audit) || { fail 'dpkg audit failed'; return 1; }
|
||||||
|
[[ -z "$report" ]] || { fail 'Repair the original unfinished dpkg operation first'; return 1; }
|
||||||
|
report=$(apt-mark showhold) || { fail 'Cannot read held packages'; return 1; }
|
||||||
|
[[ -z "$report" ]] || { fail 'Review held packages first'; return 1; }
|
||||||
|
shopt -s nullglob
|
||||||
|
for preference in /etc/apt/preferences /etc/apt/preferences.d/*; do
|
||||||
|
[[ ! -f "$preference" ]] || ! grep -Eq '^[[:space:]]*[^#[:space:]]' "$preference" || { fail 'Review APT pinning first'; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
check_source_set() {
|
||||||
|
local state=$1 original=() current=() file
|
||||||
|
mapfile -t original < "$state/source-files"
|
||||||
|
[[ ${#original[@]} -gt 0 ]] || { fail 'Empty original source plan'; return 1; }
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||||
|
[[ ! -L "$file" ]] || { fail 'APT source became a symlink'; return 1; }
|
||||||
|
[[ ! -e "$file" ]] || current+=("$file")
|
||||||
|
done
|
||||||
|
[[ "${original[*]}" == "${current[*]}" ]] || { fail 'APT source file set changed since review'; return 1; }
|
||||||
|
}
|
||||||
|
switch_sources() {
|
||||||
|
local state=$1 file next
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ -f "$file" && ! -L "$file" ]] && { cmp -s "$file" "$state/sources$file" || cmp -s "$file" "$state/sources$file.target"; } || { fail 'APT source changed after review'; return 1; }
|
||||||
|
validate_source "$state/sources$file.target" trixie >/dev/null || return 1
|
||||||
|
done < "$state/source-files"
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if cmp -s "$file" "$state/sources$file.target"; then continue; fi
|
||||||
|
next=$(mktemp "$(dirname "$file")/.platform-source-XXXXXXXX")
|
||||||
|
if ! cat "$state/sources$file.target" > "$next" || ! chmod --reference="$file" "$next" || ! chown --reference="$file" "$next" || ! mv -fT -- "$next" "$file"; then
|
||||||
|
rm -f -- "$next"; return 1
|
||||||
|
fi
|
||||||
|
done < "$state/source-files"
|
||||||
|
}
|
||||||
|
scan_sources() {
|
||||||
|
local suite=$1 file report all_reports=''
|
||||||
|
shopt -s nullglob
|
||||||
|
source_files=()
|
||||||
|
for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||||
|
[[ ! -L "$file" ]] || { fail 'Symlink APT source'; return 1; }
|
||||||
|
[[ -e "$file" ]] || continue
|
||||||
|
[[ "$file" != *$'\n'* && ! -L "$file" && -f "$file" ]] || { fail 'APT source is not a regular file'; return 1; }
|
||||||
|
report=$(validate_source "$file" "$suite") || { fail "Review unknown/mixed/third-party source syntax: $file"; return 1; }
|
||||||
|
source_files+=("$file")
|
||||||
|
all_reports+=" $report"
|
||||||
|
done
|
||||||
|
[[ "$all_reports" == *base* && "$all_reports" == *security* ]] || { fail 'Explicit Debian base and security sources required'; return 1; }
|
||||||
|
}
|
||||||
|
reboot_pending() {
|
||||||
|
[[ -e /run/reboot-required ]] || { [[ -e /vmlinuz ]] && [[ $(basename "$(readlink -f /vmlinuz)") != "vmlinuz-$(uname -r)" ]]; }
|
||||||
|
}
|
||||||
|
save_phase() {
|
||||||
|
local temporary
|
||||||
|
temporary=$(mktemp "$state/.phase-XXXXXXXX")
|
||||||
|
printf '%s\n' "$1" > "$temporary"
|
||||||
|
mv -fT -- "$temporary" "$state/phase"
|
||||||
|
}
|
||||||
|
main() {
|
||||||
|
local action=${1:---help}
|
||||||
|
case "$action" in --help|-h) usage; return 0;; --check|--upgrade|--resume|--verify) :;; *) usage; return 1;; esac
|
||||||
|
[[ $# == 1 && $EUID == 0 && -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Run on a native systemd host as root'; return 1; }
|
||||||
|
grep -Eq '^ID="?debian"?$' /etc/os-release || { fail 'Debian required'; return 1; }
|
||||||
|
local state=/var/lib/platform-debian-upgrade phase='' boot_id answer file
|
||||||
|
boot_id=$(cat /proc/sys/kernel/random/boot_id)
|
||||||
|
[[ ! -L "$state" ]] || { fail 'State must not be a symlink'; return 1; }
|
||||||
|
if [[ -e "$state" ]]; then
|
||||||
|
[[ $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || { fail 'State must be root-owned mode 0700'; return 1; }
|
||||||
|
[[ ! -L "$state/phase" ]] || { fail 'Invalid phase file'; return 1; }
|
||||||
|
[[ ! -f "$state/phase" ]] || phase=$(cat "$state/phase")
|
||||||
|
fi
|
||||||
|
package_checks
|
||||||
|
if [[ "$action" != --check ]]; then
|
||||||
|
[[ "$action" == --upgrade || -d "$state" ]] || { fail 'No original upgrade state'; return 1; }
|
||||||
|
[[ -r /dev/tty && -w /dev/tty ]] || { fail 'A real controlling terminal is required'; return 1; }
|
||||||
|
umask 077
|
||||||
|
install -d -m 0700 "$state"
|
||||||
|
[[ ! -L "$state/lock" && ( ! -e "$state/lock" || ( -f "$state/lock" && $(stat -c %h "$state/lock") == 1 ) ) ]] || { fail 'Invalid lock'; return 1; }
|
||||||
|
exec 9>>"$state/lock"; flock -n 9 || { fail 'Another upgrade is running'; return 1; }
|
||||||
|
phase=''
|
||||||
|
[[ ! -f "$state/phase" ]] || phase=$(cat "$state/phase")
|
||||||
|
fi
|
||||||
|
if [[ "$action" == --verify ]]; then
|
||||||
|
[[ "$phase" == awaiting_reboot || "$phase" == verified ]] || { fail 'No completed upgrade record'; return 1; }
|
||||||
|
grep -Eq '^VERSION_ID="?13"?$' /etc/os-release
|
||||||
|
[[ "$boot_id" != "$(cat "$state/upgrade-boot")" ]] && ! reboot_pending || { fail 'Reboot into the installed target kernel first'; return 1; }
|
||||||
|
[[ $(uname -r) != "$(cat "$state/bookworm-kernel")" && -s "/boot/vmlinuz-$(uname -r)" ]] || { fail 'Boot the installed target kernel'; return 1; }
|
||||||
|
if [[ -s "$state/identities.sha256" ]]; then sha256sum --check "$state/identities.sha256"; fi
|
||||||
|
save_phase verified
|
||||||
|
echo 'Debian 13 local verification passed. Confirm a fresh SSH connection and existing workloads.'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "$action" == --check || -z "$phase" ]]; then
|
||||||
|
grep -Eq '^VERSION_ID="?12"?$' /etc/os-release || { fail 'Start this entry on Debian 12'; return 1; }
|
||||||
|
scan_sources bookworm
|
||||||
|
[[ $(df -Pk / | awk 'NR==2{print $4}') -ge 5242880 && $(df -Pk /boot | awk 'NR==2{print $4}') -ge 262144 ]] || { fail 'Need 5 GiB free on / and 256 MiB on /boot'; return 1; }
|
||||||
|
if [[ -d /sys/firmware/efi ]]; then mountpoint -q /boot/efi || { fail 'Mount and review the EFI System Partition first'; return 1; }; fi
|
||||||
|
if [[ "$action" == --check ]]; then echo 'Source/package/storage checks passed; review bootloader and recovery access before --upgrade.'; return 0; fi
|
||||||
|
fi
|
||||||
|
[[ -r /dev/tty && -w /dev/tty ]] || { fail 'A real controlling terminal is required'; return 1; }
|
||||||
|
[[ ( "$action" == --upgrade && -z "$phase" ) || ( "$action" == --resume && -n "$phase" ) ]] || { fail 'Use --upgrade for a new run or --resume for the existing run'; return 1; }
|
||||||
|
if [[ -z "$phase" ]]; then
|
||||||
|
printf 'Bootloader, rescue access and required backups reviewed. Type UPGRADE: ' >/dev/tty
|
||||||
|
read -r answer </dev/tty; [[ "$answer" == UPGRADE ]] || return 1
|
||||||
|
tar --acls --xattrs -cpf "$state/before.tar" /etc /boot /var/lib/dpkg/status
|
||||||
|
tar -tf "$state/before.tar" >/dev/null
|
||||||
|
: > "$state/identities.sha256"
|
||||||
|
for file in /etc/ssh/ssh_host_*_key.pub /etc/komodo/keys/periphery.pub; do [[ ! -f "$file" ]] || sha256sum "$file" >> "$state/identities.sha256"; done
|
||||||
|
phase=prepared; save_phase "$phase"
|
||||||
|
fi
|
||||||
|
case "$phase" in prepared|bookworm_updated|switching_sources|sources_switched|awaiting_reboot|verified) :;; *) fail 'Unknown recorded phase'; return 1;; esac
|
||||||
|
if [[ "$phase" == awaiting_reboot || "$phase" == verified ]]; then echo 'Upgrade already finished; reboot manually and use --verify.'; return 0; fi
|
||||||
|
if [[ "$phase" == prepared ]]; then
|
||||||
|
scan_sources bookworm
|
||||||
|
apt-get update; apt-get full-upgrade </dev/tty
|
||||||
|
package_checks
|
||||||
|
printf '%s\n' "$boot_id" > "$state/bookworm-boot"
|
||||||
|
phase=bookworm_updated; save_phase "$phase"
|
||||||
|
if reboot_pending; then touch "$state/reboot-required"; echo 'Reboot Debian 12 manually, reconnect, then --resume.'; return 3; fi
|
||||||
|
fi
|
||||||
|
if [[ "$phase" == bookworm_updated ]]; then
|
||||||
|
! reboot_pending || { fail 'Reboot before continuing'; return 1; }
|
||||||
|
[[ ! -e "$state/reboot-required" || "$boot_id" != "$(cat "$state/bookworm-boot")" ]] || return 1
|
||||||
|
scan_sources bookworm
|
||||||
|
printf 'Switch the reviewed Debian sources to trixie. Type TRIXIE: ' >/dev/tty
|
||||||
|
read -r answer </dev/tty; [[ "$answer" == TRIXIE ]] || return 1
|
||||||
|
uname -r > "$state/bookworm-kernel"
|
||||||
|
install -d -m 0700 "$state/sources"
|
||||||
|
printf '%s\n' "${source_files[@]}" > "$state/source-files"
|
||||||
|
for file in "${source_files[@]}"; do
|
||||||
|
cp --parents "$file" "$state/sources/"
|
||||||
|
render_source "$file" > "$state/sources${file}.target"
|
||||||
|
done
|
||||||
|
phase=switching_sources; save_phase "$phase"
|
||||||
|
fi
|
||||||
|
check_source_set "$state"
|
||||||
|
if [[ "$phase" == switching_sources ]]; then
|
||||||
|
switch_sources "$state"
|
||||||
|
phase=sources_switched; save_phase "$phase"
|
||||||
|
fi
|
||||||
|
scan_sources trixie
|
||||||
|
while IFS= read -r file; do cmp -s "$file" "$state/sources$file.target" || { fail 'Target source changed'; return 1; }; done < "$state/source-files"
|
||||||
|
apt-get update
|
||||||
|
apt-get upgrade --without-new-pkgs </dev/tty
|
||||||
|
apt-get full-upgrade </dev/tty
|
||||||
|
package_checks
|
||||||
|
printf '%s\n' "$boot_id" > "$state/upgrade-boot"
|
||||||
|
save_phase awaiting_reboot
|
||||||
|
echo 'APT stages completed. Review kernel/bootloader output, reboot manually, reconnect and run --verify.'
|
||||||
|
}
|
||||||
|
if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi
|
||||||
BIN
vendor/komodo/2.3.4/periphery-x86_64
vendored
Normal file
BIN
vendor/komodo/2.3.4/periphery-x86_64
vendored
Normal file
Binary file not shown.
BIN
vendor/pgmq/1.13.1/pgmq.tar.gz
vendored
Normal file
BIN
vendor/pgmq/1.13.1/pgmq.tar.gz
vendored
Normal file
Binary file not shown.
BIN
vendor/redis/6.4.0/redis-6.4.0-py3-none-any.whl
vendored
Normal file
BIN
vendor/redis/6.4.0/redis-6.4.0-py3-none-any.whl
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user