Ausgangslage
Mein Setup ist ein klassisches Indie-Projekt: ein Skript, das auf einem kleinen VPS (4 GB RAM, 2 vCPU, 25 GB SSD) läuft. Es zieht ungefähr 45 RSS- und Atom-Feeds (Heise, Golem, CERT-Bund, ein paar Security-Blogs, drei Status-Pages), dazu vier Webhooks von zwei Home-Lab-Diensten und einem Git-Server. Eingehende Items landen in einer SQLite-Datenbank (watchalert.db, knapp 220 MB nach 18 Monaten), werden von einem lokalen Ollama-Modell (llama3.1:8b-instruct-q5KM) klassifiziert und bei Schwellwert-Überschreitung an Telegram, ntfy.sh und parallel als E-Mail ausgegeben.
Die Alerts laufen über fünf Secrets, die damals einfach in einer secrets.env lagen:
TELEGRAMBOTTOKEN– der Bot, der mich seit 18 Monaten piesacktTELEGRAMCHATID– keine Rotation nötig, aber dokumentiertSMTP_PASSWORD– für den morgendlichen DigestNTFYTOPICTOKEN– Topic-Token für ntfy.sh (seit 2025 Pflicht)OPENAIAPIKEY– Cloud-Fallback, falls Ollama ausfällt (max. 3× im Jahr)
Dazu kommen drei Webhook-Signing-Secrets, die aber nur lokal zwischen dem Hook-Sender und meinem Worker relevant sind. Insgesamt also acht Strings, die nie rotiert wurden, weil „funktioniert ja". Der Vorfall mit dem revoked Telegram-Token zeigte mir, dass genau dieses „funktioniert ja" eine tickende Zeitbombe ist. Telegram rotiert Tokens nach verdächtigen Aktivitäten automatisch – und ich habe keine Ahnung, was in deren Heuristik als verdächtig zählt. Ein Log-Eintrag in einem alten Docker-Container, ein versehentliches git add in einem Backup, ein bisschen viel Traffic wegen eines Adapter-Themas: reicht.
Parallel ergab ein kurzer Selbst-Audit folgende Probleme:
- Secrets lagen im Klartext in
secrets.env, lesbar für jeden Prozess, der unter meinem User läuft - Ein
git statushätte sie beinahe erfasst, weil ich mal probeweise das Repo umgezogen habe - Das Watchdog-Skript restartete den Daemon bei jedem Reload hart, was eine Minute Heartbeat-Pause bedeutete
- Es gab keine Historie, wer wann welches Secret gesetzt hat
Damit war klar: ich brauche einen Prozess, der Rotation erlaubt, ohne dass das System in der Zeit stumm wird. Klingt banal, ist aber erstaunlich fummelig, wenn man keine Downtime will.
Anforderungen
Aus dem Vorfall und dem Audit habe ich fünf harte Anforderungen an die neue Lösung abgeleitet:
Hot-Reload ohne Prozess-Restart. Der Watcher soll zur Laufzeit neue Secrets einlesen können. Ein klassischer systemctl restart watchalert.service bedeutet, dass der Heartbeat ein bis zwei Minuten aussetzt. Genau in so einem Wartungsfenster kann ein ausgefallener Disk oder ein ausgelasteter Feed-Crawler unbemerkt bleiben – und das ist genau das, was das System eigentlich verhindern soll.
Kein Doppel-Versand. Klingt erst mal komisch, ist aber real: wenn ich ein Secret rotiere und der alte sowie der neue Versand gleichzeitig laufen, kann ein und derselbe Alert zweimal rausgehen. Bei Telegram nervt das, bei ntfy auch, und im Audit-Log produziert es Geistereinträge.
Audit-Trail. Ich will in SQLite nachschauen können, wann welches Secret rotiert wurde. Nicht weil ein Compliance-Audit ansteht, sondern weil ich beim Debuggen den Zeitstempel brauche.
Fail-Safe. Wenn die neue Secret-Datei kaputt ist oder das Passwort falsch eingegeben wurde, soll das System mit den alten Secrets weiterlaufen und eine Warnung loggen – nicht hart ausfallen.
Wartbar für eine Person. Keine Vault-Cluster, keine Cloud-KMS, kein HashiCorp-Bound. Ich will am Sonntagabend in 30 Minuten ein neues Secret einsetzen können, ohne drei Dienste zu konsultieren.
Diese Anforderungen schränken den Lösungsraum schon deutlich ein: Enterprise-Tools wie HashiCorp Vault, AWS Secrets Manager oder Doppler fallen weg, weil sie entweder zu viel Infrastruktur mitbringen oder ein kostenpflichtiges Konto voraussetzen. Was bleibt, ist die Kombination aus einer verschlüsselten Lokaldatei und einem Prozess, der zur Laufzeit nachladen kann.
Drei Optionen im Vergleich
Bevor ich Code geschrieben habe, habe ich drei Optionen auf den Tisch gelegt und ehrlich gegeneinander abgewogen.
Option 1: Hard-Cutover. Altes Secret raus, neues Secret rein, systemctl restart watchalert.service. Das ist die Standard-Antwort in 90 % der Tutorials, weil sie trivial ist. In meinem Fall kostet sie aber pro Rotation zwei Minuten Heartbeat-Lücke und damit drei Heartbeats, die ich verliere. In 18 Monaten habe ich das System ganze zweimal neugestartet – bei OS-Upgrades. Sobald ich aber alle 90 Tage rotiere, wie es Best Practice ist, hätte ich 24 Neustarts im Jahr, und die Wahrscheinlichkeit, dass ausgerechnet beim Rotieren mal ein Item aus einem Feed hängenbleibt, geht gegen 100 %. Diese Option habe ich nach zehn Minuten Nachdenken verworfen.
Option 2: Dual-Lookup im Code. Ein kurzer Zeitraum, in dem der Code sowohl das alte als auch das neue Secret akzeptiert. Konkret: bei HTTP 401 mit dem neuen Token einmal das alte Token proben, dann neues Token als neu persistieren. Das ist der Ansatz, den viele Cloud-SDKs (AWS, GitHub) intern fahren, wenn du alte und neue Access-Keys gleichzeitig hast. Vorteil: kein Restart, redundante Validierung. Nachteil: es verteilt das Wissen über zwei aktive Secrets quer durch den Code. Bei fünf bis acht Secrets wird das schnell hässlich, und der Übergangszeitpunkt ist schwer zu garantieren – bei einem externen Anbieter wie Telegram weiß ich nicht, wann genau der alte Token serverseitig invalidiert wird.
Option 3: External Store mit Hot-Reload. Die Secrets liegen in einer Datei (oder einem Verzeichnis), die vom laufenden Prozess beobachtet wird. Bei Änderung wird ein Reload getriggert, ohne dass der Worker neu startet. Realisiert wird das auf Linux klassisch über inotify oder pragmatisch über einen SIGHUP-Handler, den systemd anstoßen kann. Vorteil: keine Code-Instrumentierung an allen API-Aufrufen, Rotation ist ein Dateisystem-Event. Nachteil: man muss sich mit SIGHUP-Semantik, Datei-Atomarität und Watchern auseinandersetzen.
Ich habe mich für eine Kombination aus Option 2 und Option 3 entschieden: External Store mit Hot-Reload, aber mit einem schmalen Dual-Lookup nur für genau den einen Fall, in dem ein Token-Refresh vom Anbieter serverseitig noch nicht durch ist. Konkret läuft das so: das Hauptsystem liest aus einer secrets.age-Datei, die mit age verschlüsselt ist. Beim Schreiben eines neuen Secrets wird die Datei atomar ersetzt und ein kill -HUP an den Watcher geschickt. Der Watcher hat einen SIGHUP-Handler, der die Datei neu einliest, ohne den Prozess zu beenden. Die kurze Dual-Lookup-Logik existiert nur in der Telegram-Adapter-Klasse, weil Telegram gelegentlich ein paar Minuten braucht, bis ein neu erzeugter Token wirklich aktiv ist. Alle anderen Secrets vertragen einen sofortigen Cutover ohne Übergang.
Die Lösung
Die endgültige Lösung besteht aus vier Bausteinen: einer Rotator-Klasse, die das eigentliche Rotieren kapselt, einem SecretStore, der die entschlüsselten Werte in den Speicher hält, einem SIGHUP-Handler im Watcher und einem systemd-Unit-File, das den Reload über systemctl reload zulässt.
Der SecretStore ist absichtlich klein gehalten. Er liest eine einzelne secrets.age-Datei, entschlüsselt sie mit dem age-Tool aus dem gleichnamigen Repository und parst das Ergebnis als TOML. Hier der Kern:
from __future__ import annotations
import logging
import os
import subprocess
import tomllib
from dataclasses import dataclass
from pathlib import Path
LOG = logging.getLogger(__name__)
SECRETS_PATH = Path("/etc/watchalert/secrets.age")
AGE_KEY_PATH = Path("/etc/watchalert/key.age")
DECRYPT_TIMEOUT_S = 5
@dataclass(frozen=True)
class Secrets:
telegram_bot_token: str
telegram_chat_id: str
smtp_password: str
ntfy_topic_token: str
openai_api_key: str | None
@classmethod
def from_toml(cls, data: dict[str, object]) -> "Secrets":
return cls(
telegram_bot_token=str(data["telegram_bot_token"]),
telegram_chat_id=str(data["telegram_chat_id"]),
smtp_password=str(data["smtp_password"]),
ntfy_topic_token=str(data["ntfy_topic_token"]),
openai_api_key=(
str(data["openai_api_key"])
if data.get("openai_api_key") else None
),
)
class SecretStore:
def __init__(self, path: Path = SECRETS_PATH) -> None:
self._path = path
self._current: Secrets | None = None
def load(self) -> Secrets:
plaintext = decrypt_age(self._path)
try:
data = tomllib.loads(plaintext)
finally:
# bewusst keine plaintext-Variable im langlebigen Scope
del plaintext
self._current = Secrets.from_toml(data)
LOG.info("secrets.reloaded", extra={"path": str(self._path)})
return self._current
def current(self) -> Secrets:
if self._current is None:
return self.load()
return self._current
def decrypt_age(path: Path) -> str:
result = subprocess.run(
[
"age",
"--decrypt",
"--identity", str(AGE_KEY_PATH),
str(path),
],
capture_output=True,
text=True,
timeout=DECRYPT_TIMEOUT_S,
check=True,
)
return result.stdout
Der Watcher selbst hält eine Referenz auf den SecretStore und konstruiert die Adapter bei jedem Sendevorgang neu:
def send_telegram(text: str) -> None:
secrets = _store.current()
adapter = TelegramAdapter(
token=secrets.telegram_bot_token,
chat_id=secrets.telegram_chat_id,
fallback_token=os.environ.get("TELEGRAM_FALLBACK_TOKEN"),
)
adapter.send(text)
Der fallback_token ist die einzige Stelle, an der Dual-Lookup existiert. Er ist nur dann gesetzt, wenn gerade eine Rotation läuft – und wird nach erfolgreichem Senden direkt wieder aus der Umgebung entfernt.
Der Rotator ist ein eigenes CLI-Skript, das ich in /usr/local/bin/watchalert-rotate lege. Es nimmt den Namen des Secrets, den neuen Wert und einen Operator-Namen entgegen, schreibt die Datei neu, verschlüsselt sie, und triggert den Reload:
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import logging
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import tomllib
import tomli_w
LOG = logging.getLogger("watchalert.rotate")
SECRETS_PLAIN = Path("/var/lib/watchalert/secrets.plain.toml")
SECRETS_CIPHER = Path("/etc/watchalert/secrets.age")
AUDIT_LOG = Path("/var/log/watchalert/rotations.log")
def rotate(field: str, new_value: str, operator: str) -> None:
if not SECRETS_PLAIN.exists():
raise SystemExit("Klartext-Datei fehlt – initial bootstrap nötig")
with SECRETS_PLAIN.open("rb") as fh:
data = tomllib.load(fh)
data[field] = new_value
with tempfile.NamedTemporaryFile(
"wb", delete=False, dir=SECRETS_PLAIN.parent
) as tmp:
tmp.write(tomli_w.dumps(data).encode("utf-8"))
tmp_path = Path(tmp.name)
tmp_path.replace(SECRETS_PLAIN)
encrypt_in_place(SECRETS_PLAIN, SECRETS_CIPHER)
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
with AUDIT_LOG.open("a", encoding="utf-8") as fh:
fh.write(
f"{datetime.now(timezone.utc).isoformat()} "
f"{operator} {field}
"
)
# Reload triggern – kein Restart
subprocess.run(
["systemctl", "reload", "watchalert.service"],
check=True,
)
LOG.info("rotation.ok", extra={"field": field, "operator": operator})
def encrypt_in_place(plain: Path, cipher: Path) -> None:
subprocess.run(
[
"age",
"--encrypt",
"--armor",
"--output", str(cipher),
"--recipient-file", "/etc/watchalert/pubkey.age",
str(plain),
],
check=True,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("field")
parser.add_argument("new_value")
parser.add_argument("--operator", required=True)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
try:
rotate(args.field, args.new_value, args.operator)
except subprocess.CalledProcessError as exc:
LOG.error("rotation.failed", extra={"err": str(exc)})
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Damit der Reload funktioniert, braucht die systemd-Unit ein paar Anpassungen:
[Unit]
Description=Watch & Alert Daemon
After=network-online.target
[Service]
Type=notify
ExecStart=/usr/local/bin/watchalert
ExecReload=/bin/kill -HUP $MAINPID
NotifyAccess=main
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.target
Beim ersten Lauf hat das notify nicht funktioniert, weil ich den absoluten Pfad zu kill falsch geschrieben hatte – ein Klassiker. Außerdem habe ich Type=simple benutzt, was mit NotifyAccess=main nicht harmoniert. Sobald beides korrekt auf notify stand, kam der Reload nach durchschnittlich 180 ms durch.
Tests habe ich nicht im pytest-Sinne geschrieben, sondern pragmatisch: ich habe eine Test-Rotation mit einem Dummy-Feld gefahren, parallel einen kontinuierlichen Alert-Generator (10 Alerts/Minute, dummy-data) und geprüft, dass kein Alert verloren geht und kein Doppel-Versand im Audit-Log auftaucht. Bei 1000 Test-Alerts in einer Stunde war die Drop-Rate 0, die Doppel-Rate ebenfalls 0.
Was wir gelernt haben
Erkenntnis 1: Hot-Reload ist günstiger, als er aussieht. Mein erster Instinkt war, das Problem mit Vault oder einem Cloud-KMS zu lösen. Tatsächlich reichen systemd, SIGHUP und ein 60-Zeilen-Rotator. Der Aufwand war drei Abende, nicht drei Wochen.
Erkenntnis 2: Atomare Datei-Operationen sind nicht verhandelbar. Beim ersten Versuch habe ich die secrets.age direkt überschrieben. Bei einem Crash mitten im Schreiben hatte ich eine kaputte Datei und das System stand. Mit tempfile + Path.replace ist das ausgeschlossen, weil replace auf POSIX atomar ist.
Erkenntnis 3: Dual-Lookup gehört genau dorthin, wo er wirklich nötig ist. Ich habe ihn nur in den Telegram-Adapter eingebaut, weil dort eine externe Partei (Telegram) den Zeitpunkt der Token-Aktivierung bestimmt. Für SMTP und ntfy reicht ein sofortiger Cutover, weil ich selbst der Anbieter bin.
Erkenntnis 4: Audit-Logging kostet nichts, wenn es ein Append-Only-File ist. Ich habe kurz überlegt, die Rotationen in SQLite zu protokollieren. Eine Textdatei mit Zeilen [UTC-Timestamp] operator field ist einfacher, geht durch jeden tail -f, und Korrelation mit SQLite ist trivial, weil ich die Rotation immer kurz vor oder kurz nach einem Heartbeat-Tick mache.
Erkenntnis 5: Fail-Safe ist kein optionales Add-On. Beim dritten Testlauf habe ich absichtlich ein falsches Passwort eingespielt. Erwartet: System läuft mit altem Secret weiter, Log-Eintrag secrets.reload.failed. Ergebnis: genau das, plus eine Telegram-Warnung an mich selbst. Das war der Moment, in dem ich dem Setup wirklich vertraut habe.
Bezug zum Buch
Kapitel 7 „Sicherheit & Vertiefung" des Buchs „Watch & Alert" behandelt genau die Themen, die in diesem Use-Case relevant sind: Speicherung von Secrets, Rotation, Audit-Trail und der Heartbeat-Pattern als Robustheitsanker. Der Heartbeat ist hier indirekt mit im Spiel: ohne ihn hätte ich nicht bemerkt, dass der Telegram-Bot still war. Hätte ich den Heartbeat schon damals gehabt, wäre der Ausfall in unter 11 Minuten aufgefallen, nicht erst am nächsten Morgen.
Das Buch geht über den hier gezeigten Ansatz in zwei Punkten hinaus: Erstens zeigt es eine Variante mit systemd-creds, die ohne age auskommt und direkt in die Unit-File-Mechanik integriert ist. Zweitens enthält es ein Muster für die Rotation von Webhook-Signing-Secrets, bei der Sender und Empfänger nicht synchron laufen – das ist der komplexere Bruder des hier gezeigten Fall. Wer sein Watch-&-Alert-Setup länger als ein Jahr betreibt, kommt um Kapitel 7 ohnehin nicht herum.
Wann du das nachbauen solltest – und wann nicht
Der Ansatz passt zu dir, wenn du ein Indie-Setup hast: ein einzelner Host, ein bis zwei Verantwortliche, fünf bis fünfzehn Secrets, ein Wartungsfenster von 30 Minuten pro Quartal. Wenn du ausschließlich lokale LLMs via Ollama nutzt, sparst du dir sogar das Cloud-API-Key-Thema komplett. Wenn deine Worker als root oder unter einem einzigen User laufen, hält sich der Konfigurationsaufwand in Grenzen.
Nicht passen wird es dir, wenn du mehrere Maschinen mit identischen Secrets versorgen musst. Dann brauchst du einen zentralen Store, und die Frage Vault versus age-versus-Infisical wird relevant. Auch wenn du Dritten Zugriff auf einzelne Secrets gibst – etwa einem Auftragnehmer, der nur das SMTP-Passwort braucht – wird es hakelig, weil age keine ACLs kennt. Und ganz ehrlich: wenn dein Threat-Model ist „jemand rootet meinen VPS", dann helfen dir weder Vault noch age, dann brauchst du Härtung auf einer anderen Ebene.
In meinem Fall – Indie-Projekt, ein Server, ein Verantwortlicher, ein überschaubares Bedrohungsmodell – ist die Kombination aus age + atomarem Reload + systemd-Notify seit März 2026 im Einsatz. Die erste reguläre Rotation lief am 15. Juni, exakt 90 Tage nach dem Vorfall, ohne einen einzigen verlorenen Alert. Der Heartbeat hat dabei nie ausgesetzt – und das ist am Ende das einzige Kriterium, das wirklich zählt.