Compare commits

..
9 Commits
Author SHA1 Message Date
florian.wilde 19b0751c16 Merge branch 'phase/024-owasp'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:45:35 +02:00
florian.wildeandClaude Opus 5 7775b6211f feat(security): Haertung nach OWASP Top 10 (#024)
Drei echte Luecken gefunden und geschlossen:

1. Die 27 Server Actions verliessen sich allein auf proxy.ts. Jede prueft die
   Sitzung jetzt selbst und schlaegt gegen die Datenbank nach, ob das Konto
   noch gilt - damit lassen sich Sitzungen ueberhaupt erst widerrufen.
2. Der Anmelde-Endpunkt prueft die Herkunft des Formulars; vorher war
   Login-CSRF moeglich.
3. Content-Security-Policy mit Nonce je Anfrage und strict-dynamic.

Dazu: npm audit ist ohne Befund (mysql2 und deepmerge-ts per overrides
angehoben statt Prisma zurueckzustufen), eine Benutzerverwaltung fuer die
Kommandozeile und docs/sicherheit.md mit allem, was bewusst offen bleibt -
keine Rollen, keine Zwei-Faktor-Anmeldung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:35 +02:00
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
71 changed files with 1474 additions and 179 deletions
+40
View File
@@ -17,6 +17,46 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.15.0] – 2026-09-18
### Hinzugefügt
- `docs/sicherheit.md`: Zuordnung aller Maßnahmen zu den OWASP Top 10, samt der Punkte, die bewusst offen bleiben (#024)
- Content-Security-Policy mit frischem Nonce je Anfrage (#024)
- Sitzungen lassen sich widerrufen: gesperrtes Konto oder neues Passwort beendet laufende Sitzungen sofort (#024)
- Benutzerverwaltung auf der Kommandozeile: `npm run benutzer -- liste|sperren|entsperren|passwort` (#024)
### Behoben
- Server Actions verließen sich allein auf den Schutz in `proxy.ts`; jede der 27 Aktionen prüft die Sitzung jetzt selbst (#024)
- Die Anmeldung prüfte die Herkunft des Formulars nicht – damit war Login-CSRF möglich (#024)
- `npm audit` meldete vier hohe Schwachstellen in Beiwerk von Prisma (`mysql2`, `deepmerge-ts`); über `overrides` angehoben, jetzt ohne Befund (#024)
## [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
### Hinzugefügt
+20 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 7 h 43 min
**Gesamt:** 10 h 28 min
| Issue | Zeit |
|---|---|
@@ -25,6 +25,11 @@
| #016 Handbuch | 0 h 30 min |
| #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 |
| #023 Feldtest PBS | 0 h 35 min |
| #024 OWASP-Härtung | 0 h 45 min |
| Meilenstein | Zeit |
|---|---|
@@ -47,7 +52,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.13.0
**Version:** 0.15.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -92,6 +97,7 @@
- #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
- #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
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
@@ -125,6 +131,17 @@
- 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.
- 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
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
### 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 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
```
**Wichtig:** `APP_SECRET` mitsichern. Ohne diesen Wert sind alle Sitzungen ungültig; die Zugangsdaten für
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext
– siehe Issue #021.
**Wichtig:** `APP_SECRET` mitsichern und sicher aufbewahren. Der Wert signiert die Sitzungen **und**
verschlüsselt die Zugangsdaten für SMTP und Webhook in der Datenbank. Geht er verloren, müssen diese
Zugangsdaten neu eingetragen werden; eine Sicherung allein gibt sie nicht preis.
## Dokumentation
+46 -7
View File
@@ -12,11 +12,12 @@
set -o nounset
set -o pipefail
AGENT_VERSION="0.2.0"
AGENT_VERSION="0.2.1"
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
@@ -105,15 +106,24 @@ produkt_erkennen() {
}
subscription_ausgabe() {
local ausgabe=""
case "$1" in
# PVE kennt --output-format nicht (geprueft auf 9.2), liefert aber "feld: wert".
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 ;;
*) printf '' ;;
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() {
local feld="$1" text="$2" wert
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" |
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
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.
wert=${wert%\"}
wert=${wert#\"}
@@ -128,9 +147,13 @@ 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 ;;
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;;
pve) pveversion 2>/dev/null | head -n1 | sed 's/ (running kernel.*//' ;;
# 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 ;;
*) printf '' ;;
esac
@@ -150,6 +173,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 +251,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)"),
@@ -383,8 +415,15 @@ aktion_test() {
[ "$(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() {
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})."
if [ "$AUTO_UPDATE" = "yes" ]; then
aktion_update
+23
View File
@@ -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)
@@ -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,
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:
| 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
![Kundenübersicht](bilder/kunden.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 242 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: 293 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: 270 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: 134 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: 173 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: 248 KiB

+44 -10
View File
@@ -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, Spool-Nachsendung und Uninstall geprüft (Uninstall am 18.09.2026 auf dem PBS-Host, siehe #023)
- [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,37 @@ 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 – 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
**Status:** offen
**Meilenstein:** v0.9.0
**Status:** erledigt
**Meilenstein:** v0.13.1
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel
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.
## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013)
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
- [x] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [x] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [x] Bestehende Kanäle werden beim nächsten Speichern umgeschlüsselt (Klartext wird weiterhin gelesen)
- [ ] Gleiches Verfahren für das OIDC-Client-Secret – liegt derzeit in der Umgebung, nicht in der Datenbank, und braucht daher keine Verschlüsselung
- [x] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
- [x] In der Datenbank steht kein Klartext-Passwort mehr
- [x] Ohne `APP_SECRET` lässt sich kein Geheimnis lesen oder schreiben (Fehlermeldung nennt die Ursache)
- [x] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten
#012, #013
## Notizen / Entscheidungen
- 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.
+34
View File
@@ -0,0 +1,34 @@
# #024 Härtung nach OWASP
**Status:** erledigt
**Meilenstein:** v0.15.0
**Labels:** security, backend
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
## Ziel
Vor der Freigabe als fertiges Produkt eine Durchsicht gegen die OWASP Top 10 – und die gefundenen
Lücken schließen, statt sie zu vermerken.
## Aufgaben
- [x] Zugriffskontrolle: jede Server Action prüft die Sitzung selbst, nicht nur der Proxy
- [x] Sitzungen widerrufbar machen (gesperrtes Konto, neues Passwort)
- [x] Login-CSRF: Herkunft der Anmeldung prüfen
- [x] Content-Security-Policy mit Nonce je Anfrage
- [x] Abhängigkeiten ohne bekannte Schwachstellen (`npm audit`)
- [x] Benutzerverwaltung auf der Kommandozeile (sperren, entsperren, Passwort)
- [x] `docs/sicherheit.md` mit der Zuordnung zu den OWASP Top 10
- [x] Tests für Sitzungsurteil und Herkunftsprüfung
## Gefunden und behoben
1. **Server Actions waren nur durch `proxy.ts` geschützt.** Keine einzige Aktion prüfte selbst. Der
Proxy greift zwar auch bei POST, aber eine einzige Schicht ist zu wenig für 27 schreibende Endpunkte.
2. **Login-CSRF möglich:** Der Anmelde-Endpunkt ist ein gewöhnliches Formular und prüfte die Herkunft
nicht. Eine fremde Seite hätte jemanden in ein untergeschobenes Konto anmelden können.
3. **Keine CSP.** Jetzt mit Nonce und `strict-dynamic`.
4. **Sitzungen ließen sich nicht beenden.** Ein gesperrtes Konto arbeitete bis zu zwölf Stunden weiter.
5. **`npm audit`: 4 hohe Meldungen** in `mysql2` und `deepmerge-ts` – beides Beiwerk von Prisma. Über
`overrides` angehoben statt Prisma zurückzustufen; danach keine Meldung mehr.
## Bewusst nicht umgesetzt
- **Rollen:** Wer angemeldet ist, darf alles. Für ein internes Werkzeug vertretbar, in `docs/sicherheit.md` vermerkt.
- **Zwei-Faktor-Anmeldung:** Wer sie braucht, nutzt OIDC.
- Die Einschränkung der OIDC-Anmeldung (erlaubte Domänen, automatisches Anlegen) kommt mit #025.
+6 -3
View File
@@ -21,8 +21,11 @@ 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 | in Arbeit | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **7 h 43 min** |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 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) |
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
| | **Summe** | | | **125 h** | **10 h 28 min** |
+101
View File
@@ -0,0 +1,101 @@
# Sicherheit
Dieses Dokument hält fest, was ProxLedger gegen die üblichen Angriffe tut – und was es **nicht** tut.
Die Gliederung folgt den OWASP Top 10 (2021).
## A01 – Fehlerhafte Zugriffskontrolle
- `src/proxy.ts` schickt jede Anfrage ohne gültige Sitzung zur Anmeldung. Öffentlich sind nur die
Anmeldung, die Agent-API (eigene Tokens), die Gesundheitsprüfung, die beiden Agent-Skripte und die Symbole.
- **Jede Server Action prüft zusätzlich selbst** (`sitzungErforderlich()`). Server Actions sind eigene
Endpunkte; eine einzige Schutzschicht davor wäre zu wenig, falls der Musterausdruck im Proxy je
danebengreift.
- Die Prüfung geht in die Datenbank: Ein gesperrtes Konto oder ein Widerruf beendet laufende Sitzungen
sofort (`npm run benutzer -- sperren <email>`), statt bis zum Ablauf des Tokens zu warten.
- Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes
Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst.
**Nicht vorhanden:** Rollen. Wer angemeldet ist, darf alles. Für ein Werkzeug, das ein Systemhaus intern
betreibt, ist das vertretbar – für mehrere Mandanten wäre es das nicht.
## A02 – Kryptografische Fehler
- Passwörter: argon2id (`@node-rs/argon2`), keine eigene Krypto.
- Sitzungen: signiertes JWT (HS256) aus `APP_SECRET`, 12 Stunden gültig, Cookie `httpOnly`, `sameSite=lax`
und `secure`, sobald `APP_URL` auf `https://` steht.
- Zugangsdaten für SMTP und Webhooks liegen AES-256-GCM-verschlüsselt in der Datenbank
(`src/lib/geheimnisse.ts`); der Schlüssel wird aus `APP_SECRET` abgeleitet.
- Agent- und Enrollment-Tokens werden nur als SHA-256-Hash gespeichert. Das ist hier richtig und nicht
zu schwach: Die Tokens sind 32 Byte Zufall, es gibt nichts zu raten.
**Wichtig:** `APP_SECRET` ist der Generalschlüssel. Geht er verloren, sind alle Sitzungen ungültig und die
gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
## A03 – Injektion
- Datenbankzugriffe ausschließlich über Prisma mit gebundenen Werten; kein `$queryRawUnsafe` im Projekt.
- Ausgaben maskiert React selbst.
- Zusätzlich greift eine Content-Security-Policy mit frischem Nonce je Anfrage und `strict-dynamic`
(`src/proxy.ts`). Eingeschleustes `<script>` – etwa über einen manipulierten Hostnamen aus einer
Proxmox-Ausgabe – läuft damit nicht.
- Der Agent baut sein JSON mit einer eigenen Maskierung; Sonderzeichen aus Proxmox-Ausgaben sind als
Testfall hinterlegt.
## A04 – Unsicheres Design
- Push-Modell: Das Dashboard braucht **keinen** Zugang zu den Proxmox-Hosts. Es gibt keine gespeicherten
root-Zugänge, die abhandenkommen könnten – die Hosts melden von sich aus.
- Der Agent sendet nur Statusangaben (Subscription, Version, Sockel, IPs). Keine Gastliste, keine Daten.
- Enrollment-Tokens sind befristet und in der Zahl der Nutzungen begrenzt.
## A05 – Sicherheitsrelevante Fehlkonfiguration
- Kopfzeilen: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`,
`Strict-Transport-Security` (`next.config.ts`) sowie die CSP aus dem Proxy.
- Die Anwendung startet nicht mit zu kurzem `APP_SECRET`.
- Im Container läuft die Anwendung als unprivilegierter Benutzer; Anhänge werden mit Rechten 640 abgelegt.
- Fehlermeldungen nennen keine Einzelheiten: Bei der Anmeldung steht dieselbe Meldung für „Adresse
unbekannt“ und „Passwort falsch“.
## A06 – Verwundbare Komponenten
- `npm audit --omit=dev` meldet **keine** bekannten Schwachstellen (Stand 18.09.2026).
- Zwei Fassungen werden über `overrides` angehoben: `mysql2` und `deepmerge-ts` kommen als Beiwerk von
Prisma mit, obwohl wir Postgres nutzen. Ohne die Anhebung bliebe nur ein Rückschritt auf Prisma 6.
- Die CI prüft bei jedem Push (#019).
## A07 – Fehler bei Identifizierung und Authentifizierung
- Ratenbremse: fünf Anmeldeversuche je Kennung und Viertelstunde.
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
**Nicht vorhanden:** Zwei-Faktor-Anmeldung. Wer sie braucht, stellt OIDC davor und schaltet die
Passwortanmeldung ab.
## A08 – Datenintegrität
- Der Agent prüft vor dem Selbstaktualisieren, ob die geladene Datei wie ein Bash-Skript beginnt.
- Webhooks werden mit HMAC-SHA256 signiert, damit die Gegenstelle die Herkunft prüfen kann.
- Hochgeladene Rechnungen werden nicht dem gemeldeten Typ nach beurteilt, sondern am Dateianfang
geprüft (`%PDF-`), auf 10 MB begrenzt und unter ihrer Prüfsumme abgelegt.
## A09 – Protokollierung
- Audit-Log für Anmeldungen, Änderungen an Kunden, Rechnungen, Tokens, Kanälen und Warnregeln –
einsehbar unter *Einstellungen → Protokoll*.
- Der Agent protokolliert auf dem Host über `logger` ins Journal.
## A10 – Server-Side Request Forgery
- Die einzige ausgehende Verbindung, die sich einstellen lässt, ist der Benachrichtigungs-Webhook. Er
wird bewusst von einem Angemeldeten eingetragen und ist damit kein fremdgesteuerter Aufruf.
- Sonst ruft das Dashboard keine Adressen auf, die aus Daten stammen.
## Was ein Betreiber selbst tun muss
1. HTTPS davorschalten (Beispiele unter `deploy/reverse-proxy/`) und `APP_URL` auf `https://` setzen.
2. `APP_SECRET` und die Datenbank sichern (`deploy/backup.sh`).
3. Das Dashboard nicht ins offene Internet stellen, wenn es nicht sein muss. Die Agent-API muss
erreichbar sein, die Oberfläche nicht.
4. Enrollment-Tokens nach dem Ausrollen widerrufen.
+39 -38
View File
@@ -1,12 +1,12 @@
{
"name": "proxledger",
"version": "0.12.0",
"version": "0.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxledger",
"version": "0.12.0",
"version": "0.14.1",
"hasInstallScript": true,
"dependencies": {
"@node-rs/argon2": "^2.2.1",
@@ -5071,13 +5071,23 @@
"license": "MIT"
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
"devOptional": true,
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
"node": ">=16.9.0"
}
},
"node_modules/define-data-property": {
@@ -5133,16 +5143,6 @@
"robust-predicates": "^3.0.2"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -7820,24 +7820,25 @@
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
@@ -9059,12 +9060,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
"devOptional": true
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9314,14 +9309,20 @@
"node": ">= 10.x"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"node_modules/sql-escaper": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": {
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.13.0",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -26,7 +26,8 @@
"lasttest": "tsx scripts/lasttest.ts",
"screenshots": "node scripts/screenshots.mjs",
"icons": "node scripts/icons.mjs",
"pruefe:doku": "node scripts/pruefe-doku.mjs"
"pruefe:doku": "node scripts/pruefe-doku.mjs",
"benutzer": "tsx scripts/benutzer.ts"
},
"dependencies": {
"@node-rs/argon2": "^2.2.1",
@@ -65,5 +66,9 @@
"description": "Proxmox Subscription Dashboard",
"engines": {
"node": ">=24"
},
"overrides": {
"mysql2": "^3.24.4",
"deepmerge-ts": "^8.0.0"
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Host" ADD COLUMN "clusterMitglieder" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenUngueltigAb" TIMESTAMP(3);
+4
View File
@@ -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?
@@ -454,6 +456,8 @@ model Benutzer {
oidcSubject String? @unique
aktiv Boolean @default(true)
letzterLoginAm DateTime?
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
sitzungenUngueltigAb DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
+81
View File
@@ -0,0 +1,81 @@
import "dotenv/config";
import { createInterface } from "node:readline/promises";
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Benutzerverwaltung von der Kommandozeile – solange es dafür keine Oberfläche gibt.
*
* npm run benutzer -- liste
* npm run benutzer -- sperren <email> Konto sperren und alle Sitzungen sofort beenden
* npm run benutzer -- entsperren <email>
* npm run benutzer -- passwort <email> neues Passwort setzen, beendet alle Sitzungen
*/
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
async function passwortAbfragen(): Promise<string> {
const eingabe = createInterface({ input: process.stdin, output: process.stdout });
const passwort = await eingabe.question("Neues Passwort (mindestens 12 Zeichen): ");
eingabe.close();
if (passwort.length < 12) throw new Error("Zu kurz – mindestens 12 Zeichen.");
return passwort;
}
async function main() {
const [befehl, email] = process.argv.slice(2);
if (befehl === "liste") {
const alle = await prisma.benutzer.findMany({ orderBy: { email: "asc" } });
for (const b of alle) {
console.log(`${b.aktiv ? "aktiv " : "gesperrt"} ${b.email.padEnd(32)} ${b.name}`);
}
return;
}
if (!email) throw new Error("Bitte eine E-Mail-Adresse angeben.");
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer) throw new Error(`Kein Benutzer mit der Adresse ${email}.`);
switch (befehl) {
case "sperren":
// Sperren allein genügt nicht: Das Sitzungs-Token gilt sonst bis zum Ablauf weiter.
await prisma.benutzer.update({
where: { id: benutzer.id },
data: { aktiv: false, sitzungenUngueltigAb: new Date() },
});
console.log(`${benutzer.email} ist gesperrt, alle Sitzungen sind beendet.`);
break;
case "entsperren":
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: true } });
console.log(`${benutzer.email} ist wieder freigegeben.`);
break;
case "passwort": {
const passwort = await passwortAbfragen();
await prisma.benutzer.update({
where: { id: benutzer.id },
data: {
passwortHash: await hash(passwort),
sitzungenUngueltigAb: new Date(),
},
});
console.log(`Passwort gesetzt; bestehende Sitzungen von ${benutzer.email} sind beendet.`);
break;
}
default:
throw new Error("Bekannte Befehle: liste, sperren, entsperren, passwort");
}
}
main()
.catch((fehler: unknown) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+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'
+92 -60
View File
@@ -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.");
}
@@ -42,6 +42,7 @@ export default async function BenachrichtigungenSeite() {
absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort),
// Der Wert selbst wird nie an den Browser gegeben – nur ob einer hinterlegt ist.
};
const webhookWerte: WebhookWerte = {
+35
View File
@@ -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
<Zeile bezeichnung="Cluster / Node">
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
</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="CPU">{host.cpuModell ?? "–"}</Zeile>
<Zeile bezeichnung="Sockets (lizenziert / physisch)">
+30
View File
@@ -9,6 +9,32 @@ export const dynamic = "force-dynamic";
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
*/
/**
* Prüft, ob das Formular von der eigenen Oberfläche kam.
*
* Ohne diese Prüfung könnte eine fremde Seite ein Anmeldeformular auf dieses Dashboard abschicken
* und das Opfer unbemerkt in ein untergeschobenes Konto anmelden (Login-CSRF, OWASP A01).
* Server Actions in Next.js prüfen das selbst; dieser Endpunkt ist ein gewöhnliches Formular
* und muss es daher selbst tun.
*/
export function herkunftStimmt(request: Request): boolean {
const herkunft = request.headers.get("origin");
// Formulare ohne Origin (etwa curl oder alte Browser) werden nicht ausgeschlossen –
// der Schutz richtet sich gegen fremde Seiten, die immer einen Origin senden.
if (!herkunft) return true;
const erlaubt = new Set<string>();
if (process.env.APP_URL) erlaubt.add(new URL(process.env.APP_URL).host);
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) erlaubt.add(gastgeber);
try {
return erlaubt.has(new URL(herkunft).host);
} catch {
return false;
}
}
export async function POST(request: Request) {
const formular = await request.formData();
const email = formular.get("email")?.toString().trim() ?? "";
@@ -24,6 +50,10 @@ export async function POST(request: Request) {
return Response.redirect(adresse, 303);
}
if (!herkunftStimmt(request)) {
return zurueckZurAnmeldung("Die Anmeldung kam nicht von dieser Adresse.");
}
if (!email || !passwort) {
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
}
+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.",
);
}
}
+7 -1
View File
@@ -8,6 +8,11 @@ import { SignJWT, jwtVerify } from "jose";
export const SITZUNGS_COOKIE = "pl_sitzung";
export const SITZUNGSDAUER_STUNDEN = 12;
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
export interface GeleseneSitzung extends Sitzung {
ausgestelltAm: Date;
}
export interface Sitzung {
benutzerId: string;
email: string;
@@ -37,7 +42,7 @@ export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
}
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
export async function sitzungLesen(token: string | undefined): Promise<Sitzung | null> {
export async function sitzungLesen(token: string | undefined): Promise<GeleseneSitzung | null> {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
@@ -47,6 +52,7 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
email: payload.email,
name: typeof payload.name === "string" ? payload.name : payload.email,
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
ausgestelltAm: new Date((payload.iat ?? 0) * 1000),
};
} catch {
return null;
+46 -3
View File
@@ -23,19 +23,62 @@ const OEFFENTLICH = [
"/manifest.webmanifest",
];
/**
* Content-Security-Policy je Anfrage, mit frischem Nonce.
*
* `strict-dynamic` heißt: Nur Skripte mit diesem Nonce dürfen laufen (und was sie selbst nachladen).
* Damit läuft eingeschleustes `<script>` aus der Datenbank oder einer Proxmox-Ausgabe nicht mehr –
* die zweite Verteidigungslinie hinter der Maskierung durch React (OWASP A03).
*
* `style-src` erlaubt bewusst `unsafe-inline`: Bootstrap und AdminLTE setzen Stile direkt am Element,
* und ein Stil kann keinen Code ausführen.
*/
function richtlinie(nonce: string): string {
const entwicklung = process.env.NODE_ENV !== "production";
return [
"default-src 'self'",
// In der Entwicklung braucht der Hot-Reload eval; im Betrieb nicht.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
// Verbindungen nur zur eigenen Herkunft; in der Entwicklung zusätzlich der Hot-Reload.
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(entwicklung ? [] : ["upgrade-insecure-requests"]),
].join("; ");
}
function mitRichtlinie(request: NextRequest, antwort?: NextResponse): NextResponse {
const nonce = crypto.randomUUID().replaceAll("-", "");
const csp = richtlinie(nonce);
// Next.js versieht seine eigenen Skripte mit diesem Nonce, sobald es ihn in der Anfrage findet.
const kopfzeilen = new Headers(request.headers);
kopfzeilen.set("x-nonce", nonce);
kopfzeilen.set("Content-Security-Policy", csp);
const ergebnis = antwort ?? NextResponse.next({ request: { headers: kopfzeilen } });
ergebnis.headers.set("Content-Security-Policy", csp);
return ergebnis;
}
export async function proxy(request: NextRequest) {
const pfad = request.nextUrl.pathname;
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
return NextResponse.next();
return mitRichtlinie(request);
}
const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value);
if (sitzung) return NextResponse.next();
if (sitzung) return mitRichtlinie(request);
const ziel = new URL("/login", request.url);
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
return NextResponse.redirect(ziel);
return mitRichtlinie(request, NextResponse.redirect(ziel));
}
export const config = {
+5
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln";
import type { AktionsErgebnis } from "./kunden";
@@ -15,6 +16,7 @@ function neuLaden() {
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
export async function alertQuittieren(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -25,6 +27,7 @@ export async function alertQuittieren(formular: FormData) {
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
export async function alertSchliessen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -35,6 +38,7 @@ export async function alertSchliessen(formular: FormData) {
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const ergebnis = await alertsAbgleichen();
neuLaden();
return {
@@ -47,6 +51,7 @@ export async function warnregelnSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
for (const regel of REGELN) {
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
const schwellwerte: Record<string, number | number[]> = {};
+12 -3
View File
@@ -2,7 +2,9 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten";
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
@@ -21,6 +23,7 @@ export async function emailKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587);
@@ -45,8 +48,9 @@ export async function emailKanalSpeichern(
port,
secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
passwort: passwortEingabe || (bisher?.passwort ?? ""),
// Leeres Feld heißt „unverändert“; das gespeicherte Passwort bleibt dann wie es ist.
// Neue Eingaben werden verschlüsselt abgelegt (#021).
passwort: passwortEingabe ? verschluesseln(passwortEingabe) : (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger,
};
@@ -76,6 +80,7 @@ export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? "";
@@ -90,7 +95,7 @@ export async function webhookKanalSpeichern(
const konfiguration: WebhookKonfiguration = {
url,
geheimnis: geheimnisEingabe || bisher?.geheimnis,
geheimnis: geheimnisEingabe ? verschluesseln(geheimnisEingabe) : bisher?.geheimnis,
};
const daten = {
@@ -114,6 +119,7 @@ export async function webhookKanalSpeichern(
}
export async function kanalLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -124,6 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
/** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise<void> {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -138,6 +145,7 @@ export async function testSenden(formular: FormData): Promise<void> {
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) {
await sitzungErforderlich();
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({
@@ -152,6 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
await sitzungErforderlich();
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum);
neuLaden();
+3
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden";
/** Speichert Notizen und Tags eines Hosts. */
@@ -10,6 +11,7 @@ export async function hostNotizenSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString();
if (!hostId) return { fehler: "Host nicht gefunden." };
@@ -39,6 +41,7 @@ export async function hostNotizenSpeichern(
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
export async function hostKundeSetzen(formular: FormData) {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString();
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
+3
View File
@@ -3,11 +3,13 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
import type { AktionsErgebnis } from "./kunden";
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
@@ -30,6 +32,7 @@ export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
export async function hostZuordnungAufheben(formular: FormData) {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString();
if (!hostId) return;
+5
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export interface AktionsErgebnis {
fehler?: string;
@@ -28,6 +29,7 @@ const kundeSchema = z.object({
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = leerZuNull(formular.get("id"));
const eingabe = kundeSchema.safeParse({
name: formular.get("name")?.toString().trim() ?? "",
@@ -66,6 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
}
export async function kundeLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -91,6 +94,7 @@ export async function standortSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = leerZuNull(formular.get("id"));
const eingabe = standortSchema.safeParse({
kundeId: formular.get("kundeId")?.toString() ?? "",
@@ -116,6 +120,7 @@ export async function standortSpeichern(
}
export async function standortLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
const kundeId = formular.get("kundeId")?.toString();
if (!id) return;
+7
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
@@ -48,6 +49,7 @@ async function ausfuehren(arbeit: () => Promise<string | void>): Promise<Aktions
}
export async function laufzeitBestaetigenAktion(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("laufzeitId")?.toString();
if (!id) return;
await laufzeitBestaetigen(id);
@@ -55,6 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
}
export async function laufzeitVerwerfenAktion(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("laufzeitId")?.toString();
if (!id) return;
await laufzeitVerwerfen(id);
@@ -63,6 +66,7 @@ export async function laufzeitVerwerfenAktion(formular: FormData) {
/** Häkchen aus der Rechnungstabelle. */
export async function statusUmschalten(formular: FormData): Promise<void> {
await sitzungErforderlich();
const laufzeitId = formular.get("laufzeitId")?.toString();
const feld = formular.get("feld")?.toString();
const wert = formular.get("wert")?.toString() === "true";
@@ -84,6 +88,7 @@ export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -121,6 +126,7 @@ export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -151,6 +157,7 @@ export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start"));
+4
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { erzeugeToken } from "@/lib/tokens";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden";
export interface TokenErgebnis extends AktionsErgebnis {
@@ -16,6 +17,7 @@ export async function enrollmentTokenErzeugen(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
await sitzungErforderlich();
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
@@ -58,6 +60,7 @@ export async function enrollmentTokenErzeugen(
}
export async function enrollmentTokenWiderrufen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -67,6 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
}
export async function hostTokenWiderrufen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
const hostId = formular.get("hostId")?.toString();
if (!id) return;
+1 -1
View File
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
import { AgentApiFehler } from "./service";
/** 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) {
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(),
clusterName: nullbarerText,
nodeName: nullbarerText,
clusterMitglieder: z.array(z.string().max(255)).max(64).nullish(),
kernel: nullbarerText,
uptimeSekunden: z.number().int().nonnegative().nullish(),
cpuModell: nullbarerText,
+1
View File
@@ -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,
+2
View File
@@ -30,6 +30,8 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
hosts: hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
clusterName: host.clusterName,
clusterMitglieder: host.clusterMitglieder,
kundeId: host.kundeId,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
+36
View File
@@ -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<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 = {
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,
+44
View File
@@ -0,0 +1,44 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/**
* Verlangt eine gültige Sitzung und prüft sie gegen die Datenbank.
*
* `proxy.ts` hält Unangemeldete von den Seiten fern, aber Server Actions sind eigene
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
* verlassen (OWASP A01 – Broken Access Control).
*
* Zusätzlich wirkt hier der Widerruf: Ein gesperrtes Konto oder ein zurückgesetztes
* Passwort beendet laufende Sitzungen sofort, statt bis zum Ablauf des Tokens zu warten.
*/
/** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
export function sitzungBewerten(
sitzung: GeleseneSitzung | null,
benutzer: { aktiv: boolean; sitzungenUngueltigAb: Date | null } | null,
): { gueltig: true } | { gueltig: false; grund: string } {
if (!sitzung) return { gueltig: false, grund: "Bitte anmelden." };
if (!benutzer) return { gueltig: false, grund: "Dieses Konto gibt es nicht mehr." };
if (!benutzer.aktiv) return { gueltig: false, grund: "Dieses Konto ist gesperrt." };
if (benutzer.sitzungenUngueltigAb && sitzung.ausgestelltAm <= benutzer.sitzungenUngueltigAb) {
return { gueltig: false, grund: "Die Sitzung wurde beendet. Bitte neu anmelden." };
}
return { gueltig: true };
}
export async function sitzungErforderlich(): Promise<GeleseneSitzung> {
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
const benutzer = sitzung
? await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId },
select: { aktiv: true, sitzungenUngueltigAb: true },
})
: null;
const urteil = sitzungBewerten(sitzung, benutzer);
if (!urteil.gueltig) redirect(`/login?fehler=${encodeURIComponent(urteil.grund)}`);
return sitzung as GeleseneSitzung;
}
+15 -1
View File
@@ -2,6 +2,7 @@ import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
/**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
@@ -109,6 +110,19 @@ export interface KanalEintrag {
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[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({
@@ -116,7 +130,7 @@ export async function aktiveKanaele(): Promise<KanalEintrag[]> {
typ: kanal.typ,
name: kanal.name,
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 { 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";
/**
@@ -149,7 +149,7 @@ export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachrich
typ: eintrag.typ,
name: eintrag.name,
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],
["unbekanntem Token", async () => "plx_e_unbekannt", 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],
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
const token = await tokenErzeugen();
+114 -4
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
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 { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
import { erzeugeToken } from "@/lib/tokens";
@@ -20,6 +21,36 @@ function befehlErsetzen(name: string, ausgabe: string) {
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. */
function reportErzeugen(): Record<string, never> | Record<string, unknown> {
const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], {
@@ -43,6 +74,23 @@ nextduedate: 2026-09-24
sockets: 1
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", () => {
beforeEach(() => {
stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-"));
@@ -57,8 +105,14 @@ describe("Agent-Skript", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
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("apt", "Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]");
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(
"apt",
"Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]",
);
const report = reportErzeugen() as {
reportId: string;
@@ -69,7 +123,7 @@ describe("Agent-Skript", () => {
host: Record<string, unknown>;
};
expect(report.agentVersion).toBe("0.2.0");
expect(report.agentVersion).toBe(AGENT_VERSION);
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.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", () => {
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", () => {
+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 () => {
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] });
});
@@ -82,9 +82,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(
`subscription-laeuft-ab:${host.id}:7`,
);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(`subscription-laeuft-ab:${host.id}:7`);
const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
+12 -3
View File
@@ -1,7 +1,12 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { hash } from "@node-rs/argon2";
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";
const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!";
@@ -20,12 +25,14 @@ describe("Sitzungen", () => {
});
const sitzung = await sitzungLesen(token);
expect(sitzung).toEqual({
expect(sitzung).toMatchObject({
benutzerId: "b1",
email: "admin@example.de",
name: "Florian",
herkunft: "passwort",
});
// Der Ausstellungszeitpunkt entscheidet, ob ein Widerruf die Sitzung trifft (#024).
expect(sitzung?.ausgestelltAm.getTime()).toBeLessThanOrEqual(Date.now());
});
it("weist ein verändertes oder fremd signiertes Token ab", async () => {
@@ -108,7 +115,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
await admin();
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();
});
+49 -4
View File
@@ -1,6 +1,7 @@
import { createHmac } from "node:crypto";
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 { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -43,9 +44,7 @@ describe("Nachrichten", () => {
},
],
neueAlerts: [],
baldAblaufend: [
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
],
baldAblaufend: [{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 }],
offeneRechnungspunkte: 3,
hostsOhneMeldung: 1,
},
@@ -219,6 +218,52 @@ describe.runIf(datenbankVerfuegbar)("Versand", () => {
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 () => {
await webhookKanal();
await alert();
+2 -4
View File
@@ -65,9 +65,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
expect(laufzeiten).toHaveLength(2);
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
expect(
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
).toBe(230);
expect(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 () => {
@@ -98,4 +96,4 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
await testPrisma.laufzeit.create({ data: daten });
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 () => {
for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) {
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 () => {
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: "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 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.bezahltAm).not.toBeNull();
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { herkunftStimmt } from "@/app/api/auth/anmelden/route";
import type { GeleseneSitzung } from "@/lib/sitzung";
import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
const sitzung = (ausgestelltAm: Date): GeleseneSitzung => ({
benutzerId: "b1",
email: "admin@example.de",
name: "Administrator",
herkunft: "passwort",
ausgestelltAm,
});
const anfrage = (kopfzeilen: Record<string, string>) =>
new Request("https://proxledger.example/api/auth/anmelden", { method: "POST", headers: kopfzeilen });
describe("Sitzungsprüfung", () => {
const jetzt = new Date("2026-09-18T12:00:00Z");
const aktiv = { aktiv: true, sitzungenUngueltigAb: null };
it("lässt eine gültige Sitzung eines aktiven Kontos durch", () => {
expect(sitzungBewerten(sitzung(jetzt), aktiv)).toEqual({ gueltig: true });
});
it("weist ab, wenn gar keine Sitzung da ist", () => {
expect(sitzungBewerten(null, aktiv).gueltig).toBe(false);
});
it("weist ein gesperrtes Konto ab, obwohl das Token noch gilt", () => {
const urteil = sitzungBewerten(sitzung(jetzt), { aktiv: false, sitzungenUngueltigAb: null });
expect(urteil).toEqual({ gueltig: false, grund: "Dieses Konto ist gesperrt." });
});
it("weist ein gelöschtes Konto ab", () => {
expect(sitzungBewerten(sitzung(jetzt), null).gueltig).toBe(false);
});
it("beendet Sitzungen, die vor dem Widerruf ausgestellt wurden", () => {
const widerruf = new Date("2026-09-18T13:00:00Z");
const urteil = sitzungBewerten(sitzung(jetzt), { aktiv: true, sitzungenUngueltigAb: widerruf });
expect(urteil.gueltig).toBe(false);
});
it("lässt Sitzungen gelten, die nach dem Widerruf entstanden sind", () => {
const widerruf = new Date("2026-09-18T11:00:00Z");
expect(sitzungBewerten(sitzung(jetzt), { aktiv: true, sitzungenUngueltigAb: widerruf })).toEqual({
gueltig: true,
});
});
});
describe("Herkunft der Anmeldung", () => {
it("lässt das eigene Formular durch", () => {
expect(herkunftStimmt(anfrage({ origin: "https://proxledger.example", host: "proxledger.example" }))).toBe(true);
});
it("weist eine fremde Seite ab", () => {
expect(herkunftStimmt(anfrage({ origin: "https://boese.example", host: "proxledger.example" }))).toBe(false);
});
it("weist einen unbrauchbaren Origin ab", () => {
expect(herkunftStimmt(anfrage({ origin: "kein-url", host: "proxledger.example" }))).toBe(false);
});
it("lässt Anfragen ohne Origin zu (curl, alte Browser)", () => {
expect(herkunftStimmt(anfrage({ host: "proxledger.example" }))).toBe(true);
});
it("erkennt den Host hinter einem Reverse Proxy", () => {
expect(
herkunftStimmt(anfrage({ origin: "https://dashboard.kunde.de", "x-forwarded-host": "dashboard.kunde.de" })),
).toBe(true);
});
});
+66 -4
View File
@@ -18,6 +18,8 @@ function host(überschreibungen: Partial<HostDaten> = {}): 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", () => {
@@ -253,7 +255,10 @@ describe("Warnregeln", () => {
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
expect(
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"),
ausRegel(
kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }),
"proxmox-rechnung-faellig",
),
).toHaveLength(0);
expect(
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", () => {
const k = kontext({
hosts: [host({ kundeId: null, kundeName: null })],