Files
srv-b-host/ct-baseline.sh
T
2026-08-18 10:31:57 -04:00

231 lines
9.5 KiB
Bash

#!/usr/bin/env bash
#
# ct-baseline.sh — assert every container on this host conforms to the standard.
#
# Run it any time. It changes nothing. It exits 0 when all containers conform
# and 1 when any does not, so it works as a gate as well as a report.
#
# ./ct-baseline.sh all running containers
# ./ct-baseline.sh 100 102 named containers only
#
# WHY THIS EXISTS
# ---------------
# Divergence between containers was being discovered by asking, one property at
# a time, whenever something behaved oddly. That does not scale past two
# containers and it never terminates: every check finds a new difference
# because nothing states what "the same" means.
#
# This file is that statement. A property not checked here is not part of the
# standard, and a container may differ in it freely. A property that should be
# uniform belongs here, not in someone's memory.
#
# WHAT THE STANDARD IS, AND WHY
# -----------------------------
# Containers on this host do not send mail. Only srv-b does, and only its own
# alerts, through the relay. A container with a mail agent installed but SMTP
# egress blocked is the worst case: it queues forever and delivers nothing.
# See F-025.
#
# Every check that asserts a negative first establishes that it could have
# observed the positive case. See F-027 — a test whose failure mode is
# indistinguishable from success manufactures confidence.
set -uo pipefail
RELAY_HOST="10.110.0.1"
RELAY_PORT="25"
EGRESS_URL="https://deb.debian.org/"
BASTION_KEY="/root/.ssh/id_rsa.pub"
pass=0; fail=0; skip=0
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
note() { printf ' ---- %s\n' "$1"; skip=$((skip+1)); }
head_() { printf '\n\033[1m=== CT %s (%s) ===\033[0m\n' "$1" "$2"; }
# --------------------------------------------------------------------------
# Host-level checks. Run once, not per container.
# --------------------------------------------------------------------------
check_host() {
printf '\n\033[1m=== host: %s ===\033[0m\n' "$(hostname)"
[[ "$(systemctl is-system-running 2>/dev/null)" == "running" ]] \
&& ok "systemd running, zero failed units" \
|| bad "systemd is $(systemctl is-system-running 2>/dev/null) — investigate before trusting anything below"
# The SMTP egress rule is what makes the container checks meaningful.
if iptables -S FORWARD 2>/dev/null | grep -q 'multiport --dports 25,465,587.*DROP'; then
ok "SMTP egress DROP present in FORWARD"
else
bad "SMTP egress DROP absent — container SMTP checks below prove nothing"
fi
if iptables -S FORWARD 2>/dev/null | grep -q '10.20.0.0/24 -d 10.0.0.0/24 -j DROP'; then
ok "LAN isolation DROP present"
else
bad "LAN isolation DROP absent"
fi
# Disk monitoring is a host concern. Containers need nothing.
local devs
devs=$(journalctl -u smartmontools -b --no-pager 2>/dev/null \
| grep -oE 'Monitoring [0-9]+ ATA/SATA, [0-9]+ SCSI/SAS' | tail -1)
if [[ -n "$devs" && ! "$devs" =~ 0\ ATA/SATA,\ 0\ SCSI ]]; then
ok "smartd: $devs"
else
bad "smartd monitoring zero devices — active is not the same as monitoring"
fi
[[ -f "$BASTION_KEY" ]] \
&& ok "bastion key present at $BASTION_KEY" \
|| bad "bastion key missing — container admin access cannot be verified"
}
# --------------------------------------------------------------------------
# Per-container checks.
# --------------------------------------------------------------------------
check_ct() {
local c="$1"
local name state
state=$(pct status "$c" 2>/dev/null | awk '{print $2}')
name=$(pct config "$c" 2>/dev/null | awk -F': ' '/^hostname/{print $2}')
head_ "$c" "${name:-unknown}"
if [[ "$state" != "running" ]]; then
bad "not running (state: ${state:-absent}) — every check below would be invalid"
return
fi
local cfg; cfg=$(pct config "$c")
# --- Proxmox configuration -------------------------------------------
grep -q '^onboot: 1' <<<"$cfg" \
&& ok "onboot: 1" \
|| bad "onboot not set — will not start with the host (F-026)"
grep -q '^unprivileged: 1' <<<"$cfg" \
&& ok "unprivileged" \
|| bad "privileged container"
# nesting is required for Debian 12 systemd in an unprivileged CT (F-003).
# keyctl is only needed where Docker runs, so it is reported, not required.
grep -qE '^features:.*nesting=1' <<<"$cfg" \
&& ok "nesting=1" \
|| bad "nesting=1 absent — Debian 12 systemd will fail 226/NAMESPACE (F-003)"
grep -qE '^features:.*keyctl=1' <<<"$cfg" \
&& note "keyctl=1 present (needed only where Docker runs)" \
|| note "keyctl=1 absent (correct unless this container runs Docker)"
grep -qE '^net0:.*bridge=vmbr1' <<<"$cfg" \
&& ok "on the service bridge only" \
|| bad "not on vmbr1, or has an additional interface (F-017)"
grep -qE '^net[1-9]:' <<<"$cfg" \
&& bad "has more than one interface — F-017 put containers on the LAN this way" \
|| ok "single interface"
# --- Guest health -----------------------------------------------------
local sysrun
sysrun=$(pct exec "$c" -- systemctl is-system-running 2>/dev/null)
[[ "$sysrun" == "running" ]] \
&& ok "systemd running, zero failed units" \
|| bad "systemd is ${sysrun:-unknown} — see F-021 for the usual cause"
# A stanza for an interface that no longer exists fails networking.service
# at boot while connectivity looks perfect (F-021).
local stanzas
stanzas=$(pct exec "$c" -- grep -c '^auto eth' /etc/network/interfaces 2>/dev/null)
[[ "$stanzas" == "1" ]] \
&& ok "one interface stanza" \
|| bad "$stanzas interface stanzas — stale entry will fail boot (F-021)"
# --- Base -------------------------------------------------------------
local deb; deb=$(pct exec "$c" -- cat /etc/debian_version 2>/dev/null)
[[ "$deb" == 12.* ]] && ok "Debian $deb" || bad "Debian ${deb:-unknown}, expected 12.x"
local tz; tz=$(pct exec "$c" -- timedatectl show -p Timezone --value 2>/dev/null)
[[ "$tz" == "America/Chicago" ]] && ok "timezone $tz" || bad "timezone ${tz:-unset}"
# Setting LANG does not generate a locale. F-004.
pct exec "$c" -- bash -c 'locale -a 2>/dev/null | grep -qi "^en_US.utf8$"' \
&& ok "en_US.UTF-8 generated" \
|| bad "locale configured but not generated (F-004)"
# --- Required tooling -------------------------------------------------
# F-015: do not assume a package installed in one container exists in another.
for pkg in curl ca-certificates openssh-server; do
pct exec "$c" -- dpkg -s "$pkg" >/dev/null 2>&1 \
&& ok "$pkg installed" \
|| bad "$pkg absent (F-015)"
done
[[ "$(pct exec "$c" -- systemctl is-active ssh 2>/dev/null)" == "active" ]] \
&& ok "sshd active" \
|| bad "sshd not active"
# --- Admin access -----------------------------------------------------
if pct exec "$c" -- id sandor >/dev/null 2>&1; then
ok "admin account present"
local mode
mode=$(pct exec "$c" -- stat -c '%U:%G %a' /home/sandor/.ssh/authorized_keys 2>/dev/null)
[[ "$mode" == "sandor:sandor 600" ]] \
&& ok "authorized_keys $mode" \
|| bad "authorized_keys ${mode:-missing}, expected sandor:sandor 600"
else
bad "admin account absent"
fi
# --- Mail: the standard is that containers do not send it -------------
if pct exec "$c" -- bash -c 'dpkg -l 2>/dev/null | grep -qE "^ii +(postfix|exim4|msmtp|nullmailer)"'; then
bad "a mail agent is installed — with SMTP blocked it queues forever and delivers nothing (F-025)"
else
ok "no mail agent"
fi
# F-027: prove the connection could have succeeded before calling it blocked.
if pct exec "$c" -- timeout 5 bash -c "cat < /dev/null > /dev/tcp/${RELAY_HOST}/${RELAY_PORT}" 2>/dev/null; then
bad "SMTP to ${RELAY_HOST}:${RELAY_PORT} REACHABLE — the egress block is not effective"
else
# Distinguish "blocked" from "the container cannot reach anything".
if pct exec "$c" -- timeout 8 curl -sS -o /dev/null "$EGRESS_URL" 2>/dev/null; then
ok "SMTP blocked, internet reachable"
else
bad "SMTP unreachable AND internet unreachable — this is a network fault, not a working block"
fi
fi
# --- Isolation --------------------------------------------------------
if pct exec "$c" -- timeout 5 ping -c1 -W2 10.0.0.1 >/dev/null 2>&1; then
bad "LAN gateway REACHABLE — isolation is not effective (F-018)"
else
ok "LAN gateway unreachable"
fi
}
# --------------------------------------------------------------------------
main() {
local targets=("$@")
if [[ ${#targets[@]} -eq 0 ]]; then
mapfile -t targets < <(pct list | awk 'NR>1 {print $1}')
fi
check_host
for c in "${targets[@]}"; do check_ct "$c"; done
printf '\n\033[1m=== summary ===\033[0m\n'
printf ' %d passed, %d failed, %d informational\n' "$pass" "$fail" "$skip"
if [[ $fail -eq 0 ]]; then
printf ' \033[32mAll checked containers conform to the baseline.\033[0m\n'
return 0
fi
printf ' \033[31mDivergence found. Each FAIL names the property and, where\n'
printf ' applicable, the failure that established the requirement.\033[0m\n'
return 1
}
main "$@"