Initial native bootstrap release

This commit is contained in:
Codex
2026-11-04 03:01:27 +08:00
commit f89ab4634a
12 changed files with 815 additions and 0 deletions

10
.gitattributes vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

BIN
vendor/pgmq/1.13.1/pgmq.tar.gz vendored Normal file

Binary file not shown.

Binary file not shown.