From 873933af357285103a73437ffd5cdf9e38271683 Mon Sep 17 00:00:00 2001 From: kaka Date: Wed, 4 Nov 2026 16:55:00 +0800 Subject: [PATCH] feat(host): add guarded Debian baseline and SSH transactions --- AGENTS.md | 1 + README.md | 14 ++- firewall.sh | 21 +++- host-baseline.sh | 184 +++++++++++++++++++++++++++++++++++ host-init.sh | 20 ++-- ssh-access.sh | 243 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 12 deletions(-) create mode 100644 host-baseline.sh create mode 100644 ssh-access.sh diff --git a/AGENTS.md b/AGENTS.md index f33ceaf..854d760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,5 +9,6 @@ - Caddy 发布由 platform/control/publish.sh 从完整 Git 提交选取白名单文件,生成摘要并切换 current。 - 保留固定提交 URL,日常可用稳定 /install.sh 入口。没有真实域名时只给参数示例。 - 修改脚本运行 Bash 语法和相关隔离测试,真机验证独立记录。 +- host-init.sh 仅准备前置条件;host-baseline.sh 管只读检查和受限网络策略,ssh-access.sh 管首次 SSH 联合回退。完整基线标准在 platform/docs/host-baseline.md;未知 sysctl/SSH 配置不静默覆盖,普通节点不保留安装源码,不能用本地检查替代外部 SSH 或真实重启验收。 - 固定资源由 artifacts.lock 登记并存入 vendor/,与源码共同提交;目标机仅从显式 Caddy --assets-url 下载,不回退上游。版本变更同时修改安装器固定版本/摘要并检查;发布从同一 Git 提交提取源码与资源。 diff --git a/README.md b/README.md index f0f68a7..79761bd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,18 @@ Docker、Headscale、公钥注入和安全配置后续由 Komodo 任务处理。 `host-init.sh` 先检查基础包安装状态,仅在缺包时更新 APT 索引并补齐缺失包;已有完整基线的重跑不会无条件访问软件源。Docker 固定包仍须在执行前准备,构建与资源导入单独计时。 +## 全服务器主机基线 + +`host-init.sh` 仅准备前置条件,不能作为完整主机基线验收。`--without-docker` 允许无容器角色只安装通用工具;不指定时沿用既有 Docker 契约。安装材料使用 root 受限的 `/var/tmp/platform-bootstrap/<批次>/`,不堆放 `/root`。 + +`host-baseline.sh --check --hostname NAME --release FULL_COMMIT` 输出逐项 JSON,本机差异返回 2。检查包括网络参数及持久配置、SSH、确认回执、防火墙、时钟同步、失败服务和待重启状态,另输出实际网卡队列、磁盘和安装残留信息;不等同于完整外部网络/存储/业务验收。 + +`--preflight` 检查内核模块与未知本地 sysctl 冲突;`--apply-network` 备份后仅设置 BBR 和默认 fq,失败恢复原值。不会替换活动 qdisc、清空未知配置或批量调整 conntrack/超时/内存。已有自定义文件必须先审查,不静默覆盖。 + +`ssh-access.sh --stage --hostname NAME --release FULL_COMMIT --key-sha256 SHA --firewall-sha256 SHA` 使用已由管理端验证的 root authorized_keys 摘要和目标防火墙渲染摘要,设置 61789、公钥认证和 root prohibit-password。仅用于已有确认防火墙的首次 SSH 标准化;复杂显式端口/Match/socket activation、自定义服务命令或环境停止并单独维护。先安排 240 秒回退及重启恢复,再修改配置;随后应用目标防火墙,apply/confirm 均传 `--ssh-transaction TRANSACTION`。从新 61789 SSH transport 先确认防火墙、再执行 SSH `--confirm --sha256 TRANSACTION`;配置输入、目标规则和实际规则必须与事务匹配。共享私钥不上传目标机。完整 Komodo 编排、外部确认入口、推广门禁与未实现项目见 platform 的 `docs/host-baseline.md`。 + +新增主机基线入口尚需真实 Debian 的 SSH/防火墙联合回退、重启恢复和完整验收;历史 Periphery 接入证据不能替代新入口的实机测试。 + ## Headscale 接入 `tailscale.sh` 使用显式版本、已准备的本地 DEB、SHA-256、HTTPS 控制端和节点名称。首次接入还需 root 所有、0600 权限的短期单次授权 key 文件;执行后清除临时 key 文件。既有 Running 节点直接复核身份,不需要新 key。 @@ -64,7 +76,7 @@ resume/verify 可使用同一固定版本 URL 再次下载执行,或保存该 ## 发布 Caddy 同时提供 /install.sh 稳定入口与 /releases/<40位小写Git提交>/install.sh 固定版本入口;生产接入命令固定使用后者。 -发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`tailscale.sh`、`firewall.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。 +发布脚本只提取 `install.sh`、`upgrade-debian12-13.sh`、`host-init.sh`、`host-baseline.sh`、`ssh-access.sh`、`tailscale.sh`、`firewall.sh`、README、artifacts.lock 及清单列出的 vendor 文件,生成 VERSION、SHA256SUMS;不暴露 .git 或工作区其他文件。新入口按提交中是否存在纳入清单,历史不可变 release 仍可核验。 源代码与修复就在此仓库维护,无生成器、内嵌 Python 或第二份安装源码。 ## 固定资源 diff --git a/firewall.sh b/firewall.sh index 7d70c31..663325b 100644 --- a/firewall.sh +++ b/firewall.sh @@ -123,13 +123,26 @@ UNIT systemctl enable platform-firewall.service >/dev/null systemctl restart platform-firewall.service } +check_ssh_transaction() { + local transaction=$1 digest=$2 ssh_state=${3:-/var/lib/platform-bootstrap/ssh} + if [[ -e "$ssh_state/pending" ]]; then + trusted_path "$ssh_state/pending" || return 1 + trusted_path "$ssh_state/expected-firewall-sha" || return 1 + [[ "$transaction" =~ ^[a-f0-9]{64}$ && "$transaction" == "$(cat "$ssh_state/pending")" && + "$digest" == "$(cat "$ssh_state/expected-firewall-sha")" ]] || { + fail 'SSH maintenance owns the firewall; exact transaction and target policy required'; return 1; + } + else + [[ -z "$transaction" ]] || { fail 'SSH transaction expired or already completed'; return 1; } + fi +} main() { - local action='' policy='' expected='' state=/run/platform-firewall task digest previous connection + local action='' policy='' expected='' ssh_transaction='' state=/run/platform-firewall task digest previous connection while (($#)); do case "$1" in - --help|-h) echo 'Usage: bash firewall.sh --render|--check|--apply --policy ROOT_0600_JSON | --confirm --sha256 SHA'; return 0;; + --help|-h) echo 'Usage: bash firewall.sh --render|--check|--apply --policy ROOT_0600_JSON | --confirm --sha256 SHA [--ssh-transaction SHA during SSH migration]'; return 0;; --render|--check|--apply|--confirm) [[ -z "$action" ]] || return 1; action=$1; shift;; - --policy|--sha256) (($# >= 2)) || return 1; case "$1" in --policy) policy=$2;; --sha256) expected=$2;; esac; shift 2;; + --policy|--sha256|--ssh-transaction) (($# >= 2)) || return 1; case "$1" in --policy) policy=$2;; --sha256) expected=$2;; --ssh-transaction) ssh_transaction=$2;; esac; shift 2;; *) fail "Unknown argument: $1"; return 1;; esac done @@ -144,6 +157,7 @@ main() { [[ "$expected" =~ ^[a-f0-9]{64}$ && -f "$state/pending.nft" ]] || { fail 'Exact pending policy digest required'; return 1; } digest=$(sha256sum "$state/pending.nft"); digest=${digest%% *} [[ "$expected" == "$digest" ]] || { fail 'Pending policy differs'; return 1; } + check_ssh_transaction "$ssh_transaction" "$digest" connection=${SSH_CONNECTION:-} [[ -n "$connection" && "$connection" != "$(cat "$state/original-ssh")" ]] || { fail 'Confirm from a new SSH transport after checking allowed and denied ports'; return 1; } nft -s list table inet platform_host > "$state/current.nft" @@ -172,6 +186,7 @@ main() { nft --check --file "$task/rules.nft" digest=$(sha256sum "$task/rules.nft"); digest=${digest%% *} if [[ "$action" == --check ]]; then echo "Firewall policy checked: $digest"; return; fi + check_ssh_transaction "$ssh_transaction" "$digest" [[ ! -f "$state/pending.nft" ]] || { fail 'A policy is pending confirmation; inspect it or wait for rollback'; return 1; } # Rollback only our table, preserving concurrent native Tailscale/Docker rules. printf 'add table inet platform_host\ndelete table inet platform_host\n' > "$task/rollback.nft" diff --git a/host-baseline.sh b/host-baseline.sh new file mode 100644 index 0000000..8ed9f2d --- /dev/null +++ b/host-baseline.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Debian host baseline. No identity reset, package upgrade, reboot or firewall rewrite. +set -euo pipefail +export LC_ALL=C +fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } +trusted() { + local path=$1 + while [[ "$path" != / ]]; do + [[ ! -L "$path" ]] || { fail "Symlink: $path"; return 1; } + if [[ -e "$path" ]]; then + [[ $(stat -c %u "$path") == 0 ]] && (( (8#$(stat -c %a "$path") & 0022) == 0 )) || { fail "Untrusted path: $path"; return 1; } + fi + path=$(dirname -- "$path") + done +} +sysctl_conflicts() { + # Local custom tuning must be reviewed before adopting the small managed profile. + local file directory=$1 legacy=$2 + shopt -s nullglob + for file in "$directory"/*.conf "$legacy"; do + [[ -e "$file" ]] || continue + [[ "$file" == "$directory/90-platform-baseline.conf" ]] && continue + if grep -Eq '^[[:space:]]*[^#;[:space:]]' "$file"; then + fail "Unreviewed local sysctl configuration: $file"; return 1 + fi + done +} +render_sysctl() { + printf '%s\n' '# Managed by platform host baseline v1' 'net.core.default_qdisc = fq' 'net.ipv4.tcp_congestion_control = bbr' +} +late_sysctl_conflicts() { + local directory file + shopt -s nullglob + for directory in /usr/lib/sysctl.d /usr/local/lib/sysctl.d /lib/sysctl.d; do + for file in "$directory"/*.conf; do + if [[ ${file##*/} > 90-platform-baseline.conf ]] && + grep -Eq '^[[:space:]-]*net[./](core[./]default_qdisc|ipv4[./]tcp_congestion_control)[[:space:]]*=' "$file"; then + fail "Later vendor sysctl override requires review: $file"; return 1 + fi + done + done +} +result() { + local key=$1 status=$2 detail=$3 + jq -cn --arg check "$key" --arg status "$status" --arg detail "$detail" '{check:$check,status:$status,detail:$detail}' + [[ "$status" != fail ]] || failures=$((failures + 1)) +} +collect_info() { + local key=$1 value + shift + if value=$("$@" 2>&1); then result "$key" info "$value"; + else result "$key" fail "Collection failed: $value"; fi +} +verify_ssh_receipt() { + local state=/var/lib/platform-bootstrap/ssh file + for file in confirmed.json confirmation.SHA256SUMS ssh-confirm.sh firewall-confirm.sh; do + trusted "$state/$file" || return 1 + [[ -f "$state/$file" && $(stat -c %h "$state/$file") == 1 ]] || return 1 + done + # Reuse the pinned SSH implementation for config/include/service/firewall drift. + # Receipt existence alone cannot certify that the validated state still holds. + (cd "$state" && sha256sum --status -c confirmation.SHA256SUMS) || return 1 + bash "$state/ssh-confirm.sh" --check +} +audit() { + local value config ports + failures=0 + if sysctl_conflicts /etc/sysctl.d /etc/sysctl.conf && sysctl_conflicts /run/sysctl.d /dev/null && late_sysctl_conflicts; then + result sysctl_ownership pass 'No unreviewed local sysctl files' + else result sysctl_ownership fail 'Review local sysctl owners before applying a managed profile'; fi + value=$(sysctl -n net.ipv4.tcp_congestion_control) + if [[ "$value" == bbr ]]; then result tcp pass "$value"; else result tcp fail "$value; expected bbr"; fi + value=$(sysctl -n net.core.default_qdisc) + if [[ "$value" == fq ]]; then result default_qdisc pass "$value"; else result default_qdisc fail "$value; expected fq"; fi + if [[ -f /etc/sysctl.d/90-platform-baseline.conf ]] && cmp -s <(render_sysctl) /etc/sysctl.d/90-platform-baseline.conf && + [[ -f /etc/modules-load.d/platform-bbr.conf ]] && [[ $(cat /etc/modules-load.d/platform-bbr.conf) == tcp_bbr ]]; then + result network_persistence pass 'Managed sysctl and module configuration match' + else result network_persistence fail 'Managed network configuration missing or changed'; fi + # default_qdisc does not replace existing qdiscs. Report the live state without claiming it did. + collect_info live_qdisc tc qdisc show + config=$(sshd -T) || return 1 + ports=$(awk '$1 == "port" {print $2}' <<< "$config") + if [[ "$ports" == 61789 ]] && grep -qx 'passwordauthentication no' <<< "$config" && + grep -qx 'kbdinteractiveauthentication no' <<< "$config" && grep -qx 'pubkeyauthentication yes' <<< "$config" && + grep -Eq '^permitrootlogin (prohibit-password|without-password)$' <<< "$config"; then + result ssh_configuration pass '61789, public key only; external transport verification is separate' + else result ssh_configuration fail 'SSH port/authentication baseline incomplete'; fi + if verify_ssh_receipt; then + result ssh_confirmation pass 'Pinned confirmation checks current SSH inputs, listener and exact firewall; periodic external probes still required' + else result ssh_confirmation fail 'SSH confirmation missing, pending, untrusted or drifted'; fi + if systemctl is-active --quiet platform-firewall.service && [[ ! -e /run/platform-firewall/pending.nft ]] && nft list table inet platform_host >/dev/null 2>&1; then + result firewall pass 'Managed firewall active, no pending transaction; external access tests remain separate' + else result firewall fail 'Firewall inactive or pending'; fi + value=$(timedatectl show --property=NTPSynchronized --value) + if [[ "$value" == yes ]]; then result time_sync pass synchronized; else result time_sync fail 'Clock not synchronized'; fi + value=$(systemctl --failed --no-legend --plain) + if [[ -z "$value" ]]; then result services pass 'No failed systemd units'; else result services fail "$value"; fi + if [[ -e /var/run/reboot-required ]]; then result reboot fail 'Maintenance reboot required'; else result reboot info 'No reboot-required marker; kernel/package maintenance is separately reviewed'; fi + collect_info storage df -P / /var + collect_info inodes df -Pi / /var + collect_info root_residue find /root -mindepth 1 -maxdepth 1 -type f \( -name '*.sh' -o -name '*.bundle' -o -name '*.tar' -o -name '*.log' \) -printf '%f\n' + jq -cn --arg hostname "$name" --arg release "$release" --argjson failures "$failures" '{baseline:"debian13-v1",hostname:$hostname,release:$release,failures:$failures,scope:"host-local; external network, storage capacity policy and role acceptance remain separate"}' + [[ "$failures" == 0 ]] || return 2 +} +preflight() { + sysctl_conflicts /etc/sysctl.d /etc/sysctl.conf + sysctl_conflicts /run/sysctl.d /dev/null + late_sysctl_conflicts + modinfo tcp_bbr >/dev/null || { fail 'Kernel BBR module unavailable'; return 1; } + modinfo sch_fq >/dev/null || { fail 'Kernel fq module unavailable'; return 1; } + local file + for file in /etc/sysctl.d/90-platform-baseline.conf /etc/modules-load.d/platform-bbr.conf; do + trusted "$file" + [[ ! -e "$file" || ( -f "$file" && $(stat -c %h "$file") == 1 ) ]] || { fail 'Nonregular managed configuration'; return 1; } + done + for file in /etc/sysctl.d/.platform-baseline.pending /etc/modules-load.d/.platform-bbr.pending; do + [[ ! -e "$file" && ! -L "$file" ]] || { fail 'Interrupted network baseline found; inspect backup before retrying'; return 1; } + done + if [[ -e /etc/sysctl.d/90-platform-baseline.conf ]]; then + cmp -s <(render_sysctl) /etc/sysctl.d/90-platform-baseline.conf || { fail 'Managed sysctl drift; review before overwrite'; return 1; } + fi + if [[ -e /etc/modules-load.d/platform-bbr.conf ]]; then + [[ $(cat /etc/modules-load.d/platform-bbr.conf) == tcp_bbr ]] || { fail 'Managed module drift'; return 1; } + fi +} +main() { + local action='' name='' release='' failures=0 argument + while (($#)); do + case "$1" in + --help|-h) echo 'Usage: host-baseline.sh --check|--preflight|--apply-network --hostname NAME --release FULL_COMMIT'; return 0;; + --check|--preflight|--apply-network) [[ -z "$action" ]] || return 1; action=$1; shift;; + --hostname|--release) (($# >= 2)) || return 1; case "$1" in --hostname) name=$2;; --release) release=$2;; esac; shift 2;; + *) fail "Unknown argument: $1"; return 1;; + esac + done + [[ "$name" =~ ^(dev|test|stage|prod)-[a-z0-9-]+-[0-9]{2}$ && "$release" =~ ^[a-f0-9]{40}$ ]] || { fail 'Explicit hostname and release required'; return 1; } + [[ $EUID == 0 && -d /run/systemd/system && ! -e /.dockerenv && $(hostname) == "$name" ]] || { fail 'Exact native root host required'; return 1; } + grep -Eq '^ID="?debian"?$' /etc/os-release && grep -Eq '^VERSION_ID="?13"?$' /etc/os-release || { fail 'Debian 13 required'; return 1; } + for argument in jq sysctl sshd tc nft timedatectl flock modinfo modprobe; do command -v "$argument" >/dev/null || { fail "Missing prerequisite: $argument"; return 1; }; done + if [[ "$action" == --check ]]; then audit; return; fi + [[ "$action" == --preflight || "$action" == --apply-network ]] || return 1 + preflight + if [[ "$action" == --preflight ]]; then echo 'Network baseline preflight passed; no configuration changed.'; return; fi + umask 077 + trusted /run/lock/platform-host-baseline.lock + exec 9>>/run/lock/platform-host-baseline.lock; flock -n 9 || { fail 'Baseline maintenance already running'; return 1; } + [[ ! -e /var/lib/platform-bootstrap/ssh/pending && ! -e /run/platform-firewall/pending.nft ]] || { fail 'Resolve pending SSH/firewall maintenance before network tuning'; return 1; } + preflight + if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == bbr && $(sysctl -n net.core.default_qdisc) == fq && + -f /etc/sysctl.d/90-platform-baseline.conf && -f /etc/modules-load.d/platform-bbr.conf ]]; then + jq -cn --arg release "$release" '{network_applied:true,changed:false,release:$release,full_baseline_verified:false,live_qdisc_replaced:false}' + return + fi + local backup old_tcp old_qdisc + trusted /var/backups/platform + install -d -m 0700 /var/backups/platform + backup=$(mktemp -d /var/backups/platform/baseline-XXXXXXXX) + old_tcp=$(sysctl -n net.ipv4.tcp_congestion_control); old_qdisc=$(sysctl -n net.core.default_qdisc) + [[ ! -f /etc/sysctl.d/90-platform-baseline.conf ]] || cp -p /etc/sysctl.d/90-platform-baseline.conf "$backup/sysctl.conf" + [[ ! -f /etc/modules-load.d/platform-bbr.conf ]] || cp -p /etc/modules-load.d/platform-bbr.conf "$backup/modules.conf" + printf '%s\n' "$old_tcp" "$old_qdisc" > "$backup/runtime-before" + # Subshell isolates failures so restoration is attempted even under set -e. + if ( + set -e + modprobe tcp_bbr && modprobe sch_fq && + render_sysctl > "$backup/candidate.conf" && + printf 'tcp_bbr\n' > "$backup/candidate-modules.conf" && + install -m 0644 "$backup/candidate.conf" /etc/sysctl.d/.platform-baseline.pending && + install -m 0644 "$backup/candidate-modules.conf" /etc/modules-load.d/.platform-bbr.pending && + mv -fT /etc/sysctl.d/.platform-baseline.pending /etc/sysctl.d/90-platform-baseline.conf && + mv -fT /etc/modules-load.d/.platform-bbr.pending /etc/modules-load.d/platform-bbr.conf && + sysctl -p /etc/sysctl.d/90-platform-baseline.conf && + [[ $(sysctl -n net.ipv4.tcp_congestion_control) == bbr && $(sysctl -n net.core.default_qdisc) == fq ]] + ); then + jq -cn --arg release "$release" --arg backup "$backup" '{network_applied:true,release:$release,backup:$backup,full_baseline_verified:false,live_qdisc_replaced:false}' + else + rm -f /etc/sysctl.d/.platform-baseline.pending /etc/modules-load.d/.platform-bbr.pending + if [[ -f "$backup/sysctl.conf" ]]; then cp -p "$backup/sysctl.conf" /etc/sysctl.d/90-platform-baseline.conf; else rm -f /etc/sysctl.d/90-platform-baseline.conf; fi + if [[ -f "$backup/modules.conf" ]]; then cp -p "$backup/modules.conf" /etc/modules-load.d/platform-bbr.conf; else rm -f /etc/modules-load.d/platform-bbr.conf; fi + sysctl -w "net.ipv4.tcp_congestion_control=$old_tcp" "net.core.default_qdisc=$old_qdisc" + fail "Network baseline failed; restored previous values. Backup: $backup"; return 1 + fi +} +if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi diff --git a/host-init.sh b/host-init.sh index f2c216e..7cda61c 100644 --- a/host-init.sh +++ b/host-init.sh @@ -5,7 +5,7 @@ export LC_ALL=C fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } ensure_baseline_packages() { local package missing=() - for package in ca-certificates curl jq git openssl openssh-client iproute2 nftables; do + for package in ca-certificates curl jq git openssl openssh-client openssh-server iproute2 nftables procps kmod util-linux; do if [[ $(dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null) != installed ]]; then missing+=("$package") fi @@ -27,10 +27,11 @@ check_existing_binding() { [[ "$bound" == "$expected" ]] || { fail 'Existing bootstrap binding belongs to another hostname'; return 1; } } main() { - local action='' name='' packages='' public_key='' argument + local action='' name='' packages='' public_key='' argument docker_mode=required 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;; + --help|-h) echo 'Usage: bash host-init.sh --check|--apply --hostname NAME [--docker-dir VERIFIED_DEB_DIRECTORY] [--ssh-key-file SHARED_PUBLIC_KEY] [--without-docker]'; return 0;; + --without-docker) docker_mode=none; shift;; --check|--apply) [[ -z "$action" ]] || return 1; action=$1; shift;; --hostname|--docker-dir|--ssh-key-file) (($# >= 2)) || return 1 @@ -46,6 +47,7 @@ main() { 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 + [[ "$docker_mode" != none || -z "$packages" ]] || { fail 'Docker directory conflicts with --without-docker'; return 1; } 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 @@ -60,7 +62,7 @@ main() { debs+=("$packages/$file") done < "$packages/SHA256SUMS" [[ ${#debs[@]} == 5 ]] || { fail 'All five pinned official Docker packages required'; return 1; } - else + elif [[ "$docker_mode" == required ]]; then 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 @@ -81,10 +83,12 @@ main() { 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 + if [[ "$docker_mode" == required ]]; then + systemctl enable --now docker + docker --host unix:///var/run/docker.sock info >/dev/null + docker --host unix:///var/run/docker.sock compose version + fi [[ $(hostname) == "$name" ]] - echo 'Baseline and local Docker checked. Verify a fresh SSH connection; firewall/SSH port and network acceptance are separate tasks.' + echo 'Prerequisites prepared; full host baseline NOT verified. Continue host-baseline.sh and the SSH/firewall transaction; verify a fresh external SSH connection.' } if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi diff --git a/ssh-access.sh b/ssh-access.sh new file mode 100644 index 0000000..7673b8f --- /dev/null +++ b/ssh-access.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Two-phase SSH maintenance. Confirmation must arrive over a new SSH transport. +set -euo pipefail +export LC_ALL=C +fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } +trusted() { + local path=$1 + while [[ "$path" != / ]]; do + [[ ! -L "$path" ]] || { fail "Symlink: $path"; return 1; } + if [[ -e "$path" ]]; then + [[ $(stat -c %u "$path") == 0 ]] && (( (8#$(stat -c %a "$path") & 0022) == 0 )) || { fail "Untrusted path: $path"; return 1; } + fi + path=$(dirname -- "$path") + done +} +validate_effective() { + local config=$1 + [[ $(awk '$1 == "port" {print $2}' <<< "$config") == 61789 ]] && + grep -qx 'passwordauthentication no' <<< "$config" && + grep -qx 'kbdinteractiveauthentication no' <<< "$config" && + grep -qx 'pubkeyauthentication yes' <<< "$config" && + grep -Eq '^permitrootlogin (prohibit-password|without-password)$' <<< "$config" +} +ssh_inputs_digest() { + local main_file=${1:-/etc/ssh/sshd_config} file + # Include the complete accepted input set, so additions/removals and service + # overrides invalidate an old transport confirmation. Never print contents. + { + printf '%s\n' /etc/ssh/sshd_config + sha256sum < "$main_file" || return 1 + for file in /etc/ssh/sshd_config.d/*.conf /etc/default/ssh /root/.ssh/authorized_keys; do + printf '%s\n' "$file" + if [[ -f "$file" && ! -L "$file" ]]; then sha256sum < "$file" || return 1; else printf 'absent-or-nonregular\n'; fi + done + # `show ExecStart` includes runtime PID/timestamps and changes after reboot. + # Hash the unit sources, not the runtime status serialization. + systemctl cat ssh.service || return 1 + } | sha256sum | cut -d' ' -f1 +} +verify_firewall() { + local state=$1 snapshot=$2 digest + [[ ! -e /run/platform-firewall/pending.nft && -f "$state/expected-firewall-sha" && -f /etc/platform-firewall/active.nft ]] || return 1 + digest=$(sha256sum /etc/platform-firewall/active.nft); digest=${digest%% *} + [[ "$digest" == "$(cat "$state/expected-firewall-sha")" ]] || return 1 + systemctl is-active --quiet platform-firewall.service || return 1 + # Compare canonical stateless nft output, not just existence of a table. + [[ -f "$snapshot" ]] || return 1 + local current + current=$(nft -s list table inet platform_host) || return 1 + [[ "$current" == "$(cat "$snapshot")" ]] +} +validate_service() { + local execution environment context argv + execution=$(systemctl show ssh.service --property=ExecStart --value) || return 1 + environment=$(systemctl show ssh.service --property=EnvironmentFiles --value) || return 1 + context=$(systemctl show ssh.service --property=Environment --property=PassEnvironment --property=RootDirectory --property=RootImage --value) || return 1 + [[ -z "${context//[[:space:]]/}" ]] || { fail 'Custom SSH service environment or root requires review'; return 1; } + [[ -z "$environment" || "$environment" == '/etc/default/ssh (ignore_errors=yes)' ]] || { fail 'Unknown SSH EnvironmentFile requires review'; return 1; } + argv=$(sed -n 's/^.*argv\[\]=\([^;]*\);.*$/\1/p' <<< "$execution") + argv="${argv%"${argv##*[![:space:]]}"}" + [[ "$argv" == '/usr/sbin/sshd -D $SSHD_OPTS' || "$argv" == '/usr/sbin/sshd -D' ]] || { fail 'Custom SSH service command requires review'; return 1; } +} +main() { + local action='' name='' release='' expected='' key='' firewall_sha='' arg state=/var/lib/platform-bootstrap/ssh + while (($#)); do + case "$1" in + --help|-h) echo 'Usage: ssh-access.sh --stage --hostname NAME --release FULL_COMMIT --key-sha256 SHA --firewall-sha256 SHA | --confirm --sha256 TRANSACTION_SHA | --check'; return 0;; + --stage|--confirm|--check) [[ -z "$action" ]] || return 1; action=$1; shift;; + --hostname|--release|--sha256|--key-sha256|--firewall-sha256) + (($# >= 2)) || return 1 + case "$1" in --hostname) name=$2;; --release) release=$2;; --sha256) expected=$2;; --key-sha256) key=$2;; --firewall-sha256) firewall_sha=$2;; esac; shift 2;; + *) fail "Unknown argument: $1"; return 1;; + esac + done + [[ $EUID == 0 && -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Native root systemd host required'; return 1; } + for arg in sshd jq nft flock systemctl systemd-run sha256sum ss; do command -v "$arg" >/dev/null || return 1; done + if [[ "$action" == --check ]]; then + validate_effective "$(sshd -T)" && [[ -f "$state/confirmed.json" && ! -e "$state/pending" ]] && + jq -e --arg host "$(hostname)" --arg inputs "$(cat "$state/expected-ssh-inputs")" --arg firewall "$(cat "$state/expected-firewall-sha")" \ + '.hostname == $host and .port == 61789 and .ssh_inputs_sha256 == $inputs and .firewall_sha256 == $firewall and (.transaction | type == "string" and test("^[a-f0-9]{64}$"))' "$state/confirmed.json" >/dev/null && + cmp -s /etc/ssh/sshd_config "$state/candidate" && + [[ $(ssh_inputs_digest) == "$(cat "$state/expected-ssh-inputs")" ]] && + verify_firewall "$state" "$state/confirmed-firewall.nft" && systemctl is-active --quiet ssh.service && + ss -H -lnt | awk '{print $4}' | grep -Eq ':61789$'; return + fi + trusted "$state"; trusted /etc/ssh/sshd_config; trusted /etc/platform-firewall/active.nft + umask 077 + install -d -m 0700 "$state" + trusted "$state/lock" + exec 8>>"$state/lock"; flock -n 8 || { fail 'SSH maintenance already running'; return 1; } + if [[ "$action" == --confirm ]]; then + [[ "$expected" =~ ^[a-f0-9]{64}$ && -f "$state/pending" && $(cat "$state/pending") == "$expected" ]] || { fail 'Exact pending transaction required'; return 1; } + local client client_port local_ip local_port extra + read -r client client_port local_ip local_port extra <<< "${SSH_CONNECTION:-}" + [[ -n "$client" && "$local_port" == 61789 && -z "$extra" && "${SSH_CONNECTION:-}" != "$(cat "$state/original-ssh")" ]] || { fail 'Confirm over a fresh SSH connection to 61789'; return 1; } + [[ ! -e /run/platform-firewall/pending.nft ]] && systemctl is-active --quiet platform-firewall.service || { fail 'Confirm firewall first using this fresh SSH transport'; return 1; } + install -d -m 0700 /run/platform-firewall + exec 7>>/run/platform-firewall/lock; flock -n 7 || { fail 'Firewall maintenance in progress'; return 1; } + [[ ! -e /run/platform-firewall/pending.nft ]] || { fail 'Concurrent firewall maintenance'; return 1; } + verify_firewall "$state" /run/platform-firewall/applied.nft || { fail 'Confirmed firewall differs from this SSH transaction or has rolled back'; return 1; } + cmp -s /etc/ssh/sshd_config "$state/candidate" || { fail 'SSH configuration changed while pending'; return 1; } + [[ $(ssh_inputs_digest) == "$(cat "$state/expected-ssh-inputs")" ]] || { fail 'SSH inputs changed while pending'; return 1; } + validate_effective "$(sshd -T -C "user=root,host=$client,addr=$client,laddr=$local_ip,lport=61789")" || { fail 'Effective SSH configuration differs'; return 1; } + install -m 0600 /run/platform-firewall/applied.nft "$state/confirmed-firewall.nft" + jq -n --arg transaction "$expected" --arg host "$(hostname)" --arg at "$(date -u +%FT%TZ)" --arg inputs "$(cat "$state/expected-ssh-inputs")" --arg firewall "$(cat "$state/expected-firewall-sha")" '{hostname:$host,transaction:$transaction,confirmed_at:$at,port:61789,ssh_inputs_sha256:$inputs,firewall_sha256:$firewall}' > "$state/confirmed.json.tmp" + mv -f "$state/confirmed.json.tmp" "$state/confirmed.json" + rm -f "$state/pending" + # The pending marker is the commit point. Recovery remains installed as an + # inert boot dependency, and a late timer now exits without modifying state. + systemctl stop platform-ssh-rollback.timer || echo 'WARNING: timer cleanup failed; confirmed transaction will not roll back' >&2 + echo 'SSH and firewall transaction confirmed over new transport.'; return + fi + [[ "$action" == --stage && "$name" == "$(hostname)" && "$release" =~ ^[a-f0-9]{40}$ && "$key" =~ ^[a-f0-9]{64}$ && "$firewall_sha" =~ ^[a-f0-9]{64}$ ]] || { fail 'Exact hostname, release, authorized_keys and target firewall SHA256 required'; return 1; } + grep -Eq '^ID="?debian"?$' /etc/os-release && grep -Eq '^VERSION_ID="?13"?$' /etc/os-release || return 1 + [[ ! -e "$state/pending" && ! -e /run/platform-firewall/pending.nft ]] || { fail 'Resolve pending maintenance first'; return 1; } + if [[ -f "$state/confirmed.json" ]] && cmp -s /etc/ssh/sshd_config "$state/candidate" && validate_effective "$(sshd -T)"; then + fail 'SSH is already confirmed; use --check or a separately reviewed update, not first migration'; return 1 + fi + systemctl is-active --quiet platform-firewall.service && [[ -f /etc/platform-firewall/active.nft ]] || { fail 'Confirmed managed firewall required before SSH migration'; return 1; } + systemctl is-active --quiet ssh.service || { fail 'Running SSH service required before migration'; return 1; } + if systemctl is-active --quiet ssh.socket || systemctl is-enabled --quiet ssh.socket; then fail 'Socket-activated SSH requires separate migration'; return 1; fi + validate_service + if [[ -f /etc/default/ssh ]] && grep -Eq '^[[:space:]]*SSHD_OPTS=' /etc/default/ssh && + grep -E '^[[:space:]]*SSHD_OPTS=' /etc/default/ssh | grep -Evq '^[[:space:]]*SSHD_OPTS=(""|'"''"'|)[[:space:]]*(#.*)?$'; then + fail 'Custom SSHD_OPTS requires separate migration'; return 1 + fi + trusted /root/.ssh/authorized_keys + [[ -f /root/.ssh/authorized_keys && $(stat -c %a /root/.ssh/authorized_keys) == 600 ]] || { fail 'Root authorized_keys must be 0600'; return 1; } + [[ $(sha256sum /root/.ssh/authorized_keys | cut -d' ' -f1) == "$key" ]] || { fail 'Authorized keys differ from externally verified snapshot'; return 1; } + # Preserve all original settings. Explicit Port and Match need reviewed migration, + # not a regex rewrite of an unknown sshd configuration. + local file + for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do + [[ -e "$file" ]] || continue + trusted "$file" + if grep -Eiq '^[[:space:]]*(Match|Port|ListenAddress)[[:space:]]' "$file"; then + fail "Explicit Port/ListenAddress/Match requires reviewed migration: $file"; return 1 + fi + local includes + includes=$(awk 'tolower($1) == "include" {print tolower($0)}' "$file") + if [[ -n "$includes" ]] && { [[ "$file" != /etc/ssh/sshd_config ]] || + grep -Evq '^[[:space:]]*include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf[[:space:]]*(#.*)?$' <<< "$includes"; }; then + fail "Unknown SSH Include requires reviewed migration: $file"; return 1 + fi + done + sshd -t + trusted /etc/systemd/system/platform-ssh-recover.service + install -d -m 0700 /run/platform-firewall + exec 7>>/run/platform-firewall/lock; flock -n 7 || { fail 'Firewall maintenance in progress'; return 1; } + [[ ! -e /run/platform-firewall/pending.nft ]] || { fail 'Concurrent firewall maintenance'; return 1; } + local backup + trusted /var/backups/platform; install -d -m 0700 /var/backups/platform + backup=$(mktemp -d /var/backups/platform/ssh-XXXXXXXX) + cp -p /etc/ssh/sshd_config "$backup/sshd_config" + cp -p /etc/platform-firewall/active.nft "$backup/active.nft" + printf 'add table inet platform_host\ndelete table inet platform_host\n' > "$backup/live.nft" + nft -s list table inet platform_host >> "$backup/live.nft" + cp -p "$backup/sshd_config" "$state/previous-sshd" + cp -p "$backup/active.nft" "$state/previous-firewall" + cp -p "$backup/live.nft" "$state/previous-live" + { + printf '%s\n' '# Managed SSH baseline v1' 'Port 61789' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' 'PubkeyAuthentication yes' 'PermitRootLogin prohibit-password' + cat "$state/previous-sshd" + } > "$state/candidate" + sshd -t -f "$state/candidate" + validate_effective "$(sshd -T -f "$state/candidate")" || { fail 'Candidate does not enforce the required effective settings'; return 1; } + printf '%s\n' "$firewall_sha" > "$state/expected-firewall-sha" + cat > "$state/rollback.sh" <<'ROLLBACK' +#!/usr/bin/env bash +set -euo pipefail +state=/var/lib/platform-bootstrap/ssh +# The boot dependency must be inert during this transaction's current boot: +# firewall confirmation restarts its service before SSH is committed. +if [[ ${1:-} == --boot && $(cat /proc/sys/kernel/random/boot_id) == "$(cat "$state/boot-id")" ]]; then exit 0; fi +exec 8>>"$state/lock"; flock 8 +test -f "$state/pending" || exit 0 +exec 9>>/run/lock/platform-ssh-firewall-recover.lock; flock 9 +install -d -m 0700 /run/platform-firewall +exec 7>>/run/platform-firewall/lock; flock 7 +# Cancel the firewall's later rollback before restoring this joint snapshot. +systemctl stop platform-firewall-rollback.timer || true +rm -f /run/platform-firewall/pending.nft +failed=0 +cp -p "$state/previous-firewall" /etc/platform-firewall/active.nft || failed=1 +nft -f "$state/previous-live" || failed=1 +# A firewall failure must not skip restoring the previous SSH configuration. +if cp -p "$state/previous-sshd" /etc/ssh/sshd_config && sshd -t; then + if systemctl is-active --quiet ssh.service; then systemctl reload ssh.service || failed=1; + elif [[ ${1:-} != --boot ]]; then systemctl start ssh.service || failed=1; fi +else failed=1; fi +rm -f /etc/ssh/.platform-sshd.pending +[[ "$failed" == 0 ]] || exit 1 +rm -f "$state/pending" +rm -f "$state/confirmed.json" +ROLLBACK + chmod 0700 "$state/rollback.sh" + cat > /etc/systemd/system/platform-ssh-recover.service <<'UNIT' +[Unit] +Description=Recover unconfirmed platform SSH maintenance after reboot +DefaultDependencies=no +After=local-fs.target +Before=ssh.service platform-firewall.service +ConditionPathExists=/var/lib/platform-bootstrap/ssh/pending +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/bash /var/lib/platform-bootstrap/ssh/rollback.sh --boot +[Install] +WantedBy=multi-user.target +UNIT + # Before= only orders jobs; Requires= also blocks startup if recovery fails. + for arg in ssh platform-firewall; do + trusted "/etc/systemd/system/$arg.service.d/30-platform-ssh-recover.conf" + install -d -m 0755 "/etc/systemd/system/$arg.service.d" + cat > "/etc/systemd/system/$arg.service.d/30-platform-ssh-recover.conf" <<'UNIT' +[Unit] +Requires=platform-ssh-recover.service +After=platform-ssh-recover.service +UNIT + done + systemctl daemon-reload + systemctl enable platform-ssh-recover.service >/dev/null + [[ $(sha256sum /root/.ssh/authorized_keys | cut -d' ' -f1) == "$key" ]] || { fail 'Authorized keys changed during staging'; return 1; } + ssh_inputs_digest "$state/candidate" > "$state/expected-ssh-inputs" + # Bind receipt to candidate, release and a fresh nonce, not just identical config bytes. + expected=$( { cat "$state/expected-ssh-inputs"; printf '%s\n' "$release" "$backup" "$firewall_sha"; } | sha256sum | cut -d' ' -f1) + cat /proc/sys/kernel/random/boot_id > "$state/boot-id" + printf '%s\n' "$expected" > "$state/pending" + printf '%s\n' "${SSH_CONNECTION:-}" > "$state/original-ssh" + systemctl stop platform-ssh-rollback.timer platform-ssh-rollback.service 2>/dev/null || true + systemctl reset-failed platform-ssh-rollback.service 2>/dev/null || true + if ! systemd-run --quiet --collect --unit=platform-ssh-rollback --on-active=240s /bin/bash "$state/rollback.sh"; then + rm -f "$state/pending"; fail 'Rollback timer could not be armed; SSH unchanged'; return 1 + fi + trusted /etc/ssh/.platform-sshd.pending + [[ ! -e /etc/ssh/.platform-sshd.pending ]] || { fail 'Interrupted SSH candidate exists; wait for recovery'; return 1; } + install -m 0600 "$state/candidate" /etc/ssh/.platform-sshd.pending + mv -fT /etc/ssh/.platform-sshd.pending /etc/ssh/sshd_config + sshd -t + systemctl reload ssh.service + jq -cn --arg transaction "$expected" --arg backup "$backup" '{ssh_pending:true,transaction:$transaction,backup:$backup,port:61789,rollback_seconds:240}' +} +if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi