diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b15e67..73d1b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.14.0] – 2026-09-18 + +### Hinzugefügt +- Der Agent meldet die Mitglieder seines Proxmox-Clusters; das Dashboard warnt bei Knoten ohne eigenen Agent (#022) +- Host-Detail zeigt alle Cluster-Knoten mit Zustand „meldet sich“ oder „kein Agent“ (#022) +- Wartezeiten zwischen Sendeversuchen über `PROXLEDGER_WARTEZEITEN` einstellbar, damit sich der Zwischenspeicher testen lässt (#018) + +### Behoben +- Die Produktversion enthielt bei Proxmox VE den Kernel ein zweites Mal (#018) +- Die Bilder des Handbuchs zeigten allesamt die Anmeldeseite: Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend. Das Skript nutzt jetzt einen lokalen Vermittler, der die Sitzung mitschickt, und bricht ab, wenn alle Aufnahmen gleich groß sind (#016) + ## [0.13.1] – 2026-09-17 ### Sicherheit diff --git a/DEVLOG.md b/DEVLOG.md index 8ff4346..22bd419 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 8 h 03 min +**Gesamt:** 9 h 08 min | Issue | Zeit | |---|---| @@ -26,6 +26,8 @@ | #017 Favicons | 0 h 15 min | | #019 CI-Pipeline | 0 h 20 min | | #021 Geheimnisse verschlüsseln | 0 h 20 min | +| #018 Feldtest auf echten Hosts | 0 h 40 min | +| #022 Cluster-Abgleich | 0 h 25 min | | Meilenstein | Zeit | |---|---| @@ -48,7 +50,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.13.1 +**Version:** 0.14.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -127,6 +129,10 @@ - Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen. - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) +- #018 abgeschlossen: Feldtest auf zwei echten PVE-9.2-Hosts (einer mit, einer ohne Subscription, einer im Cluster); Installation, Timer, Zwischenspeicher und Nachsenden geprüft +- #022 abgeschlossen: Cluster-Abgleich – jeder Agent meldet die Mitglieder, das Dashboard warnt bei Knoten ohne Agent +- **Fehler im eigenen Werkzeug gefunden:** Alle Handbuch-Bilder waren in Wahrheit die Anmeldeseite. Firefox verwirft eine selbst angelegte Cookie-Datei stillschweigend. Jetzt läuft ein lokaler Vermittler mit, und das Skript bricht ab, wenn alle Aufnahmen gleich groß sind. + ### Nächster Schritt - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline -- Für 1.0.0 fehlt vor allem der Feldtest auf echten Proxmox-Hosts (#018) +- Für 1.0.0 fehlen noch: Feldtest auf PBS und PMG, Uninstall auf einem echten Host diff --git a/agent/proxledger-agent.sh b/agent/proxledger-agent.sh index 02c4808..847e5a4 100755 --- a/agent/proxledger-agent.sh +++ b/agent/proxledger-agent.sh @@ -16,7 +16,8 @@ AGENT_VERSION="0.2.0" CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}" SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}" SPOOL_MAXIMUM=30 -WARTEZEITEN=(30 120 480) +# Wartezeiten zwischen den Versuchen; für Tests über PROXLEDGER_WARTEZEITEN kürzbar. +read -r -a WARTEZEITEN <<<"${PROXLEDGER_WARTEZEITEN:-30 120 480}" # --------------------------------------------------------------------- Protokoll @@ -128,8 +129,10 @@ feld_lesen() { } produkt_version() { + # Bei PVE hängt die Kernel-Angabe mit im String ("… (running kernel: …)"). + # Der Kernel wird separat gemeldet, deshalb hier abschneiden. case "$1" in - pve) pveversion 2>/dev/null | head -n1 ;; + pve) pveversion 2>/dev/null | head -n1 | sed 's/ (running kernel.*//' ;; pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;; pmg) pmgversion 2>/dev/null | head -n1 ;; *) printf '' ;; @@ -150,6 +153,14 @@ cluster_name() { fi } +# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier – +# daraus erkennt das Dashboard vergessene Knoten (#022). +cluster_mitglieder() { + if command -v pvecm >/dev/null 2>&1; then + pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//' + fi +} + node_name() { if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then hostname -s 2>/dev/null @@ -220,6 +231,7 @@ report_erzeugen() { "ips": $(ip_adressen | json_liste), "clusterName": $(json_text "$(cluster_name)"), "nodeName": $(json_text "$(node_name)"), + "clusterMitglieder": $(cluster_mitglieder | json_liste), "kernel": $(json_text "$(uname -r 2>/dev/null)"), "uptimeSekunden": $(json_zahl "$(uptime_sekunden)"), "cpuModell": $(json_text "$(cpu_modell)"), diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index 0c27f1f..c9394d7 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -11,6 +11,7 @@ auch mit Hosts hinter NAT. 2. [Erste Anmeldung](#2-erste-anmeldung) 3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen) 4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen) +4a. [Cluster](#4a-cluster) 5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen) 6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen) 7. [Warnungen](#7-warnungen) @@ -157,6 +158,22 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet. --- +## 4a. Cluster + +**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei +Knoten braucht drei Lizenzen und drei Agents. Ein Agent kann die Lizenz eines anderen Knotens nicht +stellvertretend melden. + +Damit ein vergessener Knoten auffällt, meldet jeder Agent die Liste aller Cluster-Mitglieder mit. Das +Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden: + +![Host in einem Cluster](bilder/host-detail-cluster.png) + +Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal +deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst. + +--- + ## 5. Kunden und Standorte pflegen ![Kundenübersicht](bilder/kunden.png) diff --git a/docs/anleitung/bilder/audit.png b/docs/anleitung/bilder/audit.png index 059f3a7..ba4adca 100644 Binary files a/docs/anleitung/bilder/audit.png and b/docs/anleitung/bilder/audit.png differ diff --git a/docs/anleitung/bilder/benachrichtigungen.png b/docs/anleitung/bilder/benachrichtigungen.png index 059f3a7..cca8956 100644 Binary files a/docs/anleitung/bilder/benachrichtigungen.png and b/docs/anleitung/bilder/benachrichtigungen.png differ diff --git a/docs/anleitung/bilder/dashboard-dunkel.png b/docs/anleitung/bilder/dashboard-dunkel.png index 7fdb908..eecbe44 100644 Binary files a/docs/anleitung/bilder/dashboard-dunkel.png and b/docs/anleitung/bilder/dashboard-dunkel.png differ diff --git a/docs/anleitung/bilder/dashboard.png b/docs/anleitung/bilder/dashboard.png index 059f3a7..ab0dc01 100644 Binary files a/docs/anleitung/bilder/dashboard.png and b/docs/anleitung/bilder/dashboard.png differ diff --git a/docs/anleitung/bilder/host-detail-cluster.png b/docs/anleitung/bilder/host-detail-cluster.png new file mode 100644 index 0000000..5466084 Binary files /dev/null and b/docs/anleitung/bilder/host-detail-cluster.png differ diff --git a/docs/anleitung/bilder/host-detail.png b/docs/anleitung/bilder/host-detail.png index 059f3a7..c21d7a2 100644 Binary files a/docs/anleitung/bilder/host-detail.png and b/docs/anleitung/bilder/host-detail.png differ diff --git a/docs/anleitung/bilder/hosts-dunkel.png b/docs/anleitung/bilder/hosts-dunkel.png index 7fdb908..bea3d23 100644 Binary files a/docs/anleitung/bilder/hosts-dunkel.png and b/docs/anleitung/bilder/hosts-dunkel.png differ diff --git a/docs/anleitung/bilder/hosts.png b/docs/anleitung/bilder/hosts.png index 059f3a7..6891860 100644 Binary files a/docs/anleitung/bilder/hosts.png and b/docs/anleitung/bilder/hosts.png differ diff --git a/docs/anleitung/bilder/jahresbahn.png b/docs/anleitung/bilder/jahresbahn.png index 059f3a7..57a507d 100644 Binary files a/docs/anleitung/bilder/jahresbahn.png and b/docs/anleitung/bilder/jahresbahn.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png index 059f3a7..a162ee4 100644 Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/mobil-dashboard.png b/docs/anleitung/bilder/mobil-dashboard.png index c7a3b77..aaa9a39 100644 Binary files a/docs/anleitung/bilder/mobil-dashboard.png and b/docs/anleitung/bilder/mobil-dashboard.png differ diff --git a/docs/anleitung/bilder/mobil-hosts.png b/docs/anleitung/bilder/mobil-hosts.png index c7a3b77..c288581 100644 Binary files a/docs/anleitung/bilder/mobil-hosts.png and b/docs/anleitung/bilder/mobil-hosts.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index 059f3a7..803b8ef 100644 Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ diff --git a/docs/anleitung/bilder/rechnungen-dunkel.png b/docs/anleitung/bilder/rechnungen-dunkel.png index 7fdb908..848f13b 100644 Binary files a/docs/anleitung/bilder/rechnungen-dunkel.png and b/docs/anleitung/bilder/rechnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index 059f3a7..5129ec1 100644 Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png index 059f3a7..f390292 100644 Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/anleitung/bilder/warnregeln.png b/docs/anleitung/bilder/warnregeln.png index 059f3a7..69ed67e 100644 Binary files a/docs/anleitung/bilder/warnregeln.png and b/docs/anleitung/bilder/warnregeln.png differ diff --git a/docs/anleitung/bilder/warnungen-dunkel.png b/docs/anleitung/bilder/warnungen-dunkel.png index 7fdb908..82e7662 100644 Binary files a/docs/anleitung/bilder/warnungen-dunkel.png and b/docs/anleitung/bilder/warnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/warnungen.png b/docs/anleitung/bilder/warnungen.png index 059f3a7..74c86ba 100644 Binary files a/docs/anleitung/bilder/warnungen.png and b/docs/anleitung/bilder/warnungen.png differ diff --git a/docs/issues/018-agent-feldtest.md b/docs/issues/018-agent-feldtest.md index d9cdd80..66ce9c3 100644 --- a/docs/issues/018-agent-feldtest.md +++ b/docs/issues/018-agent-feldtest.md @@ -1,22 +1,22 @@ # #018 Agent auf echten Proxmox-Hosts prüfen -**Status:** offen -**Meilenstein:** v0.2.1 +**Status:** erledigt +**Meilenstein:** v0.14.0 **Labels:** agent, test -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min ## Ziel Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er auf echten Hosts laufen. ## Aufgaben -- [ ] Installation auf Proxmox VE (8.x und 9.x) -- [ ] Installation auf Proxmox Backup Server -- [ ] Installation auf Proxmox Mail Gateway -- [ ] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und +- [x] Installation auf Proxmox VE 9.2 (zwei Hosts: mit und ohne Subscription) +- [ ] Installation auf Proxmox Backup Server (kein Host verfügbar) +- [ ] Installation auf Proxmox Mail Gateway (kein Host verfügbar) +- [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und `pmgsubscription get` mit den Annahmen im Skript abgleichen -- [ ] Verhalten ohne Subscription und mit abgelaufener Subscription prüfen -- [ ] Timer-Lauf, Spool-Nachsendung und Uninstall auf einem echten Host prüfen -- [ ] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen +- [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null) +- [x] Timer und Spool-Nachsendung geprüft; Uninstall steht noch aus (Agents laufen vorerst weiter) +- [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen ## Akzeptanzkriterien - [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch @@ -27,3 +27,36 @@ auf echten Hosts laufen. ## Notizen / Entscheidungen - Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen. + +### Ergebnis des Feldtests (18.09.2026) + +Zwei echte Hosts, beide Proxmox VE 9.2, per SSH: + +| | `hv-rz5-pve-01` | `hv-ste-pve-01` | +|---|---|---| +| Subscription | keine (`notfound`) | aktiv, Ablauf 03.06.2027 | +| Cluster | ja, drei Knoten | nein | +| Sockets | 2 | 1 | +| Ergebnis | Report korrekt, Warnungen korrekt | Report korrekt, Laufzeit-Vorschlag angelegt | + +**Was sich bestätigt hat:** Die Ausgaben von `pvesubscription get`, `pveversion`, `lscpu`, `ip -o addr`, +`pvecm status` und `apt list --upgradable` entsprechen den Annahmen. Der Agent erzeugte auf beiden Hosts +gültiges JSON, die Installation lief in einem Zug durch, der Timer ist aktiv, der Zwischenspeicher +funktioniert (Report gepuffert bei unerreichbarem Dashboard, beim nächsten Lauf nachgesendet). + +**Was anders war als gedacht:** + +1. `pvesubscription get` kennt **kein** `--output-format json` (PVE 9.2). Die Textauswertung ist also der + einzige Weg – gut, dass sie den Vorrang hatte. +2. Ohne Subscription liefert der Befehl nur vier Zeilen (`message`, `serverid`, `status`, `url`); alle + übrigen Felder fehlen vollständig. Der Agent meldet sie als `null`, das Dashboard zeigt „Keine + Subscription“. +3. `pveversion` enthält den Kernel im selben String („… (running kernel: …)“). Da der Kernel separat + gemeldet wird, schneidet der Agent ihn jetzt ab. +4. `regdate` kommt mit Uhrzeit („2026-06-03 00:00:00“), `checktime` als Unix-Sekunden – beides wurde + richtig behandelt. +5. Die Wartezeiten zwischen den Sendeversuchen lassen sich jetzt über `PROXLEDGER_WARTEZEITEN` kürzen, + sonst dauert ein Test des Zwischenspeichers zehneinhalb Minuten. +6. **Cluster:** Jeder Knoten hat eine eigene Subscription – daraus entstand #022. + +**Noch offen:** PBS und PMG (keine Hosts verfügbar), Uninstall auf einem echten Host. diff --git a/docs/issues/022-cluster-abgleich.md b/docs/issues/022-cluster-abgleich.md new file mode 100644 index 0000000..8ccc79b --- /dev/null +++ b/docs/issues/022-cluster-abgleich.md @@ -0,0 +1,38 @@ +# #022 Cluster-Abgleich +**Status:** erledigt +**Meilenstein:** v0.14.0 +**Labels:** agent, backend, alerts +**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min + +## Ziel +In einem Proxmox-Cluster hat jeder Knoten eine eigene Subscription mit eigener Server-ID. Wird beim +Ausrollen ein Knoten vergessen, fällt das heute niemandem auf – das Dashboard kennt ihn schlicht nicht. +Jeder Agent soll deshalb die Liste der Cluster-Mitglieder mitmelden, damit das Dashboard fehlende +Knoten erkennt. + +## Aufgaben +- [x] Agent meldet `clusterMitglieder` (Namen aller Knoten laut `pvecm nodes`) +- [x] Agent-API nimmt das Feld entgegen (optional, wie alle anderen) +- [x] Datenmodell: Feld am Host, Migration +- [x] Warnregel „Cluster-Knoten ohne Agent“ +- [x] Host-Detail zeigt die Mitglieder mit Zustand (meldet sich / kein Agent) +- [x] Tests: Agent mit nachgebautem `pvecm`, Regel mit vollständigem und lückenhaftem Cluster + +## Akzeptanzkriterien +- [x] Ein Cluster mit drei Knoten und zwei Agents erzeugt genau eine Warnung +- [x] Sobald der dritte Agent meldet, schließt sich die Warnung von selbst +- [x] Hosts ohne Cluster erzeugen keine Warnung + +## Abhängigkeiten +#005, #011, #018 + +## Notizen / Entscheidungen +- Entstanden beim Feldtest (#018) an einem echten Cluster mit drei Knoten: Alle drei haben eigene + Server-IDs und eigenen Status, bestätigt über `pvesh get /nodes//subscription`. +- Bewusst **kein** Sammel-Agent, der die anderen Knoten über die API mitmeldet: Fällt der meldende Knoten + aus, wäre der ganze Cluster blind, und für die übrigen Knoten gäbe es weder Updates noch Uptime oder + Kernel. Stattdessen Agent je Knoten plus dieser Abgleich. +- Am echten Cluster geprüft: `hv-rz5-pve-01` meldet alle drei Knoten, für die beiden ohne Agent entstehen + genau zwei Warnungen. +- Die Mitgliederliste kommt aus `pvecm nodes`; Hosts ohne Cluster melden eine leere Liste. +- Der Abgleich vergleicht Hostnamen ohne Rücksicht auf Groß- und Kleinschreibung. diff --git a/docs/issues/README.md b/docs/issues/README.md index dbde2dd..bc80c49 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -21,8 +21,9 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min | -| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | +| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | erledigt | v0.14.0 | 2 h | 0 h 40 min | | [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min | -| | **Summe** | | | **116 h** | **8 h 03 min** | +| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min | +| | **Summe** | | | **119 h** | **9 h 08 min** | diff --git a/package.json b/package.json index bae269b..8d94e76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.13.1", + "version": "0.14.0", "private": true, "scripts": { "dev": "next dev", diff --git a/prisma/migrations/20260918154920_cluster_mitglieder/migration.sql b/prisma/migrations/20260918154920_cluster_mitglieder/migration.sql new file mode 100644 index 0000000..eaafa25 --- /dev/null +++ b/prisma/migrations/20260918154920_cluster_mitglieder/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Host" ADD COLUMN "clusterMitglieder" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71da3d8..9819fb3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -113,6 +113,8 @@ model Host { serverId String? clusterName String? nodeName String? + /// Namen aller Knoten des Clusters laut corosync – auch solche ohne eigenen Agent (#022). + clusterMitglieder String[] @default([]) ips String[] @default([]) sockets Int? cpuModell String? diff --git a/scripts/feldtest-erheben.sh b/scripts/feldtest-erheben.sh new file mode 100755 index 0000000..819a981 --- /dev/null +++ b/scripts/feldtest-erheben.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Sammelt auf einem Proxmox-Host alle Ausgaben, die der ProxLedger-Agent auswertet. +# Es wird nichts installiert und nichts verändert – das Skript liest nur. +# +# bash feldtest-erheben.sh # Ausgabe anonymisiert (Empfehlung zum Weitergeben) +# bash feldtest-erheben.sh --vollstaendig # ohne Anonymisierung, nur für den eigenen Gebrauch +# +# Ergebnis: eine Textdatei im aktuellen Verzeichnis, die weitergegeben werden kann. + +set -o nounset +set -o pipefail + +ANONYM="ja" +[ "${1:-}" = "--vollstaendig" ] && ANONYM="nein" + +AUSGABE="proxledger-feldtest-$(hostname -s 2>/dev/null || echo host)-$(date +%Y%m%d-%H%M%S).txt" + +# Ersetzt Subscription-Keys, Server-IDs, Hostnamen und IP-Adressen durch Platzhalter. +anonymisieren() { + if [ "$ANONYM" = "nein" ]; then + cat + return + fi + sed -E \ + -e 's/(pve[0-9]?[a-z]|pbs[a-z]|pmg[a-z])-[A-Za-z0-9]+/\1-KEY-ANONYMISIERT/g' \ + -e 's/([Ss]erverid:[[:space:]]*)[A-Za-z0-9]+/\1SERVERID-ANONYMISIERT/g' \ + -e 's/\b([0-9]{1,3}\.){3}[0-9]{1,3}\b/IP-ANONYMISIERT/g' \ + -e "s/$(hostname -s 2>/dev/null || echo unbekannt)/HOSTNAME-ANONYMISIERT/g" +} + +abschnitt() { + printf '\n===== %s =====\n' "$1" +} + +# Führt einen Befehl aus und hält fest, ob es ihn überhaupt gibt. +zeige() { + local befehl="$1" + abschnitt "$befehl" + if ! command -v "${befehl%% *}" >/dev/null 2>&1; then + printf '(Befehl nicht vorhanden)\n' + return + fi + eval "$befehl" 2>&1 | head -n 40 + printf '[Rückgabewert: %s]\n' "$?" +} + +{ + printf 'ProxLedger – Erhebung für den Feldtest\n' + printf 'Erstellt am: %s\n' "$(date -Is)" + printf 'Anonymisiert: %s\n' "$ANONYM" + + abschnitt "Betriebssystem" + cat /etc/os-release 2>/dev/null | head -n 5 + printf 'Kernel: %s\n' "$(uname -r)" + + abschnitt "Erkanntes Produkt" + for befehl in pvesubscription proxmox-backup-manager pmgsubscription; do + if command -v "$befehl" >/dev/null 2>&1; then + printf 'gefunden: %s\n' "$befehl" + fi + done + + zeige "pvesubscription get" + zeige "proxmox-backup-manager subscription get" + zeige "pmgsubscription get" + + # Einige Fassungen können JSON – das wäre die stabilere Quelle. + zeige "pvesubscription get --output-format json" + zeige "proxmox-backup-manager subscription get --output-format json" + + zeige "pveversion" + zeige "proxmox-backup-manager version" + zeige "pmgversion" + + zeige "lscpu | grep -E 'Socket|Model name|Sockel|Modellname'" + zeige "ip -o addr show scope global" + zeige "hostname -f" + zeige "pvecm status" + zeige "apt list --upgradable 2>/dev/null | grep -c 'upgradable from'" + + abschnitt "Vorhandene Werkzeuge" + for befehl in curl systemctl logger jq awk sed; do + printf '%-12s %s\n' "$befehl" "$(command -v "$befehl" || echo 'FEHLT')" + done + + abschnitt "Ende" +} 2>&1 | anonymisieren > "$AUSGABE" + +printf 'Fertig: %s\n' "$AUSGABE" +printf 'Anonymisiert: %s (mit --vollstaendig ohne Anonymisierung)\n' "$ANONYM" +printf 'Bitte vor dem Weitergeben kurz durchsehen.\n' diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index 2d2da30..51c8988 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -2,19 +2,21 @@ /** * Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/). * - * npm run screenshots # heller Modus, Desktop - * npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht + * npm run screenshots # heller Modus, Desktop + * npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht + * npm run screenshots -- --seite name=/pfad # zusätzlich eine bestimmte Seite * * Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten. * Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt * und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet. */ -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { request as httpAnfrage } from "node:http"; import { SignJWT } from "jose"; import "dotenv/config"; @@ -22,6 +24,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000"; const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); const ALLE = process.argv.includes("--alle"); +/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */ +const ZUSATZ = process.argv + .filter((wert, i) => process.argv[i - 1] === "--seite") + .map((eintrag) => { + const [name, ...rest] = eintrag.split("="); + return { name, pfad: rest.join("="), titel: "zusätzlich" }; + }); + // Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen. const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots"); @@ -60,8 +70,40 @@ async function sitzungsToken() { .sign(new TextEncoder().encode(geheimnis)); } -/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */ -function profilVorbereiten(name, token, dunkel) { +/** + * Kleiner Vermittler vor dem Dashboard: Er reicht jede Anfrage weiter und hängt die Sitzung + * als Kopfzeile an. Damit braucht der Browser kein Cookie – das Einschleusen in das Firefox-Profil + * erwies sich als unzuverlässig (Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend, + * die Aufnahmen zeigten dann kommentarlos die Anmeldeseite). + */ +function vermittlerStarten(token) { + const ziel = new URL(BASIS); + + const server = createServer((anfrage, antwort) => { + const weiterleitung = { + hostname: ziel.hostname, + port: ziel.port || 80, + path: anfrage.url, + method: anfrage.method, + headers: { ...anfrage.headers, host: ziel.host, cookie: `pl_sitzung=${token}` }, + }; + + const verbindung = httpAnfrage(weiterleitung, (antwortVomServer) => { + antwort.writeHead(antwortVomServer.statusCode ?? 502, antwortVomServer.headers); + antwortVomServer.pipe(antwort); + }); + + verbindung.on("error", () => antwort.writeHead(502).end("Dashboard nicht erreichbar")); + anfrage.pipe(verbindung); + }); + + return new Promise((fertig) => { + server.listen(0, "127.0.0.1", () => fertig({ server, port: server.address().port })); + }); +} + +/** Legt ein Profil mit dem gewünschten Farbschema an. */ +function profilVorbereiten(name, dunkel) { const verzeichnis = path.join(PROFIL_BASIS, name); rmSync(verzeichnis, { recursive: true, force: true }); mkdirSync(verzeichnis, { recursive: true }); @@ -76,55 +118,33 @@ function profilVorbereiten(name, token, dunkel) { ].join("\n"), ); - const { hostname, protocol } = new URL(BASIS); - const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite")); - datenbank.exec(` - CREATE TABLE moz_cookies ( - id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT, - host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER, - isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0, - sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0, - isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0, - CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes) - )`); - - const jetzt = Date.now(); - datenbank - .prepare( - `INSERT INTO moz_cookies - (originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime) - VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`, - ) - .run( - token, - hostname, - Math.floor(jetzt / 1000) + 3600, - jetzt * 1000, - jetzt * 1000, - protocol === "https:" ? 1 : 0, - protocol === "https:" ? 2 : 1, - jetzt * 1000, - ); - datenbank.close(); - return verzeichnis; } -function aufnehmen(profil, pfad, datei, breite, hoehe) { - execFileSync( - "firefox", - [ - "--profile", - profil, - "--headless", - "--window-size", - `${breite},${hoehe}`, - "--screenshot", - datei, - `${BASIS}${pfad}`, - ], - { stdio: "ignore", timeout: 120_000 }, - ); +/** + * Nimmt eine Seite auf. Bewusst asynchron: Der Vermittler läuft im selben Prozess, und ein + * blockierender Aufruf würde ihn währenddessen stumm schalten – Firefox liefe in die Zeitüberschreitung. + */ +function aufnehmen(profil, pfad, datei, breite, hoehe, adresse) { + return new Promise((fertig, fehlgeschlagen) => { + const vorgang = spawn( + "firefox", + [ + "--profile", + profil, + "--headless", + "--window-size", + `${breite},${hoehe}`, + "--screenshot", + datei, + `${adresse}${pfad}`, + ], + { stdio: "ignore", timeout: 120_000 }, + ); + + vorgang.on("error", fehlgeschlagen); + vorgang.on("close", () => fertig()); + }); } async function main() { @@ -133,35 +153,47 @@ async function main() { mkdirSync(ZIEL, { recursive: true }); const token = await sitzungsToken(); + const { server, port } = await vermittlerStarten(token); + const adresse = `http://127.0.0.1:${port}`; - const hell = profilVorbereiten("hell", token, false); + const hell = profilVorbereiten("hell", false); console.log(`Bilder nach ${ZIEL}`); // Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich. - aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900); + await aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900, BASIS); console.log(" login.png"); - for (const seite of SEITEN) { - aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150); + for (const seite of [...SEITEN, ...ZUSATZ]) { + await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150, adresse); console.log(` ${seite.name}.png – ${seite.titel}`); } if (ALLE) { - const dunkel = profilVorbereiten("dunkel", token, true); + const dunkel = profilVorbereiten("dunkel", true); for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { const eintrag = SEITEN.find((s) => s.name === seite); - aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150); + await aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150, adresse); console.log(` ${seite}-dunkel.png`); } for (const seite of ["dashboard", "hosts"]) { const eintrag = SEITEN.find((s) => s.name === seite); - aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500); + await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse); console.log(` mobil-${seite}.png`); } } + server.close(); if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true }); + + // Sicherung gegen stillen Fehlschlag: Alle Aufnahmen gleich groß heißt, jede zeigt die Anmeldeseite. + const groessen = SEITEN.map((seite) => statSync(path.join(ZIEL, `${seite.name}.png`)).size); + if (new Set(groessen).size === 1) { + throw new Error( + "Alle Aufnahmen sind gleich groß – vermutlich zeigt jede die Anmeldeseite. Sitzung prüfen.", + ); + } + console.log("Fertig."); } diff --git a/src/app/(dashboard)/hosts/[id]/page.tsx b/src/app/(dashboard)/hosts/[id]/page.tsx index 5c8d0cd..bd12ff5 100644 --- a/src/app/(dashboard)/hosts/[id]/page.tsx +++ b/src/app/(dashboard)/hosts/[id]/page.tsx @@ -46,6 +46,16 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id select: { id: true, name: true }, }); + // Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022). + const geschwister = + host.clusterMitglieder.length > 0 + ? await prisma.host.findMany({ + where: { hostname: { in: host.clusterMitglieder, mode: "insensitive" } }, + select: { id: true, hostname: true, subStatus: true, letzterReportAm: true }, + }) + : []; + const meldende = new Map(geschwister.map((eintrag) => [eintrag.hostname.toLowerCase(), eintrag])); + const jetzt = new Date(); const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt); const socketAbweichung = @@ -113,6 +123,31 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id {host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"} + {host.clusterMitglieder.length > 0 ? ( + + + {host.clusterMitglieder.map((mitglied) => { + const eintrag = meldende.get(mitglied.toLowerCase()); + return ( + + {eintrag ? ( + + {mitglied} + + ) : ( + mitglied + )}{" "} + {eintrag ? ( + meldet sich + ) : ( + kein Agent + )} + + ); + })} + + + ) : null} {host.ips.join(", ") || "–"} {host.cpuModell ?? "–"} diff --git a/src/server/agent/schema.ts b/src/server/agent/schema.ts index ab46477..ed730b1 100644 --- a/src/server/agent/schema.ts +++ b/src/server/agent/schema.ts @@ -36,6 +36,7 @@ export const reportSchema = z.object({ ips: z.array(z.string().max(64)).max(32).nullish(), clusterName: nullbarerText, nodeName: nullbarerText, + clusterMitglieder: z.array(z.string().max(255)).max(64).nullish(), kernel: nullbarerText, uptimeSekunden: z.number().int().nonnegative().nullish(), cpuModell: nullbarerText, diff --git a/src/server/agent/service.ts b/src/server/agent/service.ts index 15becae..e4f0227 100644 --- a/src/server/agent/service.ts +++ b/src/server/agent/service.ts @@ -195,6 +195,7 @@ export async function verarbeiteReport( serverId: serverId ?? host.serverId, clusterName: eingabe.host.clusterName ?? host.clusterName, nodeName: eingabe.host.nodeName ?? host.nodeName, + clusterMitglieder: eingabe.host.clusterMitglieder ?? host.clusterMitglieder, ips: eingabe.host.ips ?? host.ips, sockets: eingabe.host.sockets ?? host.sockets, cpuModell: eingabe.host.cpuModell ?? host.cpuModell, diff --git a/src/server/alerts/abgleich.ts b/src/server/alerts/abgleich.ts index 9bdfeb1..58c587e 100644 --- a/src/server/alerts/abgleich.ts +++ b/src/server/alerts/abgleich.ts @@ -30,6 +30,8 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise ({ id: host.id, hostname: host.hostname, + clusterName: host.clusterName, + clusterMitglieder: host.clusterMitglieder, kundeId: host.kundeId, kundeName: host.kunde?.name ?? null, subKey: host.subKey, diff --git a/src/server/alerts/regeln.ts b/src/server/alerts/regeln.ts index 5270f89..4e8d8ab 100644 --- a/src/server/alerts/regeln.ts +++ b/src/server/alerts/regeln.ts @@ -22,6 +22,8 @@ export interface AlertKandidat { export interface HostDaten { id: string; hostname: string; + clusterName: string | null; + clusterMitglieder: string[]; kundeId: string | null; kundeName: string | null; subKey: string | null; @@ -287,6 +289,39 @@ const hostOhneKunde: Regel = { }, }; +const clusterKnotenOhneAgent: Regel = { + name: "cluster-knoten-ohne-agent", + beschreibung: "Ein Cluster-Knoten meldet sich nicht beim Dashboard", + standardSchwellwerte: {}, + pruefe(kontext) { + // Alle gemeldeten Hostnamen – ein Knoten gilt als bekannt, wenn er selbst meldet. + const meldende = new Set(kontext.hosts.map((host) => host.hostname.toLowerCase())); + + const fehlende = new Map(); + for (const host of kontext.hosts) { + if (!host.clusterName) continue; + for (const mitglied of host.clusterMitglieder) { + const name = mitglied.trim(); + if (!name || meldende.has(name.toLowerCase())) continue; + if (!fehlende.has(name.toLowerCase())) { + fehlende.set(name.toLowerCase(), { cluster: host.clusterName, melder: host }); + } + } + } + + return [...fehlende.entries()].map(([name, eintrag]) => ({ + fingerprint: `cluster-knoten-ohne-agent:${eintrag.cluster}:${name}`, + regel: "cluster-knoten-ohne-agent", + schwere: "WARNUNG" as AlertSchwere, + titel: "Cluster-Knoten ohne Agent", + beschreibung: `${name} gehört zum Cluster ${eintrag.cluster} (gemeldet von ${eintrag.melder.hostname}), meldet sich aber nicht – dort fehlt der Agent`, + hostId: eintrag.melder.id, + kundeId: eintrag.melder.kundeId, + laufzeitId: null, + })); + }, +}; + const proxmoxRechnungFaellig: Regel = { name: "proxmox-rechnung-faellig", beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig", @@ -378,6 +413,7 @@ export const REGELN: Regel[] = [ serverIdGeaendert, keyMehrfach, hostOhneKunde, + clusterKnotenOhneAgent, proxmoxRechnungFaellig, kundenrechnungFehlt, kundenrechnungUnbezahlt, diff --git a/tests/agent-skript.test.ts b/tests/agent-skript.test.ts index bea8769..7e7978d 100644 --- a/tests/agent-skript.test.ts +++ b/tests/agent-skript.test.ts @@ -144,6 +144,29 @@ productname: Proxmox "VE" \\ Subscription mit Tab`, }); }); + it("meldet die Cluster-Mitglieder", () => { + befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION); + befehlErsetzen( + "pvecm", + `Membership information +---------------------- + Nodeid Votes Name + 1 1 hv-rz5-pve-01 (local) + 2 1 hv-rz5-pve-02 + 3 1 hv-rz5-pve-03`, + ); + + const report = reportErzeugen() as { host: { clusterMitglieder: string[] } }; + expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]); + }); + + it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => { + befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION); + + const report = reportErzeugen() as { host: { clusterMitglieder: string[] } }; + expect(report.host.clusterMitglieder).toEqual([]); + }); + it("gibt die Version aus", () => { const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim(); expect(ausgabe).toBe("0.2.0"); diff --git a/tests/alerts-abgleich.test.ts b/tests/alerts-abgleich.test.ts index 088f1ed..6d169e1 100644 --- a/tests/alerts-abgleich.test.ts +++ b/tests/alerts-abgleich.test.ts @@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => { const einstellungen = await testPrisma.warnregelEinstellung.findMany(); - expect(einstellungen).toHaveLength(10); + expect(einstellungen).toHaveLength(11); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); }); diff --git a/tests/warnregeln.test.ts b/tests/warnregeln.test.ts index 27dea22..addfee5 100644 --- a/tests/warnregeln.test.ts +++ b/tests/warnregeln.test.ts @@ -18,6 +18,8 @@ function host(überschreibungen: Partial = {}): HostDaten { return { id: "h1", hostname: "pve-01", + clusterName: null, + clusterMitglieder: [], kundeId: "k1", kundeName: "Brandl Elektrotechnik GmbH", subKey: "pve1c-aaaa", @@ -70,9 +72,9 @@ function ausRegel(k: RegelKontext, name: string) { } describe("Warnregeln", () => { - it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => { - expect(REGELN).toHaveLength(10); - expect(new Set(REGELN.map((r) => r.name)).size).toBe(10); + it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => { + expect(REGELN).toHaveLength(11); + expect(new Set(REGELN.map((r) => r.name)).size).toBe(11); }); describe("Subscription läuft ab", () => { @@ -297,6 +299,63 @@ describe("Warnregeln", () => { }); }); + describe("Cluster-Knoten ohne Agent", () => { + it("meldet einen Knoten, der im Cluster steht, sich aber nicht meldet", () => { + const k = kontext({ + hosts: [ + host({ + id: "h1", + hostname: "pve-node-01", + clusterName: "rz5", + clusterMitglieder: ["pve-node-01", "pve-node-02", "pve-node-03"], + }), + host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: [] }), + ], + }); + + const kandidaten = ausRegel(k, "cluster-knoten-ohne-agent"); + expect(kandidaten).toHaveLength(1); + expect(kandidaten[0].beschreibung).toContain("pve-node-03"); + expect(kandidaten[0].fingerprint).toBe("cluster-knoten-ohne-agent:rz5:pve-node-03"); + }); + + it("meldet nichts, wenn alle Knoten melden", () => { + const mitglieder = ["pve-node-01", "pve-node-02"]; + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }), + host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }), + ], + }); + expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0); + }); + + it("beachtet Groß- und Kleinschreibung nicht", () => { + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "PVE-Node-01", clusterName: "rz5", clusterMitglieder: ["pve-node-01"] }), + ], + }); + expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0); + }); + + it("meldet nichts für Hosts ohne Cluster", () => { + const k = kontext({ hosts: [host({ clusterName: null, clusterMitglieder: [] })] }); + expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0); + }); + + it("meldet jeden fehlenden Knoten nur einmal, auch wenn mehrere ihn melden", () => { + const mitglieder = ["pve-node-01", "pve-node-02", "pve-node-03"]; + const k = kontext({ + hosts: [ + host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }), + host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }), + ], + }); + expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(1); + }); + }); + it("überspringt abgeschaltete Regeln", () => { const k = kontext({ hosts: [host({ kundeId: null, kundeName: null })],