diff --git a/policies/cert-renew.hcl b/policies/cert-renew.hcl index cb8bf16..b0c5247 100644 --- a/policies/cert-renew.hcl +++ b/policies/cert-renew.hcl @@ -1,2 +1,9 @@ # cert-renew — materialized from live OpenBAO (policy-as-code). path "pki_int/issue/famfi-home" { capabilities = ["update"] } + +# Ergaenzt 2026-10-06 fuer renew-mikrotik-cert.sh: das Zertifikat von +# mt-az.famfi.home liegt nicht auf einem Host, sondern muss per SSH in den +# Router importiert werden. Damit das Skript dessen Passwort nicht auf Platte +# oder in einer Kommandozeile braucht, holt es sich das Geheimnis zur Laufzeit. +# Bewusst nur dieser eine Pfad, nicht secret/mikrotik/*. +path "secret/data/mikrotik/MT-AZ/apics" { capabilities = ["read"] } diff --git a/scripts/renew-mikrotik-cert.sh b/scripts/renew-mikrotik-cert.sh new file mode 100755 index 0000000..c215253 --- /dev/null +++ b/scripts/renew-mikrotik-cert.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Renew the mt-az.famfi.home API-SSL cert (Kopie von renew-gitea-cert.sh, 2026-10-06) +# from OpenBAO's pki_int engine and install it on the MikroTik. +# +# Warum es dieses Skript gibt: das Zertifikat lief am 2026-09-26 ab und hat damit +# den CrowdSec-MikroTik-Bouncer getoetet - 967 Neustartversuche mit +# "x509: certificate has expired", 10 Tage lang keine Sperren in der +# Router-Firewall. Niemand hat es bemerkt, weil mt-az:8729 in keiner +# Blackbox-Probe steht und es - anders als fuer openbao und gitea - kein +# Erneuerungsskript gab. +# +# Idempotent: erneuert nur, wenn das Zertifikat am Router innerhalb von +# $RENEW_WINDOW_DAYS ablaeuft. Als root laufen (liest das Token). +# sudo /home/lutz/Projects/OpenBAO/scripts/renew-mikrotik-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="mt-az.famfi.home" +TTL="2160h" # 90 Tage, wie bei openbao/gitea +RT_HOST="192.168.0.2" +RT_USER="apics" # group=full, address=192.168.0.0/24 +RENEW_WINDOW_DAYS="${RENEW_WINDOW_DAYS:-21}" +FORCE="${1:-}" + +log() { printf '%s [renew-mikrotik-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")" + +# Passwort des Router-Benutzers: zur Laufzeit aus OpenBAO, damit es nirgends +# auf Platte liegt. Die Policy cert-renew braucht dafuer read auf +# secret/data/mikrotik/MT-AZ/apics. Solange das nicht erteilt ist, kann der +# Aufrufer MIKROTIK_PASS setzen (nur fuer manuelle Laeufe gedacht). +SSHPASS="${MIKROTIK_PASS:-}" +if [ -z "$SSHPASS" ]; then + SSHPASS="$(curl -sS --max-time 15 -H "X-Vault-Token: ${TOKEN}" \ + "${ADDR}/v1/secret/data/mikrotik/MT-AZ/apics" 2>/dev/null \ + | jq -r '.data.data.password // empty')" || true +fi +[ -n "$SSHPASS" ] || die "kein Router-Passwort (Policy cert-renew erweitern oder MIKROTIK_PASS setzen)" +export SSHPASS + +rt() { sshpass -e ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no \ + -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 "${RT_USER}@${RT_HOST}" "$@"; } + +# Restlaufzeit direkt am Router messen - verlaesslicher als eine lokale Kopie, +# denn entscheidend ist, was api-ssl tatsaechlich vorzeigt. +if [ "$FORCE" != "--force" ]; then + end="$(echo | timeout 20 openssl s_client -connect "${RT_HOST}:8729" 2>/dev/null \ + | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || true)" + if [ -n "$end" ]; then + 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" + else + log "konnte die Restlaufzeit nicht lesen - erneuere vorsorglich" + fi +fi + +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)"; chmod 700 "$tmp" +cleanup() { rm -rf "$tmp"; } +trap cleanup EXIT +export RESP="$resp" +python3 - "$tmp" <<'PY' +import json, os, sys +d = json.loads(os.environ["RESP"])["data"] +t = sys.argv[1] +open(f"{t}/cert.pem", "w").write(d["certificate"] + "\n") +open(f"{t}/key.pem", "w").write(d["private_key"] + "\n") +PY +chmod 600 "$tmp/key.pem" + +cmod="$(openssl x509 -in "$tmp/cert.pem" -noout -modulus | openssl md5)" +kmod="$(openssl rsa -in "$tmp/key.pem" -noout -modulus 2>/dev/null | openssl md5)" +[ "$cmod" = "$kmod" ] || die "cert/key modulus mismatch - refusing to install" + +# Datumsstempel im Namen: RouterOS leitet den Zertifikatsnamen vom Dateinamen ab. +# Mit gleichem Namen gaebe es Kollisionen mit dem noch vorhandenen alten Objekt. +STAMP="$(date +%Y%m%d%H%M)" +BASE="mt-az-${STAMP}" +sshpass -e scp -o PreferredAuthentications=password -o PubkeyAuthentication=no \ + -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \ + "$tmp/cert.pem" "${RT_USER}@${RT_HOST}:${BASE}.crt" >/dev/null +sshpass -e scp -o PreferredAuthentications=password -o PubkeyAuthentication=no \ + -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \ + "$tmp/key.pem" "${RT_USER}@${RT_HOST}:${BASE}.key" >/dev/null +log "Dateien hochgeladen: ${BASE}.crt/.key" + +# Leeres passphrase, damit nichts Geheimes in der Kommandozeile steht - RouterOS +# protokolliert Kommandos mit. +rt "/certificate import file-name=${BASE}.crt passphrase=\"\"" >/dev/null +rt "/certificate import file-name=${BASE}.key passphrase=\"\"" >/dev/null + +# Feldweise zerlegen statt mit einem Regex ueber die ganze Zeile: in der +# terse-Ausgabe steht auch subject-alt-name=, und ein gieriges ".*name=" trifft +# genau das. Beim ersten Lauf kam dadurch "DNS:mt-az.famfi.home" heraus. +NEWNAME="$(rt "/certificate print terse where name~\"${BASE}\"" \ + | tr ' ' '\n' | grep -m1 '^name=' | cut -d= -f2)" +[ -n "$NEWNAME" ] || die "importiertes Zertifikat nicht gefunden" +log "importiert als ${NEWNAME}" + +OLD="$(rt '/ip service print terse where name="api-ssl"' \ + | tr ' ' '\n' | grep -m1 '^certificate=' | cut -d= -f2)" +# Direkt beim Namen ansprechen statt ueber [find ...]: letzteres liefert bei +# /ip service auch den dynamischen Verbindungseintrag und quittierte das +# Setzen mit einer irritierenden Meldung ueber telnet. +rt "/ip service set api-ssl certificate=${NEWNAME}" +log "api-ssl nutzt jetzt ${NEWNAME} (vorher ${OLD:-unbekannt})" + +# Hochgeladene Dateien entfernen - der private Schluessel hat im Flash nichts zu suchen. +rt "/file remove [find name~\"${BASE}\"]" >/dev/null 2>&1 || true +# Altes Objekt erst nach dem Umschalten loeschen. +if [ -n "${OLD:-}" ] && [ "$OLD" != "$NEWNAME" ] && [ "$OLD" != "none" ]; then + rt "/certificate remove [find name=\"${OLD}\"]" >/dev/null 2>&1 \ + && log "altes Zertifikat ${OLD} entfernt" || log "altes Zertifikat ${OLD} blieb stehen" +fi + +sleep 2 +got="$(echo | timeout 20 openssl s_client -connect "${RT_HOST}:8729" 2>/dev/null \ + | openssl x509 -noout -subject -enddate 2>/dev/null || true)" +log "api-ssl zeigt jetzt vor: $(echo "$got" | tr '\n' ' ')" +# Abschliessend pruefen, dass wirklich ein gueltiges Zertifikat ausgeliefert +# wird - sonst war die Erneuerung wirkungslos und muss auffallen. +end_new="$(echo "$got" | sed -n 's/^notAfter=//p')" +[ -n "$end_new" ] || die "api-ssl liefert kein Zertifikat mehr aus" +left=$(( ( $(date -d "$end_new" +%s) - $(date +%s) ) / 86400 )) +[ "$left" -gt 0 ] || die "ausgeliefertes Zertifikat ist nicht gueltig (${left}d)" +log "OK - noch ${left} Tage gueltig" diff --git a/systemd/mikrotik-cert-renew.service b/systemd/mikrotik-cert-renew.service new file mode 100644 index 0000000..2dd1285 --- /dev/null +++ b/systemd/mikrotik-cert-renew.service @@ -0,0 +1,12 @@ +# Erneuert das API-SSL-Zertifikat von mt-az.famfi.home aus der OpenBAO-PKI und +# importiert es per SSH in den Router. Ohne diesen Timer lief das Zertifikat am +# 2026-09-26 unbemerkt ab und hat den CrowdSec-MikroTik-Bouncer 10 Tage lang +# lahmgelegt (967 Neustarts mit "x509: certificate has expired"). +[Unit] +Description=Erneuert mt-az.famfi.home aus der OpenBAO-PKI und importiert es in den MikroTik +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/home/lutz/Projects/OpenBAO/scripts/renew-mikrotik-cert.sh diff --git a/systemd/mikrotik-cert-renew.timer b/systemd/mikrotik-cert-renew.timer new file mode 100644 index 0000000..7dde3fe --- /dev/null +++ b/systemd/mikrotik-cert-renew.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Taeglicher Check/Renew des API-SSL-Zertifikats fuer mt-az.famfi.home + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=1h + +[Install] +WantedBy=timers.target