commit f89ab4634a7f13129eb728a5c7a3bec9fe5b203b Author: Codex Date: Wed Nov 4 03:01:27 2026 +0800 Initial native bootstrap release diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f454466 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f9ffde --- /dev/null +++ b/.gitignore @@ -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-* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f33ceaf --- /dev/null +++ b/AGENTS.md @@ -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 提交提取源码与资源。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..80a5676 --- /dev/null +++ b/README.md @@ -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 用于同版本回退。版本维护单独执行。 diff --git a/artifacts.lock b/artifacts.lock new file mode 100644 index 0000000..b3f3f45 --- /dev/null +++ b/artifacts.lock @@ -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 diff --git a/host-init.sh b/host-init.sh new file mode 100644 index 0000000..2bb3e42 --- /dev/null +++ b/host-init.sh @@ -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 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..3e9b914 --- /dev/null +++ b/install.sh @@ -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" < "$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 diff --git a/tailscale.sh b/tailscale.sh new file mode 100644 index 0000000..3ba1d3d --- /dev/null +++ b/tailscale.sh @@ -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 diff --git a/upgrade-debian12-13.sh b/upgrade-debian12-13.sh new file mode 100644 index 0000000..5f178f3 --- /dev/null +++ b/upgrade-debian12-13.sh @@ -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/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 "$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 "$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 "$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 diff --git a/vendor/komodo/2.3.4/periphery-x86_64 b/vendor/komodo/2.3.4/periphery-x86_64 new file mode 100644 index 0000000..dcb9b2d Binary files /dev/null and b/vendor/komodo/2.3.4/periphery-x86_64 differ diff --git a/vendor/pgmq/1.13.1/pgmq.tar.gz b/vendor/pgmq/1.13.1/pgmq.tar.gz new file mode 100644 index 0000000..3aeff9c Binary files /dev/null and b/vendor/pgmq/1.13.1/pgmq.tar.gz differ diff --git a/vendor/redis/6.4.0/redis-6.4.0-py3-none-any.whl b/vendor/redis/6.4.0/redis-6.4.0-py3-none-any.whl new file mode 100644 index 0000000..4b1fc2e Binary files /dev/null and b/vendor/redis/6.4.0/redis-6.4.0-py3-none-any.whl differ