#!/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 "$@"