Files
checkbot/app/web/pages.py
T

320 lines
12 KiB
Python

import re
from dataclasses import dataclass
from typing import Awaitable, Callable
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import JSONResponse
from .. import audit
from ..checkmk import CheckmkClient
from ..constants import (CHECKMK_CA_FILE, CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_URL, CHECKMK_USER,
CHECKMK_VERIFY_TLS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_TIMEOUT, FLAG_OFF,
FLAG_ON, OLLAMA_CA_FILE, OLLAMA_MODEL_AGENT, OLLAMA_MODEL_ALERT,
OLLAMA_TIMEOUT, OLLAMA_TOKEN, OLLAMA_URL)
from ..health import build_checks, run_checks
from ..ollama import OllamaClient
from ..users import MIN_PASSWORD_LEN, ROLES, User, UserError
from .deps import (FLASH_ERROR, FLASH_OK, current_user, flash, redirect, render, require_admin)
router = APIRouter()
SITE_RE = re.compile(r"^[A-Za-z0-9_-]+$")
URL_RE = re.compile(r"^https?://[^\s/]+")
AUDIT_LIMIT = 200
STATUS_INVALID = 422
ERROR_DETAIL_MAX = 300
MODEL_LIST_PREVIEW = 10
LATEST_TAG = ":latest"
MSG_SELF_DISABLE = "Das eigene Konto kann nicht gesperrt werden."
MSG_SELF_DELETE = "Das eigene Konto kann nicht gelöscht werden."
MSG_SAVED = "Gespeichert."
@dataclass(frozen=True)
class Field:
key: str
label: str
kind: str = "text"
required: bool = False
secret: bool = False
placeholder: str = ""
default: str = ""
@property
def name(self) -> str:
return self.key.replace(".", "_")
@dataclass(frozen=True)
class Section:
slug: str
title: str
fields: tuple[Field, ...]
validate: Callable[[dict], list[str]]
test: Callable[[dict], Awaitable[str]]
def _validate_checkmk(values: dict) -> list[str]:
errors = []
if not URL_RE.match(values[CHECKMK_URL]):
errors.append("URL muss mit http:// oder https:// beginnen.")
if not SITE_RE.match(values[CHECKMK_SITE]):
errors.append("Site darf nur Buchstaben, Ziffern, _ und - enthalten.")
return errors
def _validate_ollama(values: dict) -> list[str]:
errors = []
if not URL_RE.match(values[OLLAMA_URL]):
errors.append("URL muss mit http:// oder https:// beginnen.")
try:
if float(values.get(OLLAMA_TIMEOUT) or DEFAULT_OLLAMA_TIMEOUT) <= 0:
raise ValueError
except ValueError:
errors.append("Timeout muss eine positive Zahl sein.")
return errors
async def _test_checkmk(values: dict) -> str:
verify = False if values.get(CHECKMK_VERIFY_TLS) == FLAG_OFF else (values.get(CHECKMK_CA_FILE) or True)
client = CheckmkClient(values[CHECKMK_URL], values[CHECKMK_SITE], values[CHECKMK_USER],
values[CHECKMK_SECRET], verify=verify)
try:
info = await client.version()
problems = await client.problem_services()
finally:
await client.aclose()
return f"Checkmk {info.get('versions', {}).get('checkmk', '?')}, {len(problems)} Probleme gelesen"
def _has_model(name: str, available: list[str]) -> bool:
return name in available or f"{name}{LATEST_TAG}" in available
async def _test_ollama(values: dict) -> str:
client = OllamaClient(
values[OLLAMA_URL], values.get(OLLAMA_TOKEN) or None,
timeout=float(values.get(OLLAMA_TIMEOUT) or DEFAULT_OLLAMA_TIMEOUT), retries=0,
verify=values.get(OLLAMA_CA_FILE) or True,
)
try:
version = await client.version()
models = await client.list_models()
finally:
await client.aclose()
missing = [m for m in (values.get(OLLAMA_MODEL_ALERT), values.get(OLLAMA_MODEL_AGENT))
if m and not _has_model(m, models)]
if missing:
raise RuntimeError(f"Modell nicht vorhanden: {', '.join(missing)}")
return f"Ollama {version}, {len(models)} Modelle: {', '.join(models[:MODEL_LIST_PREVIEW])}"
SECTIONS = {
"checkmk": Section(
slug="checkmk",
title="Checkmk",
fields=(
Field(CHECKMK_URL, "URL", required=True, placeholder="https://checkmk.example.de"),
Field(CHECKMK_SITE, "Site", required=True, placeholder="prod"),
Field(CHECKMK_USER, "Automation-User", required=True),
Field(CHECKMK_SECRET, "Automation-Secret", kind="password", required=True, secret=True),
Field(CHECKMK_CA_FILE, "CA-Datei (optional)", placeholder="/etc/ssl/certs/intern-ca.pem"),
Field(CHECKMK_VERIFY_TLS, "TLS-Zertifikat prüfen", kind="checkbox"),
),
validate=_validate_checkmk,
test=_test_checkmk,
),
"ollama": Section(
slug="ollama",
title="Ollama",
fields=(
Field(OLLAMA_URL, "URL", required=True, placeholder="http://office.sysops.de:11434"),
Field(OLLAMA_TOKEN, "Bearer-Token (optional)", kind="password", secret=True),
Field(OLLAMA_TIMEOUT, "Timeout in Sekunden", default=DEFAULT_OLLAMA_TIMEOUT),
Field(OLLAMA_CA_FILE, "CA-Datei (optional)"),
Field(OLLAMA_MODEL_ALERT, "Modell für Alarmtexte", required=True, default=DEFAULT_OLLAMA_MODEL),
Field(OLLAMA_MODEL_AGENT, "Modell für den Agenten", required=True, default=DEFAULT_OLLAMA_MODEL),
),
validate=_validate_ollama,
test=_test_ollama,
),
}
def _section(slug: str) -> Section:
section = SECTIONS.get(slug)
if section is None:
raise HTTPException(404, "Unbekannter Bereich")
return section
def _section_done(store, section: Section) -> bool:
return all(store.get(f.key) for f in section.fields if f.required)
def _collect(form, section: Section) -> dict[str, str | None]:
submitted: dict[str, str | None] = {}
for field in section.fields:
if field.kind == "checkbox":
submitted[field.key] = FLAG_ON if form.get(field.name) else FLAG_OFF
continue
raw = str(form.get(field.name, "")).strip()
submitted[field.key] = (raw or None) if field.secret else raw
return submitted
def _effective(store, submitted: dict[str, str | None]) -> dict[str, str]:
return {key: (value if value is not None else store.get(key) or "") for key, value in submitted.items()}
def _check(section: Section, values: dict[str, str]) -> list[str]:
missing = [f"{f.label} fehlt." for f in section.fields if f.required and not values.get(f.key)]
return missing or section.validate(values)
def _form_values(section: Section, values: dict[str, str]) -> dict:
shown = {}
for field in section.fields:
if field.secret:
continue
if field.kind == "checkbox":
shown[field.name] = values.get(field.key, FLAG_ON) == FLAG_ON
else:
shown[field.name] = values.get(field.key, field.default)
return shown
def _stored_values(store, section: Section) -> dict[str, str]:
return {f.key: store.get(f.key, FLAG_ON if f.kind == "checkbox" else f.default) or ""
for f in section.fields if not f.secret}
@router.get("/")
async def dashboard(request: Request, user: User = Depends(current_user)):
store = request.app.state.store
result = await run_checks(build_checks(store))
setup_open = user.is_admin and not all(_section_done(store, s) for s in SECTIONS.values())
return render(request, "dashboard.html", user=user, result=result, setup_open=setup_open)
@router.get("/setup")
def setup(request: Request, user: User = Depends(require_admin)):
store = request.app.state.store
steps = [{"slug": s.slug, "title": s.title, "done": _section_done(store, s)} for s in SECTIONS.values()]
return render(request, "setup.html", user=user, steps=steps)
@router.get("/settings/{slug}")
def settings_form(request: Request, slug: str, user: User = Depends(require_admin)):
section = _section(slug)
store = request.app.state.store
return render(request, "settings.html", user=user, section=section,
values=_form_values(section, _stored_values(store, section)),
secret_set={f.name: store.is_set(f.key) for f in section.fields if f.secret})
@router.post("/settings/{slug}")
async def settings_save(request: Request, slug: str, user: User = Depends(require_admin)):
section = _section(slug)
store = request.app.state.store
submitted = _collect(await request.form(), section)
effective = _effective(store, submitted)
errors = _check(section, effective)
if errors:
for message in errors:
flash(request, message, FLASH_ERROR)
return render(request, "settings.html", user=user, section=section, status_code=STATUS_INVALID,
values=_form_values(section, effective),
secret_set={f.name: store.is_set(f.key) for f in section.fields if f.secret})
fields = {f.key: f for f in section.fields}
for key, value in submitted.items():
if value is None:
continue
if fields[key].secret or store.get(key) != value:
store.set(key, value, user.username, secret=fields[key].secret)
flash(request, MSG_SAVED, FLASH_OK)
return redirect(f"/settings/{slug}")
@router.post("/settings/{slug}/test")
async def settings_test(request: Request, slug: str, user: User = Depends(require_admin)):
section = _section(slug)
store = request.app.state.store
effective = _effective(store, _collect(await request.form(), section))
errors = _check(section, effective)
if errors:
return JSONResponse({"ok": False, "detail": " ".join(errors)})
try:
detail = await section.test(effective)
except Exception as exc:
return JSONResponse({"ok": False, "detail": f"{type(exc).__name__}: {exc}"[:ERROR_DETAIL_MAX]})
audit.record(request.app.state.db, user.username, "settings.test", slug)
return JSONResponse({"ok": True, "detail": detail})
@router.get("/users")
def users_page(request: Request, user: User = Depends(require_admin)):
return render(request, "users.html", user=user, users=request.app.state.users.list(),
roles=ROLES, min_len=MIN_PASSWORD_LEN)
@router.post("/users")
def users_create(request: Request, username: str = Form(""), password: str = Form(""),
role: str = Form(ROLES[-1]), admin: User = Depends(require_admin)):
try:
request.app.state.users.create(username.strip(), password, role, actor=admin.username)
flash(request, f"Benutzer '{username.strip()}' angelegt.", FLASH_OK)
except UserError as exc:
flash(request, str(exc), FLASH_ERROR)
return redirect("/users")
def _user_action(request: Request, action: Callable[[], None], ok_message: str):
try:
action()
flash(request, ok_message, FLASH_OK)
except UserError as exc:
flash(request, str(exc), FLASH_ERROR)
return redirect("/users")
@router.post("/users/{user_id}/role")
def users_role(request: Request, user_id: int, role: str = Form(""), admin: User = Depends(require_admin)):
users = request.app.state.users
return _user_action(request, lambda: users.set_role(user_id, role, admin.username), "Rolle geändert.")
@router.post("/users/{user_id}/disable")
def users_disable(request: Request, user_id: int, disabled: str = Form(FLAG_ON),
admin: User = Depends(require_admin)):
if user_id == admin.id:
flash(request, MSG_SELF_DISABLE, FLASH_ERROR)
return redirect("/users")
users = request.app.state.users
turn_off = disabled == FLAG_ON
return _user_action(request, lambda: users.set_disabled(user_id, turn_off, admin.username),
"Benutzer gesperrt." if turn_off else "Benutzer entsperrt.")
@router.post("/users/{user_id}/password")
def users_password(request: Request, user_id: int, password: str = Form(""),
admin: User = Depends(require_admin)):
users = request.app.state.users
return _user_action(request, lambda: users.set_password(user_id, password, admin.username),
"Passwort gesetzt.")
@router.post("/users/{user_id}/delete")
def users_delete(request: Request, user_id: int, admin: User = Depends(require_admin)):
if user_id == admin.id:
flash(request, MSG_SELF_DELETE, FLASH_ERROR)
return redirect("/users")
users = request.app.state.users
return _user_action(request, lambda: users.delete(user_id, admin.username), "Benutzer gelöscht.")
@router.get("/audit")
def audit_page(request: Request, user: User = Depends(require_admin)):
return render(request, "audit.html", user=user, entries=audit.recent(request.app.state.db, AUDIT_LIMIT))