320 lines
12 KiB
Python
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))
|