#!/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}" # Fuer die vollstaendige Kette muss die Zwischen-CA im Zertifikatsspeicher des # Routers liegen (2026-10-22 als famfi-int-ca-20261022.crt_0 importiert). RouterOS # verknuepft Blatt und CA beim IMPORT des Blattes; fehlt die CA, liefert api-ssl nur # das Blatt aus und strenge Clients scheitern mit "unable to get local issuer # certificate". Wird die CA je entfernt, faellt das unten in der Kettenpruefung auf. ROOT_CA="${ROOT_CA:-/home/lutz/.config/famfi-ca/famfi-home-root-ca.pem}" 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" # Kette pruefen: gegen die ROOT allein verifizieren. Das gelingt nur, wenn der # Router die Zwischen-CA mitschickt. Kein die(), denn das Zertifikat selbst ist # in Ordnung - es ist ein Mangel, kein Ausfall, und ein Abbruch hier wuerde einen # erfolgreichen Austausch als Fehlschlag melden. if [ -r "$ROOT_CA" ]; then if echo | timeout 20 openssl s_client -connect "${RT_HOST}:8729" \ -CAfile "$ROOT_CA" 2>&1 | grep -q "Verify return code: 0"; then log "Kette vollstaendig (gegen die Root-CA verifiziert)" else log "WARNUNG: api-ssl liefert keine vollstaendige Kette - liegt die Zwischen-CA" log " noch im Speicher? /certificate print where common-name~\"Intermediate\"" fi else log "Kettenpruefung uebersprungen: $ROOT_CA nicht lesbar" fi