Compare commits
4 Commits
175c0bc2d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e012654915 | |||
| f3cfe777b6 | |||
| a9e51fbc8c | |||
| d3c7d2445f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ artifacts/
|
||||
.claude/
|
||||
*-credentials.txt
|
||||
*.bak-*
|
||||
|
||||
# Audit-Logs gehoeren nicht ins Repo
|
||||
logs/
|
||||
|
||||
@@ -31,3 +31,39 @@ cluster_addr = "https://openbao:8201"
|
||||
# NOTE: OpenBAO 2.x removed mlock support. Instead, disable or encrypt swap
|
||||
# on the host so secrets can't leak to disk. See:
|
||||
# https://openbao.org/docs/install/#post-installation-hardening
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit-Log, eingerichtet 2026-10-03.
|
||||
#
|
||||
# Warum deklarativ und nicht per `bao audit enable`: OpenBAO 2.x lehnt das ueber
|
||||
# die API ab ("cannot enable audit device via API; use declarative, config-based
|
||||
# audit device management instead") - anders als Vault. Der Block wird bei
|
||||
# Neustart UND bei SIGHUP uebernommen, ein Reload genuegt also.
|
||||
#
|
||||
# Warum ueberhaupt: ohne Audit-Device gibt es keinerlei Aufzeichnung, wer welches
|
||||
# Geheimnis gelesen hat. Bei der Fehlersuche am 2026-10-03 (Identitaet "claude"
|
||||
# ohne Rechte) haette ein Blick in dieses Log die Ursache sofort gezeigt, weil
|
||||
# die beim Login vergebenen Policies darin stehen.
|
||||
#
|
||||
# log_raw bleibt auf dem Standard false: sensible Werte werden gehasht (HMAC),
|
||||
# nicht im Klartext geschrieben. Das Log zeigt also WER WAS WANN angefasst hat,
|
||||
# nicht die Geheimnisse selbst.
|
||||
#
|
||||
# ACHTUNG, Betriebsrisiko: Audit-Devices sind BLOCKIEREND. Kann das Device nicht
|
||||
# schreiben (Platte voll, Rechte weg), weist OpenBAO Anfragen ab. Deshalb ist
|
||||
# logrotate Pflicht, siehe logrotate/openbao-audit im Repo.
|
||||
#
|
||||
# ACHTUNG, Haltbarkeit: /openbao/logs ist derzeit ein ANONYMES Docker-Volume aus
|
||||
# dem Image, nicht in der compose deklariert. Es ueberlebt Neustarts und normale
|
||||
# Recreates, aber nicht `docker compose down -v`. Die compose-Datei enthaelt
|
||||
# deshalb jetzt einen Bind-Mount auf ./logs; er greift beim naechsten
|
||||
# `docker compose up -d openbao`. Der Pfad IM Container bleibt gleich, dieser
|
||||
# Block muss dafuer also nicht angepasst werden.
|
||||
audit "file" "audit-file" {
|
||||
description = "Audit-Log als JSON in /openbao/logs/audit.log"
|
||||
options {
|
||||
file_path = "/openbao/logs/audit.log"
|
||||
mode = "0600"
|
||||
format = "json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
services:
|
||||
openbao:
|
||||
image: openbao/openbao:2.6.2
|
||||
# 2026-10-24: 2.6.2 -> 2.7.1. Die CLI auf dem Host ist seit dem 01.10. 2.7.1,
|
||||
# der Server hinkte hinterher. Der Unsealer-Sidecar unten bleibt vorerst auf
|
||||
# 2.6.2: er ist Shamir-versiegelt (1/1) und muesste nach einem Austausch von
|
||||
# Hand entsiegelt werden - die Transit-API ist versionsuebergreifend stabil.
|
||||
image: openbao/openbao:2.7.1
|
||||
container_name: openbao
|
||||
restart: unless-stopped
|
||||
# The image entrypoint already runs `bao server -config=/openbao/config`
|
||||
@@ -18,6 +22,9 @@ services:
|
||||
volumes:
|
||||
- ./config:/openbao/config:ro
|
||||
- openbao-data:/openbao/file
|
||||
# Audit-Log auf den Host: /openbao/logs war vorher ein anonymes
|
||||
# Volume aus dem Image und haette ein `down -v` nicht ueberlebt.
|
||||
- ./logs:/openbao/logs
|
||||
healthcheck:
|
||||
# 200 = unsealed+active. Accept sealed (501) and standby (429) too,
|
||||
# so the container is "healthy" once the API is responding at all.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
159
scripts/renew-mikrotik-cert.sh
Executable file
159
scripts/renew-mikrotik-cert.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/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
|
||||
12
systemd/mikrotik-cert-renew.service
Normal file
12
systemd/mikrotik-cert-renew.service
Normal file
@@ -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
|
||||
10
systemd/mikrotik-cert-renew.timer
Normal file
10
systemd/mikrotik-cert-renew.timer
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user