api-ssl auf mt-az lieferte nur das Blattzertifikat aus. Gegen die famfi-Root-CA allein scheiterte die Pruefung deshalb mit "unable to get local issuer certificate" - jeder strenge TLS-Client braucht die Zwischen-CA separat zugesteckt. Der CrowdSec-Bouncer funktionierte nur, weil er mit famfi-ca-bundle.pem (Root + Intermediate) konfiguriert ist. Ursache war nicht das Skript, sondern der Zertifikatsspeicher des Routers: die Zwischen-CA lag dort gar nicht. RouterOS verknuepft Blatt und CA beim IMPORT des Blattes (Feld ca=), und ohne die CA im Speicher entsteht diese Verknuepfung nicht. Nachtraeglich laesst sie sich auch nicht setzen - "bad parameter ca". Behoben in zwei Schritten: die Zwischen-CA als famfi-int-ca-20261022.crt_0 importiert, danach ein --force-Renew, damit RouterOS das neue Blatt mit ihr verknuepft. api-ssl liefert jetzt Blatt + Zwischen-CA, und die Pruefung gegen die Root allein gibt "Verify return code: 0 (ok)". Das Skript selbst brauchte dafuer keine Aenderung - es schreibt weiterhin nur d["certificate"]. Es funktioniert aber nur, SOLANGE die Zwischen-CA im Speicher liegt. Darum hier ergaenzt: - Hinweis im Kopf auf diese Abhaengigkeit. - Kettenpruefung am Ende: nach dem Austausch wird gegen $ROOT_CA verifiziert. Bewusst nur eine Warnung, kein die() - das Zertifikat waere ja in Ordnung, die fehlende Kette ist ein Mangel, kein Ausfall. Ein Abbruch wuerde einen erfolgreichen Austausch als Fehlschlag melden. Die Pruefung laeuft nur bei einer tatsaechlichen Erneuerung, nicht beim taeglichen Leerlauf - also genau dann, wenn die Verknuepfung verloren gehen koennte. Die Zeit dazwischen deckt die Blackbox-Probe ab: deren Modul tcp_tls_famfi zeigt seit heute auf die Root-CA allein, scheitert also, wenn die Kette je wieder wegfaellt. Nachgewiesen: Bouncer nach dem Zertifikatswechsel neu gestartet, frischer TLS-Handshake ohne x509-Fehler, arbeitet Entscheidungen ab; 16145 Eintraege in der Router-Sperrliste, 82591 verworfene Pakete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
7.8 KiB
Bash
Executable File
160 lines
7.8 KiB
Bash
Executable File
#!/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
|