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).
This commit is contained in:
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates a private CA and a mutual-TLS cert/key pair for the Provisioner <-> rn-lab-host-01
|
||||
# Docker Engine connection (docs/PRODUCTION_NETWORK_SECURITY.md §7). The application already
|
||||
# supports this (platform/provisioner/src/docker.js buildClient() has required mTLS since
|
||||
# Phase 1) — this script only ever needed to exist for the CERTIFICATES, not new app code.
|
||||
#
|
||||
# Output goes to $OUT_DIR (default ./out, gitignored) — never into a tracked path. Deploy the
|
||||
# resulting files with the `docker-tls` Ansible role, which places:
|
||||
# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd's --tlsverify config
|
||||
# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem
|
||||
# -> /etc/rootnode/docker-tls/ (referenced by lab-hosts.yaml)
|
||||
#
|
||||
# Usage:
|
||||
# ./generate-docker-tls.sh [--out-dir DIR] [--lab-host-ip IP] [--lab-host-dns NAME] [--days N]
|
||||
set -euo pipefail
|
||||
|
||||
OUT_DIR="./out"
|
||||
LAB_HOST_IP="127.0.0.1"
|
||||
LAB_HOST_DNS="rn-lab-host-01.internal"
|
||||
DAYS=825 # ~2.25 years — matches modern browser/OS max cert lifetime conventions; see rotation runbook for cadence
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--out-dir) OUT_DIR="$2"; shift 2 ;;
|
||||
--lab-host-ip) LAB_HOST_IP="$2"; shift 2 ;;
|
||||
--lab-host-dns) LAB_HOST_DNS="$2"; shift 2 ;;
|
||||
--days) DAYS="$2"; shift 2 ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
cd "$OUT_DIR"
|
||||
umask 077 # private keys must never be group/world readable, from the moment they're written
|
||||
|
||||
echo "==> Generating CA"
|
||||
openssl genrsa -out ca-key.pem 4096
|
||||
openssl req -new -x509 -days "$DAYS" -key ca-key.pem -sha256 -out ca.pem \
|
||||
-subj "/O=RootNode Academy/CN=RootNode Docker TLS CA"
|
||||
|
||||
echo "==> Generating server cert for rn-lab-host-01 (dockerd)"
|
||||
openssl genrsa -out server-key.pem 4096
|
||||
openssl req -subj "/CN=$LAB_HOST_DNS" -sha256 -new -key server-key.pem -out server.csr
|
||||
|
||||
cat > server-extfile.cnf <<EOF
|
||||
subjectAltName = DNS:$LAB_HOST_DNS,IP:$LAB_HOST_IP
|
||||
extendedKeyUsage = serverAuth
|
||||
EOF
|
||||
|
||||
openssl x509 -req -days "$DAYS" -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \
|
||||
-CAcreateserial -out server-cert.pem -extfile server-extfile.cnf
|
||||
|
||||
echo "==> Generating client cert for the Provisioner"
|
||||
openssl genrsa -out provisioner-client-key.pem 4096
|
||||
openssl req -subj "/CN=rootnode-provisioner" -new -key provisioner-client-key.pem -out client.csr
|
||||
|
||||
cat > client-extfile.cnf <<EOF
|
||||
extendedKeyUsage = clientAuth
|
||||
EOF
|
||||
|
||||
openssl x509 -req -days "$DAYS" -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \
|
||||
-CAcreateserial -out provisioner-client-cert.pem -extfile client-extfile.cnf
|
||||
|
||||
rm -f server.csr client.csr server-extfile.cnf client-extfile.cnf
|
||||
chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem
|
||||
chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem
|
||||
|
||||
echo "==> Done. Generated in $OUT_DIR:"
|
||||
ls -la
|
||||
echo
|
||||
echo "Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)."
|
||||
echo "Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)."
|
||||
echo "Verify with: ../verify-docker-tls.sh $OUT_DIR <lab-host-ip>"
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verifies a generated Docker TLS cert bundle is internally consistent (CA signed both certs,
|
||||
# key/cert pairs match, SANs are correct) BEFORE deploying it — cheap, local, no real dockerd
|
||||
# needed. Once deployed, also verifies live mTLS against a real endpoint if given one.
|
||||
#
|
||||
# Usage: ./verify-docker-tls.sh <cert-dir> [lab-host-ip:port]
|
||||
set -euo pipefail
|
||||
DIR="${1:?usage: verify-docker-tls.sh <cert-dir> [lab-host-ip:port]}"
|
||||
ENDPOINT="${2:-}"
|
||||
|
||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||
|
||||
echo "==> Checking file presence"
|
||||
for f in ca.pem server-cert.pem server-key.pem provisioner-client-cert.pem provisioner-client-key.pem; do
|
||||
[ -f "$DIR/$f" ] || fail "missing $f"
|
||||
done
|
||||
echo "OK: all expected files present"
|
||||
|
||||
echo "==> Checking key/cert pairs match (modulus comparison)"
|
||||
for pair in "server-cert.pem server-key.pem" "provisioner-client-cert.pem provisioner-client-key.pem"; do
|
||||
set -- $pair
|
||||
cert_mod=$(openssl x509 -noout -modulus -in "$DIR/$1" | openssl md5)
|
||||
key_mod=$(openssl rsa -noout -modulus -in "$DIR/$2" | openssl md5)
|
||||
[ "$cert_mod" = "$key_mod" ] || fail "$1 / $2 do not match"
|
||||
done
|
||||
echo "OK: cert/key pairs match"
|
||||
|
||||
echo "==> Checking both certs are signed by the CA"
|
||||
openssl verify -CAfile "$DIR/ca.pem" "$DIR/server-cert.pem" | grep -q "OK" || fail "server-cert.pem not CA-signed"
|
||||
openssl verify -CAfile "$DIR/ca.pem" "$DIR/provisioner-client-cert.pem" | grep -q "OK" || fail "provisioner-client-cert.pem not CA-signed"
|
||||
echo "OK: both certs verify against ca.pem"
|
||||
|
||||
echo "==> Checking private key permissions"
|
||||
for f in ca-key.pem server-key.pem provisioner-client-key.pem; do
|
||||
[ -f "$DIR/$f" ] || continue
|
||||
perm=$(stat -c '%a' "$DIR/$f" 2>/dev/null || stat -f '%Lp' "$DIR/$f")
|
||||
[ "$perm" = "400" ] || echo "WARNING: $f has permissions $perm, expected 400"
|
||||
done
|
||||
|
||||
if [ -n "$ENDPOINT" ]; then
|
||||
echo "==> Testing live mTLS handshake against $ENDPOINT"
|
||||
echo | openssl s_client -connect "$ENDPOINT" \
|
||||
-cert "$DIR/provisioner-client-cert.pem" -key "$DIR/provisioner-client-key.pem" \
|
||||
-CAfile "$DIR/ca.pem" 2>&1 | grep -q "Verify return code: 0" \
|
||||
&& echo "OK: live mTLS handshake succeeded" \
|
||||
|| fail "live handshake against $ENDPOINT failed — check firewall/dockerd TLS config"
|
||||
else
|
||||
echo "(skipped live handshake test — no endpoint given; this only proves the cert bundle is internally consistent, not that dockerd is actually listening with it)"
|
||||
fi
|
||||
|
||||
echo "==> All checks passed"
|
||||
Reference in New Issue
Block a user