From cf1b5e47758ed3d45aee1fad55414e5fc85e1823 Mon Sep 17 00:00:00 2001 From: TheRON Date: Tue, 18 Aug 2026 10:31:57 -0400 Subject: [PATCH] Initial push --- ct-baseline.sh | 230 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 ct-baseline.sh diff --git a/ct-baseline.sh b/ct-baseline.sh new file mode 100644 index 0000000..c44a7ef --- /dev/null +++ b/ct-baseline.sh @@ -0,0 +1,230 @@ +#!/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 "$@"