From c6eef176a27eb72aea9493bfe4b2b5025c21202e Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:15:01 +0200 Subject: [PATCH] P42-S.M.: Server-Monitoring-Agent als Docker-Container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Misst CPU, RAM, Swap, Platten, Netzwerk, Temperaturen und Prozesse des Hosts und liefert sie als JSON-API (/api/metrics, /api/history) für das P42-Dashboard. Nur Python-Standardbibliothek, Token-geschützt, Container ohne Root und mit schreibgeschütztem Dateisystem. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 8 ++ .env.example | 21 +++ .gitignore | 2 + Dockerfile | 25 ++++ README.md | 183 +++++++++++++++++++++++ app/__init__.py | 0 app/__main__.py | 14 ++ app/collector.py | 311 ++++++++++++++++++++++++++++++++++++++++ app/server.py | 108 ++++++++++++++ deploy/update-docker.sh | 24 ++++ docker-compose.yml | 31 ++++ 11 files changed, 727 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/collector.py create mode 100644 app/server.py create mode 100755 deploy/update-docker.sh create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec3f80f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +.git +.vscode +deploy +README.md +Dockerfile +docker-compose.yml +**/__pycache__ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9cdadc8 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Wird von deploy/update-docker.sh nach .env kopiert (inkl. neu erzeugtem Token). + +# Zugangs-Token für die API. P42-F.W. sendet ihn als Header "Authorization: Bearer ". +# Erzeugen: openssl rand -hex 32 +SM_TOKEN= + +# Port auf dem Server +SM_PORT=9142 + +# 127.0.0.1 = nur vom Server selbst erreichbar. Das Dashboard im Docker-Netz "p42" braucht +# den Port gar nicht. Soll ein anderer Rechner im LAN abfragen: lokale Server-IP eintragen +# (hostname -I) – nicht 0.0.0.0, sonst ist der Port offen im Netz. +SM_BIND=127.0.0.1 + +# Messintervall in Sekunden und Länge des Verlaufs (720 × 5 s = 1 Stunde) +SM_INTERVAL=5 +SM_HISTORY=720 + +# Benutzer-ID, als die der Container läuft. Muss die Ordner bis zu eingehängten Platten +# durchsuchen dürfen (z. B. /home//festplatte → UID dieses Benutzers, siehe "id -u"). +SM_UID=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18d4824 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.env +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21e0be3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# P42-S.M. – liefert die Auslastung des Host-Servers als JSON-API (nur Python-Standardbibliothek). +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Europe/Berlin \ + HOST=0.0.0.0 \ + PORT=9142 \ + HOST_PROC=/host/proc \ + HOST_SYS=/sys \ + HOST_ROOT=/host/root + +WORKDIR /app +COPY app ./app + +# Ohne Root-Rechte laufen (UID über SM_UID in .env änderbar, siehe README) +RUN useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin sm +USER 1000 + +EXPOSE 9142 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9142/api/health', timeout=4).status == 200 else 1)" + +CMD ["python", "-m", "app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1e2539 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# P42-S.M. – Server-Monitoring-Agent + +Kleiner Docker-Dienst, der die Auslastung des Ubuntu-Servers misst und als JSON-API bereitstellt. +**P42-F.W.** (Dashboard) liest die Werte aus und stellt sie dar. + +Gemessen wird alle 5 Sekunden: CPU (gesamt, je Thread, iowait, Takt, Load), RAM, Swap, Platten +(Belegung je Laufwerk, Lese-/Schreibrate), Netzwerk (Rate je Schnittstelle und Summe), +Temperaturen, Prozesse, Uptime. Die letzte Stunde wird als Verlauf vorgehalten. + +- Nur Python-Standardbibliothek, keine Pakete. Das Image ist `python:3.13-slim` plus etwa 15 KB Code. +- Liest `/proc` und `/` des Hosts **nur lesend** ein und läuft ohne Root, ohne Capabilities und mit + schreibgeschütztem Dateisystem. +- Kein Docker-Socket: der Container sieht keine anderen Container und kann nichts steuern. + +## Betrieb + +### Einrichten und Starten + +```bash +git clone ~/festplatte/dockercontainer/p42-sm # oder Ordner kopieren +cd ~/festplatte/dockercontainer/p42-sm +./deploy/update-docker.sh +``` + +Beim ersten Aufruf legt das Skript `.env` mit einem zufälligen Token an und gibt ihn aus. **Diesen Token +an P42-F.W. weitergeben.** Nach etwa 10 Sekunden zeigt `docker compose ps` den Zustand `healthy`. + +Test auf dem Server: + +```bash +curl -H "Authorization: Bearer $(grep ^SM_TOKEN= .env | cut -d= -f2)" http://127.0.0.1:9142/api/metrics +``` + +### Hoch- und runterfahren, beobachten + +```bash +docker compose stop # anhalten +docker compose start # wieder starten +docker compose restart # nach Änderungen an .env: besser "docker compose up -d" +docker compose down # Container entfernen (Netz "p42" wird mit entfernt, falls unbenutzt) +docker compose ps # Status inkl. healthy/unhealthy +docker compose logs -f # Log: nur Start, Fehler und abgelehnte Anfragen +docker stats p42-sm # Verbrauch des Agenten selbst (wenige MB RAM) +``` + +Updates: `./deploy/update-docker.sh` holt per `git pull` den neuen Stand und baut den Container neu. + +### Einstellungen (`.env`) + +| Variable | Standard | Bedeutung | +|---|---|---| +| `SM_TOKEN` | – (Pflicht) | Zugangs-Token der API | +| `SM_PORT` | `9142` | Port auf dem Server | +| `SM_BIND` | `127.0.0.1` | An welche Adresse der Port gebunden wird. Für Abfragen aus dem LAN die Server-IP, nie `0.0.0.0` | +| `SM_INTERVAL` | `5` | Messintervall in Sekunden | +| `SM_HISTORY` | `720` | Anzahl Verlaufspunkte (720 × 5 s = 1 h) | +| `SM_UID` | `1000` | Benutzer-ID des Containers, siehe unten | + +**Fehlt eine Platte in `disks`?** Der Container muss die Ordner bis zum Einhängepunkt durchsuchen dürfen. +Liegt die Platte z. B. unter `/home//festplatte` (Rechte 750), muss `SM_UID` die UID dieses Benutzers +sein (`id -u `). Netzlaufwerke (NFS/SMB) werden bewusst nicht gemessen. + +## Anbindung an P42-F.W. + +### Erreichbarkeit + +**A) Dashboard läuft als Container auf demselben Server (empfohlen):** P42-S.M. legt das Docker-Netz `p42` an. +In der `docker-compose.yml` von P42-F.W. ergänzen: + +```yaml +services: + p42: + # ... wie bisher ... + networks: [default, p42] + +networks: + p42: + external: true +``` + +Danach erreicht das Dashboard-Backend den Dienst unter **`http://p42-sm:9142`**. Ein offener Port nach außen +ist dafür nicht nötig. P42-S.M. muss zuerst gestartet sein, da es das Netz anlegt. + +**B) Dashboard läuft auf einem anderen Rechner:** In `.env` `SM_BIND` auf die LAN-IP des Servers setzen. +Die Adresse lautet dann `http://:9142`. Per Firewall sollte nur der Dashboard-Rechner zugreifen dürfen. + +Abgefragt wird **serverseitig** aus dem Dashboard-Backend, nicht aus dem Browser. Token und Port bleiben so +verborgen, deshalb gibt es kein CORS. + +### API + +Alle Antworten sind JSON. Byte-Werte sind ganze Zahlen in Byte, Raten in Byte pro Sekunde, Prozente auf +eine Nachkommastelle gerundet, und Zeitstempel stehen in ISO 8601 mit Zeitzone. Sinnvolles Abfrageintervall: +alle 5–30 Sekunden; häufiger bringt nichts, weil nur alle `intervalSeconds` neu gemessen wird. + +| Anfrage | Token | Antwort | +|---|---|---| +| `GET /api/health` | nein | `200 {"status":"ok"}`, bzw. `503`, solange noch keine Werte da sind oder die Messung hängt | +| `GET /api/metrics` | ja | aktueller Stand, siehe unten | +| `GET /api/history` | ja | Verlauf; `?minutes=N` begrenzt auf die letzten N Minuten | + +Token als Header: `Authorization: Bearer `. Fehlt er oder ist er falsch, kommt `401`. +Bis die erste Messung vorliegt (etwa 1 s nach dem Start), kommt `503`. + +#### `GET /api/metrics` + +```json +{ + "timestamp": "2026-10-14T14:59:01+02:00", + "intervalSeconds": 5, + "host": { "hostname": "server", "os": "Ubuntu 24.04.1 LTS", "kernel": "6.8.0-45-generic", "uptimeSeconds": 15943 }, + "cpu": { + "model": "AMD Ryzen 7 5700X 8-Core Processor", + "threads": 16, + "percent": 6.2, + "iowaitPercent": 0.0, + "perCorePercent": [2.6, 2.0, 5.5, 21.0], + "mhz": 2894, + "load": { "1m": 0.81, "5m": 0.91, "15m": 0.8 } + }, + "memory": { "totalBytes": 16682397696, "usedBytes": 6809317376, "availableBytes": 9873080320, "cachedBytes": 5789290496, "percent": 40.8 }, + "swap": { "totalBytes": 536866816, "usedBytes": 0, "percent": 0.0 }, + "disks": [ + { "mount": "/", "device": "/dev/sda2", "fs": "ext4", "totalBytes": 983040749568, "usedBytes": 883751505920, "freeBytes": 49278058496, "percent": 94.7 } + ], + "diskIo": { "readBytesPerSec": 44951, "writeBytesPerSec": 73557 }, + "network": { + "rxBytesPerSec": 10279, + "txBytesPerSec": 51353, + "interfaces": [ + { "interface": "eth0", "virtual": false, "rxBytesPerSec": 10279, "txBytesPerSec": 51353, "rxBytesTotal": 704405487, "txBytesTotal": 89327228 } + ] + }, + "temperatures": [ + { "sensor": "k10temp", "label": "Tctl", "celsius": 51.0, "criticalCelsius": null }, + { "sensor": "nvme", "label": "Composite", "celsius": 39.85, "criticalCelsius": 79.85 } + ], + "processes": { "total": 429, "running": 1 } +} +``` + +Hinweise zu den Feldern: + +- `memory.usedBytes` ist der tatsächlich belegte Speicher, also ohne Cache, der jederzeit freigegeben werden + kann. Das entspricht der Spalte „used“ von `free`. +- `disks[].percent` wird wie bei `df` berechnet. `freeBytes` ist der für Benutzer verfügbare Platz. +- `network.rxBytesPerSec`/`txBytesPerSec` summiert nur echte Schnittstellen. Docker-Bridges und `veth*` + stehen mit `"virtual": true` in der Liste, werden aber nicht mitgezählt, sonst zählt Container-Verkehr doppelt. +- `temperatures`: welche Sensoren es gibt, hängt von der Hardware ab. Die Liste kann leer sein (z. B. bei VMs) + und `criticalCelsius` kann `null` sein. +- `cpu.mhz` ist der Durchschnitt aller Threads und kann bei VMs `null` sein. + +#### `GET /api/history?minutes=30` + +```json +{ + "intervalSeconds": 5, + "points": [ + { "timestamp": "2026-10-14T14:58:59+02:00", "cpuPercent": 5.0, "memoryPercent": 40.9, "load1m": 0.81, + "rxBytesPerSec": 2769, "txBytesPerSec": 6111, "readBytesPerSec": 0, "writeBytesPerSec": 2757518 } + ] +} +``` + +Älteste Punkte zuerst. Der Verlauf liegt nur im Speicher und beginnt nach einem Neustart des Containers von vorn. +Für längere Zeiträume speichert das Dashboard die Werte selbst. + +## Lokal testen (ohne Docker) + +```bash +HOST_PROC=/proc HOST_ROOT=/ SM_TOKEN=test PORT=9142 HOST=127.0.0.1 python3 -m app +``` + +## Aufbau + +``` +app/collector.py liest /proc und /sys, berechnet Raten aus zwei Messungen +app/server.py HTTP-API, Token-Prüfung, Mess-Thread, Verlauf +app/__main__.py Start, sauberes Beenden bei "docker stop" +Dockerfile, docker-compose.yml +deploy/update-docker.sh Bauen/Aktualisieren, legt beim ersten Mal .env mit Token an +.env Token und Einstellungen (nicht ins Git) +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..4d77138 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,14 @@ +import logging +import signal +import sys + +from .server import serve + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") +# "docker stop" sendet SIGTERM – sofort sauber beenden statt 10 s auf SIGKILL zu warten +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + +try: + serve() +except KeyboardInterrupt: + pass diff --git a/app/collector.py b/app/collector.py new file mode 100644 index 0000000..0cf76db --- /dev/null +++ b/app/collector.py @@ -0,0 +1,311 @@ +"""Liest die Auslastung des Hosts direkt aus /proc und /sys – ohne Fremdpakete. + +Im Container sind die Host-Verzeichnisse eingebunden (siehe docker-compose.yml): + HOST_PROC /proc des Hosts (Standard /host/proc) + HOST_SYS /sys des Hosts (Standard /sys – sysfs ist im Container ohnehin das des Hosts) + HOST_ROOT / des Hosts (Standard /host/root – für Plattenbelegung und os-release) +Lokal ohne Docker: HOST_PROC=/proc HOST_ROOT=/ setzen. +""" +import os +import re +import time +from datetime import datetime + +HOST_PROC = os.environ.get("HOST_PROC", "/host/proc") +HOST_SYS = os.environ.get("HOST_SYS", "/sys") +HOST_ROOT = os.environ.get("HOST_ROOT", "/host/root") + +# Dateisysteme, die als echte Platten zählen. Netzlaufwerke (nfs, cifs) bewusst nicht: +# statvfs kann dort bei Verbindungsproblemen hängen. +DISK_FS = {"ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "vfat", "exfat", "ntfs", "ntfs3", "fuseblk", "f2fs", "jfs"} +# Ganze Laufwerke ohne Zählwert für die Platten-Aktivität (virtuell oder doppelt gezählt) +SKIP_BLOCK = re.compile(r"^(loop|ram|zram|sr|dm-|md)") +# Virtuelle Netzwerk-Schnittstellen: werden gelistet, aber nicht in die Summe gerechnet, +# sonst zählt Container-Verkehr doppelt (veth -> Bridge -> echte Karte) +VIRTUAL_NET = re.compile(r"^(lo$|veth|docker|br-|virbr|cni|flannel|cali)") +SECTOR = 512 # /proc/diskstats zählt immer in 512-Byte-Sektoren + + +def _read(path: str) -> str: + with open(path, encoding="utf-8", errors="replace") as f: + return f.read() + + +def _read_or(path: str, default=None): + try: + return _read(path).strip() + except OSError: + return default + + +def _host_path(path: str) -> str: + return os.path.join(HOST_ROOT, path.lstrip("/")) + + +# ---------- Rohwerte (für Raten werden zwei Messungen verglichen) ---------- + +def _cpu_times() -> dict: + """{'cpu': (busy, total), 'cpu0': ..., ...} aus /proc/stat.""" + out = {} + for line in _read(f"{HOST_PROC}/stat").splitlines(): + if not line.startswith("cpu"): + continue + name, *vals = line.split() + v = [int(x) for x in vals[:8]] # user nice system idle iowait irq softirq steal + total = sum(v) + idle = v[3] + v[4] + out[name] = (total - idle, total, v[4]) + return out + + +def _disk_bytes() -> tuple[int, int]: + """Gelesene/geschriebene Bytes aller echten Laufwerke (ohne Partitionen, loop, dm).""" + try: + disks = {d for d in os.listdir(f"{HOST_SYS}/block") if not SKIP_BLOCK.match(d)} + except OSError: + disks = None + read = write = 0 + for line in _read(f"{HOST_PROC}/diskstats").splitlines(): + f = line.split() + if len(f) < 10: + continue + name = f[2] + if disks is not None and name not in disks: + continue + read += int(f[5]) * SECTOR + write += int(f[9]) * SECTOR + return read, write + + +def _net_bytes() -> dict: + """{'eth0': (rx, tx), ...} – aus dem Netz-Namespace des Hosts (PID 1), nicht des Containers.""" + path = f"{HOST_PROC}/1/net/dev" + if not os.path.exists(path): + path = f"{HOST_PROC}/net/dev" + out = {} + for line in _read(path).splitlines()[2:]: + name, _, data = line.partition(":") + f = data.split() + if len(f) >= 9: + out[name.strip()] = (int(f[0]), int(f[8])) + return out + + +def raw_sample() -> dict: + return {"time": time.monotonic(), "cpu": _cpu_times(), "disk": _disk_bytes(), "net": _net_bytes()} + + +# ---------- Momentwerte ---------- + +def _meminfo() -> dict: + info = {} + for line in _read(f"{HOST_PROC}/meminfo").splitlines(): + key, _, rest = line.partition(":") + info[key] = int(rest.split()[0]) * 1024 + total = info.get("MemTotal", 0) + available = info.get("MemAvailable", info.get("MemFree", 0)) + swap_total = info.get("SwapTotal", 0) + swap_used = swap_total - info.get("SwapFree", 0) + return { + "memory": { + "totalBytes": total, + "usedBytes": total - available, + "availableBytes": available, + "cachedBytes": info.get("Cached", 0) + info.get("Buffers", 0), + "percent": _pct(total - available, total), + }, + "swap": {"totalBytes": swap_total, "usedBytes": swap_used, "percent": _pct(swap_used, swap_total)}, + } + + +def _unescape_mount(path: str) -> str: + # /proc/mounts schreibt Leerzeichen usw. als \040 + return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), path) + + +def _disks() -> list[dict]: + path = f"{HOST_PROC}/1/mounts" + if not os.path.exists(path): + path = f"{HOST_PROC}/mounts" + seen, out = set(), [] + for line in _read(path).splitlines(): + f = line.split() + if len(f) < 3 or f[2] not in DISK_FS: + continue + device, mount, fstype = f[0], _unescape_mount(f[1]), f[2] + if device in seen: # btrfs-Subvolumes, Bind-Mounts: dasselbe Laufwerk nur einmal + continue + try: + st = os.statvfs(_host_path(mount)) + except OSError: + continue + seen.add(device) + total = st.f_blocks * st.f_frsize + used = (st.f_blocks - st.f_bfree) * st.f_frsize + free = st.f_bavail * st.f_frsize + out.append({ + "mount": mount, + "device": device, + "fs": fstype, + "totalBytes": total, + "usedBytes": used, + "freeBytes": free, + "percent": _pct(used, used + free), # wie df: reservierte Blöcke nicht mitgezählt + }) + return out + + +def _temperatures() -> list[dict]: + out = [] + base = f"{HOST_SYS}/class/hwmon" + try: + sensors = sorted(os.listdir(base)) + except OSError: + return out + for hw in sensors: + d = os.path.join(base, hw) + name = _read_or(f"{d}/name", hw) + try: + inputs = sorted(f for f in os.listdir(d) if re.fullmatch(r"temp\d+_input", f)) + except OSError: + continue + for inp in inputs: + prefix = inp[: -len("_input")] + value = _read_or(f"{d}/{inp}") + if value is None or not value.lstrip("-").isdigit(): + continue + crit = _read_or(f"{d}/{prefix}_crit") + out.append({ + "sensor": name, + "label": _read_or(f"{d}/{prefix}_label", prefix), + "celsius": int(value) / 1000, + "criticalCelsius": int(crit) / 1000 if crit and crit.lstrip("-").isdigit() else None, + }) + return out + + +def _cpu_model() -> str | None: + for line in _read(f"{HOST_PROC}/cpuinfo").splitlines(): + if line.startswith("model name"): + return line.partition(":")[2].strip() + return None + + +def _cpu_mhz() -> float | None: + freqs = [] + try: + cpus = os.listdir(f"{HOST_SYS}/devices/system/cpu") + except OSError: + return None + for cpu in cpus: + if re.fullmatch(r"cpu\d+", cpu): + v = _read_or(f"{HOST_SYS}/devices/system/cpu/{cpu}/cpufreq/scaling_cur_freq") + if v and v.isdigit(): + freqs.append(int(v) / 1000) + return round(sum(freqs) / len(freqs)) if freqs else None + + +def _os_name() -> str | None: + for path in (_host_path("/etc/os-release"), _host_path("/usr/lib/os-release")): + text = _read_or(path) + if text: + m = re.search(r'^PRETTY_NAME="?([^"\n]*)"?', text, re.M) + if m: + return m[1] + return None + + +def _pct(part: float, whole: float) -> float: + return round(part / whole * 100, 1) if whole else 0.0 + + +def _rate(new: int, old: int, seconds: float) -> int: + # Zähler können beim Neustart einer Schnittstelle zurückspringen + return max(0, round((new - old) / seconds)) if seconds > 0 else 0 + + +# ---------- Gesamtbild ---------- + +def snapshot(prev: dict, cur: dict, interval: int) -> dict: + """Alle Werte zu einem JSON-fähigen Dict; Raten aus der Differenz prev -> cur.""" + seconds = cur["time"] - prev["time"] + + cores, cpu_total, iowait = [], 0.0, 0.0 + for name, (busy, total, wait) in cur["cpu"].items(): + p_busy, p_total, p_wait = prev["cpu"].get(name, (busy, total, wait)) + pct = _pct(busy - p_busy, total - p_total) + if name == "cpu": + cpu_total = pct + iowait = _pct(wait - p_wait, total - p_total) + else: + cores.append(pct) + + net, rx_sum, tx_sum = [], 0, 0 + for name, (rx, tx) in sorted(cur["net"].items()): + if name == "lo": + continue + p_rx, p_tx = prev["net"].get(name, (rx, tx)) + rx_rate, tx_rate = _rate(rx, p_rx, seconds), _rate(tx, p_tx, seconds) + virtual = bool(VIRTUAL_NET.match(name)) + if not virtual: + rx_sum += rx_rate + tx_sum += tx_rate + net.append({ + "interface": name, + "virtual": virtual, + "rxBytesPerSec": rx_rate, + "txBytesPerSec": tx_rate, + "rxBytesTotal": rx, + "txBytesTotal": tx, + }) + + load = _read(f"{HOST_PROC}/loadavg").split() + stat = _read(f"{HOST_PROC}/stat") + running = re.search(r"^procs_running (\d+)", stat, re.M) + uptime = float(_read(f"{HOST_PROC}/uptime").split()[0]) + + return { + "timestamp": datetime.now().astimezone().isoformat(timespec="seconds"), + "intervalSeconds": interval, + "host": { + "hostname": _read_or(f"{HOST_PROC}/sys/kernel/hostname"), + "os": _os_name(), + "kernel": _read_or(f"{HOST_PROC}/sys/kernel/osrelease"), + "uptimeSeconds": int(uptime), + }, + "cpu": { + "model": _cpu_model(), + "threads": len(cores), # logische CPUs (mit Hyperthreading) + "percent": cpu_total, + "iowaitPercent": iowait, + "perCorePercent": cores, + "mhz": _cpu_mhz(), + "load": {"1m": float(load[0]), "5m": float(load[1]), "15m": float(load[2])}, + }, + **_meminfo(), + "disks": _disks(), + "diskIo": { + "readBytesPerSec": _rate(cur["disk"][0], prev["disk"][0], seconds), + "writeBytesPerSec": _rate(cur["disk"][1], prev["disk"][1], seconds), + }, + "network": {"rxBytesPerSec": rx_sum, "txBytesPerSec": tx_sum, "interfaces": net}, + "temperatures": _temperatures(), + "processes": { + "total": sum(1 for p in os.listdir(HOST_PROC) if p.isdigit()), + "running": int(running[1]) if running else None, + }, + } + + +def history_point(snap: dict) -> dict: + """Kurzform für den Verlauf (Diagramme).""" + return { + "timestamp": snap["timestamp"], + "cpuPercent": snap["cpu"]["percent"], + "memoryPercent": snap["memory"]["percent"], + "load1m": snap["cpu"]["load"]["1m"], + "rxBytesPerSec": snap["network"]["rxBytesPerSec"], + "txBytesPerSec": snap["network"]["txBytesPerSec"], + "readBytesPerSec": snap["diskIo"]["readBytesPerSec"], + "writeBytesPerSec": snap["diskIo"]["writeBytesPerSec"], + } diff --git a/app/server.py b/app/server.py new file mode 100644 index 0000000..f254d90 --- /dev/null +++ b/app/server.py @@ -0,0 +1,108 @@ +"""HTTP-API: liefert die zuletzt gemessenen Werte als JSON. + + GET /api/health ohne Token – für Docker-Healthcheck und Erreichbarkeitsprüfung + GET /api/metrics aktueller Stand (Token nötig) + GET /api/history Verlauf, optional ?minutes=N (Token nötig) +""" +import hmac +import json +import logging +import os +import threading +import time +from collections import deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from . import collector + +log = logging.getLogger("p42-sm") + +TOKEN = os.environ.get("SM_TOKEN", "").strip() +INTERVAL = max(1, int(os.environ.get("SM_INTERVAL", "5"))) +HISTORY = max(1, int(os.environ.get("SM_HISTORY", "720"))) # 720 × 5 s = 1 Stunde + +_lock = threading.Lock() +_latest: dict | None = None +_history: deque = deque(maxlen=HISTORY) +_last_update = 0.0 + + +def _collect_loop() -> None: + global _latest, _last_update + prev = collector.raw_sample() + time.sleep(1) # erste Messung schon nach 1 s, damit die API schnell Daten hat + while True: + try: + cur = collector.raw_sample() + snap = collector.snapshot(prev, cur, INTERVAL) + with _lock: + _latest = snap + _history.append(collector.history_point(snap)) + _last_update = time.monotonic() + prev = cur + except Exception: + log.exception("Messung fehlgeschlagen") + time.sleep(INTERVAL) + + +class Handler(BaseHTTPRequestHandler): + server_version = "P42-SM" + sys_version = "" + + def _send(self, status: int, body: dict) -> None: + data = json.dumps(body, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + + def _authorized(self) -> bool: + if not TOKEN: + return True + header = self.headers.get("Authorization", "") + scheme, _, given = header.partition(" ") + return scheme.lower() == "bearer" and hmac.compare_digest(given.strip().encode(), TOKEN.encode()) + + def do_GET(self) -> None: + url = urlparse(self.path) + with _lock: + latest, history, age = _latest, list(_history), time.monotonic() - _last_update + + if url.path == "/api/health": + # Ungesund, wenn die Messung hängt (drei Intervalle ohne neue Werte) + ok = latest is not None and age < INTERVAL * 3 + 5 + return self._send(200 if ok else 503, {"status": "ok" if ok else "starting"}) + + if url.path not in ("/api/metrics", "/api/history"): + return self._send(404, {"error": "Nicht gefunden."}) + if not self._authorized(): + return self._send(401, {"error": "Token fehlt oder ist falsch."}) + if latest is None: + return self._send(503, {"error": "Noch keine Messwerte – bitte gleich erneut versuchen."}) + + if url.path == "/api/metrics": + return self._send(200, latest) + + minutes = parse_qs(url.query).get("minutes", [""])[0] + if minutes.isdigit(): + history = history[-max(1, int(minutes) * 60 // INTERVAL):] + return self._send(200, {"intervalSeconds": INTERVAL, "points": history}) + + def log_message(self, fmt: str, *args) -> None: + # Nur Fehler loggen – der Abruf alle paar Sekunden würde das Log sonst fluten + if len(args) >= 2 and str(args[1]).startswith(("4", "5")): + log.warning("%s %s", self.address_string(), fmt % args) + + +def serve() -> None: + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", "9142")) + if not TOKEN: + log.warning("SM_TOKEN ist leer – die API ist ohne Anmeldung abrufbar!") + threading.Thread(target=_collect_loop, daemon=True, name="collector").start() + httpd = ThreadingHTTPServer((host, port), Handler) + log.info("P42-S.M. läuft auf %s:%s (Messintervall %s s, Verlauf %s Punkte)", host, port, INTERVAL, HISTORY) + httpd.serve_forever() diff --git a/deploy/update-docker.sh b/deploy/update-docker.sh new file mode 100755 index 0000000..780532e --- /dev/null +++ b/deploy/update-docker.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Container bauen und (neu) starten – auf dem Server ausführen: /deploy/update-docker.sh +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +# Neuen Code holen, falls das Projekt per Git auf den Server kam +if [ -d .git ]; then + git pull --ff-only +fi + +# Beim ersten Mal: .env mit zufälligem Token anlegen +if [ ! -f .env ]; then + cp .env.example .env + sed -i "s/^SM_TOKEN=.*/SM_TOKEN=$(openssl rand -hex 32)/" .env + chmod 600 .env + echo ".env wurde angelegt. Token für P42-F.W.:" + grep '^SM_TOKEN=' .env | cut -d= -f2 +fi + +docker compose up -d --build +docker image prune -f >/dev/null + +sleep 5 +docker compose ps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fef0e2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + sm: + build: . + image: p42-sm + container_name: p42-sm + restart: unless-stopped + user: "${SM_UID:-1000}" + ports: + # Standardmäßig nur vom Server selbst erreichbar. Port/Adresse in .env änderbar (siehe .env.example). + - "${SM_BIND:-127.0.0.1}:${SM_PORT:-9142}:9142" + environment: + TZ: Europe/Berlin + SM_TOKEN: "${SM_TOKEN:?SM_TOKEN fehlt in .env – deploy/update-docker.sh legt ihn an}" + SM_INTERVAL: "${SM_INTERVAL:-5}" + SM_HISTORY: "${SM_HISTORY:-720}" + volumes: + # Messwerte des Hosts – alles nur lesend + - /proc:/host/proc:ro + # Für Plattenbelegung (auch später eingehängte Platten, daher rslave) und os-release + - /:/host/root:ro,rslave + # Der Container selbst braucht keinerlei Rechte und schreibt nichts + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + networks: [default, p42] + +networks: + # Gemeinsames Netz für P42-Container: das Dashboard (P42-F.W.) erreicht diesen Dienst darin + # unter http://p42-sm:9142, ohne dass ein Port nach außen offen sein muss. + p42: + name: p42