feat: test base (logging with secret masking, timezone, Gitea workflow) and roadmap
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-06 01:24:24 +01:00
1 parent 44d4fd9bd1
commit d2e2f829ad
15 files changed
+532 -9

No files matched your search

+25
View File
@@ -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"
+19
View File
@@ -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.
-5
View File
@@ -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
+2 -1
View File
@@ -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,
+4
View File
@@ -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),
)
+95
View File
@@ -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)
+3
View File
@@ -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)
+2 -2
View File
@@ -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
+25
View File
@@ -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<token>/` und `Bearer <token>` 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/<site>`), 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
+7
View File
@@ -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
+46
View File
@@ -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
}
+8 -1
View File
@@ -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
+3
View File
@@ -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
+182
View File
@@ -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}"
+111
View File
@@ -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()