Compare commits

..
9 Commits
Author SHA1 Message Date
florian.wilde ffc80a8fcf Merge branch 'phase/025-einstellungen'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:58:30 +02:00
florian.wildeandClaude Opus 5 d416f3869a feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)
Vier Bereiche unter Einstellungen -> System: Adresse und Agent,
Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als
eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren
Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm.

Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus
landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft),
der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token,
die Firmenangaben im Kopf der PDF-Dateien.

Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein
Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts
nach den eingestellten Fristen auf - der juengste Report je Host bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:58:30 +02:00
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
83 changed files with 2223 additions and 190 deletions
+46
View File
@@ -17,6 +17,52 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.16.0] – 2026-09-18
### Hinzugefügt
- *Einstellungen → System*: Dashboard-Adresse, Melderhythmus und Selbstaktualisierung der Agents, kaufmännische Vorgaben, Sicherheitsgrenzen, Firmenangaben und Zeitzone (#025)
- Die Dashboard-Adresse und der Melderhythmus landen im ausgelieferten Installations-Einzeiler und im systemd-Timer (#025)
- OIDC lässt sich auf bestimmte E-Mail-Domänen begrenzen; das automatische Anlegen unbekannter Konten ist abschaltbar (#025)
- Nächtliches Aufräumen nach einstellbaren Aufbewahrungsfristen für Reports und Protokoll – der jüngste Report jedes Hosts bleibt erhalten (#025)
- Firmenangaben erscheinen im Kopf der PDF-Ausgaben (#025)
### Geändert
- Steuersatz, Zahlungsziel und Aufschlag kommen aus den Einstellungen statt aus fest eingetragenen 19 % (#025)
## [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
+23 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 8 h 03 min
**Gesamt:** 11 h 38 min
| Issue | Zeit |
|---|---|
@@ -26,6 +26,11 @@
| #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 |
| #025 Einstellungen | 1 h 10 min |
| Meilenstein | Zeit |
|---|---|
@@ -48,7 +53,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.13.1
**Version:** 0.16.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -127,6 +132,21 @@
- 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
- Einstellungen im Dashboard (#025): vier Bereiche, alle Werte wirken auch wirklich – der Melderhythmus zum Beispiel bis in den erzeugten systemd-Timer hinein, live geprüft
- Dabei die OIDC-Lücke aus #024 geschlossen: Domänenliste und Schalter gegen automatisch angelegte Konten
- Bewusst nicht gebaut: „stumm nach X Tagen“ (steht schon bei den Warnregeln) und die Währung in der Browseransicht
### 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)
+15 -7
View File
@@ -16,9 +16,17 @@ set -o nounset
set -o pipefail
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
# Melderhythmus und Streuung setzt das Dashboard beim Ausliefern ein (Einstellungen → System).
# Wird das Skript unverändert aus dem Quelltext ausgeführt, bleiben die Platzhalter stehen;
# dann gelten die Vorgaben.
TIMER_ONCALENDAR="@@TIMER_ONCALENDAR@@"
TIMER_STREUUNG="@@TIMER_STREUUNG@@"
case "$TIMER_ONCALENDAR" in *@@*) TIMER_ONCALENDAR="daily" ;; esac
case "$TIMER_STREUUNG" in *@@*) TIMER_STREUUNG="4h" ;; esac
TOKEN=""
CA_CERT=""
AUTO_UPDATE="no"
AUTO_UPDATE="@@AUTO_UPDATE@@"
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
AKTION="install"
CONFIG_VERZEICHNIS="/etc/proxledger"
@@ -202,14 +210,14 @@ ProtectSystem=full
ProtectHome=true
UNIT
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT'
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
[Unit]
Description=Täglicher ProxLedger-Report
Description=ProxLedger-Report
[Timer]
OnCalendar=daily
# Verteilt die Meldungen aller Hosts über vier Stunden.
RandomizedDelaySec=4h
OnCalendar=${TIMER_ONCALENDAR}
# Verteilt die Meldungen aller Hosts über diesen Zeitraum.
RandomizedDelaySec=${TIMER_STREUUNG}
Persistent=true
Unit=proxledger.service
@@ -219,7 +227,7 @@ UNIT
systemctl daemon-reload
systemctl enable --now proxledger.timer >/dev/null
erfolg "systemd-Timer aktiviert (täglich, zufällig um bis zu 4 Stunden versetzt)."
erfolg "systemd-Timer aktiviert (${TIMER_ONCALENDAR}, zufällig um bis zu ${TIMER_STREUUNG} versetzt)."
hinweis "Sende ersten Report …"
if "$AGENT_PFAD" --report; then
+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
+54
View File
@@ -11,11 +11,13 @@ 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)
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
9. [Exporte](#9-exporte)
9a. [Einstellungen](#9a-einstellungen)
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
11. [Fehlersuche](#11-fehlersuche)
@@ -112,6 +114,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 +165,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)
@@ -287,6 +311,36 @@ Jahresliste für die Buchhaltung.
---
## 9a. Einstellungen
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
![Systemeinstellungen](bilder/system.png)
**Adresse und Agent.** Die Dashboard-Adresse landet im Installations-Einzeiler und in den
Benachrichtigungen. Bleibt das Feld leer, gilt `APP_URL` aus der Umgebung. Melderhythmus, Streuung und
Selbstaktualisierung gelten für **neu installierte** Agents – bestehende übernehmen sie erst, wenn der
Einzeiler dort erneut läuft.
**Kaufmännisches.** Steuersatz, Zahlungsziel und Aufschlag füllen leere Rechnungsfelder vor. Der
Aufschlag rechnet aus dem Einkaufspreis einen Vorschlag für den Verkaufspreis; eingetragene Beträge
bleiben unberührt.
**Sicherheit.** Sitzungsdauer und Anmeldeversuche gelten ab der nächsten Anmeldung. Die beiden
OIDC-Felder sind wichtig, wenn ein Identitätsanbieter viele Konten kennt: Ohne Einschränkung darf
jeder, der sich dort anmelden kann, hier mitarbeiten. Mit einer Domänenliste oder abgeschaltetem
„Konten anlegen“ kommt nur herein, wer hier vorgesehen ist. Die Aufbewahrungsfristen räumen nachts um
halb vier auf; der jüngste Report jedes Hosts bleibt immer erhalten, sonst stünde ein lange stummer
Host plötzlich ohne Angaben da.
**Firma und Darstellung.** Name, Anschrift und E-Mail stehen im Kopf der erzeugten PDF-Dateien, die
Zeitzone gilt für PDF, CSV und Benachrichtigungen.
> `APP_SECRET` und `DATABASE_URL` stehen bewusst **nicht** hier: Beide werden gebraucht, bevor die
> Anwendung die Datenbank überhaupt lesen kann.
---
## 10. Sicherung und Aktualisierung
**Sichern:**
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: 29 KiB

After

Width:  |  Height:  |  Size: 32 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: 135 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.

After

Width:  |  Height:  |  Size: 206 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).
+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.
+47
View File
@@ -0,0 +1,47 @@
# #025 Einstellungen im Dashboard
**Status:** erledigt
**Meilenstein:** v0.16.0
**Labels:** backend, ui
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
## Ziel
Werte, die heute im Quelltext oder in der Umgebung stehen, sollen sich im Dashboard ändern lassen –
allen voran die Dashboard-Adresse, die im Installations-Einzeiler landet.
## Aufgaben
- [x] Typisierter Einstellungsdienst mit Vorgaben und Prüfung (zod), alles in einer JSON-Zeile
- [x] Oberfläche unter *Einstellungen → System* mit vier Bereichen
- [x] **Adresse und Agent:** Dashboard-Adresse, Melderhythmus, Streuung, Selbstaktualisierung
- [x] **Kaufmännisches:** Währung, Steuersatz, Zahlungsziel, Aufschlag
- [x] **Sicherheit:** Sitzungsdauer, Anmeldeversuche, OIDC-Domänen, Konten anlegen, Aufbewahrungsfristen
- [x] **Firma und Darstellung:** Name, Anschrift, E-Mail, Zeitzone
- [x] Werte wirksam machen (nicht nur speichern)
- [x] Nächtliches Aufräumen nach den Aufbewahrungsfristen
- [x] Tests und Handbuchkapitel
## Wo die Werte wirken
| Einstellung | Wirkt in |
|---|---|
| Dashboard-Adresse | `/install.sh`, `/agent.sh`, Benachrichtigungen |
| Rhythmus, Streuung, Selbstaktualisierung | erzeugte systemd-Timer und `agent.conf` |
| Steuersatz | neue Rechnungspositionen (Einkauf und Verkauf) |
| Zahlungsziel | vorgeschlagenes Fälligkeitsdatum |
| Aufschlag | vorgeschlagener Verkaufspreis aus dem Einkaufspreis |
| Sitzungsdauer | Gültigkeit des Tokens und Lebensdauer des Cookies |
| Anmeldeversuche | Ratenbremse der Anmeldung |
| OIDC-Domänen, Konten anlegen | `mitOidcAnmelden` – schließt die in #024 vermerkte Lücke |
| Aufbewahrungsfristen | nächtlicher Lauf im Worker (03:30) |
| Firma, Zeitzone | Kopf und Datum der PDF-Ausgaben |
## Entscheidungen
- **Eine JSON-Zeile statt vieler Schlüssel:** Speichern ist ein Schreibvorgang, fehlende Felder
bekommen ihren Vorgabewert, und eine kaputte Zeile legt den Betrieb nicht lahm – dann gelten die Vorgaben.
- **Zehn Sekunden Zwischenspeicher:** Die Werte werden bei fast jeder Anfrage gebraucht, ändern sich
aber selten. Eine Änderung ist trotzdem sofort spürbar.
- **`APP_SECRET` und `DATABASE_URL` bleiben in der Umgebung**; beide werden vor dem ersten
Datenbankzugriff gebraucht.
- **„Stumm nach X Tagen“ bewusst weggelassen:** Das steht schon als Schwellwert bei den Warnregeln.
Zwei Stellen für denselben Wert wären eine Fehlerquelle.
- **Währung und Zeitzone wirken vorerst nur serverseitig** (PDF, CSV, Benachrichtigungen). Die
Browseransicht rechnet weiterhin fest in Euro und Europe/Berlin – das nachzuziehen heißt, die
Formatierung durch alle Client-Komponenten zu reichen, und gehört in einen eigenen Schritt.
+6 -2
View File
@@ -21,8 +21,12 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min |
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | erledigt | v0.14.0 | 2 h | 0 h 40 min |
| [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| | **Summe** | | | **116 h** | **8 h 03 min** |
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
| [#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 |
| [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min |
| | **Summe** | | | **130 h** | **11 h 38 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.1",
"version": "0.16.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'
+79 -46
View File
@@ -4,17 +4,19 @@
*
* 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");
@@ -38,6 +48,7 @@ const SEITEN = [
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
];
async function sitzungsToken() {
@@ -60,8 +71,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,42 +119,16 @@ 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(
/**
* 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",
@@ -121,10 +138,14 @@ function aufnehmen(profil, pfad, datei, breite, hoehe) {
`${breite},${hoehe}`,
"--screenshot",
datei,
`${BASIS}${pfad}`,
`${adresse}${pfad}`,
],
{ stdio: "ignore", timeout: 120_000 },
);
vorgang.on("error", fehlgeschlagen);
vorgang.on("close", () => fertig());
});
}
async function main() {
@@ -133,35 +154,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.");
}
@@ -1,23 +1,32 @@
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { APP_VERSION } from "@/lib/version";
import { einstellungen } from "@/server/einstellungen";
export const metadata = { title: "System" };
export default function SystemSeite() {
export default async function SystemSeite() {
const werte = await einstellungen();
return (
<>
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Systeminformationen" rahmen="primary">
<SystemFormular werte={werte} appUrl={process.env.APP_URL ?? ""} />
<Card titel="Systeminformationen" rahmen="keiner">
<dl className="row mb-0">
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
<dd className="col-8">{APP_VERSION}</dd>
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
<dd className="col-8">Konfigurierbar ab #011</dd>
<dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
<dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
<dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
<dd className="col-8">
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
die Datenbank gelesen werden kann.
</dd>
</dl>
</Card>
</div>
+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)">
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
import { einstellungen } from "@/server/einstellungen";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
@@ -34,8 +35,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Host-Liste",
untertitel: `${alle.length} Hosts`,
quer: true,
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10);
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [
@@ -1,6 +1,7 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { einstellungen } from "@/server/einstellungen";
import {
kundenListe,
offeneRechnungspunkteProKunde,
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`,
quer: true,
+11 -4
View File
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -28,6 +29,12 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
@@ -148,9 +155,9 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
werte={{
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19),
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null
@@ -179,8 +186,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag,
ustSatz: Number(verkauf?.ustSatz ?? 19),
nettoBetrag: verkaufVorschlag,
ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null,
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true,
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request));
const inhalt = await skriptLaden("proxledger-agent.sh", await basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh");
}
+38 -3
View File
@@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers";
import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -9,6 +10,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 +51,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.");
}
@@ -32,13 +63,17 @@ export async function POST(request: Request) {
const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({
const { sitzungsdauerStunden } = await einstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
});
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
},
sitzungsdauerStunden,
);
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel, request.url), 303);
} catch (fehler) {
+8 -3
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -25,13 +26,17 @@ export async function GET(request: Request) {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({
const { sitzungsdauerStunden } = await systemEinstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
});
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
},
sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
} catch (fehler) {
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request));
const inhalt = await skriptLaden("install.sh", await basisUrl(request));
return skriptAntwort(inhalt, "install.sh");
}
@@ -0,0 +1,341 @@
"use client";
import { useActionState } from "react";
import { Card } from "@/components/ui/Card";
import { type AktionsErgebnis, einstellungenSpeichernAktion } from "@/server/actions/einstellungen";
import type { Einstellungen } from "@/server/einstellungen";
const LEER: AktionsErgebnis = {};
function Feld({
name,
beschriftung,
hinweis,
fehler,
children,
}: {
name: string;
beschriftung: string;
hinweis?: string;
fehler?: string;
children: React.ReactNode;
}) {
return (
<div className="mb-3">
<label className="form-label" htmlFor={name}>
{beschriftung}
</label>
{children}
{hinweis ? <div className="form-text">{hinweis}</div> : null}
{fehler ? <div className="invalid-feedback d-block">{fehler}</div> : null}
</div>
);
}
export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl: string }) {
const [ergebnis, aktion, laeuft] = useActionState(einstellungenSpeichernAktion, LEER);
const fehler = ergebnis.felder ?? {};
return (
<form action={aktion}>
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="row g-4">
<div className="col-12 col-xl-6">
<Card titel="Adresse und Agent" rahmen="primary">
<Feld
name="dashboardUrl"
beschriftung="Adresse des Dashboards"
hinweis={`Steht im Installations-Einzeiler und in Benachrichtigungen. Leer lassen heißt: aus der Umgebung (${appUrl || "nicht gesetzt"}).`}
fehler={fehler.dashboardUrl}
>
<input
type="url"
className="form-control"
id="dashboardUrl"
name="dashboardUrl"
defaultValue={werte.dashboardUrl}
placeholder="https://proxledger.example.de"
/>
</Feld>
<Feld
name="agentRhythmus"
beschriftung="Wie oft meldet ein Host?"
hinweis="Gilt für neu installierte Agents; bestehende übernehmen es bei der nächsten Installation."
>
<select className="form-select" id="agentRhythmus" name="agentRhythmus" defaultValue={werte.agentRhythmus}>
<option value="taeglich">täglich</option>
<option value="zwoelfstuendlich">alle 12 Stunden</option>
<option value="stuendlich">stündlich</option>
</select>
</Feld>
<Feld
name="agentStreuungStunden"
beschriftung="Zufällige Verzögerung (Stunden)"
hinweis="Verteilt die Meldungen, damit nicht alle Hosts gleichzeitig anklopfen."
fehler={fehler.agentStreuungStunden}
>
<input
type="number"
min={0}
max={12}
className="form-control"
id="agentStreuungStunden"
name="agentStreuungStunden"
defaultValue={werte.agentStreuungStunden}
/>
</Feld>
<div className="form-check">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="agentAutoUpdate"
name="agentAutoUpdate"
value="an"
defaultChecked={werte.agentAutoUpdate}
/>
<label className="form-check-label" htmlFor="agentAutoUpdate">
Agents dürfen sich selbst aktualisieren
</label>
<div className="form-text">
Der Agent lädt bei einer neueren Fassung das Skript vom Dashboard nach.
</div>
</div>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Kaufmännisches" rahmen="info">
<Feld name="waehrung" beschriftung="Währung">
<select className="form-select" id="waehrung" name="waehrung" defaultValue={werte.waehrung}>
<option value="EUR">Euro (€)</option>
<option value="CHF">Schweizer Franken (CHF)</option>
</select>
</Feld>
<Feld
name="steuersatz"
beschriftung="Steuersatz in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen."
fehler={fehler.steuersatz}
>
<input
type="number"
step="0.1"
min={0}
max={100}
className="form-control"
id="steuersatz"
name="steuersatz"
defaultValue={werte.steuersatz}
/>
</Feld>
<Feld
name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen"
hinweis="Setzt das Fälligkeitsdatum neuer Rechnungen vor."
fehler={fehler.zahlungszielTage}
>
<input
type="number"
min={0}
max={120}
className="form-control"
id="zahlungszielTage"
name="zahlungszielTage"
defaultValue={werte.zahlungszielTage}
/>
</Feld>
<Feld
name="margeProzent"
beschriftung="Aufschlag in Prozent"
hinweis="Vorschlag für den Verkaufspreis, ausgehend vom Einkaufspreis bei Proxmox."
fehler={fehler.margeProzent}
>
<input
type="number"
step="0.5"
min={0}
max={500}
className="form-control"
id="margeProzent"
name="margeProzent"
defaultValue={werte.margeProzent}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Sicherheit" rahmen="danger">
<Feld
name="sitzungsdauerStunden"
beschriftung="Sitzungsdauer in Stunden"
hinweis="Nach dieser Zeit ist eine neue Anmeldung nötig."
fehler={fehler.sitzungsdauerStunden}
>
<input
type="number"
min={1}
max={168}
className="form-control"
id="sitzungsdauerStunden"
name="sitzungsdauerStunden"
defaultValue={werte.sitzungsdauerStunden}
/>
</Feld>
<Feld
name="anmeldeversuche"
beschriftung="Anmeldeversuche je Viertelstunde"
fehler={fehler.anmeldeversuche}
>
<input
type="number"
min={3}
max={50}
className="form-control"
id="anmeldeversuche"
name="anmeldeversuche"
defaultValue={werte.anmeldeversuche}
/>
</Feld>
<Feld
name="oidcDomaenen"
beschriftung="Erlaubte E-Mail-Domänen für OIDC"
hinweis="Mehrere durch Komma trennen, etwa „wilde-server.de, kunde.de“. Leer heißt: alle."
>
<input
type="text"
className="form-control"
id="oidcDomaenen"
name="oidcDomaenen"
defaultValue={werte.oidcDomaenen}
placeholder="firma.de"
/>
</Feld>
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="oidcKontenAnlegen"
name="oidcKontenAnlegen"
value="an"
defaultChecked={werte.oidcKontenAnlegen}
/>
<label className="form-check-label" htmlFor="oidcKontenAnlegen">
Unbekannte OIDC-Anmeldungen legen ein Konto an
</label>
<div className="form-text">
Aus heißt: Nur wer hier schon ein Konto hat, kommt herein – auch wenn die Anmeldung
beim Identitätsanbieter klappt.
</div>
</div>
<Feld
name="aufbewahrungReportsTage"
beschriftung="Reports aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt. Der aktuelle Stand jedes Hosts bleibt immer erhalten."
fehler={fehler.aufbewahrungReportsTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungReportsTage"
name="aufbewahrungReportsTage"
defaultValue={werte.aufbewahrungReportsTage}
/>
</Feld>
<Feld
name="aufbewahrungAuditTage"
beschriftung="Protokoll aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt."
fehler={fehler.aufbewahrungAuditTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungAuditTage"
name="aufbewahrungAuditTage"
defaultValue={werte.aufbewahrungAuditTage}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Firma und Darstellung" rahmen="keiner">
<Feld
name="firmaName"
beschriftung="Firmenname"
hinweis="Erscheint im Kopf der erzeugten PDF-Rechnungen."
>
<input
type="text"
className="form-control"
id="firmaName"
name="firmaName"
defaultValue={werte.firmaName}
/>
</Feld>
<Feld name="firmaAnschrift" beschriftung="Anschrift">
<textarea
className="form-control"
id="firmaAnschrift"
name="firmaAnschrift"
rows={3}
defaultValue={werte.firmaAnschrift}
/>
</Feld>
<Feld name="firmaEmail" beschriftung="E-Mail-Adresse" fehler={fehler.firmaEmail}>
<input
type="email"
className="form-control"
id="firmaEmail"
name="firmaEmail"
defaultValue={werte.firmaEmail}
/>
</Feld>
<Feld
name="zeitzone"
beschriftung="Zeitzone"
hinweis="Gilt für PDF, CSV-Ausgaben und Benachrichtigungen."
fehler={fehler.zeitzone}
>
<input
type="text"
className="form-control"
id="zeitzone"
name="zeitzone"
defaultValue={werte.zeitzone}
placeholder="Europe/Berlin"
/>
</Feld>
</Card>
</div>
</div>
<div className="mb-4">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
);
}
+15 -5
View File
@@ -6,8 +6,14 @@ import { SignJWT, jwtVerify } from "jose";
*/
export const SITZUNGS_COOKIE = "pl_sitzung";
/** Rückfall, solange nichts in den Einstellungen steht (#025). */
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;
@@ -26,18 +32,21 @@ function schluessel(): Uint8Array {
return new TextEncoder().encode(geheimnis);
}
export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
export async function sitzungErzeugen(
sitzung: Sitzung,
dauerStunden: number = SITZUNGSDAUER_STUNDEN,
): Promise<string> {
return new SignJWT({ ...sitzung })
.setProtectedHeader({ alg: "HS256" })
.setSubject(sitzung.benutzerId)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`)
.setExpirationTime(`${dauerStunden}h`)
.sign(schluessel());
}
/** 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 +56,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;
@@ -64,12 +74,12 @@ export function ueberHttps(): boolean {
return process.env.NODE_ENV === "production";
}
export function cookieOptionen() {
export function cookieOptionen(dauerStunden: number = SITZUNGSDAUER_STUNDEN) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: ueberHttps(),
path: "/",
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
maxAge: dauerStunden * 3600,
};
}
+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[]> = {};
+7
View File
@@ -4,6 +4,7 @@ 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";
@@ -22,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);
@@ -78,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() ?? "";
@@ -116,6 +119,7 @@ export async function webhookKanalSpeichern(
}
export async function kanalLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString();
if (!id) return;
@@ -126,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;
@@ -140,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({
@@ -154,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();
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungen, einstellungenSchema, einstellungenSpeichern } from "@/server/einstellungen";
export interface AktionsErgebnis {
fehler?: string;
felder?: Record<string, string>;
erfolg?: string;
}
const zahl = (formular: FormData, feld: string) => {
const roh = formular.get(feld)?.toString().replace(",", ".").trim();
return roh === undefined || roh === "" ? undefined : Number(roh);
};
const text = (formular: FormData, feld: string) => formular.get(feld)?.toString().trim() ?? "";
const schalter = (formular: FormData, feld: string) => formular.get(feld) === "an";
export async function einstellungenSpeichernAktion(
_zustand: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const vorher = await einstellungen();
const ergebnis = einstellungenSchema.safeParse({
dashboardUrl: text(formular, "dashboardUrl"),
agentRhythmus: text(formular, "agentRhythmus") || vorher.agentRhythmus,
agentStreuungStunden: zahl(formular, "agentStreuungStunden"),
agentAutoUpdate: schalter(formular, "agentAutoUpdate"),
waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"),
zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"),
sitzungsdauerStunden: zahl(formular, "sitzungsdauerStunden"),
anmeldeversuche: zahl(formular, "anmeldeversuche"),
oidcDomaenen: text(formular, "oidcDomaenen"),
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
firmaName: text(formular, "firmaName"),
firmaAnschrift: text(formular, "firmaAnschrift"),
firmaEmail: text(formular, "firmaEmail"),
zeitzone: text(formular, "zeitzone") || vorher.zeitzone,
});
if (!ergebnis.success) {
const felder: Record<string, string> = {};
for (const fehler of ergebnis.error.issues) {
const feld = fehler.path[0];
if (typeof feld === "string" && !felder[feld]) felder[feld] = fehler.message;
}
return { fehler: "Bitte die markierten Felder prüfen.", felder };
}
await einstellungenSpeichern(ergebnis.data);
// Nur die geänderten Felder ins Protokoll – sonst steht bei jedem Speichern alles darin.
const geaendert = Object.fromEntries(
Object.entries(ergebnis.data).filter(
([feld, wert]) => wert !== vorher[feld as keyof typeof vorher],
),
);
await protokolliere("einstellungen.gespeichert", "Einstellung", "system", geaendert);
revalidatePath("/einstellungen/system");
return { erfolg: "Einstellungen gespeichert." };
}
+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;
+9 -2
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." };
@@ -101,7 +106,7 @@ export async function proxmoxRechnungSpeichern(
datum: rechnungsdatum,
faelligAm: datum(formular.get("faelligAm")),
nettoBetrag: netto,
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
rabatt: betrag(formular.get("rabatt")),
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
bezahlt: formular.get("bezahlt") === "on",
@@ -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." };
@@ -133,7 +139,7 @@ export async function kundenrechnungSpeichern(
nummer: formular.get("nummer")?.toString().trim() || null,
datum: datum(formular.get("datum")),
nettoBetrag: betrag(formular.get("nettoBetrag")),
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")),
anhangId,
@@ -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,
+17 -6
View File
@@ -1,21 +1,32 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { einstellungen, onCalendar } from "@/server/einstellungen";
import { AGENT_VERSION } from "./http";
/**
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei
* Dashboard-Adresse und Agent-Version ein.
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
* Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
* Selbstaktualisierung).
*/
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
const pfad = path.join(process.cwd(), "agent", datei);
const inhalt = await readFile(pfad, "utf8");
const [inhalt, werte] = await Promise.all([readFile(pfad, "utf8"), einstellungen()]);
return inhalt
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION);
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION)
.replaceAll("@@TIMER_ONCALENDAR@@", onCalendar(werte.agentRhythmus))
.replaceAll("@@TIMER_STREUUNG@@", `${werte.agentStreuungStunden}h`)
.replaceAll("@@AUTO_UPDATE@@", werte.agentAutoUpdate ? "yes" : "no");
}
/** Bevorzugt die konfigurierte Adresse; sonst die Herkunft der Anfrage. */
export function basisUrl(request: Request): string {
/**
* Bevorzugt die im Dashboard eingetragene Adresse, dann `APP_URL`, sonst die Herkunft der Anfrage.
* Die Reihenfolge ist wichtig: Hinter einem Reverse Proxy stimmt die Herkunft oft nicht.
*/
export async function basisUrl(request: Request): Promise<string> {
const werte = await einstellungen();
if (werte.dashboardUrl) return werte.dashboardUrl;
if (process.env.APP_URL) return process.env.APP_URL;
return new URL(request.url).origin;
}
+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,
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { einstellungen } from "@/server/einstellungen";
/**
* Löscht alte Reports und Protokolleinträge nach den eingestellten Fristen (#025).
*
* Der jüngste Report jedes Hosts bleibt **immer** erhalten – sonst stünde ein Host, der lange
* nicht gemeldet hat, plötzlich ohne jede Angabe da. Eine Frist von 0 heißt: nichts löschen.
*/
export async function altdatenLoeschen(jetzt: Date = new Date()): Promise<{
reports: number;
protokoll: number;
}> {
const { aufbewahrungReportsTage, aufbewahrungAuditTage } = await einstellungen();
let reports = 0;
if (aufbewahrungReportsTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungReportsTage * 86_400_000);
const neueste = await prisma.report.groupBy({
by: ["hostId"],
_max: { erhobenAm: true },
});
const behalten = await prisma.report.findMany({
where: {
OR: neueste
.filter((eintrag) => eintrag._max.erhobenAm !== null)
.map((eintrag) => ({ hostId: eintrag.hostId, erhobenAm: eintrag._max.erhobenAm as Date })),
},
select: { id: true },
});
const ergebnis = await prisma.report.deleteMany({
where: {
erhobenAm: { lt: grenze },
id: { notIn: behalten.map((eintrag) => eintrag.id) },
},
});
reports = ergebnis.count;
}
let protokoll = 0;
if (aufbewahrungAuditTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungAuditTage * 86_400_000);
const ergebnis = await prisma.auditEintrag.deleteMany({ where: { erstelltAm: { lt: grenze } } });
protokoll = ergebnis.count;
}
return { reports, protokoll };
}
+34 -4
View File
@@ -3,9 +3,26 @@ import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
import { domaeneErlaubt, einstellungen } from "@/server/einstellungen";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */
export const anmeldeRateLimit = new RateLimit(5, 5 / 900);
/**
* Anmeldeversuche je Kennung und Viertelstunde. Die Zahl steht in den Einstellungen;
* für jede Einstellung wird eine eigene Bremse gehalten, damit der Zustand beim Ändern
* nicht verlorengeht.
*/
const bremsen = new Map<number, RateLimit>();
export function anmeldeBremse(versuche: number): RateLimit {
const vorhanden = bremsen.get(versuche);
if (vorhanden) return vorhanden;
const neu = new RateLimit(versuche, versuche / 900);
bremsen.set(versuche, neu);
return neu;
}
/** Vorgabe für Aufrufer ohne Einstellungen (Tests). */
export const anmeldeRateLimit = anmeldeBremse(5);
export class AnmeldeFehler extends Error {}
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
passwort: string,
quelle: string,
): Promise<BenutzerModel> {
const { anmeldeversuche } = await einstellungen();
const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) {
if (!anmeldeBremse(anmeldeversuche).pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
}
@@ -41,10 +59,17 @@ export async function mitPasswortAnmelden(
/**
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten.
* Ob ein unbekanntes Konto angelegt werden darf und welche E-Mail-Domänen zugelassen sind,
* steht in den Einstellungen (#025). Vorgabe bleibt: anlegen, alle Domänen.
*/
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim();
const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen();
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
throw new AnmeldeFehler("Diese E-Mail-Domäne ist für die Anmeldung nicht zugelassen.");
}
const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
@@ -54,6 +79,11 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
if (!vorhanden && !oidcKontenAnlegen) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "kein-konto" });
throw new AnmeldeFehler("Für diese Adresse gibt es hier kein Konto.");
}
const benutzer = vorhanden
? await prisma.benutzer.update({
where: { id: vorhanden.id },
+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;
}
+120
View File
@@ -0,0 +1,120 @@
import { z } from "zod";
import { prisma } from "@/lib/db";
/**
* Betriebseinstellungen, die sich im Dashboard ändern lassen (#025).
*
* Alles liegt in **einer** Zeile der Tabelle `Einstellung` als JSON – so ist ein Speichern
* ein einziger Schreibvorgang, und fehlende Felder bekommen ihren Vorgabewert.
*
* Bewusst **nicht** hier: `APP_SECRET` und `DATABASE_URL`. Die braucht die Anwendung, bevor sie
* die Datenbank überhaupt lesen kann; sie bleiben in der Umgebung.
*/
const SCHLUESSEL = "system";
export const einstellungenSchema = z.object({
// --- Adresse und Agent ---
/** Adresse, die im Installations-Einzeiler und in Benachrichtigungen steht. Leer = aus `APP_URL`. */
dashboardUrl: z.union([z.string().url("Bitte eine vollständige Adresse angeben."), z.literal("")]).default(""),
agentRhythmus: z.enum(["taeglich", "zwoelfstuendlich", "stuendlich"]).default("taeglich"),
/** Zufällige Verzögerung, damit nicht alle Hosts gleichzeitig melden. */
agentStreuungStunden: z.number().int().min(0).max(12).default(4),
agentAutoUpdate: z.boolean().default(false),
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
steuersatz: z.number().min(0).max(100).default(19),
zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
// --- Sicherheit ---
sitzungsdauerStunden: z.number().int().min(1).max(168).default(12),
anmeldeversuche: z.number().int().min(3).max(50).default(5),
/** Komma-getrennte Liste erlaubter E-Mail-Domänen für OIDC. Leer = alle. */
oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true),
/** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
// --- Firma und Darstellung ---
firmaName: z.string().max(120).default(""),
firmaAnschrift: z.string().max(400).default(""),
firmaEmail: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.literal("")]).default(""),
zeitzone: z.string().min(1).default("Europe/Berlin"),
});
export type Einstellungen = z.infer<typeof einstellungenSchema>;
export const VORGABEN: Einstellungen = einstellungenSchema.parse({});
/**
* Kurzer Zwischenspeicher: Die Einstellungen werden bei fast jeder Anfrage gebraucht,
* ändern sich aber selten. Zehn Sekunden reichen, damit eine Änderung sofort spürbar ist.
*/
let zwischenspeicher: { werte: Einstellungen; bis: number } | null = null;
export async function einstellungen(): Promise<Einstellungen> {
if (zwischenspeicher && zwischenspeicher.bis > Date.now()) return zwischenspeicher.werte;
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
// Unbekannte oder kaputte Werte fallen auf die Vorgabe zurück, statt den Betrieb anzuhalten.
const ergebnis = einstellungenSchema.safeParse(eintrag?.wert ?? {});
const werte = ergebnis.success ? ergebnis.data : VORGABEN;
zwischenspeicher = { werte, bis: Date.now() + 10_000 };
return werte;
}
export async function einstellungenSpeichern(werte: Einstellungen): Promise<void> {
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: werte },
update: { wert: werte },
});
zwischenspeicher = null;
}
/** Nur für Tests: erzwingt das nächste Lesen aus der Datenbank. */
export function zwischenspeicherLeeren(): void {
zwischenspeicher = null;
}
/**
* Fälligkeitsdatum, das ein leeres Rechnungsformular vorschlägt: heute plus Zahlungsziel.
* Bewusst hier und nicht in der Seite – eine Komponente darf beim Rendern nicht die Uhr lesen.
*/
export async function faelligkeitsVorschlag(jetzt: Date = new Date()): Promise<string> {
const { zahlungszielTage } = await einstellungen();
return new Date(jetzt.getTime() + zahlungszielTage * 86_400_000).toISOString();
}
/** systemd-Angabe für den Melderhythmus des Agents. */
export function onCalendar(rhythmus: Einstellungen["agentRhythmus"]): string {
switch (rhythmus) {
case "stuendlich":
return "hourly";
case "zwoelfstuendlich":
return "0/12:00:00";
default:
return "daily";
}
}
/**
* Prüft, ob eine E-Mail-Adresse zu den erlaubten OIDC-Domänen gehört.
* Ohne Angabe ist jede Domäne erlaubt – so verhält es sich wie bisher.
*/
export function domaeneErlaubt(email: string, domaenen: string): boolean {
const liste = domaenen
.split(",")
.map((eintrag) => eintrag.trim().toLowerCase().replace(/^@/, ""))
.filter(Boolean);
if (liste.length === 0) return true;
const domaene = email.toLowerCase().split("@")[1] ?? "";
return liste.includes(domaene);
}
+9 -3
View File
@@ -121,14 +121,17 @@ export function Bericht({
untertitel,
children,
quer = false,
firma,
}: {
titel: string;
untertitel?: string;
children: React.ReactNode;
quer?: boolean;
/** Firmenangaben und Zeitzone aus den Einstellungen (#025). */
firma?: { name: string; anschrift: string; email: string; zeitzone: string };
}) {
const erstellt = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
timeZone: firma?.zeitzone || "Europe/Berlin",
dateStyle: "long",
}).format(new Date());
@@ -138,8 +141,11 @@ export function Bericht({
<View style={stile.kopf} fixed>
<Logo />
<View style={stile.kopfText}>
<Text style={stile.marke}>ProxLedger</Text>
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text>
<Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
<Text style={stile.untertitel}>
{firma?.anschrift ? firma.anschrift.replace(/\n/g, " · ") : "Proxmox Subscription Dashboard"}
</Text>
{firma?.email ? <Text style={stile.untertitel}>{firma.email}</Text> : null}
</View>
</View>
+6 -3
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { einstellungen } from "@/server/einstellungen";
export class RechnungsFehler extends Error {}
@@ -67,6 +68,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
@@ -79,7 +81,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
data: {
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? 19,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
@@ -94,7 +96,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? 19,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
@@ -146,6 +148,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung.");
}
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer?.trim() || null;
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
const betrag = eingabe.nettoBetrag ?? 0;
@@ -160,7 +163,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
erstellt: eingabe.erstellt,
nummer,
datum: eingabe.datum ?? null,
ustSatz: eingabe.ustSatz ?? 19,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null,
+15 -1
View File
@@ -2,6 +2,7 @@ import { Cron } from "croner";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { altdatenLoeschen } from "@/server/aufraeumen";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
/**
@@ -14,6 +15,7 @@ import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/
const ZEITZONE = "Europe/Berlin";
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr
const AUFRAEUM_ZEITPLAN = process.env.AUFRAEUM_CRON ?? "30 3 * * *"; // nachts, wenn nichts los ist
function protokoll(nachricht: string) {
console.log(`[${new Date().toISOString()}] ${nachricht}`);
@@ -60,10 +62,21 @@ async function zusammenfassung() {
}
}
async function aufraeumen() {
try {
const ergebnis = await altdatenLoeschen();
if (ergebnis.reports > 0 || ergebnis.protokoll > 0) {
protokoll(`Aufgeräumt: ${ergebnis.reports} Reports, ${ergebnis.protokoll} Protokolleinträge gelöscht.`);
}
} catch (fehler) {
console.error("Aufräumen fehlgeschlagen:", fehler);
}
}
async function start() {
await warnregelnSicherstellen();
protokoll(
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}" (${ZEITZONE}).`,
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}", Aufräumen "${AUFRAEUM_ZEITPLAN}" (${ZEITZONE}).`,
);
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
@@ -72,6 +85,7 @@ async function start() {
const aufgaben = [
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
new Cron(AUFRAEUM_ZEITPLAN, { timezone: ZEITZONE }, aufraeumen),
];
for (const signal of ["SIGINT", "SIGTERM"] as const) {
+106 -2
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-"));
@@ -75,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" });
@@ -144,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", () => {
+1 -1
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] });
});
+92
View File
@@ -0,0 +1,92 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { altdatenLoeschen } from "@/server/aufraeumen";
import { einstellungenSpeichern, VORGABEN, zwischenspeicherLeeren } from "@/server/einstellungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const JETZT = new Date("2026-09-18T12:00:00Z");
const tageVorher = (tage: number) => new Date(JETZT.getTime() - tage * 86_400_000);
async function hostMitReports(tage: number[]) {
const host = await testPrisma.host.create({
data: { agentId: "agent-1", hostname: "pve-01", produkt: "PVE", letzterReportAm: JETZT },
});
for (const alter of tage) {
await testPrisma.report.create({
data: {
hostId: host.id,
reportId: `report-${alter}`,
erhobenAm: tageVorher(alter),
empfangenAm: tageVorher(alter),
subStatus: "ACTIVE",
rohdaten: {},
},
});
}
return host;
}
describe.runIf(datenbankVerfuegbar)("Altdaten löschen", () => {
beforeEach(async () => {
await datenbankLeeren();
zwischenspeicherLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("löscht Reports, die älter sind als die Frist", async () => {
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungReportsTage: 30 });
await hostMitReports([1, 10, 45, 400]);
const ergebnis = await altdatenLoeschen(JETZT);
expect(ergebnis.reports).toBe(2);
expect(await testPrisma.report.count()).toBe(2);
});
it("behält den jüngsten Report jedes Hosts, auch wenn er zu alt ist", async () => {
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungReportsTage: 30 });
await hostMitReports([200, 400]);
await altdatenLoeschen(JETZT);
const uebrig = await testPrisma.report.findMany();
expect(uebrig).toHaveLength(1);
expect(uebrig[0].erhobenAm).toEqual(tageVorher(200));
});
it("löscht nichts, wenn die Frist auf 0 steht", async () => {
await einstellungenSpeichern({
...VORGABEN,
aufbewahrungReportsTage: 0,
aufbewahrungAuditTage: 0,
});
await hostMitReports([400, 800]);
await testPrisma.auditEintrag.create({
data: { aktion: "test", entitaet: "Host", erstelltAm: tageVorher(3000) },
});
const ergebnis = await altdatenLoeschen(JETZT);
expect(ergebnis).toEqual({ reports: 0, protokoll: 0 });
expect(await testPrisma.report.count()).toBe(2);
expect(await testPrisma.auditEintrag.count()).toBe(1);
});
it("räumt auch das Protokoll auf", async () => {
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungAuditTage: 365 });
for (const alter of [10, 400]) {
await testPrisma.auditEintrag.create({
data: { aktion: "test", entitaet: "Host", erstelltAm: tageVorher(alter) },
});
}
const ergebnis = await altdatenLoeschen(JETZT);
expect(ergebnis.protokoll).toBe(1);
expect(await testPrisma.auditEintrag.count()).toBe(1);
});
});
+3 -1
View File
@@ -25,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 () => {
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { domaeneErlaubt, einstellungenSchema, onCalendar, VORGABEN } from "@/server/einstellungen";
describe("Einstellungen", () => {
it("füllt fehlende Werte mit den Vorgaben", () => {
const werte = einstellungenSchema.parse({});
expect(werte.steuersatz).toBe(19);
expect(werte.sitzungsdauerStunden).toBe(12);
expect(werte.agentRhythmus).toBe("taeglich");
expect(werte.oidcKontenAnlegen).toBe(true);
});
it("übernimmt gesetzte Werte und lässt den Rest auf Vorgabe", () => {
const werte = einstellungenSchema.parse({ steuersatz: 7.7, waehrung: "CHF" });
expect(werte.steuersatz).toBe(7.7);
expect(werte.waehrung).toBe("CHF");
expect(werte.zahlungszielTage).toBe(VORGABEN.zahlungszielTage);
});
it("weist unsinnige Werte ab", () => {
expect(einstellungenSchema.safeParse({ steuersatz: 250 }).success).toBe(false);
expect(einstellungenSchema.safeParse({ sitzungsdauerStunden: 0 }).success).toBe(false);
expect(einstellungenSchema.safeParse({ dashboardUrl: "kein-url" }).success).toBe(false);
expect(einstellungenSchema.safeParse({ anmeldeversuche: 1 }).success).toBe(false);
});
it("lässt eine leere Dashboard-Adresse zu (dann gilt die Umgebung)", () => {
expect(einstellungenSchema.parse({ dashboardUrl: "" }).dashboardUrl).toBe("");
});
it("übersetzt den Rhythmus nach systemd", () => {
expect(onCalendar("taeglich")).toBe("daily");
expect(onCalendar("stuendlich")).toBe("hourly");
expect(onCalendar("zwoelfstuendlich")).toBe("0/12:00:00");
});
});
describe("Erlaubte OIDC-Domänen", () => {
it("lässt ohne Angabe jede Domäne zu", () => {
expect(domaeneErlaubt("wer@auch-immer.de", "")).toBe(true);
});
it("lässt nur die genannten Domänen zu", () => {
expect(domaeneErlaubt("fw@wilde-server.de", "wilde-server.de")).toBe(true);
expect(domaeneErlaubt("fremd@example.com", "wilde-server.de")).toBe(false);
});
it("versteht mehrere Domänen, Leerzeichen und führendes @", () => {
const liste = " @wilde-server.de, kunde.de ";
expect(domaeneErlaubt("a@kunde.de", liste)).toBe(true);
expect(domaeneErlaubt("b@WILDE-SERVER.DE", liste)).toBe(true);
expect(domaeneErlaubt("c@fremd.de", liste)).toBe(false);
});
it("weist eine Adresse ohne Domäne ab", () => {
expect(domaeneErlaubt("kaputt", "kunde.de")).toBe(false);
});
});
+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);
});
});
+62 -3
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", () => {
@@ -297,6 +299,63 @@ describe("Warnregeln", () => {
});
});
describe("Cluster-Knoten ohne Agent", () => {
it("meldet einen Knoten, der im Cluster steht, sich aber nicht meldet", () => {
const k = kontext({
hosts: [
host({
id: "h1",
hostname: "pve-node-01",
clusterName: "rz5",
clusterMitglieder: ["pve-node-01", "pve-node-02", "pve-node-03"],
}),
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: [] }),
],
});
const kandidaten = ausRegel(k, "cluster-knoten-ohne-agent");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("pve-node-03");
expect(kandidaten[0].fingerprint).toBe("cluster-knoten-ohne-agent:rz5:pve-node-03");
});
it("meldet nichts, wenn alle Knoten melden", () => {
const mitglieder = ["pve-node-01", "pve-node-02"];
const k = kontext({
hosts: [
host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }),
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }),
],
});
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
});
it("beachtet Groß- und Kleinschreibung nicht", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "PVE-Node-01", clusterName: "rz5", clusterMitglieder: ["pve-node-01"] }),
],
});
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
});
it("meldet nichts für Hosts ohne Cluster", () => {
const k = kontext({ hosts: [host({ clusterName: null, clusterMitglieder: [] })] });
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
});
it("meldet jeden fehlenden Knoten nur einmal, auch wenn mehrere ihn melden", () => {
const mitglieder = ["pve-node-01", "pve-node-02", "pve-node-03"];
const k = kontext({
hosts: [
host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }),
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }),
],
});
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(1);
});
});
it("überspringt abgeschaltete Regeln", () => {
const k = kontext({
hosts: [host({ kundeId: null, kundeName: null })],