244 lines
15 KiB
Bash
244 lines
15 KiB
Bash
#!/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
|