#!/usr/bin/env bash # # Generate the lab CA and the broker certificate for the Internet Use Control # MQTT plane (AGENTS.md §11 Phase 3, §10 rule 6). # # The CA is what makes "TLS" mean something on a home LAN: every device and the # Control Service trust this CA, and the broker presents a certificate it signed. # A rogue machine cannot impersonate the broker and hand a child new rules. # # Output (all gitignored, mode 0644 so the Mosquitto container user can read it): # # tls/ca.crt the lab CA certificate — ship this to every device # tls/ca.key the CA private key — keep offline, never ship # tls/server.crt the broker certificate # tls/server.key the broker private key # # Re-run with --force to replace an existing set. Existing files are kept # otherwise, so this is safe to call from `make mqtt-config`. # # Hostname override: # MQTT_TLS_HOSTNAME=broker.example.lan MQTT_TLS_SANS=DNS:broker.example.lan,IP:10.0.0.5 \ # deploy/mosquitto/generate-tls.sh --force # # The certificates are NOT for the public internet. They exist to authenticate # the broker to the kids' devices on the LAN. set -euo pipefail here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" tls_dir="$here/tls" hostname="${MQTT_TLS_HOSTNAME:-mosquitto.lab.audasmedia.com.au}" sans="${MQTT_TLS_SANS:-DNS:${hostname},DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1,IP:192.168.20.13}" days="${MQTT_TLS_DAYS:-3650}" force=0 case "${1:-}" in --force | -f) force=1 ;; --help | -h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; "") ;; *) echo "usage: $(basename "$0") [--force]" >&2 exit 2 ;; esac mkdir -p "$tls_dir" work_dir="$PWD" cd "$tls_dir" if [[ -s "$tls_dir/ca.crt" && -s "$tls_dir/server.crt" && $force -eq 0 ]]; then echo "generate-tls: $tls_dir already holds a CA and a server certificate; leaving it alone." echo "generate-tls: re-run with --force to replace it." exit 0 fi # Prefer a host openssl. `.13` has no openssl, so fall back to a bounded # container run (never an unbounded `docker run`, in line with the project # guardrails). The container needs root to install the package, so it hands the # generated files back to the calling user before it exits. openssl_run() { if command -v openssl >/dev/null 2>&1; then openssl "$@" return fi timeout 300 docker run --rm --network host \ -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ -v "$tls_dir":/tls -w /tls alpine:3.21 sh -c ' apk add --no-cache openssl >/dev/null 2>&1 || exit 1 openssl "$@" || exit 1 chown -R "$HOST_UID:$HOST_GID" /tls' -- "$@" } echo "generate-tls: CA + broker certificate for $hostname" echo "generate-tls: SANs $sans" # 1. The lab CA. openssl_run req -x509 -newkey rsa:4096 -sha256 -days "$days" -nodes \ -keyout ca.key -out ca.crt \ -subj "/O=Family Home Lab/CN=Internet Use Control Lab CA" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" # 2. The broker certificate, signed by the CA. The extension file lives in the # certificate directory so the same relative path works for a host openssl and # for the fallback container, which is started with that directory as its cwd. ext=.ext.cnf cat >"$ext" <