feat(host): add guarded Debian baseline and SSH transactions

This commit is contained in:
kaka
2026-11-04 16:55:00 +08:00
parent 62ef4b458c
commit 873933af35
6 changed files with 471 additions and 12 deletions

View File

@@ -9,5 +9,6 @@
- Caddy 发布由 platform/control/publish.sh 从完整 Git 提交选取白名单文件,生成摘要并切换 current。 - Caddy 发布由 platform/control/publish.sh 从完整 Git 提交选取白名单文件,生成摘要并切换 current。
- 保留固定提交 URL,日常可用稳定 /install.sh 入口。没有真实域名时只给参数示例。 - 保留固定提交 URL,日常可用稳定 /install.sh 入口。没有真实域名时只给参数示例。
- 修改脚本运行 Bash 语法和相关隔离测试,真机验证独立记录。 - 修改脚本运行 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 提交提取源码与资源。 - 固定资源由 artifacts.lock 登记并存入 vendor/,与源码共同提交;目标机仅从显式 Caddy --assets-url 下载,不回退上游。版本变更同时修改安装器固定版本/摘要并检查;发布从同一 Git 提交提取源码与资源。

View File

@@ -31,6 +31,18 @@ Docker、Headscale、公钥注入和安全配置后续由 Komodo 任务处理。
`host-init.sh` 先检查基础包安装状态,仅在缺包时更新 APT 索引并补齐缺失包;已有完整基线的重跑不会无条件访问软件源。Docker 固定包仍须在执行前准备,构建与资源导入单独计时。 `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 接入 ## Headscale 接入
`tailscale.sh` 使用显式版本、已准备的本地 DEB、SHA-256、HTTPS 控制端和节点名称。首次接入还需 root 所有、0600 权限的短期单次授权 key 文件;执行后清除临时 key 文件。既有 Running 节点直接复核身份,不需要新 key。 `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 固定版本入口;生产接入命令固定使用后者。 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 或第二份安装源码。 源代码与修复就在此仓库维护,无生成器、内嵌 Python 或第二份安装源码。
## 固定资源 ## 固定资源

View File

@@ -123,13 +123,26 @@ UNIT
systemctl enable platform-firewall.service >/dev/null systemctl enable platform-firewall.service >/dev/null
systemctl restart platform-firewall.service 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() { 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 while (($#)); do
case "$1" in 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;; --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;; *) fail "Unknown argument: $1"; return 1;;
esac esac
done done
@@ -144,6 +157,7 @@ main() {
[[ "$expected" =~ ^[a-f0-9]{64}$ && -f "$state/pending.nft" ]] || { fail 'Exact pending policy digest required'; return 1; } [[ "$expected" =~ ^[a-f0-9]{64}$ && -f "$state/pending.nft" ]] || { fail 'Exact pending policy digest required'; return 1; }
digest=$(sha256sum "$state/pending.nft"); digest=${digest%% *} digest=$(sha256sum "$state/pending.nft"); digest=${digest%% *}
[[ "$expected" == "$digest" ]] || { fail 'Pending policy differs'; return 1; } [[ "$expected" == "$digest" ]] || { fail 'Pending policy differs'; return 1; }
check_ssh_transaction "$ssh_transaction" "$digest"
connection=${SSH_CONNECTION:-} 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; } [[ -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" nft -s list table inet platform_host > "$state/current.nft"
@@ -172,6 +186,7 @@ main() {
nft --check --file "$task/rules.nft" nft --check --file "$task/rules.nft"
digest=$(sha256sum "$task/rules.nft"); digest=${digest%% *} digest=$(sha256sum "$task/rules.nft"); digest=${digest%% *}
if [[ "$action" == --check ]]; then echo "Firewall policy checked: $digest"; return; fi 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; } [[ ! -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. # 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" printf 'add table inet platform_host\ndelete table inet platform_host\n' > "$task/rollback.nft"

184
host-baseline.sh Normal file
View File

@@ -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

View File

@@ -5,7 +5,7 @@ export LC_ALL=C
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
ensure_baseline_packages() { ensure_baseline_packages() {
local package missing=() 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 if [[ $(dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null) != installed ]]; then
missing+=("$package") missing+=("$package")
fi fi
@@ -27,10 +27,11 @@ check_existing_binding() {
[[ "$bound" == "$expected" ]] || { fail 'Existing bootstrap binding belongs to another hostname'; return 1; } [[ "$bound" == "$expected" ]] || { fail 'Existing bootstrap binding belongs to another hostname'; return 1; }
} }
main() { main() {
local action='' name='' packages='' public_key='' argument local action='' name='' packages='' public_key='' argument docker_mode=required
while (($#)); do while (($#)); do
case "$1" in 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;; --check|--apply) [[ -z "$action" ]] || return 1; action=$1; shift;;
--hostname|--docker-dir|--ssh-key-file) --hostname|--docker-dir|--ssh-key-file)
(($# >= 2)) || return 1 (($# >= 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; } 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" check_existing_binding /var/lib/platform-bootstrap "$name"
local debs=() file package version architecture installed seen=' ' sha extra 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 if [[ -n "$packages" ]]; then
[[ "$packages" == /* && -d "$packages" && ! -L "$packages" && -f "$packages/SHA256SUMS" && ! -L "$packages/SHA256SUMS" ]] || { fail 'Prepared package directory and manifest required'; return 1; } [[ "$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 while read -r sha file extra; do
@@ -60,7 +62,7 @@ main() {
debs+=("$packages/$file") debs+=("$packages/$file")
done < "$packages/SHA256SUMS" done < "$packages/SHA256SUMS"
[[ ${#debs[@]} == 5 ]] || { fail 'All five pinned official Docker packages required'; return 1; } [[ ${#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; } 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 fi
if [[ -n "$public_key" ]]; then if [[ -n "$public_key" ]]; then
@@ -81,10 +83,12 @@ main() {
touch /root/.ssh/authorized_keys; chmod 0600 /root/.ssh/authorized_keys 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 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 fi
systemctl enable --now docker if [[ "$docker_mode" == required ]]; then
docker --host unix:///var/run/docker.sock info >/dev/null systemctl enable --now docker
docker --host unix:///var/run/docker.sock compose version docker --host unix:///var/run/docker.sock info >/dev/null
docker --host unix:///var/run/docker.sock compose version
fi
[[ $(hostname) == "$name" ]] [[ $(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 if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi

243
ssh-access.sh Normal file
View File

@@ -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