P42-S.M.: Server-Monitoring-Agent als Docker-Container
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 <noreply@anthropic.com>
This commit is contained in:
commit
c6eef176a2
11 files changed
+727
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
deploy
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
**/__pycache__
|
||||||
@@ -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 <token>".
|
||||||
|
# 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/<user>/festplatte → UID dieses Benutzers, siehe "id -u").
|
||||||
|
SM_UID=1000
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/.env
|
||||||
|
__pycache__/
|
||||||
+25
@@ -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"]
|
||||||
@@ -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 <repo> ~/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/<user>/festplatte` (Rechte 750), muss `SM_UID` die UID dieses Benutzers
|
||||||
|
sein (`id -u <user>`). 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://<server-ip>: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 <SM_TOKEN>`. 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)
|
||||||
|
```
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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"],
|
||||||
|
}
|
||||||
+108
@@ -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()
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Container bauen und (neu) starten – auf dem Server ausführen: <projektordner>/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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user