#!/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