From 14275a56271634e8ac783b77e3f59d3299b17fec Mon Sep 17 00:00:00 2001 From: lutz Date: Sat, 26 Sep 2026 09:25:44 +0200 Subject: [PATCH] Auto-Unseal beim Boot und internes Zertifikat fuer gitea.famfi.home Beim ersten Neustart seit 106 Tagen (2026-09-20) zeigte sich: der Unsealer kommt versiegelt hoch, die Hauptinstanz startet daraufhin endlos neu und nichts kann mehr Secrets lesen - Backups, Traefik-Zertifikate, Skripte. Bisher musste das jemand von Hand entsiegeln. - scripts/unseal-unsealer.sh + systemd/openbao-auto-unseal.service: entsiegelt die Sidecar nach docker.service, startet die Hauptinstanz nach und schreibt die Metrik openbao_unsealer_unsealed (cp1 alarmiert darauf). Idempotent. Zwei Fallstricke, die das Skript umgeht: `bao operator unseal -` (Schluessel ueber stdin) wird abgelehnt, und `bao status` liefert Exit 2 bei versiegelt - was trotzdem heisst, dass der Container antwortet. Der Schluessel liegt in /etc/openbao-unsealer.key (root, 0400), nicht im Repo. - scripts/renew-gitea-cert.sh + systemd/gitea-cert-renew.*: gitea.famfi.home bekommt ein Zertifikat aus der eigenen PKI (90 Tage, Erneuerung ab 21 Tagen Restlaufzeit), Aufbau wie bei renew-openbao-cert.sh. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + README.md | 15 ++++-- scripts/renew-gitea-cert.sh | 75 +++++++++++++++++++++++++++ scripts/unseal-unsealer.sh | 80 +++++++++++++++++++++++++++++ systemd/gitea-cert-renew.service | 9 ++++ systemd/gitea-cert-renew.timer | 10 ++++ systemd/openbao-auto-unseal.service | 19 +++++++ 7 files changed, 206 insertions(+), 3 deletions(-) create mode 100755 scripts/renew-gitea-cert.sh create mode 100755 scripts/unseal-unsealer.sh create mode 100644 systemd/gitea-cert-renew.service create mode 100644 systemd/gitea-cert-renew.timer create mode 100644 systemd/openbao-auto-unseal.service diff --git a/.gitignore b/.gitignore index 78388ea..e4400c8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ data/ artifacts/ .claude/ *-credentials.txt +*.bak-* diff --git a/README.md b/README.md index 38c3ad0..c1821c4 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,18 @@ main openbao ──seal:transit──▶ openbao-unsealer (transit/autounseal) docker compose exec openbao-unsealer bao operator unseal ``` -- Caveat: the unsealer currently runs on the same host (SPOF). For real - separation, relocate it to another host and point `seal.hcl` at it. To make - host reboots fully hands-off, give the unsealer its own boot-unseal. +- **Since 2026-09-20 this is automatic on boot.** `systemd/openbao-auto-unseal.service` + (installed to /etc/systemd/system/, enabled) runs `scripts/unseal-unsealer.sh` (installed to + /usr/local/sbin/openbao-unseal-unsealer.sh) after docker.service: it waits for the sidecar, + unseals it with the key from **/etc/openbao-unsealer.key** (root, 0400), restarts the main + node and writes the metric `openbao_unsealer_unsealed` into node_exporter's textfile dir. + The script is idempotent, so it can be run at any time. + Two pitfalls it works around: `bao operator unseal -` (key on stdin) is rejected, so the key + goes in as an env var; and `bao status` returns exit code 2 when sealed, which still means + "the container answers". +- Caveat: the unsealer runs on the same host (SPOF) and now its key lives here too, so anyone + with root on this host can unseal. That key was already in `unsealer-init.json`. For real + separation, relocate the unsealer to another host and point `seal.hcl` at it. ## 4. Log in & use diff --git a/scripts/renew-gitea-cert.sh b/scripts/renew-gitea-cert.sh new file mode 100755 index 0000000..16f8255 --- /dev/null +++ b/scripts/renew-gitea-cert.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Renew the gitea.famfi.home leaf cert (Kopie von renew-openbao-cert.sh, 2026-09-20) from OpenBAO's pki_int engine and +# install it where Traefik serves it. Idempotent: only renews when the current +# cert expires within $RENEW_WINDOW_DAYS. Run as root (writes Traefik's tls dir). +# +# Installed as a systemd timer (gitea-cert-renew.timer). Manual run: +# sudo /home/lutz/Projects/OpenBAO/scripts/renew-gitea-cert.sh [--force] +set -euo pipefail + +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +TOKEN_FILE="/etc/openbao-cert-renew.token" +ROLE="pki_int/issue/famfi-home" +CN="gitea.famfi.home" +TTL="2160h" # 90 days +DEST="/srv/TRAEFIK/etc/traefik/tls/gitea" +DYN="/srv/TRAEFIK/etc/traefik/traefik.d/tls-gitea.yml" +RENEW_WINDOW_DAYS="${RENEW_WINDOW_DAYS:-21}" # renew when <= this many days left +FORCE="${1:-}" + +log() { printf '%s [renew-cert] %s\n' "$(date '+%F %T')" "$*"; } +die() { log "ERROR: $*"; exit 1; } + +[ -r "$TOKEN_FILE" ] || die "token file $TOKEN_FILE not readable (run as root?)" +TOKEN="$(cat "$TOKEN_FILE")" + +# Skip if the current cert is still good (unless --force) +if [ "$FORCE" != "--force" ] && [ -f "$DEST/fullchain.pem" ]; then + end="$(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)" + days_left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) + if [ "$days_left" -gt "$RENEW_WINDOW_DAYS" ]; then + log "cert valid ${days_left}d (> ${RENEW_WINDOW_DAYS}d) — nothing to do" + exit 0 + fi + log "cert has ${days_left}d left (<= ${RENEW_WINDOW_DAYS}d) — renewing" +fi + +# Issue a fresh cert +resp="$(curl -sS --fail-with-body --max-time 15 \ + -H "X-Vault-Token: ${TOKEN}" \ + --data "{\"common_name\":\"${CN}\",\"ttl\":\"${TTL}\"}" \ + "${ADDR}/v1/${ROLE}")" || die "issue request failed" + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +export RESP="$resp" +python3 - "$tmp" </dev/null | openssl md5)" +[ "$cmod" = "$kmod" ] || die "cert/key modulus mismatch — refusing to install" + +install -d -o root -g root -m 0755 "$DEST" +install -o root -g root -m 0644 "$tmp/fullchain.pem" "$DEST/fullchain.pem" +install -o root -g root -m 0600 "$tmp/privkey.pem" "$DEST/privkey.pem" + +# Rewrite the watched dynamic config (with a fresh timestamp) so Traefik's file +# provider reparses and reloads the cert from disk — changing the cert file +# alone does NOT trigger a reload. +cat > "$DYN" < "${PROM}.tmp" && mv "${PROM}.tmp" "$PROM" && chmod 644 "$PROM" +} + +[ -r "$KEY_FILE" ] || { log "ABBRUCH: $KEY_FILE fehlt oder ist nicht lesbar"; metric 0; exit 1; } + +# 1. Warten, bis der Container laeuft und seine API antwortet. +# 'bao status' liefert 0 = offen, 2 = versiegelt, 1 = nicht erreichbar. 0 und 2 heissen +# beide "der Container antwortet" — genau darauf wird gewartet. +deadline=$(( $(date +%s) + TIMEOUT )) +while :; do + docker exec "$CONTAINER" bao status >/dev/null 2>&1 + rc=$? + case "$rc" in 0|2) break;; esac + [ "$(date +%s)" -lt "$deadline" ] || { log "ABBRUCH: $CONTAINER antwortet nicht binnen ${TIMEOUT}s (rc=$rc)"; metric 0; exit 1; } + sleep 5 +done + +# 2. Schon offen? Dann nichts tun (idempotent — die Unit darf jederzeit laufen). +if docker exec "$CONTAINER" bao status 2>/dev/null | grep -qE '^Sealed[[:space:]]+false'; then + log "$CONTAINER ist bereits entsiegelt" + metric 1 + exit 0 +fi + +# 3. Entsiegeln. Der Schluessel geht als Umgebungsvariable in den Container; +# 'bao operator unseal -' (stdin) funktioniert nicht, es lehnt den Schluessel ab. +UK="$(tr -d '\r\n' < "$KEY_FILE")" +docker exec -e UK="$UK" "$CONTAINER" sh -c 'bao operator unseal "$UK"' >/dev/null 2>&1 +unset UK + +if docker exec "$CONTAINER" bao status 2>/dev/null | grep -qE '^Sealed[[:space:]]+false'; then + log "$CONTAINER entsiegelt" + metric 1 +else + log "FEHLER: $CONTAINER ist weiterhin versiegelt" + metric 0 + exit 1 +fi + +# 4. Die Hauptinstanz anstupsen, damit sie nicht erst den naechsten Restart-Zyklus abwartet. +if docker inspect "$MAIN" >/dev/null 2>&1; then + docker restart "$MAIN" >/dev/null 2>&1 && log "$MAIN neu gestartet" + for i in $(seq 1 24); do + docker exec "$MAIN" bao status 2>/dev/null | grep -qE '^Sealed[[:space:]]+false' && { log "$MAIN ist entsiegelt"; exit 0; } + sleep 5 + done + log "HINWEIS: $MAIN ist nach 120s noch nicht entsiegelt — Logs pruefen" +fi +exit 0 diff --git a/systemd/gitea-cert-renew.service b/systemd/gitea-cert-renew.service new file mode 100644 index 0000000..e6e0f2f --- /dev/null +++ b/systemd/gitea-cert-renew.service @@ -0,0 +1,9 @@ +[Unit] +Description=Erneuert gitea.famfi.home aus der OpenBAO-PKI und laedt Traefik neu +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/home/lutz/Projects/OpenBAO/scripts/renew-gitea-cert.sh diff --git a/systemd/gitea-cert-renew.timer b/systemd/gitea-cert-renew.timer new file mode 100644 index 0000000..9a0f3ab --- /dev/null +++ b/systemd/gitea-cert-renew.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Taeglicher Check/Renew des internen Zertifikats fuer gitea.famfi.home + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=1h + +[Install] +WantedBy=timers.target diff --git a/systemd/openbao-auto-unseal.service b/systemd/openbao-auto-unseal.service new file mode 100644 index 0000000..c15d1b7 --- /dev/null +++ b/systemd/openbao-auto-unseal.service @@ -0,0 +1,19 @@ +[Unit] +Description=Entsiegelt die openbao-unsealer-Sidecar nach dem Start (sonst startet OpenBAO endlos neu) +Documentation=file:///home/lutz/Projects/OpenBAO/README.md +After=docker.service network-online.target +Wants=docker.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/openbao-unseal-unsealer.sh +TimeoutStartSec=420 +# Ein fehlgeschlagener Versuch soll sichtbar sein, aber den Boot nicht blockieren. +Restart=on-failure +RestartSec=30 +StartLimitBurst=3 + +[Install] +WantedBy=multi-user.target