feat: test base (logging with secret masking, timezone, Gitea workflow) and roadmap
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
44d4fd9bd1
commit
d2e2f829ad
15 files changed
+532
-9
No files matched your search
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user