Files
bootstrap-public/host-baseline.sh

185 lines
11 KiB
Bash

#!/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/platform-host-baseline.lock
exec 9>>/run/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