wrolpi/scripts/generate_certificates.sh
Roland e8c6d8415b
Certificates: only include SANs permitted by the Root CA name constraints.
Global IPs from `hostname -I` (e.g. routable IPv6 assigned via SLAAC)
violated the Root CA's critical nameConstraints, failing leaf verification
with "permitted subtree violation" (error 47) and aborting
install/repair/upgrade. Filter SAN candidates, including EXTRA_SANS, to the
permitted private ranges and log skipped entries; normalize the IP:/DNS:
prefix case in EXTRA_SANS since openssl rejects lowercase prefixes.

Also guard the service stops in upgrade.sh so upgrading a box whose
install died before the units were installed can still recover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:38:08 -06:00

126 lines
4.3 KiB
Bash
Executable file

#!/usr/bin/env bash
# Generate a root CA and signed leaf certificate for WROLPi.
#
# Root CA: stored in ${MEDIA_DIRECTORY}/config/ssl/ — persists forever.
# Leaf cert: stored in ${CERT_DIR}/ — regenerated each run to pick up hostname/IP changes.
#
# Users trust the root CA once; leaf certs can be regenerated without re-trusting.
set -e
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}"
CERT_DIR="${CERT_DIR:-/etc/ssl/wrolpi}"
CA_DIR="${MEDIA_DIRECTORY}/config/ssl"
CA_KEY="${CA_DIR}/ca.key"
CA_CERT="${CA_DIR}/ca.crt"
LEAF_KEY="${CERT_DIR}/cert.key"
LEAF_CERT="${CERT_DIR}/cert.crt"
mkdir -p "${CA_DIR}" "${CERT_DIR}"
# --- Root CA (only created once) ---
if [[ -f "${CA_CERT}" && -f "${CA_KEY}" ]]; then
echo "Root CA already exists at ${CA_CERT}"
else
echo "Generating WROLPi Root CA..."
openssl genrsa -out "${CA_KEY}" 4096
openssl req -new -x509 -nodes -key "${CA_KEY}" -out "${CA_CERT}" -days 36500 \
-subj "/C=US/ST=State/L=City/O=WROLPi/CN=WROLPi Root CA" \
-addext "basicConstraints = critical, CA:TRUE" \
-addext "keyUsage = critical, keyCertSign, cRLSign" \
-addext "nameConstraints = critical, permitted;DNS:.local, permitted;DNS:localhost, permitted;IP:127.0.0.0/255.0.0.0, permitted;IP:10.0.0.0/255.0.0.0, permitted;IP:172.16.0.0/255.240.0.0, permitted;IP:192.168.0.0/255.255.0.0, permitted;IP:::1/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff, permitted;IP:fc00::/fe00::, permitted;IP:fe80::/ffc0::"
chmod 600 "${CA_KEY}"
chmod 644 "${CA_CERT}"
echo "Root CA generated."
fi
# --- Leaf certificate (always regenerated) ---
echo "Generating WROLPi leaf certificate..."
# Only SANs inside the Root CA's name constraints may be included; any other
# (e.g. a global IPv6 address from the router) fails verification with
# "permitted subtree violation". Takes one SAN like "IP:192.168.0.1" or "DNS:wrolpi.local".
san_permitted() {
local san
san="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "${san}" in
dns:localhost|dns:*.local) return 0 ;;
ip:127.*|ip:10.*|ip:192.168.*) return 0 ;;
ip:172.1[6-9].*|ip:172.2[0-9].*|ip:172.3[01].*) return 0 ;;
ip:::1) return 0 ;;
ip:f[cd]*) return 0 ;; # fc00::/7 unique-local
ip:fe[89ab]*) return 0 ;; # fe80::/10 link-local
*) return 1 ;;
esac
}
# Build SAN list dynamically.
SAN="DNS:localhost,DNS:wrolpi.local"
HOSTNAME_SHORT="$(hostname)"
if [[ -n "${HOSTNAME_SHORT}" ]]; then
SAN="${SAN},DNS:${HOSTNAME_SHORT}.local"
fi
SAN="${SAN},IP:127.0.0.1"
# Add all local IPs.
for ip in $(hostname -I 2>/dev/null || true); do
if san_permitted "IP:${ip}"; then
SAN="${SAN},IP:${ip}"
else
echo "Skipping IP:${ip}: not permitted by Root CA name constraints"
fi
done
# Append any extra SANs (e.g. host IP when running in Docker).
if [[ -n "${EXTRA_SANS:-}" ]]; then
for extra in ${EXTRA_SANS//,/ }; do
# openssl requires the canonical prefix case (IP:/DNS:).
case "$(printf '%s' "${extra%%:*}" | tr '[:lower:]' '[:upper:]')" in
IP) extra="IP:${extra#*:}" ;;
DNS) extra="DNS:${extra#*:}" ;;
esac
if san_permitted "${extra}"; then
SAN="${SAN},${extra}"
else
echo "Skipping ${extra}: not permitted by Root CA name constraints"
fi
done
fi
echo "SANs: ${SAN}"
# Create CSR and sign with root CA.
openssl genrsa -out "${LEAF_KEY}" 2048
openssl req -new -key "${LEAF_KEY}" \
-subj "/C=US/ST=State/L=City/O=WROLPi/CN=${HOSTNAME_SHORT:-wrolpi}.local" \
-out "${CERT_DIR}/cert.csr"
openssl x509 -req -in "${CERT_DIR}/cert.csr" \
-CA "${CA_CERT}" -CAkey "${CA_KEY}" -CAcreateserial \
-days 825 \
-extfile <(printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth" "${SAN}") \
-out "${LEAF_CERT}"
rm -f "${CERT_DIR}/cert.csr"
chmod 640 "${LEAF_KEY}" "${LEAF_CERT}"
# Allow the caddy user to read the leaf cert/key (native installs).
if id caddy >/dev/null 2>&1; then
chown root:caddy "${LEAF_KEY}" "${LEAF_CERT}"
fi
# Validate the chain.
openssl verify -CAfile "${CA_CERT}" "${LEAF_CERT}"
echo "Leaf certificate generated and verified."
# Install root CA into system trust store so the device trusts its own certs.
if command -v update-ca-certificates >/dev/null 2>&1; then
cp "${CA_CERT}" /usr/local/share/ca-certificates/wrolpi-ca.crt
update-ca-certificates
echo "Root CA installed in system trust store."
fi