diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..2bdf971 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,25 @@ +name: test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -r requirements-dev.txt + - run: pytest -m "not integration" diff --git a/CLAUDE.md b/CLAUDE.md index 8627b96..7c34f17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,22 @@ tests/ - Local Check: "nicht konfiguriert" ergibt WARN, "deaktiviert" (Telegram/Matrix fehlen im JSON) OK, Fehler/Bot nicht erreichbar CRIT. - `master.key` wird nie mit der DB gesichert. Schlüssel erzeugt `setup-lxc.sh` als root und übergibt sie an `checkbot` (das Konfigurationsverzeichnis ist für `checkbot` nur lesbar). - Unprivilegierte LXCs: Drop-In `checkbot-lxc-relaxed.conf` (`setup-lxc.sh --lxc-relaxed`). + +## Weiterer Weg (entschieden) +Reihenfolge: Testbasis, erster Lauf im Test-LXC (`pytest`), Korrekturrunde, Phase 6, Phase 6b, Phase 6c, Phase 8, Abnahme, Parallelbetrieb, Tag `v0.1.0`. Verfeinerungen passieren am Live-System, nicht vorab. +- Testbasis: `pytest` manuell im Test-LXC (Dev-venv mit `requirements-dev.txt`), dazu `.gitea/workflows/test.yml` für einen späteren Gitea-Actions-Runner. Logging als Zeilenlog nach journald, Level über `CHECKBOT_LOG_LEVEL` (Default INFO), nie Secrets in Logs (Test für die Adapter). Zeitzone über `CHECKBOT_TZ` im Setup-Skript, Default `Europe/Berlin`. +- Phase 6: Alarm- und Agenten-Prompt versioniert mit Rollback und Testbereich; der Schutzhinweis gegen Prompt Injection wird immer fest angehängt und ist nicht editierbar. Retention einstellbar: Alarmverlauf/Zustellungen 90 Tage, Agenten-Läufe 90 Tage, `state_log` 30 Tage, Audit 1 Jahr, abgelaufene Mutes 7 Tage, tägliches Aufräumen mit Log der gelöschten Zeilen. Erinnerungen für offene CRIT-Probleme, Standard aus, nicht bei Downtime/Mute. +- Phase 6b (Trigger-Regeln, Routing-Seite wird "Regeln"): Zusatzfelder pro Regel: KI-Hinweistext (nur Zusatzkontext hinter dem festen Schutzhinweis), KI an/aus, Erinnerungsintervall (nur CRIT), laut/leise (Telegram `disable_notification`). Zusatzbedingungen: Plugin-Output enthält Text (Teilstring, keine Regex wegen ReDoS) und Mindestdauer in Minuten. Keine Checkmk-Labels/Tags. Beispiel: Service `*zpool*`, ab WARN, eigener Raum, eigener KI-Hinweis. +- Phase 6c (Betriebsrobustheit, nach 6b, vor 8): + - Baseline beim Erstlauf: Zustand still merken, eine Zusammenfassung ohne KI (Anzahl, 10 kritischste); Button in der UI setzt die Baseline neu. + - Bestätigte Probleme (Acknowledged in Checkmk) werden mit Grund "bestätigt" unterdrückt, bei Verschlechterung wieder gemeldet. Regel für alle Unterdrückungen: Recovery nur, wenn das Problem vorher gemeldet wurde. + - Host-Alarme: Hosts mitabfragen, "Host DOWN" als eigener Alarm, Service-Alarme des Hosts mit Grund "Host down" unterdrücken, Recovery bei Rückkehr. + - Checkmk nicht erreichbar: nach 3 Fehlzyklen eine Meldung "Checkmk nicht erreichbar seit HH:MM" an die Standardziele, bei Rückkehr eine zweite; währenddessen keine Recoveries. + - Sendelimit 15 Nachrichten pro Minute und Ziel, Überschuss als Sammelmeldung pro Minute, alles bleibt im Verlauf. + - Datenschutz pro Ziel: Inhalt voll oder kurz (nur Host, Service, Status, ohne Plugin-Output und KI-Text), Standard voll, Entscheidung am Live-System. + - Optionale `ui.base_url`: Alarme enthalten Link "Details" auf den Verlauf (Telegram-Button, Matrix-Text), nur wenn gesetzt. +- Aktionen: Der Bot führt nie Shell-Befehle aus (weder lokal noch per SSH). Webhook pro Regel (POST JSON an Allowlist-URLs, Audit) kommt erst nach der Abnahme. +- Phase 8: Integrationstests mit Marker `integration` (`pytest -m integration`), Zugangsdaten per Umgebung, nur lesend; Downtime nur mit ausdrücklichem Flag und gegen einen Testhost. Testhost `checkbot-test` in Checkmk mit schaltbarem lokalem Check plus halbautomatisches `tests/e2e/run.py`. +- Abnahme (`docs/ACCEPTANCE.md`): CRIT in Telegram und Element, KI-Analyse binnen 90 s, Recovery ohne KI, Downtime und `mute` unterdrücken, Ollama aus ergibt Rohalarm mit "KI nicht verfügbar", Neustart ohne Doppelalarme, Backup und Restore real getestet, `/health` im Checkmk grün, Baseline-Zusammenfassung beim Erstlauf, Host-down unterdrückt Service-Alarme, Meldung bei Checkmk-Ausfall, 24 h Dauerlauf ohne Fehler im Journal. +- Einführung: erste Woche nur Testziele (separate Gruppe/Raum) per Routing-Regel, danach auf Produktivziele umschalten. Release: Tag `v0.1.0` und kurze `CHANGELOG.md`. +- Bewusst nicht enthalten: Ack, mehrere Checkmk-Sites, weitere Kanäle (Mail/Slack), E2EE in Matrix. diff --git a/app/chat/base.py b/app/chat/base.py index 41bf67b..e67cffc 100644 --- a/app/chat/base.py +++ b/app/chat/base.py @@ -1,4 +1,3 @@ -import logging from dataclasses import dataclass from typing import NamedTuple, Protocol from urllib.parse import quote @@ -7,10 +6,6 @@ import httpx MASK = "***" -# httpx loggt Request-URLs auf INFO, die bei Telegram das Bot-Token enthalten. -logging.getLogger("httpx").setLevel(logging.WARNING) -logging.getLogger("httpcore").setLevel(logging.WARNING) - class ChatError(Exception): pass diff --git a/app/chat/telegram.py b/app/chat/telegram.py index 846ca64..635f4ae 100644 --- a/app/chat/telegram.py +++ b/app/chat/telegram.py @@ -14,6 +14,7 @@ STATUS_TOO_MANY = 429 NOT_MODIFIED = "message is not modified" ALLOWED_UPDATES = ["message", "callback_query"] ANSWER_MAX = 200 +ERROR_MAX = 300 def _markup(buttons: Buttons) -> dict: @@ -54,7 +55,7 @@ class TelegramAdapter: if response.status_code < 400 and body.get("ok"): return body.get("result") or {} description = str(body.get("description", "")) - raise TelegramError(self._mask(f"HTTP {response.status_code}: {description}"[:300])) + raise TelegramError(self._mask(f"HTTP {response.status_code}: {description}")[:ERROR_MAX]) raise TelegramError("Telegram nicht erreichbar (Rate-Limit)") async def send(self, target: str, text: str, buttons: Buttons = (), reply_to: str | None = None, diff --git a/app/fileconfig.py b/app/fileconfig.py index e09e70c..c3e166a 100644 --- a/app/fileconfig.py +++ b/app/fileconfig.py @@ -10,11 +10,13 @@ ENV_DB_PATH = "CHECKBOT_DB" ENV_BIND_HOST = "CHECKBOT_BIND_HOST" ENV_BIND_PORT = "CHECKBOT_BIND_PORT" ENV_SECURE_COOKIES = "CHECKBOT_SECURE_COOKIES" +ENV_LOG_LEVEL = "CHECKBOT_LOG_LEVEL" DEFAULT_CONFIG_DIR = "/etc/checkbot" DEFAULT_DB_PATH = "/var/lib/checkbot/checkbot.db" DEFAULT_BIND_HOST = "127.0.0.1" DEFAULT_BIND_PORT = "8080" +DEFAULT_LOG_LEVEL = "INFO" MASTER_KEY_FILE = "master.key" MASTER_KEY_LEN = 32 @@ -29,6 +31,7 @@ class FileConfig: bind_host: str bind_port: int secure_cookies: bool = True + log_level: str = DEFAULT_LOG_LEVEL def load_file_config() -> FileConfig: @@ -38,6 +41,7 @@ def load_file_config() -> FileConfig: bind_host=os.environ.get(ENV_BIND_HOST, DEFAULT_BIND_HOST), bind_port=int(os.environ.get(ENV_BIND_PORT, DEFAULT_BIND_PORT)), secure_cookies=os.environ.get(ENV_SECURE_COOKIES, FLAG_ON) != FLAG_OFF, + log_level=os.environ.get(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL), ) diff --git a/app/logsetup.py b/app/logsetup.py new file mode 100644 index 0000000..82d4a26 --- /dev/null +++ b/app/logsetup.py @@ -0,0 +1,95 @@ +import logging +import re +import sys +import time +from typing import Callable + +from .chat.base import MASK, mask_secret + +LOG_FORMAT = "%(levelname)s %(name)s: %(message)s" +FALLBACK_LEVEL = logging.INFO +QUIET_LOGGERS = ("httpx", "httpcore") +SECRET_CACHE_SECONDS = 30.0 +MIN_SECRET_LEN = 4 +HANDLER_MARK = "_checkbot_handler" + +# Bot-Token in Telegram-URLs und Bearer-Header, auch wenn der Wert nicht (mehr) im SettingsStore steht. +BOT_URL_RE = re.compile(r"(/bot)[^/\s\"']+(?=/)") +BEARER_RE = re.compile(r"(Bearer\s+)\S+", re.IGNORECASE) + +SecretProvider = Callable[[], list[str]] + + +def mask_patterns(text: str) -> str: + return BEARER_RE.sub(rf"\g<1>{MASK}", BOT_URL_RE.sub(rf"\g<1>{MASK}", text)) + + +def parse_level(raw: str | None) -> tuple[int, bool]: + name = (raw or "").strip().upper() + if not name: + return FALLBACK_LEVEL, True + level = logging.getLevelNamesMapping().get(name) + if isinstance(level, int) and level != logging.NOTSET: + return level, True + return FALLBACK_LEVEL, False + + +class SecretMaskFilter(logging.Filter): + def __init__(self, provider: SecretProvider, ttl: float = SECRET_CACHE_SECONDS, + clock: Callable[[], float] = time.monotonic): + super().__init__() + self._provider = provider + self._ttl = ttl + self._clock = clock + self._secrets: list[str] = [] + self._loaded_at: float | None = None + + def _current_secrets(self) -> list[str]: + now = self._clock() + if self._loaded_at is None or now - self._loaded_at >= self._ttl: + self._loaded_at = now + try: + values = [v for v in self._provider() if isinstance(v, str) and len(v) >= MIN_SECRET_LEN] + except Exception: + values = self._secrets + self._secrets = sorted(values, key=len, reverse=True) + return self._secrets + + def _mask(self, text: str) -> str: + for secret in self._current_secrets(): + text = mask_secret(text, secret) + return mask_patterns(text) + + def filter(self, record: logging.LogRecord) -> bool: + try: + record.msg = self._mask(record.getMessage()) + record.args = None + if record.exc_info and not record.exc_text: + record.exc_text = logging.Formatter().formatException(record.exc_info) + if record.exc_text: + record.exc_text = self._mask(record.exc_text) + if record.stack_info: + record.stack_info = self._mask(record.stack_info) + except Exception: + record.msg, record.args = "Log-Meldung wegen Maskierungsfehler unterdrückt", None + record.exc_info = record.exc_text = record.stack_info = None + return True + + +def setup_logging(raw_level: str | None, provider: SecretProvider) -> None: + level, valid = parse_level(raw_level) + root = logging.getLogger() + for handler in list(root.handlers): + if getattr(handler, HANDLER_MARK, False): + root.removeHandler(handler) + handler = logging.StreamHandler(sys.stderr) + setattr(handler, HANDLER_MARK, True) + handler.setFormatter(logging.Formatter(LOG_FORMAT)) + handler.addFilter(SecretMaskFilter(provider)) + root.addHandler(handler) + root.setLevel(level) + # httpx loggt Request-URLs auf INFO, die bei Telegram das Bot-Token enthalten. + for name in QUIET_LOGGERS: + logging.getLogger(name).setLevel(logging.WARNING) + if not valid: + logging.getLogger(__name__).warning("Ungültiger Wert für CHECKBOT_LOG_LEVEL: %r, verwende INFO", raw_level) diff --git a/app/main.py b/app/main.py index 304d228..2ab2b38 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,9 @@ from .crypto import SecretBox from .db import Database from .fileconfig import (MASTER_KEY_FILE, SESSION_SECRET_FILE, FileConfig, load_file_config, load_master_key, load_session_secret) +from .agent_runs import configured_secrets from .health import build_checks, run_checks +from .logsetup import setup_logging from .ratelimit import FailureLimiter from .runtime import Runtime from .settings import SettingsStore @@ -39,6 +41,7 @@ def create_app(config: FileConfig | None = None) -> FastAPI: db = Database(config.db_path) store = SettingsStore(db, box) + setup_logging(config.log_level, lambda: configured_secrets(store)) runtime = Runtime(db, store) app = FastAPI(title="checkbot", dependencies=[Depends(verify_csrf)], lifespan=runtime.lifespan, docs_url=None, redoc_url=None, openapi_url=None) diff --git a/app/web/deps.py b/app/web/deps.py index de5134b..13dc56f 100644 --- a/app/web/deps.py +++ b/app/web/deps.py @@ -24,11 +24,11 @@ FLASH_INFO = "info" FLASH_OK = "ok" FLASH_ERROR = "error" -TS_FORMAT = "%d.%m.%Y %H:%M:%S" +TS_FORMAT = "%d.%m.%Y %H:%M:%S %Z" REDIRECT_STATUS = 303 templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) -templates.env.filters["fmt_ts"] = lambda ts: datetime.fromtimestamp(ts).strftime(TS_FORMAT) +templates.env.filters["fmt_ts"] = lambda ts: datetime.fromtimestamp(ts).astimezone().strftime(TS_FORMAT) STATE_CLASSES = {0: "ok", 1: "warn", 2: "error", 3: "error"} templates.env.filters["state_name"] = state_name diff --git a/deploy/README.md b/deploy/README.md index 7e99dcd..436ad3e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -68,6 +68,7 @@ CHECKBOT_FQDN=checkbot.example.lan CHECKBOT_MONITOR_IP=192.168.1.10 deploy/setup | `CHECKBOT_MONITOR_IP` / `--monitor-ip` | Monitoring-Server(n), die `/health` per HTTPS abrufen dürfen (Komma-getrennt) | | `CHECKBOT_TLS_MODE` / `--tls-mode` | `internal` (Caddy-interne CA, Standard) oder `files` | | `CHECKBOT_TLS_CERT`, `CHECKBOT_TLS_KEY` | PEM-Dateien für `files` (Zertifikat der Unternehmens-CA, mit Kette) | +| `CHECKBOT_TZ` / `--tz` | Systemzeitzone des LXC (Standard `Europe/Berlin`, geprüft gegen `/usr/share/zoneinfo`). Die UI zeigt Zeiten samt Kürzel (CET/CEST). Lässt sich die Zeitzone im unprivilegierten LXC nicht setzen, warnt das Skript und fährt fort | | `--lxc-relaxed` | gelockertes systemd-Drop-In (siehe unten) | | `--no-prompt` | nie nachfragen | @@ -88,6 +89,10 @@ Ist Caddy nicht im Debian-Repository, richtet das Skript das offizielle Caddy-Re - `internal`: Caddy erzeugt eine eigene CA und stellt das Zertifikat aus. Browser und Clients müssen das Root-Zertifikat einmalig akzeptieren (siehe Fehlersuche). - `files`: eigenes Zertifikat der Unternehmens-CA. Das Skript kopiert die Dateien nach `/etc/caddy/certs/checkbot.{crt,key}` (Schlüssel 0640 root:caddy). Bei Erneuerung neue Dateien bereitstellen und `setup-lxc.sh` mit denselben Parametern erneut laufen lassen (oder die Dateien tauschen und `systemctl reload caddy`). +### Logging + +Der Bot schreibt Zeilen nach stderr, journald stempelt die Zeit (`journalctl -u checkbot`). Das Level steht in `/etc/checkbot/checkbot.env` als `CHECKBOT_LOG_LEVEL` (`DEBUG`, `INFO`, `WARNING`, `ERROR`, Standard `INFO`; ungültige Werte ergeben `INFO` plus Warnung), Änderung wirkt nach `systemctl restart checkbot`. Bekannte Secrets (Tokens, Checkmk-Secrets) sowie Muster wie `/bot/` und `Bearer ` werden aus Meldungen und Exception-Texten maskiert. Die Zeitzone (`CHECKBOT_TZ`) wird nur beim Setup gesetzt, siehe oben. + ## 3. Erste Konfiguration in der UI - **Checkmk**: In Checkmk (Setup, Benutzer) einen Automation-User mit Rolle "Guest" bzw. einer Rolle mit reinem Lesezugriff auf Hosts und Services anlegen, Secret kopieren. In der UI URL der Site (inkl. Site-Name, z.B. `https://checkmk.example.lan/`), User und Secret eintragen und den Verbindungstest ausführen. @@ -96,6 +101,26 @@ Ist Caddy nicht im Debian-Repository, richtet das Skript das offizielle Caddy-Re - **Matrix**: Auf dem Homeserver einen Bot-User anlegen, Access-Token erzeugen (z.B. per Login-API oder Element unter Einstellungen, Hilfe, Access-Token), unverschlüsselten Raum anlegen, den Bot einladen und beitreten lassen, Raum-ID (`!abc:server`) eintragen. - **Ollama**: Host-URL (z.B. `http://office.sysops.de:11434`), bei Reverse-Proxy mit Authentifizierung das Token, bei interner CA das CA-Feld. Die beiden Modelle (Alarmtext, Agent) einstellen und testen. +## Erster Testlauf + +Vor dem ersten echten Betrieb die Tests im Test-LXC laufen lassen, in einer eigenen Dev-venv und nicht unter `/opt/checkbot` (dort liegen weder Tests noch Dev-Abhängigkeiten): + +```bash +apt-get install -y python3-venv python3-pip +cd /root/checkbot +python3 -m venv /root/checkbot-devenv +/root/checkbot-devenv/bin/pip install -r requirements-dev.txt +/root/checkbot-devenv/bin/pytest -x -q +``` + +Für eine vollständige Fehlerausgabe ohne `-x` und zum Sichern: + +```bash +/root/checkbot-devenv/bin/pytest -q 2>&1 | tee pytest-run.log +``` + +Die Datei `pytest-run.log` geht unverändert an den Entwickler zurück (alle Fehler gesammelt, damit sie in einer Runde korrigiert werden). Integrationstests (`-m integration`) laufen nie versehentlich mit, sie sind standardmäßig ausgeschlossen. + ## 4. Update ```bash diff --git a/deploy/checkbot.env.example b/deploy/checkbot.env.example index 4507a80..91f07fc 100644 --- a/deploy/checkbot.env.example +++ b/deploy/checkbot.env.example @@ -14,6 +14,13 @@ CHECKBOT_BIND_PORT=8080 # 1 = Session-Cookie nur über HTTPS (Betrieb hinter Caddy). 0 nur für lokale Tests ohne TLS. CHECKBOT_SECURE_COOKIES=1 +# Log-Level des Zeilenlogs (journald): DEBUG, INFO, WARNING, ERROR. Ungültige Werte ergeben INFO mit einer Warnung. +# Secrets werden maskiert; DEBUG ist für die Fehlersuche gedacht. Nach einer Änderung: systemctl restart checkbot +CHECKBOT_LOG_LEVEL=INFO + +# Zeitzone (nur Hinweis): wird nicht hier gesetzt, sondern von setup-lxc.sh über CHECKBOT_TZ bzw. --tz +# (Standard Europe/Berlin) als Systemzeitzone des LXC eingerichtet. Die UI zeigt Zeiten in dieser Zone. + # Für Checkmk und Ollama gibt es in der UI jeweils ein Feld "CA-Datei". Nur für andere Ziele mit interner CA # (z.B. ein Matrix-Homeserver) die CA im System installieren (update-ca-certificates) und hier das Bundle nutzen. # SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt diff --git a/deploy/lib/timezone.sh b/deploy/lib/timezone.sh new file mode 100644 index 0000000..a5598ed --- /dev/null +++ b/deploy/lib/timezone.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Systemzeitzone setzen (wird von setup-lxc.sh gesourced). Setzt common.sh voraus. + +ZONEINFO_DIR=/usr/share/zoneinfo +TZ_RE='^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$' +LOCALTIME_FILE=/etc/localtime +TIMEZONE_FILE=/etc/timezone + +validate_timezone_name() { + [[ $1 =~ $TZ_RE ]] || die "Ungültiger Zeitzonenname: '$1' (Beispiel: Europe/Berlin)" +} + +current_timezone() { + local tz="" target="" + if command -v timedatectl >/dev/null 2>&1; then + tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz="" + fi + if [[ -z $tz || $tz == n/a ]]; then + target=$(readlink -f "$LOCALTIME_FILE" 2>/dev/null) || target="" + tz=${target#"$ZONEINFO_DIR"/} + [[ $tz != "$target" ]] || tz="" + fi + printf '%s' "$tz" +} + +set_timezone_files() { + ln -sfn "$ZONEINFO_DIR/$1" "$LOCALTIME_FILE" && printf '%s\n' "$1" > "$TIMEZONE_FILE" +} + +# In unprivilegierten LXCs fehlt oft ein nutzbares timedatectl (kein D-Bus); dann Dateien direkt schreiben, sonst nur warnen. +ensure_timezone() { + local tz=$1 + [[ -f $ZONEINFO_DIR/$tz ]] || die "Zeitzone '$tz' fehlt in $ZONEINFO_DIR (Paket tzdata?)." + if [[ $(current_timezone) == "$tz" ]]; then + log "Zeitzone ist bereits $tz." + return 0 + fi + if command -v timedatectl >/dev/null 2>&1 && timedatectl set-timezone "$tz" >/dev/null 2>&1; then + log "Zeitzone auf $tz gesetzt." + elif set_timezone_files "$tz" 2>/dev/null; then + log "Zeitzone auf $tz gesetzt (über $LOCALTIME_FILE)." + else + warn "Zeitzone konnte nicht auf $tz gesetzt werden (unprivilegierter LXC?). Zeitzone am Host/LXC-Konfiguration einstellen." + fi + return 0 +} diff --git a/deploy/setup-lxc.sh b/deploy/setup-lxc.sh index f7b25fa..84cf30a 100755 --- a/deploy/setup-lxc.sh +++ b/deploy/setup-lxc.sh @@ -7,8 +7,10 @@ SELF=$(readlink -f "${BASH_SOURCE[0]}") source "$(dirname "$SELF")/lib/common.sh" # shellcheck source=lib/caddy.sh source "$COMMON_DIR/caddy.sh" +# shellcheck source=lib/timezone.sh +source "$COMMON_DIR/timezone.sh" -PACKAGES=(python3 python3-venv python3-pip ca-certificates curl rsync) +PACKAGES=(python3 python3-venv python3-pip ca-certificates curl rsync tzdata) CLI_LINK=/usr/local/sbin/checkbot-cli RELAXED_DROPIN_SRC=$DEPLOY_DIR/systemd/checkbot-lxc-relaxed.conf RELAXED_DROPIN_DST=$SYSTEMD_DIR/checkbot.service.d/lxc-relaxed.conf @@ -23,6 +25,7 @@ MONITOR_IP=${CHECKBOT_MONITOR_IP:-} TLS_MODE=${CHECKBOT_TLS_MODE:-internal} TLS_CERT=${CHECKBOT_TLS_CERT:-} TLS_KEY=${CHECKBOT_TLS_KEY:-} +TIMEZONE=${CHECKBOT_TZ:-Europe/Berlin} ASSUME_NO_PROMPT=0 RELAXED=0 @@ -39,6 +42,7 @@ Optionen (Umgebungsvariable in Klammern): --tls-mode MODUS internal (Caddy-interne CA, Standard) oder files (CHECKBOT_TLS_MODE) --tls-cert DATEI Zertifikat (PEM) für --tls-mode files (CHECKBOT_TLS_CERT) --tls-key DATEI privater Schlüssel (PEM) für --tls-mode files (CHECKBOT_TLS_KEY) + --tz ZONE Systemzeitzone des LXC, Standard Europe/Berlin (CHECKBOT_TZ) --lxc-relaxed gelockertes systemd-Drop-In für unprivilegierte LXCs installieren --no-prompt nie nachfragen (z.B. wegen Admin-Anlage) -h, --help diese Hilfe @@ -56,6 +60,7 @@ parse_args() { --tls-mode) TLS_MODE=${2:?--tls-mode braucht einen Wert}; shift 2 ;; --tls-cert) TLS_CERT=${2:?--tls-cert braucht einen Wert}; shift 2 ;; --tls-key) TLS_KEY=${2:?--tls-key braucht einen Wert}; shift 2 ;; + --tz) TIMEZONE=${2:?--tz braucht einen Wert}; shift 2 ;; --lxc-relaxed) RELAXED=1; shift ;; --no-prompt) ASSUME_NO_PROMPT=1; shift ;; -h|--help) usage; exit 0 ;; @@ -69,6 +74,7 @@ validate_args() { FQDN=$(hostname -f 2>/dev/null || hostname) fi [[ $FQDN =~ $HOSTNAME_RE ]] || die "Ungültiger Hostname für die UI: '$FQDN'" + validate_timezone_name "$TIMEZONE" MONITOR_IP=${MONITOR_IP//,/ } [[ $MONITOR_IP =~ $IP_LIST_RE ]] || die "Ungültige Monitoring-IP: '$MONITOR_IP'" case $TLS_MODE in @@ -238,6 +244,7 @@ main() { check_os umask 022 install_packages + ensure_timezone "$TIMEZONE" check_python_version ensure_user_and_dirs sync_code diff --git a/pytest.ini b/pytest.ini index 78c5011..07416a8 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,6 @@ [pytest] asyncio_mode = auto testpaths = tests +addopts = -m "not integration" +markers = + integration: braucht ein echtes Checkmk/Ollama/Chat-Backend (Zugangsdaten per Umgebung), nur mit -m integration diff --git a/tests/test_logsetup.py b/tests/test_logsetup.py new file mode 100644 index 0000000..c568974 --- /dev/null +++ b/tests/test_logsetup.py @@ -0,0 +1,182 @@ +import logging +import sys + +import pytest + +from app import logsetup +from app.fileconfig import DEFAULT_LOG_LEVEL, ENV_LOG_LEVEL, load_file_config +from app.logsetup import SecretMaskFilter, parse_level, setup_logging + +TOKEN = "123456:SECRET-token_abc" +MX_TOKEN = "syt_matrixSecretValue" + + +def record(msg, *args, exc_info=None): + return logging.LogRecord("t", logging.WARNING, __file__, 1, msg, args, exc_info) + + +def masked(flt, msg, *args, exc_info=None): + rec = record(msg, *args, exc_info=exc_info) + assert flt.filter(rec) is True + return rec + + +@pytest.fixture +def clean_logging(): + root = logging.getLogger() + level = root.level + yield + for handler in list(root.handlers): + if getattr(handler, logsetup.HANDLER_MARK, False): + root.removeHandler(handler) + root.setLevel(level) + for name in logsetup.QUIET_LOGGERS: + logging.getLogger(name).setLevel(logging.NOTSET) + + +@pytest.mark.parametrize("raw,expected", [ + ("DEBUG", logging.DEBUG), ("debug", logging.DEBUG), (" warning ", logging.WARNING), + ("ERROR", logging.ERROR), ("INFO", logging.INFO), +]) +def test_parse_level_valid(raw, expected): + assert parse_level(raw) == (expected, True) + + +@pytest.mark.parametrize("raw", [None, "", " "]) +def test_parse_level_unset_is_default_without_warning(raw): + assert parse_level(raw) == (logging.INFO, True) + + +@pytest.mark.parametrize("raw", ["LAUT", "15", "NOTSET", "INFO!"]) +def test_parse_level_invalid_falls_back(raw): + assert parse_level(raw) == (logging.INFO, False) + + +def test_file_config_reads_log_level(monkeypatch): + monkeypatch.delenv(ENV_LOG_LEVEL, raising=False) + assert load_file_config().log_level == DEFAULT_LOG_LEVEL + monkeypatch.setenv(ENV_LOG_LEVEL, "debug") + assert load_file_config().log_level == "debug" + + +def test_setup_sets_level_quiets_httpx_and_is_idempotent(clean_logging): + setup_logging("DEBUG", list) + setup_logging("DEBUG", list) + root = logging.getLogger() + ours = [h for h in root.handlers if getattr(h, logsetup.HANDLER_MARK, False)] + assert len(ours) == 1 + assert root.level == logging.DEBUG + assert logging.getLogger("httpx").level == logging.WARNING + assert logging.getLogger("httpcore").level == logging.WARNING + + +def test_setup_invalid_level_warns_and_uses_info(clean_logging, caplog): + with caplog.at_level(logging.WARNING): + setup_logging("LAUT", list) + assert logging.getLogger().level == logging.INFO + assert "CHECKBOT_LOG_LEVEL" in caplog.text + + +def test_setup_format_has_no_timestamp(clean_logging, capsys): + setup_logging("INFO", list) + logging.getLogger("app.x").info("hallo") + err = capsys.readouterr().err + assert err.strip().endswith("INFO app.x: hallo") + assert err.startswith("INFO ") + + +def test_filter_masks_known_secret_in_message_and_args(): + flt = SecretMaskFilter(lambda: [TOKEN, MX_TOKEN]) + rec = masked(flt, "fehler %s und %s", f"x {TOKEN}", MX_TOKEN) + assert rec.getMessage() == "fehler x *** und ***" + + +def test_filter_masks_url_encoded_secret(): + flt = SecretMaskFilter(lambda: [TOKEN]) + rec = masked(flt, "url /x?t=123456%3ASECRET-token_abc") + assert "SECRET" not in rec.getMessage() + + +def test_filter_masks_bearer_and_bot_url_without_known_secret(): + flt = SecretMaskFilter(list) + rec = masked(flt, "GET https://api.telegram.org/bot999:ABC-def/getMe mit Authorization: Bearer abc.def-123") + text = rec.getMessage() + assert "999:ABC-def" not in text + assert "abc.def-123" not in text + assert "/bot***/getMe" in text + assert "Bearer ***" in text + + +def test_filter_masks_exception_text(): + flt = SecretMaskFilter(lambda: [TOKEN]) + try: + raise RuntimeError(f"kaputt bei /bot{TOKEN}/sendMessage") + except RuntimeError: + rec = masked(flt, "fehlgeschlagen", exc_info=sys.exc_info()) + formatted = logging.Formatter("%(message)s").format(rec) + assert "SECRET-token" not in formatted + assert "RuntimeError" in formatted + + +def test_filter_masks_chained_exception_text(): + flt = SecretMaskFilter(lambda: [MX_TOKEN]) + try: + try: + raise ValueError(f"innen {MX_TOKEN}") + except ValueError as inner: + raise RuntimeError("außen") from inner + except RuntimeError: + rec = masked(flt, "x", exc_info=sys.exc_info()) + assert MX_TOKEN not in logging.Formatter("%(message)s").format(rec) + + +def test_filter_survives_empty_store_and_none_values(): + flt = SecretMaskFilter(lambda: []) + assert masked(flt, "nichts %d", 3).getMessage() == "nichts 3" + flt = SecretMaskFilter(lambda: [None, "", "ab"]) + assert masked(flt, "ab bleibt").getMessage() == "ab bleibt" + + +def test_filter_survives_provider_error_and_bad_format(): + def broken(): + raise RuntimeError("store kaputt") + + flt = SecretMaskFilter(broken) + assert masked(flt, "ok").getMessage() == "ok" + rec = record("zu viele %s %s", "nur eins") + assert flt.filter(rec) is True + assert rec.getMessage() + + +def test_filter_keeps_last_secrets_when_provider_fails_later(): + state = {"fail": False} + now = [0.0] + + def provider(): + if state["fail"]: + raise RuntimeError("weg") + return [TOKEN] + + flt = SecretMaskFilter(provider, ttl=30, clock=lambda: now[0]) + assert masked(flt, TOKEN).getMessage() == "***" + state["fail"] = True + now[0] = 31 + assert masked(flt, TOKEN).getMessage() == "***" + + +def test_filter_caches_and_refreshes_after_ttl(): + calls = [] + now = [100.0] + + def provider(): + calls.append(1) + return [TOKEN] if len(calls) == 1 else [MX_TOKEN] + + flt = SecretMaskFilter(provider, ttl=30, clock=lambda: now[0]) + masked(flt, "a") + masked(flt, "b") + assert len(calls) == 1 + now[0] += 31 + rec = masked(flt, f"{MX_TOKEN} {TOKEN}") + assert len(calls) == 2 + assert rec.getMessage() == f"*** {TOKEN}" diff --git a/tests/test_secret_free_logs.py b/tests/test_secret_free_logs.py new file mode 100644 index 0000000..c191d1d --- /dev/null +++ b/tests/test_secret_free_logs.py @@ -0,0 +1,111 @@ +import logging +import traceback + +import httpx +import pytest + +from app.alerts.delivery import Deliverer +from app.chat import ChatError, MatrixAdapter, MessageRef, Target, TelegramAdapter +from app.downtime import CheckmkDowntimeClient, DowntimeError +from app.logsetup import SecretMaskFilter, setup_logging +from tests.test_logsetup import clean_logging # noqa: F401 (Fixture) + +TG_TOKEN = "123456:SECRET-token_abc" +MX_TOKEN = "syt_matrixSecretValue" +DT_SECRET = "dt-secret-123" +ROOM = "!room:example.org" + + +def tg(handler): + return TelegramAdapter(TG_TOKEN, "42", transport=httpx.MockTransport(handler), sleep=_no_sleep) + + +def mx(handler): + return MatrixAdapter("https://hs.example.org", "@bot:example.org", MX_TOKEN, ROOM, + transport=httpx.MockTransport(handler), sleep=_no_sleep) + + +def dt(handler): + return CheckmkDowntimeClient("https://cmk.example.de", "prod", "dtuser", DT_SECRET, + transport=httpx.MockTransport(handler)) + + +async def _no_sleep(_seconds): + return None + + +def connect_error(request): + raise httpx.ConnectError(f"Verbindung zu {request.url} mit {request.headers.get('authorization')} verweigert") + + +def echo_error(request): + echo = f"{request.url} {request.headers.get('authorization')}" + return httpx.Response(401, json={"ok": False, "description": echo, "errcode": "M_UNKNOWN_TOKEN", + "error": echo, "title": "x", "detail": echo}) + + +def everything(caplog, exc) -> str: + parts = [caplog.text] + parts += [record.getMessage() for record in caplog.records] + parts += [str(exc), repr(exc), "".join(traceback.format_exception(exc))] + return "\n".join(parts) + + +def assert_clean(text): + for secret in (TG_TOKEN, MX_TOKEN, DT_SECRET, "SECRET-token", "matrixSecretValue"): + assert secret not in text + + +CASES = [ + (lambda h: tg(h).send("42", "x"), ChatError), + (lambda h: tg(h).check(), ChatError), + (lambda h: mx(h).send(ROOM, "x"), ChatError), + (lambda h: mx(h).check(), ChatError), + (lambda h: dt(h).version(), DowntimeError), +] + + +@pytest.mark.parametrize("handler", [connect_error, echo_error]) +@pytest.mark.parametrize("call,error", CASES) +async def test_adapter_errors_carry_no_secret(call, error, handler, caplog): + caplog.set_level(logging.DEBUG) + with pytest.raises(error) as info: + await call(handler) + logging.getLogger("app.test").error("Fehler beim Aufruf", exc_info=info.value) + logging.getLogger("app.test").warning("Fehler: %s", info.value, exc_info=info.value) + assert_clean(everything(caplog, info.value)) + + +@pytest.mark.parametrize("handler", [connect_error, echo_error]) +async def test_deliverer_logs_no_secret(handler, caplog): + caplog.set_level(logging.DEBUG) + adapters = [tg(handler), mx(handler)] + + async def provider(): + return adapters + + deliverer = Deliverer(provider) + await deliverer.send("Text", [Target("telegram", "42"), Target("matrix", ROOM)]) + await deliverer.edit([MessageRef("telegram", "42", "1"), MessageRef("matrix", ROOM, "$e")], "neu") + assert caplog.records + assert_clean(caplog.text) + + +async def test_successful_requests_do_not_log_urls(clean_logging, caplog): # noqa: F811 + setup_logging("DEBUG", lambda: [TG_TOKEN]) + caplog.set_level(logging.DEBUG) + + def handler(request): + return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}}) + + await tg(handler).send("42", "x") + assert_clean(caplog.text) + assert not [r for r in caplog.records if r.name.startswith(("httpx", "httpcore"))] + + +def test_filter_masks_what_adapters_do_not_know(): + flt = SecretMaskFilter(lambda: []) + record = logging.LogRecord("x", logging.ERROR, __file__, 1, + f"Fehler bei https://api.telegram.org/bot{TG_TOKEN}/getMe", (), None) + flt.filter(record) + assert TG_TOKEN not in record.getMessage()