Compare commits

...
7 Commits
Author SHA1 Message Date
florian.wilde 94f1eb4e6f Merge branch 'phase/023-feldtest-pbs'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:27:02 +02:00
florian.wildeandClaude Opus 5 1bfd549838 fix(agent): PBS-Ausgaben richtig auswerten (#023)
Feldtest auf einem echten Proxmox Backup Server 4.2: `proxmox-backup-manager
subscription get` gibt eine Rahmentabelle aus, keine "feld: wert"-Zeilen. Der
Agent hat dort bisher nichts gelesen - nicht einmal die Server-ID. PBS kann
(anders als PVE) `--output-format json`; das wird jetzt genutzt, die Tabelle
dient als Rueckfall. Die Testfaelle stammen ab sofort vom echten Host, der
bisherige PBS-Testfall war erfunden und hat den Fehler verdeckt.

Ausserdem: die PBS-Version enthielt die laufende Fassung doppelt, und die
Versionspruefung meldete "neue Version" auch bei aelteren Fassungen.

Installation, Report, Warnungen und Uninstall wurden auf dem Host vollstaendig
durchgespielt - damit ist auch der letzte offene Punkt aus #018 erledigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:27:02 +02:00
florian.wildeandClaude Opus 5 52466eee3f docs(feldtest): Feldtest fuer PBS und PMG als #023 festhalten
Prüfung / pruefung (push) Canceled after 0s
PBS-Hosts stehen spaeter zur Verfuegung, allerdings nur unlizenziert; fuer
PMG gibt es derzeit keinen Host. Der heutige PBS-Testfall im Testlauf ist
erfunden (JSON) und gehoert gegen echte Ausgaben geprueft - bei PVE war
genau diese Annahme falsch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:45:36 +02:00
florian.wilde 2e79dfb111 Merge branch 'phase/018-feldtest'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 18:05:13 +02:00
florian.wildeandClaude Opus 5 7728fcb565 feat(cluster): Cluster-Knoten ohne Agent erkennen (#022, #018)
Feldtest auf zwei echten Proxmox-VE-9.2-Hosts (#018): Installation, Timer,
Zwischenspeicher und Nachsenden laufen wie vorgesehen. `pvesubscription get`
kennt kein JSON, ohne Subscription fehlen fast alle Felder, `pveversion`
enthaelt den Kernel doppelt - alles im Agent beruecksichtigt.

Dabei zeigte sich: in Proxmox hat jeder Knoten eine eigene Subscription.
Der Agent meldet darum die Cluster-Mitglieder aus `pvecm nodes`, und eine
elfte Warnregel meldet Knoten, von denen sich kein Agent meldet (#022).

Ausserdem behoben: die Bilder des Handbuchs zeigten allesamt die
Anmeldeseite, weil Firefox eine selbst erzeugte Cookie-Datei stillschweigend
verwirft. Das Skript nutzt jetzt einen lokalen Vermittler und bricht ab,
wenn alle Aufnahmen gleich gross sind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:05:08 +02:00
florian.wilde dc685980d8 Merge branch 'phase/021-geheimnisse'
Prüfung / pruefung (push) Canceled after 0s
2026-09-17 23:50:46 +02:00
florian.wildeandClaude Opus 5 db3d38aa3c feat(security): Zugangsdaten verschluesselt speichern (#021)
AES-256-GCM mit Schluessel aus APP_SECRET fuer SMTP-Passwort und
Webhook-Geheimnis. Bestehende Klartextwerte werden weiter gelesen und beim
naechsten Speichern umgeschluesselt. 156 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:50:46 +02:00
55 changed files with 953 additions and 135 deletions
+27
View File
@@ -17,6 +17,33 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.14.1] – 2026-09-18
### Behoben
- Auf Proxmox Backup Server las der Agent **keine einzige Angabe** aus: `proxmox-backup-manager subscription get` gibt eine Rahmentabelle aus, keine `feld: wert`-Zeilen. Jetzt wird für PBS `--output-format json` genutzt, und die Tabelle wird als Rückfall ausgewertet (#023)
- Die PBS-Version enthielt die laufende Fassung ein zweites Mal (#023)
- Der Agent meldete eine „neue Version“, auch wenn die installierte neuer war als die des Dashboards (#023)
### Geändert
- Agent-Version 0.2.1; die Tests prüfen die Versionsangabe jetzt gegen die Konstante des Dashboards statt gegen eine abgeschriebene Zahl (#023)
## [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
- SMTP-Passwort und Webhook-Geheimnis werden verschlüsselt in der Datenbank abgelegt (AES-256-GCM, Schlüssel aus `APP_SECRET`); eine Sicherung gibt sie damit nicht mehr im Klartext preis (#021)
- Werte aus der Zeit davor werden weiterhin gelesen und beim nächsten Speichern umgeschlüsselt (#021)
## [0.13.0] – 2026-09-17 ## [0.13.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+16 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 7 h 43 min **Gesamt:** 9 h 43 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -25,6 +25,10 @@
| #016 Handbuch | 0 h 30 min | | #016 Handbuch | 0 h 30 min |
| #017 Favicons | 0 h 15 min | | #017 Favicons | 0 h 15 min |
| #019 CI-Pipeline | 0 h 20 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 |
| #023 Feldtest PBS | 0 h 35 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -47,7 +51,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.13.0 **Version:** 0.14.1
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -92,6 +96,7 @@
- #016 abgeschlossen: Handbuch mit elf Kapiteln und 16 Bildern, dazu `npm run screenshots` zum Auffrischen - #016 abgeschlossen: Handbuch mit elf Kapiteln und 16 Bildern, dazu `npm run screenshots` zum Auffrischen
- #017 abgeschlossen: Symbole für Tab, Startbildschirm und Manifest, erzeugt mit `npm run icons` aus SVG-Vorlagen - #017 abgeschlossen: Symbole für Tab, Startbildschirm und Manifest, erzeugt mit `npm run icons` aus SVG-Vorlagen
- #019 abgeschlossen: Gitea-Actions-Workflow mit Lint, Typen, Tests, Build, ShellCheck und Doku-Prüfung - #019 abgeschlossen: Gitea-Actions-Workflow mit Lint, Typen, Tests, Build, ShellCheck und Doku-Prüfung
- #021 abgeschlossen: SMTP-Passwort und Webhook-Geheimnis liegen verschlüsselt in der Datenbank (AES-256-GCM, Schlüssel aus APP_SECRET)
### Entscheidungen ### Entscheidungen
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s. - **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
@@ -125,6 +130,14 @@
- Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen. - 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) - 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.
- Feldtest auf einem echten Proxmox Backup Server (#023): der Agent hätte dort **nichts** gelesen – PBS gibt eine Rahmentabelle aus, kein `feld: wert`. Behoben, mit den echten Ausgaben als Testfälle
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #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 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+3 -3
View File
@@ -180,9 +180,9 @@ Zum Zurückspielen:
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
``` ```
**Wichtig:** `APP_SECRET` mitsichern. Ohne diesen Wert sind alle Sitzungen ungültig; die Zugangsdaten für **Wichtig:** `APP_SECRET` mitsichern und sicher aufbewahren. Der Wert signiert die Sitzungen **und**
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext verschlüsselt die Zugangsdaten für SMTP und Webhook in der Datenbank. Geht er verloren, müssen diese
– siehe Issue #021. Zugangsdaten neu eingetragen werden; eine Sicherung allein gibt sie nicht preis.
## Dokumentation ## Dokumentation
+46 -7
View File
@@ -12,11 +12,12 @@
set -o nounset set -o nounset
set -o pipefail set -o pipefail
AGENT_VERSION="0.2.0" AGENT_VERSION="0.2.1"
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}" CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}" SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
SPOOL_MAXIMUM=30 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 # --------------------------------------------------------------------- Protokoll
@@ -105,15 +106,24 @@ produkt_erkennen() {
} }
subscription_ausgabe() { subscription_ausgabe() {
local ausgabe=""
case "$1" in case "$1" in
# PVE kennt --output-format nicht (geprueft auf 9.2), liefert aber "feld: wert".
pve) pvesubscription get 2>/dev/null ;; pve) pvesubscription get 2>/dev/null ;;
pbs) proxmox-backup-manager subscription get 2>/dev/null ;; # PBS gibt ohne --output-format eine Rahmentabelle aus, aus der sich schlecht lesen laesst.
# JSON kann es (geprueft auf 4.2); aeltere Fassungen faengt die Tabellenauswertung ab.
pbs)
ausgabe=$(proxmox-backup-manager subscription get --output-format json 2>/dev/null)
[ -n "$ausgabe" ] || ausgabe=$(proxmox-backup-manager subscription get 2>/dev/null)
printf '%s' "$ausgabe"
;;
pmg) pmgsubscription get 2>/dev/null ;; pmg) pmgsubscription get 2>/dev/null ;;
*) printf '' ;; *) printf '' ;;
esac esac
} }
# Liest ein Feld aus der Subscription-Ausgabe. Unterstützt "feld: wert" und JSON. # Liest ein Feld aus der Subscription-Ausgabe. Unterstuetzt "feld: wert", JSON und die
# Rahmentabelle von PBS ("| feld | wert |" mit Unicode-Strichen).
feld_lesen() { feld_lesen() {
local feld="$1" text="$2" wert local feld="$1" text="$2" wert
wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1) wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1)
@@ -121,6 +131,15 @@ feld_lesen() {
wert=$(printf '%s\n' "$text" | wert=$(printf '%s\n' "$text" |
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1) sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
fi fi
if [ -z "$wert" ]; then
# Tabellenzeile: Trennzeichen ist U+2502; awk zerlegt daran und liefert die zweite Spalte.
wert=$(printf '%s\n' "$text" |
awk -F'\xe2\x94\x82' -v feld="$feld" '
NF >= 3 {
name = $2; gsub(/^[ \t]+|[ \t]+$/, "", name)
if (name == feld) { wert = $3; gsub(/^[ \t]+|[ \t]+$/, "", wert); print wert; exit }
}')
fi
# Umschließende Anführungszeichen und Leerzeichen entfernen. # Umschließende Anführungszeichen und Leerzeichen entfernen.
wert=${wert%\"} wert=${wert%\"}
wert=${wert#\"} wert=${wert#\"}
@@ -128,9 +147,13 @@ feld_lesen() {
} }
produkt_version() { 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 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 ;; # PBS haengt die laufende Fassung an ("… 4.2.6-1 running version: 4.2.1");
# gemeldet wird die installierte.
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 | sed 's/ running version.*//' ;;
pmg) pmgversion 2>/dev/null | head -n1 ;; pmg) pmgversion 2>/dev/null | head -n1 ;;
*) printf '' ;; *) printf '' ;;
esac esac
@@ -150,6 +173,14 @@ cluster_name() {
fi 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() { node_name() {
if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then
hostname -s 2>/dev/null hostname -s 2>/dev/null
@@ -220,6 +251,7 @@ report_erzeugen() {
"ips": $(ip_adressen | json_liste), "ips": $(ip_adressen | json_liste),
"clusterName": $(json_text "$(cluster_name)"), "clusterName": $(json_text "$(cluster_name)"),
"nodeName": $(json_text "$(node_name)"), "nodeName": $(json_text "$(node_name)"),
"clusterMitglieder": $(cluster_mitglieder | json_liste),
"kernel": $(json_text "$(uname -r 2>/dev/null)"), "kernel": $(json_text "$(uname -r 2>/dev/null)"),
"uptimeSekunden": $(json_zahl "$(uptime_sekunden)"), "uptimeSekunden": $(json_zahl "$(uptime_sekunden)"),
"cpuModell": $(json_text "$(cpu_modell)"), "cpuModell": $(json_text "$(cpu_modell)"),
@@ -383,8 +415,15 @@ aktion_test() {
[ "$(printf '%s' "$antwort" | tail -n1)" = "200" ] [ "$(printf '%s' "$antwort" | tail -n1)" = "200" ]
} }
# Ist $1 eine neuere Fassung als $2? `sort -V` vergleicht Versionsnummern der Reihe nach.
version_neuer() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
}
version_pruefen() { version_pruefen() {
if [ -n "${NEUESTE_VERSION:-}" ] && [ "$NEUESTE_VERSION" != "$AGENT_VERSION" ]; then # Nur melden, wenn das Dashboard wirklich eine neuere Fassung kennt. Sonst warnt ein Agent,
# der einer Testinstallation vorauseilt, gegen sich selbst (im Feldtest #023 aufgefallen).
if [ -n "${NEUESTE_VERSION:-}" ] && version_neuer "$NEUESTE_VERSION" "$AGENT_VERSION"; then
protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})." protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})."
if [ "$AUTO_UPDATE" = "yes" ]; then if [ "$AUTO_UPDATE" = "yes" ]; then
aktion_update aktion_update
+23
View File
@@ -11,6 +11,7 @@ auch mit Hosts hinter NAT.
2. [Erste Anmeldung](#2-erste-anmeldung) 2. [Erste Anmeldung](#2-erste-anmeldung)
3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen) 3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen)
4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen) 4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen)
4a. [Cluster](#4a-cluster)
5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen) 5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen)
6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen) 6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen)
7. [Warnungen](#7-warnungen) 7. [Warnungen](#7-warnungen)
@@ -112,6 +113,12 @@ Der Installer erkennt selbst, ob es sich um Proxmox VE, Backup Server oder Mail
registriert den Host, legt die Konfiguration unter `/etc/proxledger/agent.conf` (Rechte 600) ab, registriert den Host, legt die Konfiguration unter `/etc/proxledger/agent.conf` (Rechte 600) ab,
richtet einen systemd-Timer ein und schickt sofort den ersten Report. richtet einen systemd-Timer ein und schickt sofort den ersten Report.
> **Stand der Erprobung:** Proxmox VE (9.2, mit und ohne Subscription, einzeln und im Cluster) und
> Proxmox Backup Server (4.2, ohne Subscription) sind auf echten Hosts geprüft. Für **Mail Gateway**
> stand bisher kein Host zur Verfügung – dort beruht die Auswertung noch auf Annahmen, und bei PBS hat
> sich genau so eine Annahme als falsch erwiesen. Bitte den ersten PMG-Host nach der Installation
> kurz gegenprüfen (`proxledger-agent --dry-run` zeigt, was gemeldet würde).
Nützliche Befehle auf dem Host: Nützliche Befehle auf dem Host:
| Befehl | Zweck | | Befehl | Zweck |
@@ -157,6 +164,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 ## 5. Kunden und Standorte pflegen
![Kundenübersicht](bilder/kunden.png) ![Kundenübersicht](bilder/kunden.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 258 KiB

+44 -10
View File
@@ -1,22 +1,22 @@
# #018 Agent auf echten Proxmox-Hosts prüfen # #018 Agent auf echten Proxmox-Hosts prüfen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.1 **Meilenstein:** v0.14.0
**Labels:** agent, test **Labels:** agent, test
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Ziel ## Ziel
Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er
auf echten Hosts laufen. auf echten Hosts laufen.
## Aufgaben ## Aufgaben
- [ ] Installation auf Proxmox VE (8.x und 9.x) - [x] Installation auf Proxmox VE 9.2 (zwei Hosts: mit und ohne Subscription)
- [ ] Installation auf Proxmox Backup Server - [ ] Installation auf Proxmox Backup Server (kein Host verfügbar)
- [ ] Installation auf Proxmox Mail Gateway - [ ] Installation auf Proxmox Mail Gateway (kein Host verfügbar)
- [ ] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und - [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
`pmgsubscription get` mit den Annahmen im Skript abgleichen `pmgsubscription get` mit den Annahmen im Skript abgleichen
- [ ] Verhalten ohne Subscription und mit abgelaufener Subscription prüfen - [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null)
- [ ] Timer-Lauf, Spool-Nachsendung und Uninstall auf einem echten Host prüfen - [x] Timer, Spool-Nachsendung und Uninstall geprüft (Uninstall am 18.09.2026 auf dem PBS-Host, siehe #023)
- [ ] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen - [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch - [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch
@@ -27,3 +27,37 @@ auf echten Hosts laufen.
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen. - 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 – dafür ist #023 angelegt. PBS-Hosts stellt Florian bereit, allerdings nur
unlizenziert; für PMG gibt es derzeit keinen Host. Der Uninstall-Durchlauf ist inzwischen erledigt (#023).
+20 -11
View File
@@ -1,27 +1,36 @@
# #021 Zugangsdaten verschlüsselt speichern # #021 Zugangsdaten verschlüsselt speichern
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.9.0 **Meilenstein:** v0.13.1
**Labels:** sicherheit **Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest, SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest,
liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden. liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden.
## Aufgaben ## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen - [x] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce) - [x] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln - [x] Bestehende Kanäle werden beim nächsten Speichern umgeschlüsselt (Klartext wird weiterhin gelesen)
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013) - [ ] Gleiches Verfahren für das OIDC-Client-Secret – liegt derzeit in der Umgebung, nicht in der Datenbank, und braucht daher keine Verschlüsselung
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren - [x] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr - [x] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht - [x] Ohne `APP_SECRET` lässt sich kein Geheimnis lesen oder schreiben (Fehlermeldung nennt die Ursache)
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis - [x] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten ## Abhängigkeiten
#012, #013 #012, #013
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen. - Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen.
- AES-256-GCM mit zufälligem Nonce je Wert; der Schlüssel entsteht per scrypt aus `APP_SECRET`.
Format: `plx1:<nonce>:<authTag>:<chiffrat>`.
- **Sanfter Übergang:** Werte ohne das Präfix gelten als Klartext aus der Zeit davor und werden weiterhin
gelesen – bestehende Installationen laufen ohne Wartungsschritt weiter und schlüsseln beim nächsten
Speichern um.
- Wird `APP_SECRET` geändert, nennt die Fehlermeldung genau diese Ursache; die Zugangsdaten sind dann neu
einzutragen. Das steht auch im README bei den Sicherungshinweisen.
- Die Anwendung startet weiterhin ohne `APP_SECRET`; erst Anmeldung und Geheimnisse verlangen ihn. Ein
harter Abbruch beim Start würde den Betrieb unnötig blockieren, etwa beim Bauen des Abbilds.
+38
View File
@@ -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/<name>/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.
+69
View File
@@ -0,0 +1,69 @@
# #023 Feldtest auf Proxmox Backup Server und Mail Gateway
**Status:** offen
**Meilenstein:** v1.0.0
**Labels:** agent, feldtest
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min (PBS erledigt, PMG offen)
## Ziel
Der Feldtest in #018 lief auf zwei Proxmox-VE-Hosts. Für Backup Server und Mail Gateway beruht die
Auswertung im Agent bisher nur auf Annahmen – und bei PVE hat sich genau eine solche Annahme als
falsch erwiesen (`--output-format json` gibt es dort nicht). Das gehört vor 1.0.0 geprüft.
## Ausgangslage
- **PBS:** Hosts stellt Florian bereit, allerdings **nur unlizenziert**. Damit lässt sich der Fall
„aktive Subscription“ auf PBS nicht prüfen – wohl aber Produkterkennung, Version, Server-ID und der
Zustand `notfound`, der bei PVE die meisten Überraschungen geliefert hat.
- **PMG:** derzeit kein Host verfügbar. Der Zweig bleibt ungeprüft; das ist im Handbuch zu vermerken.
## Aufgaben
- [x] `scripts/feldtest-erheben.sh` auf einem PBS-Host laufen lassen (liest nur, ändert nichts)
- [x] Prüfen, ob `proxmox-backup-manager subscription get` Text oder JSON liefert und welche Felder
ohne Subscription überhaupt erscheinen
- [x] Ausgabe von `proxmox-backup-manager version` gegen `produkt_version()` prüfen
- [x] Erhobene Ausgaben als Testfälle in `tests/agent-skript.test.ts` aufnehmen – der heutige
PBS-Testfall ist erfunden (JSON) und damit womöglich am echten Verhalten vorbei
- [x] Agent auf einem PBS-Host installieren, einen Report abwarten, wieder deinstallieren
- [ ] PMG offen lassen und die Lücke im Handbuch benennen
## Notizen
Der Cluster-Abgleich aus #022 betrifft nur PVE: PBS und PMG kennen kein `pvecm`, dort bleibt die
Mitgliederliste leer.
## Ergebnis (18.09.2026, `bup-ste-pbs-01`, PBS 4.2 auf Debian 13, ohne Subscription)
**Ein echter Fehler gefunden – und er war kein kleiner.** `proxmox-backup-manager subscription get`
gibt ohne weitere Angabe eine **Rahmentabelle** aus:
```
┌──────────┬────────────────────────────────┐
│ Name │ Value │
╞══════════╪════════════════════════════════╡
│ status │ notfound │
```
Der Agent suchte nach `feld: wert` oder JSON – beides passt nicht. Auf einem echten PBS hätte er
**alle Felder als null gemeldet, nicht einmal die Server-ID**. Der erfundene Testfall hatte genau das
verdeckt, weil er JSON unterstellte.
Behoben in drei Schritten:
1. Für PBS wird `--output-format json` verwendet. Anders als PVE **kann** PBS das (auf 4.2 geprüft).
2. Bleibt die Ausgabe leer, wird die Textform genommen und die Rahmentabelle ausgewertet
(`awk -F'│'`) – für Fassungen, die das JSON-Format nicht kennen.
3. `proxmox-backup-manager version` liefert „proxmox-backup-server 4.2.6-1 running version: 4.2.1“;
gemeldet wird jetzt die installierte Fassung ohne den Zusatz.
Beide Formen sind als Testfälle hinterlegt, mit den echten Ausgaben des Hosts.
**Nebenbefund:** Der Agent meldete „Neue Agent-Version verfügbar: 0.2.0 (installiert: 0.2.1)“ – er
verglich nur auf Ungleichheit. Jetzt wird mit `sort -V` geprüft, ob die Fassung wirklich neuer ist.
**Ende-zu-Ende geprüft:** Installation → Registrierung → erster Report → Warnungen. Im Dashboard kamen
an: Produkt `PBS`, Version `proxmox-backup-server 4.2.6-1`, Server-ID `69AED6…`, 1 Sockel, 106
ausstehende Pakete, leere Cluster-Liste (PBS kennt kein `pvecm`), Warnungen „Keine Subscription
gefunden“ und „Host ist keinem Kunden zugewiesen“. Anschließend `--uninstall`: Timer, Dienst,
`/usr/local/bin/proxledger-agent`, `/etc/proxledger` und `/var/lib/proxledger` restlos entfernt. Damit
ist auch der letzte offene Punkt aus #018 erledigt.
**Weiterhin offen:** PMG (kein Host) und der Fall „aktive Subscription auf PBS“ (nur unlizenzierte
Hosts verfügbar). Dieses Issue bleibt deshalb offen.
+5 -3
View File
@@ -21,8 +21,10 @@ 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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | in Arbeit | v0.9.0 | 2 h | – | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| | **Summe** | | | **116 h** | **7 h 43 min** | | [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
| | **Summe** | | | **121 h** | **9 h 43 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.13.0", "version": "0.14.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Host" ADD COLUMN "clusterMitglieder" TEXT[] DEFAULT ARRAY[]::TEXT[];
+2
View File
@@ -113,6 +113,8 @@ model Host {
serverId String? serverId String?
clusterName String? clusterName String?
nodeName String? nodeName String?
/// Namen aller Knoten des Clusters laut corosync – auch solche ohne eigenen Agent (#022).
clusterMitglieder String[] @default([])
ips String[] @default([]) ips String[] @default([])
sockets Int? sockets Int?
cpuModell String? cpuModell String?
+91
View File
@@ -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'
+78 -46
View File
@@ -4,17 +4,19 @@
* *
* npm run screenshots # heller Modus, Desktop * npm run screenshots # heller Modus, Desktop
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht * 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. * 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 * 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. * und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
*/ */
import { execFileSync } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http";
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
import { DatabaseSync } from "node:sqlite"; import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import "dotenv/config"; 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 ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle"); 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. // Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots"); const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
@@ -60,8 +70,40 @@ async function sitzungsToken() {
.sign(new TextEncoder().encode(geheimnis)); .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); const verzeichnis = path.join(PROFIL_BASIS, name);
rmSync(verzeichnis, { recursive: true, force: true }); rmSync(verzeichnis, { recursive: true, force: true });
mkdirSync(verzeichnis, { recursive: true }); mkdirSync(verzeichnis, { recursive: true });
@@ -76,42 +118,16 @@ function profilVorbereiten(name, token, dunkel) {
].join("\n"), ].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; return verzeichnis;
} }
function aufnehmen(profil, pfad, datei, breite, hoehe) { /**
execFileSync( * 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", "firefox",
[ [
"--profile", "--profile",
@@ -121,10 +137,14 @@ function aufnehmen(profil, pfad, datei, breite, hoehe) {
`${breite},${hoehe}`, `${breite},${hoehe}`,
"--screenshot", "--screenshot",
datei, datei,
`${BASIS}${pfad}`, `${adresse}${pfad}`,
], ],
{ stdio: "ignore", timeout: 120_000 }, { stdio: "ignore", timeout: 120_000 },
); );
vorgang.on("error", fehlgeschlagen);
vorgang.on("close", () => fertig());
});
} }
async function main() { async function main() {
@@ -133,35 +153,47 @@ async function main() {
mkdirSync(ZIEL, { recursive: true }); mkdirSync(ZIEL, { recursive: true });
const token = await sitzungsToken(); 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}`); console.log(`Bilder nach ${ZIEL}`);
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich. // 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"); console.log(" login.png");
for (const seite of SEITEN) { for (const seite of [...SEITEN, ...ZUSATZ]) {
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150); await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`); console.log(` ${seite.name}.png – ${seite.titel}`);
} }
if (ALLE) { if (ALLE) {
const dunkel = profilVorbereiten("dunkel", token, true); const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
const eintrag = SEITEN.find((s) => s.name === seite); 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`); console.log(` ${seite}-dunkel.png`);
} }
for (const seite of ["dashboard", "hosts"]) { for (const seite of ["dashboard", "hosts"]) {
const eintrag = SEITEN.find((s) => s.name === seite); 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`); console.log(` mobil-${seite}.png`);
} }
} }
server.close();
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true }); 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."); console.log("Fertig.");
} }
@@ -42,6 +42,7 @@ export default async function BenachrichtigungenSeite() {
absender: emailKonfiguration?.absender ?? "", absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "", empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort), passwortGesetzt: Boolean(emailKonfiguration?.passwort),
// Der Wert selbst wird nie an den Browser gegeben – nur ob einer hinterlegt ist.
}; };
const webhookWerte: WebhookWerte = { const webhookWerte: WebhookWerte = {
+35
View File
@@ -46,6 +46,16 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
select: { id: true, name: true }, 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 jetzt = new Date();
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt); const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
const socketAbweichung = const socketAbweichung =
@@ -113,6 +123,31 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<Zeile bezeichnung="Cluster / Node"> <Zeile bezeichnung="Cluster / Node">
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"} {host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
</Zeile> </Zeile>
{host.clusterMitglieder.length > 0 ? (
<Zeile bezeichnung={`Cluster-Knoten (${host.clusterMitglieder.length})`}>
<span className="d-inline-block text-start">
{host.clusterMitglieder.map((mitglied) => {
const eintrag = meldende.get(mitglied.toLowerCase());
return (
<span key={mitglied} className="d-block small">
{eintrag ? (
<Link href={`/hosts/${eintrag.id}`} className="text-decoration-none">
{mitglied}
</Link>
) : (
mitglied
)}{" "}
{eintrag ? (
<Badge farbe="success">meldet sich</Badge>
) : (
<Badge farbe="warning">kein Agent</Badge>
)}
</span>
);
})}
</span>
</Zeile>
) : null}
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile> <Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile> <Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
<Zeile bezeichnung="Sockets (lizenziert / physisch)"> <Zeile bezeichnung="Sockets (lizenziert / physisch)">
+67
View File
@@ -0,0 +1,67 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
/**
* Verschlüsselt einzelne Werte (SMTP-Passwort, Webhook-Geheimnis) mit AES-256-GCM.
* Der Schlüssel wird aus `APP_SECRET` abgeleitet – wer eine Sicherung der Datenbank liest,
* bekommt damit noch keine Zugangsdaten.
*
* Format: `plx1:<nonce>:<authTag>:<chiffrat>`, alle Teile base64url.
* Werte ohne dieses Präfix gelten als Klartext aus der Zeit vor der Verschlüsselung und
* werden beim nächsten Speichern umgeschlüsselt.
*/
const PRAEFIX = "plx1";
const SALZ = "proxledger-geheimnisse-v1";
export class GeheimnisFehler extends Error {}
function schluessel(): Buffer {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new GeheimnisFehler("APP_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).");
}
return scryptSync(geheimnis, SALZ, 32);
}
export function verschluesseln(klartext: string): string {
if (klartext === "") return "";
const nonce = randomBytes(12);
const chiffre = createCipheriv("aes-256-gcm", schluessel(), nonce);
const daten = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
return [
PRAEFIX,
nonce.toString("base64url"),
chiffre.getAuthTag().toString("base64url"),
daten.toString("base64url"),
].join(":");
}
export function istVerschluesselt(wert: string): boolean {
return wert.startsWith(`${PRAEFIX}:`);
}
/**
* Entschlüsselt einen Wert. Unverschlüsselte Werte kommen unverändert zurück –
* so funktionieren bestehende Installationen ohne Wartungsschritt weiter.
*/
export function entschluesseln(wert: string): string {
if (!wert || !istVerschluesselt(wert)) return wert;
const [, nonce, authTag, daten] = wert.split(":");
if (!nonce || !authTag || !daten) {
throw new GeheimnisFehler("Der gespeicherte Wert ist unvollständig.");
}
try {
const chiffre = createDecipheriv("aes-256-gcm", schluessel(), Buffer.from(nonce, "base64url"));
chiffre.setAuthTag(Buffer.from(authTag, "base64url"));
return Buffer.concat([chiffre.update(Buffer.from(daten, "base64url")), chiffre.final()]).toString("utf8");
} catch {
// Häufigste Ursache: APP_SECRET wurde geändert.
throw new GeheimnisFehler(
"Der Wert lässt sich nicht entschlüsseln. Wurde APP_SECRET geändert? Dann die Zugangsdaten neu eintragen.",
);
}
}
+5 -3
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele"; import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten"; import { testNachricht } from "@/server/notifications/nachrichten";
@@ -45,8 +46,9 @@ export async function emailKanalSpeichern(
port, port,
secure: port === 465, secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "", benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden. // Leeres Feld heißt „unverändert“; das gespeicherte Passwort bleibt dann wie es ist.
passwort: passwortEingabe || (bisher?.passwort ?? ""), // Neue Eingaben werden verschlüsselt abgelegt (#021).
passwort: passwortEingabe ? verschluesseln(passwortEingabe) : (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "", absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger, empfaenger,
}; };
@@ -90,7 +92,7 @@ export async function webhookKanalSpeichern(
const konfiguration: WebhookKonfiguration = { const konfiguration: WebhookKonfiguration = {
url, url,
geheimnis: geheimnisEingabe || bisher?.geheimnis, geheimnis: geheimnisEingabe ? verschluesseln(geheimnisEingabe) : bisher?.geheimnis,
}; };
const daten = { const daten = {
+1 -1
View File
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
import { AgentApiFehler } from "./service"; import { AgentApiFehler } from "./service";
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */ /** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
export const AGENT_VERSION = "0.2.0"; export const AGENT_VERSION = "0.2.1";
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) { export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra }); return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
+1
View File
@@ -36,6 +36,7 @@ export const reportSchema = z.object({
ips: z.array(z.string().max(64)).max(32).nullish(), ips: z.array(z.string().max(64)).max(32).nullish(),
clusterName: nullbarerText, clusterName: nullbarerText,
nodeName: nullbarerText, nodeName: nullbarerText,
clusterMitglieder: z.array(z.string().max(255)).max(64).nullish(),
kernel: nullbarerText, kernel: nullbarerText,
uptimeSekunden: z.number().int().nonnegative().nullish(), uptimeSekunden: z.number().int().nonnegative().nullish(),
cpuModell: nullbarerText, cpuModell: nullbarerText,
+1
View File
@@ -195,6 +195,7 @@ export async function verarbeiteReport(
serverId: serverId ?? host.serverId, serverId: serverId ?? host.serverId,
clusterName: eingabe.host.clusterName ?? host.clusterName, clusterName: eingabe.host.clusterName ?? host.clusterName,
nodeName: eingabe.host.nodeName ?? host.nodeName, nodeName: eingabe.host.nodeName ?? host.nodeName,
clusterMitglieder: eingabe.host.clusterMitglieder ?? host.clusterMitglieder,
ips: eingabe.host.ips ?? host.ips, ips: eingabe.host.ips ?? host.ips,
sockets: eingabe.host.sockets ?? host.sockets, sockets: eingabe.host.sockets ?? host.sockets,
cpuModell: eingabe.host.cpuModell ?? host.cpuModell, cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
+2
View File
@@ -30,6 +30,8 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
hosts: hosts.map((host) => ({ hosts: hosts.map((host) => ({
id: host.id, id: host.id,
hostname: host.hostname, hostname: host.hostname,
clusterName: host.clusterName,
clusterMitglieder: host.clusterMitglieder,
kundeId: host.kundeId, kundeId: host.kundeId,
kundeName: host.kunde?.name ?? null, kundeName: host.kunde?.name ?? null,
subKey: host.subKey, subKey: host.subKey,
+36
View File
@@ -22,6 +22,8 @@ export interface AlertKandidat {
export interface HostDaten { export interface HostDaten {
id: string; id: string;
hostname: string; hostname: string;
clusterName: string | null;
clusterMitglieder: string[];
kundeId: string | null; kundeId: string | null;
kundeName: string | null; kundeName: string | null;
subKey: 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<string, { cluster: string; melder: HostDaten }>();
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 = { const proxmoxRechnungFaellig: Regel = {
name: "proxmox-rechnung-faellig", name: "proxmox-rechnung-faellig",
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig", beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
@@ -378,6 +413,7 @@ export const REGELN: Regel[] = [
serverIdGeaendert, serverIdGeaendert,
keyMehrfach, keyMehrfach,
hostOhneKunde, hostOhneKunde,
clusterKnotenOhneAgent,
proxmoxRechnungFaellig, proxmoxRechnungFaellig,
kundenrechnungFehlt, kundenrechnungFehlt,
kundenrechnungUnbezahlt, kundenrechnungUnbezahlt,
+15 -1
View File
@@ -2,6 +2,7 @@ import { createHmac } from "node:crypto";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums"; import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
/** /**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook, * Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
@@ -109,6 +110,19 @@ export interface KanalEintrag {
konfiguration: EmailKonfiguration | WebhookKonfiguration; konfiguration: EmailKonfiguration | WebhookKonfiguration;
} }
/** Holt die Zugangsdaten aus der Datenbank und entschlüsselt sie für den Versand. */
export function konfigurationLesen(
typ: NotificationKanal,
gespeichert: unknown,
): EmailKonfiguration | WebhookKonfiguration {
if (typ === "EMAIL") {
const eintrag = gespeichert as EmailKonfiguration;
return { ...eintrag, passwort: entschluesseln(eintrag.passwort ?? "") };
}
const eintrag = gespeichert as WebhookKonfiguration;
return { ...eintrag, geheimnis: eintrag.geheimnis ? entschluesseln(eintrag.geheimnis) : undefined };
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> { export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } }); const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({ return kanaele.map((kanal) => ({
@@ -116,7 +130,7 @@ export async function aktiveKanaele(): Promise<KanalEintrag[]> {
typ: kanal.typ, typ: kanal.typ,
name: kanal.name, name: kanal.name,
aktiv: kanal.aktiv, aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration, konfiguration: konfigurationLesen(kanal.typ, kanal.konfiguration),
})); }));
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts"; import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele"; import { aktiveKanaele, konfigurationLesen, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten"; import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/** /**
@@ -149,7 +149,7 @@ export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachrich
typ: eintrag.typ, typ: eintrag.typ,
name: eintrag.name, name: eintrag.name,
aktiv: eintrag.aktiv, aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never, konfiguration: konfigurationLesen(eintrag.typ, eintrag.konfiguration),
}; };
})()); })());
+1 -5
View File
@@ -165,11 +165,7 @@ describe.runIf(datenbankVerfuegbar)("Agent-API", () => {
["fehlendem Token", async () => undefined, 401], ["fehlendem Token", async () => undefined, 401],
["unbekanntem Token", async () => "plx_e_unbekannt", 401], ["unbekanntem Token", async () => "plx_e_unbekannt", 401],
["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401], ["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401],
[ ["abgelaufenem Token", async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }), 401],
"abgelaufenem Token",
async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }),
401,
],
["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401], ["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401],
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => { ])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
const token = await tokenErzeugen(); const token = await tokenErzeugen();
+114 -4
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route"; import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
import { AGENT_VERSION } from "@/server/agent/http";
import { POST as reportPost } from "@/app/api/agent/v1/report/route"; import { POST as reportPost } from "@/app/api/agent/v1/report/route";
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit"; import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
import { erzeugeToken } from "@/lib/tokens"; import { erzeugeToken } from "@/lib/tokens";
@@ -20,6 +21,36 @@ function befehlErsetzen(name: string, ausgabe: string) {
chmodSync(datei, 0o755); chmodSync(datei, 0o755);
} }
/**
* Ahmt `proxmox-backup-manager` nach: Das Werkzeug beantwortet `version` anders als
* `subscription get`, und auf `--output-format json` antwortet es anders als ohne.
* Genau daran hängt die Auswertung im Agent.
*/
function pbsErsetzen(antworten: { json: string; tabelle: string; version: string }) {
const datei = path.join(stubVerzeichnis, "proxmox-backup-manager");
writeFileSync(
datei,
[
"#!/usr/bin/env bash",
'if [ "$1" = "version" ]; then',
"cat <<'VERSION'",
antworten.version,
"VERSION",
'elif [[ "$*" == *--output-format*json* ]]; then',
"cat <<'JSON'",
antworten.json,
"JSON",
"else",
"cat <<'TEXT'",
antworten.tabelle,
"TEXT",
"fi",
"",
].join("\n"),
);
chmodSync(datei, 0o755);
}
/** Führt den Agent mit den vorbereiteten Befehlen aus und liefert den Report. */ /** Führt den Agent mit den vorbereiteten Befehlen aus und liefert den Report. */
function reportErzeugen(): Record<string, never> | Record<string, unknown> { function reportErzeugen(): Record<string, never> | Record<string, unknown> {
const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], { const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], {
@@ -43,6 +74,23 @@ nextduedate: 2026-09-24
sockets: 1 sockets: 1
level: c`; level: c`;
// Echte Ausgaben von einem Proxmox Backup Server 4.2 ohne Subscription (Feldtest #023).
const PBS_TABELLE = `┌──────────┬──────────────────────────────────────────────────────────┐
│ Name │ Value │
╞══════════╪══════════════════════════════════════════════════════════╡
│ status │ notfound │
├──────────┼──────────────────────────────────────────────────────────┤
│ message │ There is no subscription key │
├──────────┼──────────────────────────────────────────────────────────┤
│ serverid │ 69AED6ABA8A34EBE9AE1075333BBF8FE │
├──────────┼──────────────────────────────────────────────────────────┤
│ url │ https://www.proxmox.com/en/proxmox-backup-server/pricing │
└──────────┴──────────────────────────────────────────────────────────┘`;
const PBS_VERSION = "proxmox-backup-server 4.2.6-1 running version: 4.2.1";
const PBS_JSON = `{"message":"There is no subscription key","serverid":"69AED6ABA8A34EBE9AE1075333BBF8FE","status":"notfound","url":"https://www.proxmox.com/en/proxmox-backup-server/pricing"}`;
describe("Agent-Skript", () => { describe("Agent-Skript", () => {
beforeEach(() => { beforeEach(() => {
stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-")); stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-"));
@@ -57,8 +105,14 @@ describe("Agent-Skript", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION); befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e"); befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G"); befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G");
befehlErsetzen("ip", "1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0"); befehlErsetzen(
befehlErsetzen("apt", "Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]"); "ip",
"1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0",
);
befehlErsetzen(
"apt",
"Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]",
);
const report = reportErzeugen() as { const report = reportErzeugen() as {
reportId: string; reportId: string;
@@ -69,7 +123,7 @@ describe("Agent-Skript", () => {
host: Record<string, unknown>; host: Record<string, unknown>;
}; };
expect(report.agentVersion).toBe("0.2.0"); expect(report.agentVersion).toBe(AGENT_VERSION);
expect(report.reportId).toMatch(/.{8,}/); expect(report.reportId).toMatch(/.{8,}/);
expect(report.erhobenAm).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); expect(report.erhobenAm).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
expect(report.produkt).toEqual({ typ: "pve", version: "pve-manager/8.3.2/dc2b2f4e" }); expect(report.produkt).toEqual({ typ: "pve", version: "pve-manager/8.3.2/dc2b2f4e" });
@@ -138,9 +192,65 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
}); });
}); });
it("liest einen PBS ohne Subscription (echte Ausgaben, JSON bevorzugt)", () => {
pbsErsetzen({ json: PBS_JSON, tabelle: PBS_TABELLE, version: PBS_VERSION });
const report = reportErzeugen() as {
produkt: { typ: string };
subscription: Record<string, unknown>;
host: { clusterMitglieder: string[] };
};
expect(report.produkt.typ).toBe("pbs");
expect(report.subscription.status).toBe("notfound");
expect(report.subscription.serverid).toBe("69AED6ABA8A34EBE9AE1075333BBF8FE");
expect(report.subscription.key).toBeNull();
expect(report.subscription.sockets).toBeNull();
// Ohne pvecm bleibt die Mitgliederliste leer – PBS kennt keinen Cluster in diesem Sinne.
expect(report.host.clusterMitglieder).toEqual([]);
});
it("liest die Rahmentabelle, falls ein PBS kein JSON ausgibt", () => {
// Ältere Fassungen könnten das JSON-Format nicht kennen; dann bleibt nur die Tabelle.
befehlErsetzen("proxmox-backup-manager", PBS_TABELLE);
const report = reportErzeugen() as { subscription: Record<string, unknown> };
expect(report.subscription.status).toBe("notfound");
expect(report.subscription.serverid).toBe("69AED6ABA8A34EBE9AE1075333BBF8FE");
});
it("meldet die PBS-Version ohne die laufende Fassung", () => {
pbsErsetzen({ json: PBS_JSON, tabelle: PBS_TABELLE, version: PBS_VERSION });
const report = reportErzeugen() as { produkt: { version: string } };
expect(report.produkt.version).toBe("proxmox-backup-server 4.2.6-1");
});
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", () => { it("gibt die Version aus", () => {
const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim(); const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim();
expect(ausgabe).toBe("0.2.0"); expect(ausgabe).toBe(AGENT_VERSION);
}); });
describe.runIf(datenbankVerfuegbar)("Zusammenspiel mit der Agent-API", () => { describe.runIf(datenbankVerfuegbar)("Zusammenspiel mit der Agent-API", () => {
+2 -4
View File
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
it("legt die Standard-Einstellungen für alle Regeln an", async () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany(); const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(10); expect(einstellungen).toHaveLength(11);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
}); });
@@ -82,9 +82,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
}); });
const ergebnis = await alertsAbgleichen(JETZT); const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain( expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(`subscription-laeuft-ab:${host.id}:7`);
`subscription-laeuft-ab:${host.id}:7`,
);
const alerts = await testPrisma.alert.findMany({ const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` }, where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
}); });
+9 -2
View File
@@ -1,7 +1,12 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { hash } from "@node-rs/argon2"; import { hash } from "@node-rs/argon2";
import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung"; import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
import { AnmeldeFehler, anmeldeRateLimit, mitOidcAnmelden, mitPasswortAnmelden } from "@/server/auth/benutzer"; import {
AnmeldeFehler,
anmeldeRateLimit,
mitOidcAnmelden,
mitPasswortAnmelden,
} from "@/server/auth/benutzer";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!"; const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!";
@@ -108,7 +113,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
await admin(); await admin();
await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler); await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler);
const protokoll = await testPrisma.auditEintrag.findFirst({ where: { aktion: "anmeldung.fehlgeschlagen" } }); const protokoll = await testPrisma.auditEintrag.findFirst({
where: { aktion: "anmeldung.fehlgeschlagen" },
});
expect(protokoll).not.toBeNull(); expect(protokoll).not.toBeNull();
}); });
+49 -4
View File
@@ -1,6 +1,7 @@
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele"; import { verschluesseln } from "@/lib/geheimnisse";
import { konfigurationLesen, perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten"; import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand"; import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -43,9 +44,7 @@ describe("Nachrichten", () => {
}, },
], ],
neueAlerts: [], neueAlerts: [],
baldAblaufend: [ baldAblaufend: [{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 }],
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
],
offeneRechnungspunkte: 3, offeneRechnungspunkte: 3,
hostsOhneMeldung: 1, hostsOhneMeldung: 1,
}, },
@@ -219,6 +218,52 @@ describe.runIf(datenbankVerfuegbar)("Versand", () => {
expect(zweiter.gesendet).toBe(1); expect(zweiter.gesendet).toBe(1);
}); });
it("nutzt ein verschlüsselt gespeichertes Webhook-Geheimnis für die Signatur", async () => {
process.env.APP_SECRET = "test-geheimnis-mit-mindestens-32-zeichen!!";
const aufrufe: RequestInit[] = [];
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
aufrufe.push(optionen);
return new Response("ok", { status: 200 });
});
await testPrisma.benachrichtigungskanal.create({
data: {
typ: "WEBHOOK",
name: "Signierter Hook",
aktiv: true,
konfiguration: { url: "https://hook.example.de/x", geheimnis: verschluesseln("s3cret") },
},
});
const eintrag = await alert();
await neueAlertsMelden([eintrag.fingerprint]);
const kopfzeilen = aufrufe[0].headers as Record<string, string>;
const koerper = aufrufe[0].body as string;
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
);
// In der Datenbank steht kein Klartext.
const gespeichert = await testPrisma.benachrichtigungskanal.findFirstOrThrow();
expect(JSON.stringify(gespeichert.konfiguration)).not.toContain("s3cret");
});
it("liest Zugangsdaten aus der Zeit vor der Verschlüsselung weiter", () => {
const konfiguration = konfigurationLesen("EMAIL", {
host: "smtp.example.de",
port: 587,
secure: false,
benutzer: "post",
passwort: "alt-im-klartext",
absender: "post@example.de",
empfaenger: ["florian@example.de"],
});
expect((konfiguration as { passwort: string }).passwort).toBe("alt-im-klartext");
});
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => { it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
await webhookKanal(); await webhookKanal();
await alert(); await alert();
+2 -4
View File
@@ -65,9 +65,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
expect(laufzeiten).toHaveLength(2); expect(laufzeiten).toHaveLength(2);
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true); expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
expect( expect(laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0)).toBe(230);
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
).toBe(230);
}); });
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => { it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
@@ -98,4 +96,4 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
await testPrisma.laufzeit.create({ data: daten }); await testPrisma.laufzeit.create({ data: daten });
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow(); await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
}); });
}) });
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it } from "vitest";
import { entschluesseln, GeheimnisFehler, istVerschluesselt, verschluesseln } from "@/lib/geheimnisse";
const SCHLUESSEL = "test-geheimnis-mit-mindestens-32-zeichen!!";
describe("Geheimnisse", () => {
beforeEach(() => {
process.env.APP_SECRET = SCHLUESSEL;
});
it("verschlüsselt und entschlüsselt einen Wert", () => {
const klartext = "sehr-geheimes-smtp-passwort";
const chiffrat = verschluesseln(klartext);
expect(chiffrat).not.toContain(klartext);
expect(istVerschluesselt(chiffrat)).toBe(true);
expect(entschluesseln(chiffrat)).toBe(klartext);
});
it("erzeugt bei gleichem Klartext unterschiedliche Chiffrate", () => {
// Ein zufälliger Nonce verhindert, dass gleiche Passwörter erkennbar sind.
expect(verschluesseln("gleich")).not.toBe(verschluesseln("gleich"));
});
it("lässt Klartext aus der Zeit vor der Verschlüsselung unverändert durch", () => {
expect(istVerschluesselt("altes-passwort")).toBe(false);
expect(entschluesseln("altes-passwort")).toBe("altes-passwort");
});
it("behandelt leere Werte", () => {
expect(verschluesseln("")).toBe("");
expect(entschluesseln("")).toBe("");
});
it("meldet einen geänderten Schlüssel verständlich", () => {
const chiffrat = verschluesseln("geheim");
process.env.APP_SECRET = "ein-voellig-anderes-geheimnis-mit-32-z!";
expect(() => entschluesseln(chiffrat)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(chiffrat)).toThrow(/APP_SECRET/);
});
it("erkennt Manipulation am Chiffrat", () => {
const chiffrat = verschluesseln("geheim");
const [praefix, nonce, authTag, daten] = chiffrat.split(":");
const veraendert = [praefix, nonce, authTag, `${daten.slice(0, -2)}xy`].join(":");
expect(() => entschluesseln(veraendert)).toThrow(GeheimnisFehler);
});
it("verlangt einen ausreichend langen Schlüssel", () => {
process.env.APP_SECRET = "kurz";
expect(() => verschluesseln("geheim")).toThrow(/APP_SECRET/);
});
});
+7 -2
View File
@@ -101,7 +101,10 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
it("findet Hosts über Server-ID, IP, FQDN und Kundenname", async () => { it("findet Hosts über Server-ID, IP, FQDN und Kundenname", async () => {
for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) { for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) {
const { hosts } = await hostListe({ q: suche }, JETZT); const { hosts } = await hostListe({ q: suche }, JETZT);
expect(hosts.map((h) => h.hostname), `Suche nach ${suche}`).toContain("pve-brandl-01"); expect(
hosts.map((h) => h.hostname),
`Suche nach ${suche}`,
).toContain("pve-brandl-01");
} }
}); });
@@ -117,7 +120,9 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
}); });
it("filtert nach Kunde, Status und Produkt", async () => { it("filtert nach Kunde, Status und Produkt", async () => {
const { kunde } = { kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }) }; const { kunde } = {
kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }),
};
expect((await hostListe({ kunde: kunde.id }, JETZT)).gesamt).toBe(1); expect((await hostListe({ kunde: kunde.id }, JETZT)).gesamt).toBe(1);
expect((await hostListe({ kunde: "ohne" }, JETZT)).gesamt).toBe(1); expect((await hostListe({ kunde: "ohne" }, JETZT)).gesamt).toBe(1);
+3 -1
View File
@@ -264,7 +264,9 @@ describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF); await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await einkaufBezahltSetzen(laufzeitA.id, true); await einkaufBezahltSetzen(laufzeitA.id, true);
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } }); let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: EINKAUF.nummer },
});
expect(rechnung.bezahlt).toBe(true); expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull(); expect(rechnung.bezahltAm).not.toBeNull();
+66 -4
View File
@@ -18,6 +18,8 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
return { return {
id: "h1", id: "h1",
hostname: "pve-01", hostname: "pve-01",
clusterName: null,
clusterMitglieder: [],
kundeId: "k1", kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH", kundeName: "Brandl Elektrotechnik GmbH",
subKey: "pve1c-aaaa", subKey: "pve1c-aaaa",
@@ -70,9 +72,9 @@ function ausRegel(k: RegelKontext, name: string) {
} }
describe("Warnregeln", () => { describe("Warnregeln", () => {
it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => { it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(10); expect(REGELN).toHaveLength(11);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(10); expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
}); });
describe("Subscription läuft ab", () => { describe("Subscription läuft ab", () => {
@@ -253,7 +255,10 @@ describe("Warnregeln", () => {
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => { it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
expect( expect(
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"), ausRegel(
kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }),
"proxmox-rechnung-faellig",
),
).toHaveLength(0); ).toHaveLength(0);
expect( expect(
ausRegel( ausRegel(
@@ -294,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", () => { it("überspringt abgeschaltete Regeln", () => {
const k = kontext({ const k = kontext({
hosts: [host({ kundeId: null, kundeName: null })], hosts: [host({ kundeId: null, kundeName: null })],