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

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