From a9e51fbc8cb1b41803876955e6de78afb54f3ac4 Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Tue, 6 Oct 2026 20:45:38 +0200 Subject: [PATCH] MikroTik-Zertifikat erneuern und automatisieren Das API-SSL-Zertifikat von mt-az.famfi.home (CN=mt-az.famfi.home, ausgestellt von famfi.home Intermediate CA, 90 Tage) lief am 2026-09-26 ab. Folge: der Container crowdsec-mikrotik-bouncer starb im Minutentakt mit "x509: certificate has expired" - 967 Neustarts, letzter erfolgreicher API-Pull 2026-09-27 02:29. Zehn Tage lang wurden CrowdSec-Sperren also nur noch von Traefik durchgesetzt und nicht mehr in die Router-Firewall geschoben. Die eigentliche Ursache war nicht das Zertifikat, sondern dass niemand hinsah: fuer openbao und gitea gibt es Erneuerungsskripte samt Timer (daher sind die mit 58 bzw. 73 Tagen in Ordnung), fuer den Router gab es keins. Und die Regeln TLSCertificateExpiringSoon/-Critical haengen am blackbox_exporter, der nur openbao.famfi.home und www_mahle_com prueft - mt-az:8729 steht in keiner Probe. renew-mikrotik-cert.sh ist die Kopie von renew-gitea-cert.sh mit dem Router-spezifischen Installationsschritt: Zertifikat und Schluessel per scp hochladen, zweimal /certificate import mit leerem passphrase (RouterOS protokolliert Kommandos mit, deshalb darf dort nichts Geheimes stehen), api-ssl umschalten, hochgeladene Dateien entfernen und das alte Objekt erst danach loeschen. Der Name traegt einen Datumsstempel, weil RouterOS den Zertifikatsnamen vom Dateinamen ableitet und es sonst Kollisionen gibt. Das Router-Passwort holt das Skript zur Laufzeit aus OpenBAO, damit es nirgends auf Platte und in keiner Kommandozeile liegt. Die Policy cert-renew bekommt dafuer read auf genau secret/data/mikrotik/MT-AZ/apics, nicht auf secret/mikrotik/*. Zwei Fehler des ersten Laufs sind behoben: die Namensauslese benutzte einen gierigen Regex ".*name=", der in der terse-Ausgabe das spaetere subject-alt-name= traf und "DNS:mt-az.famfi.home" zurueckgab; jetzt wird feldweise zerlegt. Und /ip service set spricht den Dienst direkt beim Namen an statt ueber [find ...], das auch den dynamischen Verbindungseintrag erfasste und eine irritierende Meldung ueber telnet quittierte. Am Ende prueft das Skript, dass api-ssl wirklich ein gueltiges Zertifikat ausliefert. Ergebnis: neues Zertifikat gueltig bis 2027-01-04, der Bouncer laeuft und arbeitet den Rueckstand ab ("Address ... blocked in mikrotik"). Die IP-Dienste des Routers sind unveraendert, telnet/ftp/www-ssl bleiben deaktiviert. Timer taeglich mit 21-Tage-Fenster, wie bei den anderen beiden. Co-Authored-By: Claude Opus 5 --- policies/cert-renew.hcl | 7 ++ scripts/renew-mikrotik-cert.sh | 137 ++++++++++++++++++++++++++++ systemd/mikrotik-cert-renew.service | 12 +++ systemd/mikrotik-cert-renew.timer | 10 ++ 4 files changed, 166 insertions(+) create mode 100755 scripts/renew-mikrotik-cert.sh create mode 100644 systemd/mikrotik-cert-renew.service create mode 100644 systemd/mikrotik-cert-renew.timer 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