Archived
pve-test was briefly clustered with pve1 then deliberately de-clustered so it could move to wifi-primary networking (4addr bridge mode, bonded with a wired LAN backup) - a change not achievable while clustered given corosync's latency requirements. Captures that as a reproducible script plus docs: cluster separation procedure, the wifi network design and the live-cutover pitfalls hit along the way, and node-role/history context. Also adds CLAUDE.md guardrails for pve1 (production) vs pve-test (sandbox) - this repo had none before, despite scripts here being able to make real changes to both. Separately: both nodes' mgmt firewalls were dropping ICMP by default (TCP 22/8006 only), which looked like an outage mid-troubleshooting even though SSH/web UI were fine. Added an explicit ping-allow rule to the firewall template, applied it live on both nodes, and added an audit.sh check so it stays enforced.
106 lines
3.9 KiB
Bash
Executable File
106 lines
3.9 KiB
Bash
Executable File
#!/bin/bash
|
|
# Read-only Stage 1 base-hardening audit. Checks the current state of a PVE
|
|
# host against the checklist in docs/04-security-hardening.md and prints
|
|
# PASS/FAIL per item. Exits non-zero if anything fails, so it can gate CI or
|
|
# be run periodically as a compliance check. Makes no changes.
|
|
#
|
|
# Usage: ./audit.sh (run as root on the PVE host)
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck source=lib/common.sh
|
|
source "${SCRIPT_DIR}/lib/common.sh"
|
|
require_root
|
|
|
|
AUDIT_FAIL=0
|
|
|
|
# --- apt repos: no enabled enterprise source ---
|
|
ENTERPRISE_ENABLED=0
|
|
for f in /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do
|
|
[ -f "$f" ] || continue
|
|
grep -qi 'enterprise.proxmox.com' "$f" 2>/dev/null && ENTERPRISE_ENABLED=1
|
|
done
|
|
if [ "$ENTERPRISE_ENABLED" -eq 0 ]; then
|
|
audit_pass "no enabled enterprise apt repo"
|
|
else
|
|
audit_fail "an enterprise apt repo is still enabled (needs a subscription to update)"
|
|
fi
|
|
|
|
# --- SSH ---
|
|
SSHD_T="$(sshd -T 2>/dev/null)"
|
|
if echo "$SSHD_T" | grep -qiE '^permitrootlogin (prohibit-password|without-password)'; then
|
|
audit_pass "sshd: PermitRootLogin prohibit-password (key-only)"
|
|
else
|
|
audit_fail "sshd: PermitRootLogin is not key-only (prohibit-password/without-password)"
|
|
fi
|
|
if echo "$SSHD_T" | grep -qi '^passwordauthentication no'; then
|
|
audit_pass "sshd: PasswordAuthentication no"
|
|
else
|
|
audit_fail "sshd: PasswordAuthentication is not disabled"
|
|
fi
|
|
|
|
# --- fail2ban ---
|
|
if systemctl is-active --quiet fail2ban 2>/dev/null; then
|
|
audit_pass "fail2ban is active"
|
|
else
|
|
audit_fail "fail2ban is not active"
|
|
fi
|
|
|
|
# --- PVE firewall ---
|
|
FW_STATUS="$(pve-firewall status 2>/dev/null || true)"
|
|
if echo "$FW_STATUS" | grep -qi '^Status: enabled'; then
|
|
audit_pass "pve-firewall is enabled"
|
|
else
|
|
audit_fail "pve-firewall is not enabled (status: ${FW_STATUS:-unknown})"
|
|
fi
|
|
if [ -f /etc/pve/firewall/cluster.fw ] && grep -qi '^policy_in:\s*DROP' /etc/pve/firewall/cluster.fw 2>/dev/null; then
|
|
audit_pass "cluster.fw has default-deny inbound policy"
|
|
else
|
|
audit_fail "cluster.fw missing or does not default-deny inbound"
|
|
fi
|
|
if [ -f /etc/pve/firewall/cluster.fw ] && grep -qi 'icmp-type echo-request' /etc/pve/firewall/cluster.fw 2>/dev/null; then
|
|
audit_pass "cluster.fw allows ICMP echo-request from mgmt (ping works)"
|
|
else
|
|
audit_fail "cluster.fw does not allow ping from mgmt - see docs/04-security-hardening.md firewall section"
|
|
fi
|
|
|
|
# --- unattended-upgrades ---
|
|
if dpkg -s unattended-upgrades >/dev/null 2>&1 && systemctl is-enabled --quiet unattended-upgrades 2>/dev/null; then
|
|
audit_pass "unattended-upgrades installed and enabled"
|
|
else
|
|
audit_fail "unattended-upgrades not installed/enabled"
|
|
fi
|
|
if [ -f /var/run/reboot-required ]; then
|
|
audit_warn "a reboot is pending (/var/run/reboot-required) - schedule one"
|
|
fi
|
|
|
|
# --- named admin user (not just root@pam) ---
|
|
if pveum user list --output-format json 2>/dev/null | grep -q '"userid":"[^"]*@pve"'; then
|
|
audit_pass "a named @pve admin user exists (root@pam is not the only account)"
|
|
else
|
|
audit_fail "no named @pve user found - root@pam is the only account"
|
|
fi
|
|
|
|
# --- time sync ---
|
|
if timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qx 'yes'; then
|
|
audit_pass "clock is NTP-synchronized"
|
|
else
|
|
audit_fail "clock is not NTP-synchronized"
|
|
fi
|
|
|
|
# --- subscription nag (cosmetic - warn only, never fails the audit) ---
|
|
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
|
if [ -f "$JS_FILE" ] && ! grep -qF "data.status.toLowerCase() !== 'active'" "$JS_FILE"; then
|
|
audit_pass "subscription nag patch applied"
|
|
else
|
|
audit_warn "subscription nag patch not applied (cosmetic only, see scripts/disable-subscription-nag.sh)"
|
|
fi
|
|
|
|
echo
|
|
if [ "$AUDIT_FAIL" -eq 0 ]; then
|
|
echo "All Stage 1 base-hardening checks passed."
|
|
else
|
|
echo "One or more checks failed - see FAIL lines above."
|
|
fi
|
|
exit "$AUDIT_FAIL"
|