"""Server-Monitoring pro Konto: eigene Serverliste + Prüfung (HTTP, TCP-Port oder Auslastung über P42-S.M.).""" import asyncio import logging import re import time import httpx from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user from .db import now_iso, transaction from .links import ValidationError, normalize_url router = APIRouter() log = logging.getLogger("p42.monitoring") TYPES = {"http", "tcp", "agent"} MAX_MONITORS = 50 TIMEOUT = 5 HOST_PORT = re.compile(r"(?P[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):(?P\d{1,5})") TOKEN = re.compile(r"[A-Za-z0-9._~+/=-]{8,200}") # Verlauf der Auslastung (Typ "agent"): jede Minute ein Mittelwert, 2 Tage aufbewahrt SAMPLE_SECONDS = 60 KEEP_SECONDS = 2 * 24 * 3600 MAX_HISTORY_POINTS = 480 # so viele Punkte liefert /history höchstens; längere Zeiträume werden gemittelt def _monitor(row) -> dict: return { "id": row["id"], "name": row["name"], "type": row["type"], "target": row["target"], "hasToken": bool(row["token"]), "createdAt": row["created_at"], } def _validate(data: dict, is_new: bool) -> dict: name = data.get("name").strip() if isinstance(data.get("name"), str) else "" if not name or len(name) > 60: raise ValidationError("Name fehlt oder ist zu lang (max. 60 Zeichen).") kind = data.get("type") if kind not in TYPES: raise ValidationError("Ungültiger Prüf-Typ.") target = data.get("target").strip() if isinstance(data.get("target"), str) else "" token = None if kind in ("http", "agent"): target = normalize_url(target, "die Adresse") if kind == "agent": target = target.rstrip("/") # Pfade wie /api/metrics werden angehängt if not target: raise ValidationError("Adresse fehlt.") else: m = HOST_PORT.fullmatch(target) if not m or not 0 < int(m["port"]) < 65536: raise ValidationError("Bitte Host und Port angeben, z. B. 192.168.0.10:22") if kind == "agent": token = data.get("token").strip() if isinstance(data.get("token"), str) else "" # Beim Bearbeiten heißt ein leeres Feld: Token bleibt wie er ist if token and not TOKEN.fullmatch(token): raise ValidationError("Der Token sieht ungültig aus.") if not token and is_new: raise ValidationError("Token fehlt (steht in der .env von P42-S.M.).") return {"name": name, "type": kind, "target": target, "token": token or None} def _get_own(conn, monitor_id: int, user_id: int): row = conn.execute("SELECT * FROM monitors WHERE id = ? AND user_id = ?", (monitor_id, user_id)).fetchone() if not row: raise HTTPException(404, "Nicht gefunden.") return row # ---------- Verwaltung ---------- @router.get("/api/monitors") def list_monitors(user: dict = Depends(current_user)): with transaction() as conn: rows = conn.execute("SELECT * FROM monitors WHERE user_id = ? ORDER BY id", (user["id"],)).fetchall() return [_monitor(r) for r in rows] @router.post("/api/monitors", status_code=201) def create_monitor(data: dict = Body(...), user: dict = Depends(current_user)): values = _validate(data, is_new=True) with transaction() as conn: count = conn.execute("SELECT COUNT(*) FROM monitors WHERE user_id = ?", (user["id"],)).fetchone()[0] if count >= MAX_MONITORS: raise ValidationError(f"Maximal {MAX_MONITORS} Server pro Konto.") cur = conn.execute( "INSERT INTO monitors (user_id, name, type, target, token, created_at) VALUES (?, ?, ?, ?, ?, ?)", (user["id"], values["name"], values["type"], values["target"], values["token"], now_iso()), ) return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (cur.lastrowid,)).fetchone()) @router.put("/api/monitors/{monitor_id}") def update_monitor(monitor_id: int, data: dict = Body(...), user: dict = Depends(current_user)): values = _validate(data, is_new=False) with transaction() as conn: old = _get_own(conn, monitor_id, user["id"]) token = values["token"] or (old["token"] if values["type"] == "agent" else None) if values["type"] == "agent" and not token: raise ValidationError("Token fehlt (steht in der .env von P42-S.M.).") conn.execute( "UPDATE monitors SET name = ?, type = ?, target = ?, token = ? WHERE id = ?", (values["name"], values["type"], values["target"], token, monitor_id), ) # Anderer Server → alter Verlauf gehört nicht mehr dazu if values["type"] != "agent" or values["target"] != old["target"]: conn.execute("DELETE FROM monitor_samples WHERE monitor_id = ?", (monitor_id,)) return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)).fetchone()) @router.delete("/api/monitors/{monitor_id}") def delete_monitor(monitor_id: int, user: dict = Depends(current_user)): with transaction() as conn: cur = conn.execute("DELETE FROM monitors WHERE id = ? AND user_id = ?", (monitor_id, user["id"])) if not cur.rowcount: raise HTTPException(404, "Nicht gefunden.") return {"ok": True} # ---------- Prüfung ---------- async def _check_http(client: httpx.AsyncClient, url: str) -> dict: start = time.perf_counter() try: res = await client.get(url) except httpx.TimeoutException: return {"ok": False, "detail": "Zeitüberschreitung"} except httpx.ConnectError: return {"ok": False, "detail": "Nicht erreichbar"} except httpx.HTTPError as e: return {"ok": False, "detail": type(e).__name__} ms = round((time.perf_counter() - start) * 1000) # 4xx heißt: Server antwortet (z. B. Anmeldung nötig) – also erreichbar return {"ok": res.status_code < 500, "ms": ms, "detail": f"HTTP {res.status_code}"} async def _check_tcp(target: str) -> dict: m = HOST_PORT.fullmatch(target) host = m["host"].strip("[]") start = time.perf_counter() try: _, writer = await asyncio.wait_for(asyncio.open_connection(host, int(m["port"])), TIMEOUT) except asyncio.TimeoutError: return {"ok": False, "detail": "Zeitüberschreitung"} except OSError as e: return {"ok": False, "detail": "Verbindung abgelehnt" if isinstance(e, ConnectionRefusedError) else "Nicht erreichbar"} ms = round((time.perf_counter() - start) * 1000) writer.close() return {"ok": True, "ms": ms, "detail": f"Port {m['port']} offen"} async def _agent_get(client: httpx.AsyncClient, base: str, token: str, path: str) -> tuple[dict | None, dict]: """Abruf bei P42-S.M.: (Daten, Status) – Daten None bei Fehler.""" start = time.perf_counter() try: res = await client.get(f"{base}{path}", headers={"Authorization": f"Bearer {token}"}) except httpx.TimeoutException: return None, {"ok": False, "detail": "Zeitüberschreitung"} except httpx.ConnectError: return None, {"ok": False, "detail": "Nicht erreichbar"} except httpx.HTTPError as e: return None, {"ok": False, "detail": type(e).__name__} ms = round((time.perf_counter() - start) * 1000) if res.status_code == 401: return None, {"ok": False, "ms": ms, "detail": "Token falsch"} if res.status_code == 503: return None, {"ok": False, "ms": ms, "detail": "Agent startet gerade"} try: data = res.json() if res.status_code == 200 else None except ValueError: data = None if not isinstance(data, dict): return None, {"ok": False, "ms": ms, "detail": f"Keine P42-S.M.-Antwort (HTTP {res.status_code})"} return data, {"ok": True, "ms": ms} async def _check_agent(client: httpx.AsyncClient, base: str, token: str) -> dict: metrics, status = await _agent_get(client, base, token, "/api/metrics") if metrics is None: return status cpu = (metrics.get("cpu") or {}).get("percent") mem = (metrics.get("memory") or {}).get("percent") return {**status, "detail": f"CPU {cpu} % · RAM {mem} %", "metrics": metrics} def _client() -> httpx.AsyncClient: # Zertifikate werden nicht geprüft: im Heimnetz sind selbstsignierte Zertifikate üblich return httpx.AsyncClient(timeout=TIMEOUT, verify=False, follow_redirects=True) @router.get("/api/monitors/status") async def monitor_status(user: dict = Depends(current_user)): with transaction() as conn: rows = conn.execute("SELECT id, type, target, token FROM monitors WHERE user_id = ?", (user["id"],)).fetchall() async with _client() as client: def check(r): if r["type"] == "http": return _check_http(client, r["target"]) if r["type"] == "agent": return _check_agent(client, r["target"], r["token"]) return _check_tcp(r["target"]) results = await asyncio.gather(*(check(r) for r in rows)) checked = now_iso() return [{"id": r["id"], "checkedAt": checked, **res} for r, res in zip(rows, results)] @router.get("/api/monitors/{monitor_id}/metrics") async def monitor_metrics(monitor_id: int, user: dict = Depends(current_user)): """Aktuelle Werte eines P42-S.M.-Servers (für die Detailansicht, wird dort öfter abgefragt).""" with transaction() as conn: row = _get_own(conn, monitor_id, user["id"]) if row["type"] != "agent": raise HTTPException(400, "Dieser Eintrag liefert keine Auslastung.") async with _client() as client: result = await _check_agent(client, row["target"], row["token"]) return {"checkedAt": now_iso(), **result} @router.get("/api/monitors/{monitor_id}/history") def monitor_history(monitor_id: int, hours: int = 48, user: dict = Depends(current_user)): hours = min(max(hours, 1), KEEP_SECONDS // 3600) with transaction() as conn: row = _get_own(conn, monitor_id, user["id"]) if row["type"] != "agent": raise HTTPException(400, "Dieser Eintrag liefert keine Auslastung.") # Auf höchstens MAX_HISTORY_POINTS Punkte mitteln (volle Minuten) bucket = max(SAMPLE_SECONDS, -(-hours * 3600 // MAX_HISTORY_POINTS // SAMPLE_SECONDS) * SAMPLE_SECONDS) since = int(time.time()) - hours * 3600 points = conn.execute( """SELECT (ts / :b) * :b AS t, AVG(cpu) AS cpu, AVG(mem) AS mem, AVG(swap) AS swap, AVG(load1) AS load, AVG(rx) AS rx, AVG(tx) AS tx, AVG(rd) AS read, AVG(wr) AS write, MAX(temp) AS temp FROM monitor_samples WHERE monitor_id = :id AND ts >= :since GROUP BY t ORDER BY t""", {"b": bucket, "id": monitor_id, "since": since}, ).fetchall() return { "hours": hours, "bucketSeconds": bucket, "points": [{k: (round(p[k], 2) if isinstance(p[k], float) else p[k]) for k in p.keys()} for p in points], } # ---------- Verlauf sammeln (läuft im Hintergrund, unabhängig davon, ob jemand die Seite offen hat) ---------- def _avg(points: list, key: str): values = [p[key] for p in points if isinstance(p.get(key), (int, float))] return sum(values) / len(values) if values else None async def _sample_once() -> None: with transaction() as conn: rows = conn.execute("SELECT id, target, token FROM monitors WHERE type = 'agent' AND token IS NOT NULL").fetchall() if not rows: return async with _client() as client: results = await asyncio.gather(*(_agent_get(client, r["target"], r["token"], "/api/history?minutes=1") for r in rows)) ts = int(time.time()) // SAMPLE_SECONDS * SAMPLE_SECONDS with transaction() as conn: for r, (data, _) in zip(rows, results): points = (data or {}).get("points") if not isinstance(points, list) or not points: continue # Server nicht erreichbar → Lücke im Diagramm points = [p for p in points if isinstance(p, dict)] temps = [p["tempMaxCelsius"] for p in points if isinstance(p.get("tempMaxCelsius"), (int, float))] conn.execute( "INSERT OR REPLACE INTO monitor_samples VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( r["id"], ts, _avg(points, "cpuPercent"), _avg(points, "memoryPercent"), _avg(points, "swapPercent"), _avg(points, "load1m"), _avg(points, "rxBytesPerSec"), _avg(points, "txBytesPerSec"), _avg(points, "readBytesPerSec"), _avg(points, "writeBytesPerSec"), max(temps) if temps else None, ), ) conn.execute("DELETE FROM monitor_samples WHERE ts < ?", (ts - KEEP_SECONDS,)) async def sample_loop() -> None: while True: try: await _sample_once() except Exception: log.exception("Verlauf der Server-Auslastung konnte nicht gespeichert werden") # Auf die nächste volle Minute warten await asyncio.sleep(SAMPLE_SECONDS - time.time() % SAMPLE_SECONDS + 1)