Files
rootnode-academy/infra/firewall/validate-firewall.sh
T
ced4568 d2280d0f0d feat(security): production Compose topology, firewall rulesets, Docker mTLS
Named-bridge-network production Compose (not host networking, unlike dev)
for rn-platform-01; egress-proxy + agents for rn-lab-host-01. Caddy reverse
proxy, single public origin, Grafana restricted to management_cidr.
iptables-restore firewall rulesets for all three hosts (Proxmox host,
rn-platform-01, rn-lab-host-01) codifying the Platform/Lab-Host boundary:
no student-to-student traffic, no direct Proxmox exposure, no direct
Postgres/Provisioner exposure from the Lab Host.

Mutual TLS for the Provisioner<->Lab Host Docker connection: certificate
generation/verification scripts, dockerd TLS config, firewall-restricted
port 2376. Application support already existed since Phase 1; this is
purely the operational half.

Validated: docker compose config, caddy validate, firewall structural lint,
and a real openssl s_server/s_client mTLS handshake proving the cert chain
and mutual-auth rejection (legitimate client verified, rogue client
correctly rejected with verify code 19).
2026-08-18 17:13:09 -05:00

64 lines
2.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Local test (Phase 4 §29): renders each .rules file with dummy IPs and validates it.
#
# `iptables-restore --test` needs root (it talks to netfilter) — if this shell has passwordless
# sudo, we use it for a real semantic check. Otherwise we fall back to a structural lint
# (balanced *table/COMMIT blocks, every rule starts with a real flag, no unrendered {{ }}
# placeholders left over) — good enough to catch a typo'd rule or a missing COMMIT, NOT a
# substitute for the real `iptables-restore --test` a human/Ansible with actual root runs
# before this ever reaches a live host. HARDWARE-DEPENDENT: proving the real IPs/CIDRs are
# correct needs the actual production network, not this script.
set -euo pipefail
cd "$(dirname "$0")"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
render() {
sed \
-e 's/{{ management_cidr }}/203.0.113.0\/24/g' \
-e 's/{{ platform_ip }}/10.10.10.10/g' \
-e 's/{{ lab_host_ip }}/10.10.10.20/g' \
-e 's/{{ lab_subnet_range }}/10.42.0.0\/16/g' \
-e 's/{{ proxmox_mgmt_cidr }}/203.0.113.0\/24/g' \
-e "s/{{ lab_gateway_port_range | default('30000:32767') }}/30000:32767/g" \
"$1" > "$2"
}
structural_lint() {
local f="$1"
grep -q '^\*filter' "$f" || { echo " missing *filter table declaration"; return 1; }
grep -q '^COMMIT' "$f" || { echo " missing COMMIT"; return 1; }
grep -v '^#' "$f" | grep -q '{{' && { echo " unrendered {{ }} placeholder left in a non-comment line"; return 1; }
# every -A/-I rule line should end in a real target (ACCEPT/DROP/REJECT/LOG/a custom chain)
while IFS= read -r line; do
[[ "$line" =~ ^-[AI]\ ]] || continue
[[ "$line" =~ -j\ [A-Za-z0-9_-]+ ]] || { echo " rule missing a -j target: $line"; return 1; }
done < "$f"
return 0
}
HAVE_SUDO=0
sudo -n true 2>/dev/null && HAVE_SUDO=1
FAIL=0
for f in proxmox-host.rules rn-platform-01.rules rn-lab-host-01.rules; do
render "$f" "$TMPDIR/$f"
echo "==> $f"
if [ "$HAVE_SUDO" = "1" ] && command -v iptables-restore >/dev/null 2>&1; then
if sudo -n iptables-restore --test < "$TMPDIR/$f"; then
echo "OK: $f passed a real iptables-restore --test"
else
echo "FAIL: $f did not pass iptables-restore --test" >&2; FAIL=1
fi
else
if structural_lint "$TMPDIR/$f"; then
echo "OK: $f passes structural lint (no root available for a real iptables-restore --test — see script header)"
else
echo "FAIL: $f failed structural lint" >&2; FAIL=1
fi
fi
done
exit $FAIL