31. Juli 2026 · 10 Min Lesezeit Secret RotationWatch & AlertTelegram Bot Token

Secret-Rotation ohne Downtime

Stell dir vor, du wachst morgens auf, dein Watch-&-Alert-Daemon hat brav seinen Heartbeat um 06:10, 06:20, 06:30 gesendet – und dann, beim vierten Tick, Stille. Du loggst dich ein, schaust ins Log und findest: `HTTP 401 Unauthorized: bot token revoked`. Der Telegram-Bot, der dich stets über neue Feed-Einträge und Pipeline-Fehler informiert hat, ist tot. Genau das ist mir an einem Sonntagmorgen passiert – nur dass ich Glück hatte, weil ich parallel noch ntfy als Backup-Kanal laufen hatte. Der Vorfall war der Auslöser, mein Secret-Handling endlich auf rotierende Füße zu stellen. Genau darüber schreibe ich hier: wie ich Telegram-Bot-Token, SMTP-Passwörter, Webhook-Secrets und einen optionalen Cloud-LLM-API-Key rotiere, ohne dass auch nur ein Alert verloren geht. Kapitel 7 des Buchs „Watch & Alert" behandelt das Thema Sicherheit & Vertiefung; dieser Blogpost ist die ausführliche Use-Case-Geschichte dazu.

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:

  1. TELEGRAMBOTTOKEN – der Bot, der mich seit 18 Monaten piesackt
  2. TELEGRAMCHATID – keine Rotation nötig, aber dokumentiert
  3. SMTP_PASSWORD – für den morgendlichen Digest
  4. NTFYTOPICTOKEN – Topic-Token für ntfy.sh (seit 2025 Pflicht)
  5. 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 status hä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.