Files
docs-alpha.jayfield.org/scripts/alpha/sanity-check.sh
T
jensandClaude Sonnet 5 e9b51dbffe Check in scripts/configs, organized and documented by host OS
Every script/config built this session (sanity-check, attacker-check,
the Gitea backup pipeline on both ends) now lives in this repo as
reference copies, not just described in prose - previously they only
existed on the live servers.

Split into scripts/alpha/ (Ubuntu 24.04.4 LTS) and
scripts/clients/vlda-01/ (Unraid 7.3.2), each with its own README
stating the exact OS/kernel and a file-by-file map to deployed paths,
since a script written for one host's conventions doesn't just work
unchanged on the other - the vlda-01 README in particular documents
the persistence gotchas that actually broke earlier attempts (RAM-
backed root filesystem, VFAT /boot with no execute bit, Unassigned
Device auto-mount). Root README.md now links directly to these files
from each relevant section instead of only describing them.

Explicitly not included: the dedicated SSH private key vlda-01 uses to
authenticate to alpha - noted in clients/vlda-01/README.md to
regenerate rather than ever commit one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 17:21:05 +02:00

215 lines
7.9 KiB
Bash

#!/bin/bash
# alpha.jayfield.org post-change sanity check.
# Run manually after any significant change (apt upgrade, config change);
# runs automatically once per boot via sanity-check.service (covers OS
# upgrades, kernel updates, and anything else that ends in a reboot).
set -u
LOGDIR=/var/log/sanity-check
mkdir -p "$LOGDIR"
LOG="$LOGDIR/$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG") 2>&1
PASS=0
FAIL=0
WARN=0
ok() { printf ' OK %s\n' "$1"; PASS=$((PASS+1)); }
bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL+1)); }
warn() { printf ' WARN %s\n' "$1"; WARN=$((WARN+1)); }
section() { printf '\n== %s ==\n' "$1"; }
echo "alpha.jayfield.org sanity check - $(date -Is)"
. /etc/os-release
echo "OS: $PRETTY_NAME, kernel $(uname -r), uptime $(uptime -p)"
# ---------- System package consistency ----------
section "System package consistency"
if [ -z "$(sudo dpkg --audit 2>&1)" ]; then
ok "dpkg --audit clean"
else
bad "dpkg --audit found issues"
fi
if sudo apt-get check >/dev/null 2>&1; then
ok "apt-get check clean"
else
bad "apt-get check reported broken dependencies"
fi
UPGRADABLE=$(apt list --upgradable 2>/dev/null | grep -vc '^Listing')
if [ "$UPGRADABLE" -eq 0 ]; then
ok "no pending package upgrades"
else
warn "$UPGRADABLE package(s) still upgradable"
fi
NEEDRESTART_SVC=$(sudo needrestart -b 2>/dev/null | grep -c '^NEEDRESTART-SVC')
if [ "$NEEDRESTART_SVC" -le 1 ]; then
ok "no outstanding service restarts needed (allowing the harmless per-login user@ unit)"
else
warn "$NEEDRESTART_SVC services still flagged by needrestart"
fi
# ---------- Disk space ----------
section "Disk space"
df -h / /var /boot
ROOT_PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$ROOT_PCT" -lt 90 ]; then
ok "root filesystem usage ${ROOT_PCT}%"
else
warn "root filesystem usage ${ROOT_PCT}% - getting full"
fi
# ---------- SSH ----------
section "SSH hardening"
SSHD_T=$(sudo sshd -T 2>/dev/null)
echo "$SSHD_T" | grep -q '^permitrootlogin no' && ok "PermitRootLogin no" || bad "PermitRootLogin not disabled"
echo "$SSHD_T" | grep -q '^passwordauthentication no' && ok "PasswordAuthentication no" || bad "PasswordAuthentication not disabled"
systemctl is-active --quiet ssh && ok "ssh.service active" || bad "ssh.service not active"
# ---------- DNS / BIND ----------
section "DNS / BIND"
systemctl is-active --quiet named && ok "named active" || bad "named not active"
if [ "$(dig @127.0.0.1 jayfield.org +short)" = "89.58.8.149" ]; then
ok "jayfield.org resolves to the expected IP"
else
bad "jayfield.org did not resolve to the expected IP"
fi
DNSSEC_STATUS=$(dig @127.0.0.1 github.com +noall +comment 2>/dev/null | grep -oP 'status: \K[A-Z]+')
if [ "$DNSSEC_STATUS" = "NOERROR" ]; then
ok "DNSSEC validation working (github.com -> NOERROR)"
else
bad "DNSSEC validation broken (github.com -> ${DNSSEC_STATUS:-no response})"
fi
for h in uschi vpn; do
IP=$(dig @127.0.0.1 "$h.dyndns.jayfield.org" A +short)
if [ -n "$IP" ]; then
ok "$h.dyndns.jayfield.org resolves ($IP)"
else
warn "$h.dyndns.jayfield.org has no A record"
fi
done
# ---------- Mail stack ----------
section "Mail stack"
systemctl is-active --quiet postfix@- && ok "postfix active" || bad "postfix not active"
systemctl is-active --quiet dovecot && ok "dovecot active" || bad "dovecot not active"
systemctl is-active --quiet rspamd && ok "rspamd active" || bad "rspamd not active"
SMTP_BANNER=$(printf 'QUIT\r\n' | timeout 5 nc 127.0.0.1 25 2>/dev/null | head -1)
echo "$SMTP_BANNER" | grep -q '220.*Postfix' && ok "SMTP banner OK" || bad "SMTP banner missing/unexpected: $SMTP_BANNER"
IMAP_BANNER=$(printf 'a LOGOUT\r\n' | timeout 5 nc 127.0.0.1 143 2>/dev/null | head -1)
echo "$IMAP_BANNER" | grep -qi 'dovecot ready\|^\* OK' && ok "IMAP banner OK" || bad "IMAP banner missing/unexpected: $IMAP_BANNER"
for port in 11332 11333 11334; do
if sudo ss -ltn | grep -q ":$port "; then
ok "rspamd listening on $port"
else
bad "rspamd NOT listening on $port"
fi
done
RSPAMD_TEST=$(printf 'Subject: sanity test\nFrom: test@example.com\nTo: jens@jayfield.org\n\nbody\n' | rspamc symbols 2>&1)
if echo "$RSPAMD_TEST" | grep -q 'Action:'; then
ok "rspamd genuinely scanning (rspamc symbols responded)"
else
bad "rspamd did not respond to a live scan test - check for a phantom 'active' status"
fi
if sudo ss -ltn | grep -q ':11332 '; then
ok "postfix's configured milter target (11332) is listening"
else
bad "nothing listening on postfix's milter port - mail is flowing unfiltered/unsigned"
fi
# ---------- MariaDB / vmail ----------
section "MariaDB / vmail"
systemctl is-active --quiet mariadb && ok "mariadb active" || bad "mariadb not active"
DOMAINS=$(sudo mysql -N -e "SELECT COUNT(*) FROM vmail.domains;" 2>/dev/null)
ACCOUNTS=$(sudo mysql -N -e "SELECT COUNT(*) FROM vmail.accounts;" 2>/dev/null)
[ -n "$DOMAINS" ] && [ "$DOMAINS" -ge 1 ] && ok "vmail.domains has $DOMAINS row(s)" || bad "vmail.domains query failed or returned zero rows"
[ -n "$ACCOUNTS" ] && [ "$ACCOUNTS" -ge 1 ] && ok "vmail.accounts has $ACCOUNTS row(s)" || bad "vmail.accounts query failed or returned zero rows"
if sudo postmap -q jayfield.org mysql:/etc/postfix/sql/domains.cf 2>/dev/null | grep -q jayfield.org; then
ok "Postfix's MySQL domain lookup works"
else
bad "Postfix's MySQL domain lookup failed"
fi
# ---------- fail2ban ----------
section "fail2ban"
systemctl is-active --quiet fail2ban && ok "fail2ban active" || bad "fail2ban not active"
JAIL_COUNT=$(sudo fail2ban-client status 2>/dev/null | grep -oP 'Number of jail:\s*\K[0-9]+')
if [ -n "$JAIL_COUNT" ] && [ "$JAIL_COUNT" -ge 15 ]; then
ok "$JAIL_COUNT jails loaded"
else
bad "only ${JAIL_COUNT:-0} jail(s) loaded (expected ~19)"
fi
SSHD_FT=$(sudo fail2ban-client get sshd findtime 2>/dev/null)
DOVECOT_FT=$(sudo fail2ban-client get dovecot findtime 2>/dev/null)
[ "$SSHD_FT" = "86400" ] && ok "sshd findtime hardening intact (86400s)" || bad "sshd findtime is ${SSHD_FT:-unknown}, expected 86400"
[ "$DOVECOT_FT" = "86400" ] && ok "dovecot findtime hardening intact (86400s)" || bad "dovecot findtime is ${DOVECOT_FT:-unknown}, expected 86400"
# ---------- Apache / vhosts ----------
section "Apache / vhosts"
systemctl is-active --quiet apache2 && ok "apache2 active" || bad "apache2 not active"
sudo apache2ctl configtest >/dev/null 2>&1 && ok "apache2ctl configtest clean" || bad "apache2ctl configtest failed"
check_url() {
local url=$1 expected=$2 code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url")
if [ "$code" = "$expected" ]; then
ok "$url -> $code"
else
bad "$url -> $code (expected $expected)"
fi
}
check_url "https://jayfield.org/" 200
check_url "https://www.jayfield.org/" 200
check_url "https://dyndns.jayfield.org/" 401
check_url "https://mail.jayfield.org/" 403
check_url "https://cloud.jayfield.org/" 302
check_url "https://portainer.jayfield.org/" 200
check_url "https://git.jayfield.org/" 200
# ---------- Docker ----------
section "Docker"
systemctl is-active --quiet docker && ok "docker active" || bad "docker not active"
for c in portainer nextcloud nextcloud-redis nextcloud-db web gitea; do
STATUS=$(sudo docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null)
if [ "$STATUS" = "running" ]; then
ok "container $c running"
else
bad "container $c is '${STATUS:-missing}' (expected running)"
fi
done
GITEA_REPO_COUNT=$(curl -s --max-time 5 'https://git.jayfield.org/api/v1/repos/search?limit=1' | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("data",[])))' 2>/dev/null)
if [ "$GITEA_REPO_COUNT" = "1" ]; then
ok "Gitea API returns real repo data (not just a static page)"
else
bad "Gitea API did not return expected repo data"
fi
# ---------- Summary ----------
section "Summary"
echo "PASS: $PASS WARN: $WARN FAIL: $FAIL"
echo "Full log: $LOG"
if command -v mail >/dev/null 2>&1; then
SUBJECT="alpha.jayfield.org sanity check: $([ "$FAIL" -gt 0 ] && echo "$FAIL FAILURE(S)" || echo "OK")"
mail -s "$SUBJECT" jens@jayfield.org < "$LOG"
fi
[ "$FAIL" -eq 0 ]