Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc80a8fcf | ||
|
|
d416f3869a | ||
|
|
19b0751c16 | ||
|
|
7775b6211f | ||
|
|
94f1eb4e6f | ||
|
|
1bfd549838 | ||
|
|
52466eee3f | ||
|
|
2e79dfb111 | ||
|
|
7728fcb565 |
@@ -17,6 +17,52 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### 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
|
## [0.13.1] – 2026-09-17
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 8 h 03 min
|
**Gesamt:** 11 h 38 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -26,6 +26,11 @@
|
|||||||
| #017 Favicons | 0 h 15 min |
|
| #017 Favicons | 0 h 15 min |
|
||||||
| #019 CI-Pipeline | 0 h 20 min |
|
| #019 CI-Pipeline | 0 h 20 min |
|
||||||
| #021 Geheimnisse verschlüsseln | 0 h 20 min |
|
| #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 |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -48,7 +53,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.13.1
|
**Version:** 0.16.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- 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.
|
- Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen.
|
||||||
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
|
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
|
||||||
|
|
||||||
|
- #018 abgeschlossen: Feldtest auf zwei echten PVE-9.2-Hosts (einer mit, einer ohne Subscription, einer im Cluster); Installation, Timer, Zwischenspeicher und Nachsenden geprüft
|
||||||
|
- #022 abgeschlossen: Cluster-Abgleich – jeder Agent meldet die Mitglieder, das Dashboard warnt bei Knoten ohne Agent
|
||||||
|
- **Fehler im eigenen Werkzeug gefunden:** Alle Handbuch-Bilder waren in Wahrheit die Anmeldeseite. Firefox verwirft eine selbst angelegte Cookie-Datei stillschweigend. Jetzt läuft ein lokaler Vermittler mit, und das Skript bricht ab, wenn alle Aufnahmen gleich groß sind.
|
||||||
|
|
||||||
|
- Feldtest auf einem echten Proxmox Backup Server (#023): der Agent hätte dort **nichts** gelesen – PBS gibt eine Rahmentabelle aus, kein `feld: wert`. Behoben, mit den echten Ausgaben als Testfälle
|
||||||
|
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
|
||||||
|
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
|
||||||
|
|
||||||
|
- 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
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||||
- Für 1.0.0 fehlt vor allem der Feldtest auf echten Proxmox-Hosts (#018)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ set -o nounset
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
|
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=""
|
TOKEN=""
|
||||||
CA_CERT=""
|
CA_CERT=""
|
||||||
AUTO_UPDATE="no"
|
AUTO_UPDATE="@@AUTO_UPDATE@@"
|
||||||
|
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
|
||||||
AKTION="install"
|
AKTION="install"
|
||||||
|
|
||||||
CONFIG_VERZEICHNIS="/etc/proxledger"
|
CONFIG_VERZEICHNIS="/etc/proxledger"
|
||||||
@@ -202,14 +210,14 @@ ProtectSystem=full
|
|||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
UNIT
|
UNIT
|
||||||
|
|
||||||
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT'
|
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Täglicher ProxLedger-Report
|
Description=ProxLedger-Report
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=daily
|
OnCalendar=${TIMER_ONCALENDAR}
|
||||||
# Verteilt die Meldungen aller Hosts über vier Stunden.
|
# Verteilt die Meldungen aller Hosts über diesen Zeitraum.
|
||||||
RandomizedDelaySec=4h
|
RandomizedDelaySec=${TIMER_STREUUNG}
|
||||||
Persistent=true
|
Persistent=true
|
||||||
Unit=proxledger.service
|
Unit=proxledger.service
|
||||||
|
|
||||||
@@ -219,7 +227,7 @@ UNIT
|
|||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now proxledger.timer >/dev/null
|
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 …"
|
hinweis "Sende ersten Report …"
|
||||||
if "$AGENT_PFAD" --report; then
|
if "$AGENT_PFAD" --report; then
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
set -o nounset
|
set -o nounset
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
AGENT_VERSION="0.2.0"
|
AGENT_VERSION="0.2.1"
|
||||||
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
||||||
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
||||||
SPOOL_MAXIMUM=30
|
SPOOL_MAXIMUM=30
|
||||||
WARTEZEITEN=(30 120 480)
|
# Wartezeiten zwischen den Versuchen; für Tests über PROXLEDGER_WARTEZEITEN kürzbar.
|
||||||
|
read -r -a WARTEZEITEN <<<"${PROXLEDGER_WARTEZEITEN:-30 120 480}"
|
||||||
|
|
||||||
# --------------------------------------------------------------------- Protokoll
|
# --------------------------------------------------------------------- Protokoll
|
||||||
|
|
||||||
@@ -105,15 +106,24 @@ produkt_erkennen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscription_ausgabe() {
|
subscription_ausgabe() {
|
||||||
|
local ausgabe=""
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
# PVE kennt --output-format nicht (geprueft auf 9.2), liefert aber "feld: wert".
|
||||||
pve) pvesubscription get 2>/dev/null ;;
|
pve) pvesubscription get 2>/dev/null ;;
|
||||||
pbs) proxmox-backup-manager subscription get 2>/dev/null ;;
|
# PBS gibt ohne --output-format eine Rahmentabelle aus, aus der sich schlecht lesen laesst.
|
||||||
|
# JSON kann es (geprueft auf 4.2); aeltere Fassungen faengt die Tabellenauswertung ab.
|
||||||
|
pbs)
|
||||||
|
ausgabe=$(proxmox-backup-manager subscription get --output-format json 2>/dev/null)
|
||||||
|
[ -n "$ausgabe" ] || ausgabe=$(proxmox-backup-manager subscription get 2>/dev/null)
|
||||||
|
printf '%s' "$ausgabe"
|
||||||
|
;;
|
||||||
pmg) pmgsubscription get 2>/dev/null ;;
|
pmg) pmgsubscription get 2>/dev/null ;;
|
||||||
*) printf '' ;;
|
*) printf '' ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Liest ein Feld aus der Subscription-Ausgabe. Unterstützt "feld: wert" und JSON.
|
# Liest ein Feld aus der Subscription-Ausgabe. Unterstuetzt "feld: wert", JSON und die
|
||||||
|
# Rahmentabelle von PBS ("| feld | wert |" mit Unicode-Strichen).
|
||||||
feld_lesen() {
|
feld_lesen() {
|
||||||
local feld="$1" text="$2" wert
|
local feld="$1" text="$2" wert
|
||||||
wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1)
|
wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1)
|
||||||
@@ -121,6 +131,15 @@ feld_lesen() {
|
|||||||
wert=$(printf '%s\n' "$text" |
|
wert=$(printf '%s\n' "$text" |
|
||||||
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
|
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
|
||||||
fi
|
fi
|
||||||
|
if [ -z "$wert" ]; then
|
||||||
|
# Tabellenzeile: Trennzeichen ist U+2502; awk zerlegt daran und liefert die zweite Spalte.
|
||||||
|
wert=$(printf '%s\n' "$text" |
|
||||||
|
awk -F'\xe2\x94\x82' -v feld="$feld" '
|
||||||
|
NF >= 3 {
|
||||||
|
name = $2; gsub(/^[ \t]+|[ \t]+$/, "", name)
|
||||||
|
if (name == feld) { wert = $3; gsub(/^[ \t]+|[ \t]+$/, "", wert); print wert; exit }
|
||||||
|
}')
|
||||||
|
fi
|
||||||
# Umschließende Anführungszeichen und Leerzeichen entfernen.
|
# Umschließende Anführungszeichen und Leerzeichen entfernen.
|
||||||
wert=${wert%\"}
|
wert=${wert%\"}
|
||||||
wert=${wert#\"}
|
wert=${wert#\"}
|
||||||
@@ -128,9 +147,13 @@ feld_lesen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
produkt_version() {
|
produkt_version() {
|
||||||
|
# Bei PVE hängt die Kernel-Angabe mit im String ("… (running kernel: …)").
|
||||||
|
# Der Kernel wird separat gemeldet, deshalb hier abschneiden.
|
||||||
case "$1" in
|
case "$1" in
|
||||||
pve) pveversion 2>/dev/null | head -n1 ;;
|
pve) pveversion 2>/dev/null | head -n1 | sed 's/ (running kernel.*//' ;;
|
||||||
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;;
|
# PBS haengt die laufende Fassung an ("… 4.2.6-1 running version: 4.2.1");
|
||||||
|
# gemeldet wird die installierte.
|
||||||
|
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 | sed 's/ running version.*//' ;;
|
||||||
pmg) pmgversion 2>/dev/null | head -n1 ;;
|
pmg) pmgversion 2>/dev/null | head -n1 ;;
|
||||||
*) printf '' ;;
|
*) printf '' ;;
|
||||||
esac
|
esac
|
||||||
@@ -150,6 +173,14 @@ cluster_name() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
||||||
|
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
||||||
|
cluster_mitglieder() {
|
||||||
|
if command -v pvecm >/dev/null 2>&1; then
|
||||||
|
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
node_name() {
|
node_name() {
|
||||||
if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then
|
if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then
|
||||||
hostname -s 2>/dev/null
|
hostname -s 2>/dev/null
|
||||||
@@ -220,6 +251,7 @@ report_erzeugen() {
|
|||||||
"ips": $(ip_adressen | json_liste),
|
"ips": $(ip_adressen | json_liste),
|
||||||
"clusterName": $(json_text "$(cluster_name)"),
|
"clusterName": $(json_text "$(cluster_name)"),
|
||||||
"nodeName": $(json_text "$(node_name)"),
|
"nodeName": $(json_text "$(node_name)"),
|
||||||
|
"clusterMitglieder": $(cluster_mitglieder | json_liste),
|
||||||
"kernel": $(json_text "$(uname -r 2>/dev/null)"),
|
"kernel": $(json_text "$(uname -r 2>/dev/null)"),
|
||||||
"uptimeSekunden": $(json_zahl "$(uptime_sekunden)"),
|
"uptimeSekunden": $(json_zahl "$(uptime_sekunden)"),
|
||||||
"cpuModell": $(json_text "$(cpu_modell)"),
|
"cpuModell": $(json_text "$(cpu_modell)"),
|
||||||
@@ -383,8 +415,15 @@ aktion_test() {
|
|||||||
[ "$(printf '%s' "$antwort" | tail -n1)" = "200" ]
|
[ "$(printf '%s' "$antwort" | tail -n1)" = "200" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ist $1 eine neuere Fassung als $2? `sort -V` vergleicht Versionsnummern der Reihe nach.
|
||||||
|
version_neuer() {
|
||||||
|
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
|
||||||
|
}
|
||||||
|
|
||||||
version_pruefen() {
|
version_pruefen() {
|
||||||
if [ -n "${NEUESTE_VERSION:-}" ] && [ "$NEUESTE_VERSION" != "$AGENT_VERSION" ]; then
|
# Nur melden, wenn das Dashboard wirklich eine neuere Fassung kennt. Sonst warnt ein Agent,
|
||||||
|
# der einer Testinstallation vorauseilt, gegen sich selbst (im Feldtest #023 aufgefallen).
|
||||||
|
if [ -n "${NEUESTE_VERSION:-}" ] && version_neuer "$NEUESTE_VERSION" "$AGENT_VERSION"; then
|
||||||
protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})."
|
protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})."
|
||||||
if [ "$AUTO_UPDATE" = "yes" ]; then
|
if [ "$AUTO_UPDATE" = "yes" ]; then
|
||||||
aktion_update
|
aktion_update
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ auch mit Hosts hinter NAT.
|
|||||||
2. [Erste Anmeldung](#2-erste-anmeldung)
|
2. [Erste Anmeldung](#2-erste-anmeldung)
|
||||||
3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen)
|
3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen)
|
||||||
4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen)
|
4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen)
|
||||||
|
4a. [Cluster](#4a-cluster)
|
||||||
5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen)
|
5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen)
|
||||||
6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen)
|
6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen)
|
||||||
7. [Warnungen](#7-warnungen)
|
7. [Warnungen](#7-warnungen)
|
||||||
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
||||||
9. [Exporte](#9-exporte)
|
9. [Exporte](#9-exporte)
|
||||||
|
9a. [Einstellungen](#9a-einstellungen)
|
||||||
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
||||||
11. [Fehlersuche](#11-fehlersuche)
|
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,
|
registriert den Host, legt die Konfiguration unter `/etc/proxledger/agent.conf` (Rechte 600) ab,
|
||||||
richtet einen systemd-Timer ein und schickt sofort den ersten Report.
|
richtet einen systemd-Timer ein und schickt sofort den ersten Report.
|
||||||
|
|
||||||
|
> **Stand der Erprobung:** Proxmox VE (9.2, mit und ohne Subscription, einzeln und im Cluster) und
|
||||||
|
> Proxmox Backup Server (4.2, ohne Subscription) sind auf echten Hosts geprüft. Für **Mail Gateway**
|
||||||
|
> stand bisher kein Host zur Verfügung – dort beruht die Auswertung noch auf Annahmen, und bei PBS hat
|
||||||
|
> sich genau so eine Annahme als falsch erwiesen. Bitte den ersten PMG-Host nach der Installation
|
||||||
|
> kurz gegenprüfen (`proxledger-agent --dry-run` zeigt, was gemeldet würde).
|
||||||
|
|
||||||
Nützliche Befehle auf dem Host:
|
Nützliche Befehle auf dem Host:
|
||||||
|
|
||||||
| Befehl | Zweck |
|
| Befehl | Zweck |
|
||||||
@@ -157,6 +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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
||||||
|
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Kunden und Standorte pflegen
|
## 5. Kunden und Standorte pflegen
|
||||||
|
|
||||||

|

|
||||||
@@ -287,6 +311,36 @@ Jahresliste für die Buchhaltung.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9a. Einstellungen
|
||||||
|
|
||||||
|
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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
|
## 10. Sicherung und Aktualisierung
|
||||||
|
|
||||||
**Sichern:**
|
**Sichern:**
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 248 KiB |
@@ -1,22 +1,22 @@
|
|||||||
# #018 Agent auf echten Proxmox-Hosts prüfen
|
# #018 Agent auf echten Proxmox-Hosts prüfen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.1
|
**Meilenstein:** v0.14.0
|
||||||
**Labels:** agent, test
|
**Labels:** agent, test
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er
|
Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er
|
||||||
auf echten Hosts laufen.
|
auf echten Hosts laufen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Installation auf Proxmox VE (8.x und 9.x)
|
- [x] Installation auf Proxmox VE 9.2 (zwei Hosts: mit und ohne Subscription)
|
||||||
- [ ] Installation auf Proxmox Backup Server
|
- [ ] Installation auf Proxmox Backup Server (kein Host verfügbar)
|
||||||
- [ ] Installation auf Proxmox Mail Gateway
|
- [ ] Installation auf Proxmox Mail Gateway (kein Host verfügbar)
|
||||||
- [ ] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
|
- [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
|
||||||
`pmgsubscription get` mit den Annahmen im Skript abgleichen
|
`pmgsubscription get` mit den Annahmen im Skript abgleichen
|
||||||
- [ ] Verhalten ohne Subscription und mit abgelaufener Subscription prüfen
|
- [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null)
|
||||||
- [ ] Timer-Lauf, Spool-Nachsendung und Uninstall auf einem echten Host prüfen
|
- [x] Timer, Spool-Nachsendung und Uninstall geprüft (Uninstall am 18.09.2026 auf dem PBS-Host, siehe #023)
|
||||||
- [ ] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
|
- [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch
|
- [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch
|
||||||
@@ -27,3 +27,37 @@ auf echten Hosts laufen.
|
|||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
- Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen.
|
- Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen.
|
||||||
|
|
||||||
|
### Ergebnis des Feldtests (18.09.2026)
|
||||||
|
|
||||||
|
Zwei echte Hosts, beide Proxmox VE 9.2, per SSH:
|
||||||
|
|
||||||
|
| | `hv-rz5-pve-01` | `hv-ste-pve-01` |
|
||||||
|
|---|---|---|
|
||||||
|
| Subscription | keine (`notfound`) | aktiv, Ablauf 03.06.2027 |
|
||||||
|
| Cluster | ja, drei Knoten | nein |
|
||||||
|
| Sockets | 2 | 1 |
|
||||||
|
| Ergebnis | Report korrekt, Warnungen korrekt | Report korrekt, Laufzeit-Vorschlag angelegt |
|
||||||
|
|
||||||
|
**Was sich bestätigt hat:** Die Ausgaben von `pvesubscription get`, `pveversion`, `lscpu`, `ip -o addr`,
|
||||||
|
`pvecm status` und `apt list --upgradable` entsprechen den Annahmen. Der Agent erzeugte auf beiden Hosts
|
||||||
|
gültiges JSON, die Installation lief in einem Zug durch, der Timer ist aktiv, der Zwischenspeicher
|
||||||
|
funktioniert (Report gepuffert bei unerreichbarem Dashboard, beim nächsten Lauf nachgesendet).
|
||||||
|
|
||||||
|
**Was anders war als gedacht:**
|
||||||
|
|
||||||
|
1. `pvesubscription get` kennt **kein** `--output-format json` (PVE 9.2). Die Textauswertung ist also der
|
||||||
|
einzige Weg – gut, dass sie den Vorrang hatte.
|
||||||
|
2. Ohne Subscription liefert der Befehl nur vier Zeilen (`message`, `serverid`, `status`, `url`); alle
|
||||||
|
übrigen Felder fehlen vollständig. Der Agent meldet sie als `null`, das Dashboard zeigt „Keine
|
||||||
|
Subscription“.
|
||||||
|
3. `pveversion` enthält den Kernel im selben String („… (running kernel: …)“). Da der Kernel separat
|
||||||
|
gemeldet wird, schneidet der Agent ihn jetzt ab.
|
||||||
|
4. `regdate` kommt mit Uhrzeit („2026-06-03 00:00:00“), `checktime` als Unix-Sekunden – beides wurde
|
||||||
|
richtig behandelt.
|
||||||
|
5. Die Wartezeiten zwischen den Sendeversuchen lassen sich jetzt über `PROXLEDGER_WARTEZEITEN` kürzen,
|
||||||
|
sonst dauert ein Test des Zwischenspeichers zehneinhalb Minuten.
|
||||||
|
6. **Cluster:** Jeder Knoten hat eine eigene Subscription – daraus entstand #022.
|
||||||
|
|
||||||
|
**Noch offen:** PBS und PMG – dafür ist #023 angelegt. PBS-Hosts stellt Florian bereit, allerdings nur
|
||||||
|
unlizenziert; für PMG gibt es derzeit keinen Host. Der Uninstall-Durchlauf ist inzwischen erledigt (#023).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
|
||||||
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
|
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
|
||||||
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min |
|
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min |
|
||||||
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
|
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | erledigt | v0.14.0 | 2 h | 0 h 40 min |
|
||||||
| [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min |
|
| [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min |
|
||||||
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
|
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
|
||||||
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 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** |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.12.0",
|
"version": "0.14.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.12.0",
|
"version": "0.14.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
@@ -5071,13 +5071,23 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge-ts": {
|
"node_modules/deepmerge-ts": {
|
||||||
"version": "7.1.5",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
|
||||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
|
||||||
"devOptional": true,
|
"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",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/define-data-property": {
|
"node_modules/define-data-property": {
|
||||||
@@ -5133,16 +5143,6 @@
|
|||||||
"robust-predicates": "^3.0.2"
|
"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": {
|
"node_modules/destr": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
@@ -7820,24 +7820,25 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.15.3",
|
"version": "3.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-ssl-profiles": "^1.1.1",
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
"denque": "^2.1.0",
|
|
||||||
"generate-function": "^2.3.1",
|
"generate-function": "^2.3.1",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.3",
|
||||||
"long": "^5.2.1",
|
"long": "^5.3.2",
|
||||||
"lru.min": "^1.0.0",
|
"lru.min": "^1.1.4",
|
||||||
"named-placeholders": "^1.1.3",
|
"named-placeholders": "^1.1.6",
|
||||||
"seq-queue": "^0.0.5",
|
"sql-escaper": "^1.5.1"
|
||||||
"sqlstring": "^2.3.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8.0"
|
"node": ">= 8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/named-placeholders": {
|
"node_modules/named-placeholders": {
|
||||||
@@ -9059,12 +9060,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -9314,14 +9309,20 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sqlstring": {
|
"node_modules/sql-escaper": {
|
||||||
"version": "2.3.3",
|
"version": "1.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
|
||||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.13.1",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"lasttest": "tsx scripts/lasttest.ts",
|
"lasttest": "tsx scripts/lasttest.ts",
|
||||||
"screenshots": "node scripts/screenshots.mjs",
|
"screenshots": "node scripts/screenshots.mjs",
|
||||||
"icons": "node scripts/icons.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": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
@@ -65,5 +66,9 @@
|
|||||||
"description": "Proxmox Subscription Dashboard",
|
"description": "Proxmox Subscription Dashboard",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"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);
|
||||||
@@ -113,6 +113,8 @@ model Host {
|
|||||||
serverId String?
|
serverId String?
|
||||||
clusterName String?
|
clusterName String?
|
||||||
nodeName String?
|
nodeName String?
|
||||||
|
/// Namen aller Knoten des Clusters laut corosync – auch solche ohne eigenen Agent (#022).
|
||||||
|
clusterMitglieder String[] @default([])
|
||||||
ips String[] @default([])
|
ips String[] @default([])
|
||||||
sockets Int?
|
sockets Int?
|
||||||
cpuModell String?
|
cpuModell String?
|
||||||
@@ -454,6 +456,8 @@ model Benutzer {
|
|||||||
oidcSubject String? @unique
|
oidcSubject String? @unique
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
letzterLoginAm DateTime?
|
letzterLoginAm DateTime?
|
||||||
|
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
|
||||||
|
sitzungenUngueltigAb DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -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'
|
||||||
@@ -2,19 +2,21 @@
|
|||||||
/**
|
/**
|
||||||
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
|
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
|
||||||
*
|
*
|
||||||
* npm run screenshots # heller Modus, Desktop
|
* npm run screenshots # heller Modus, Desktop
|
||||||
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
|
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
|
||||||
|
* npm run screenshots -- --seite name=/pfad # zusätzlich eine bestimmte Seite
|
||||||
*
|
*
|
||||||
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
|
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
|
||||||
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
|
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
|
||||||
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
|
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { createServer } from "node:http";
|
||||||
|
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { request as httpAnfrage } from "node:http";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
@@ -22,6 +24,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
|||||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||||
const ALLE = process.argv.includes("--alle");
|
const ALLE = process.argv.includes("--alle");
|
||||||
|
|
||||||
|
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
||||||
|
const ZUSATZ = process.argv
|
||||||
|
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||||
|
.map((eintrag) => {
|
||||||
|
const [name, ...rest] = eintrag.split("=");
|
||||||
|
return { name, pfad: rest.join("="), titel: "zusätzlich" };
|
||||||
|
});
|
||||||
|
|
||||||
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
|
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
|
||||||
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
|
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
|
||||||
|
|
||||||
@@ -38,6 +48,7 @@ const SEITEN = [
|
|||||||
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
||||||
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||||
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||||
|
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function sitzungsToken() {
|
async function sitzungsToken() {
|
||||||
@@ -60,8 +71,40 @@ async function sitzungsToken() {
|
|||||||
.sign(new TextEncoder().encode(geheimnis));
|
.sign(new TextEncoder().encode(geheimnis));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */
|
/**
|
||||||
function profilVorbereiten(name, token, dunkel) {
|
* Kleiner Vermittler vor dem Dashboard: Er reicht jede Anfrage weiter und hängt die Sitzung
|
||||||
|
* als Kopfzeile an. Damit braucht der Browser kein Cookie – das Einschleusen in das Firefox-Profil
|
||||||
|
* erwies sich als unzuverlässig (Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend,
|
||||||
|
* die Aufnahmen zeigten dann kommentarlos die Anmeldeseite).
|
||||||
|
*/
|
||||||
|
function vermittlerStarten(token) {
|
||||||
|
const ziel = new URL(BASIS);
|
||||||
|
|
||||||
|
const server = createServer((anfrage, antwort) => {
|
||||||
|
const weiterleitung = {
|
||||||
|
hostname: ziel.hostname,
|
||||||
|
port: ziel.port || 80,
|
||||||
|
path: anfrage.url,
|
||||||
|
method: anfrage.method,
|
||||||
|
headers: { ...anfrage.headers, host: ziel.host, cookie: `pl_sitzung=${token}` },
|
||||||
|
};
|
||||||
|
|
||||||
|
const verbindung = httpAnfrage(weiterleitung, (antwortVomServer) => {
|
||||||
|
antwort.writeHead(antwortVomServer.statusCode ?? 502, antwortVomServer.headers);
|
||||||
|
antwortVomServer.pipe(antwort);
|
||||||
|
});
|
||||||
|
|
||||||
|
verbindung.on("error", () => antwort.writeHead(502).end("Dashboard nicht erreichbar"));
|
||||||
|
anfrage.pipe(verbindung);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => fertig({ server, port: server.address().port }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt ein Profil mit dem gewünschten Farbschema an. */
|
||||||
|
function profilVorbereiten(name, dunkel) {
|
||||||
const verzeichnis = path.join(PROFIL_BASIS, name);
|
const verzeichnis = path.join(PROFIL_BASIS, name);
|
||||||
rmSync(verzeichnis, { recursive: true, force: true });
|
rmSync(verzeichnis, { recursive: true, force: true });
|
||||||
mkdirSync(verzeichnis, { recursive: true });
|
mkdirSync(verzeichnis, { recursive: true });
|
||||||
@@ -76,55 +119,33 @@ function profilVorbereiten(name, token, dunkel) {
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { hostname, protocol } = new URL(BASIS);
|
|
||||||
const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite"));
|
|
||||||
datenbank.exec(`
|
|
||||||
CREATE TABLE moz_cookies (
|
|
||||||
id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT,
|
|
||||||
host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER,
|
|
||||||
isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0,
|
|
||||||
sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0,
|
|
||||||
isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0,
|
|
||||||
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
|
|
||||||
)`);
|
|
||||||
|
|
||||||
const jetzt = Date.now();
|
|
||||||
datenbank
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO moz_cookies
|
|
||||||
(originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime)
|
|
||||||
VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`,
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
token,
|
|
||||||
hostname,
|
|
||||||
Math.floor(jetzt / 1000) + 3600,
|
|
||||||
jetzt * 1000,
|
|
||||||
jetzt * 1000,
|
|
||||||
protocol === "https:" ? 1 : 0,
|
|
||||||
protocol === "https:" ? 2 : 1,
|
|
||||||
jetzt * 1000,
|
|
||||||
);
|
|
||||||
datenbank.close();
|
|
||||||
|
|
||||||
return verzeichnis;
|
return verzeichnis;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aufnehmen(profil, pfad, datei, breite, hoehe) {
|
/**
|
||||||
execFileSync(
|
* Nimmt eine Seite auf. Bewusst asynchron: Der Vermittler läuft im selben Prozess, und ein
|
||||||
"firefox",
|
* blockierender Aufruf würde ihn währenddessen stumm schalten – Firefox liefe in die Zeitüberschreitung.
|
||||||
[
|
*/
|
||||||
"--profile",
|
function aufnehmen(profil, pfad, datei, breite, hoehe, adresse) {
|
||||||
profil,
|
return new Promise((fertig, fehlgeschlagen) => {
|
||||||
"--headless",
|
const vorgang = spawn(
|
||||||
"--window-size",
|
"firefox",
|
||||||
`${breite},${hoehe}`,
|
[
|
||||||
"--screenshot",
|
"--profile",
|
||||||
datei,
|
profil,
|
||||||
`${BASIS}${pfad}`,
|
"--headless",
|
||||||
],
|
"--window-size",
|
||||||
{ stdio: "ignore", timeout: 120_000 },
|
`${breite},${hoehe}`,
|
||||||
);
|
"--screenshot",
|
||||||
|
datei,
|
||||||
|
`${adresse}${pfad}`,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore", timeout: 120_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
vorgang.on("error", fehlgeschlagen);
|
||||||
|
vorgang.on("close", () => fertig());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -133,35 +154,47 @@ async function main() {
|
|||||||
|
|
||||||
mkdirSync(ZIEL, { recursive: true });
|
mkdirSync(ZIEL, { recursive: true });
|
||||||
const token = await sitzungsToken();
|
const token = await sitzungsToken();
|
||||||
|
const { server, port } = await vermittlerStarten(token);
|
||||||
|
const adresse = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
const hell = profilVorbereiten("hell", token, false);
|
const hell = profilVorbereiten("hell", false);
|
||||||
console.log(`Bilder nach ${ZIEL}`);
|
console.log(`Bilder nach ${ZIEL}`);
|
||||||
|
|
||||||
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
|
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
|
||||||
aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900);
|
await aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900, BASIS);
|
||||||
console.log(" login.png");
|
console.log(" login.png");
|
||||||
|
|
||||||
for (const seite of SEITEN) {
|
for (const seite of [...SEITEN, ...ZUSATZ]) {
|
||||||
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150);
|
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150, adresse);
|
||||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ALLE) {
|
if (ALLE) {
|
||||||
const dunkel = profilVorbereiten("dunkel", token, true);
|
const dunkel = profilVorbereiten("dunkel", true);
|
||||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150);
|
await aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150, adresse);
|
||||||
console.log(` ${seite}-dunkel.png`);
|
console.log(` ${seite}-dunkel.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const seite of ["dashboard", "hosts"]) {
|
for (const seite of ["dashboard", "hosts"]) {
|
||||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500);
|
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
||||||
console.log(` mobil-${seite}.png`);
|
console.log(` mobil-${seite}.png`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.close();
|
||||||
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
|
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Sicherung gegen stillen Fehlschlag: Alle Aufnahmen gleich groß heißt, jede zeigt die Anmeldeseite.
|
||||||
|
const groessen = SEITEN.map((seite) => statSync(path.join(ZIEL, `${seite.name}.png`)).size);
|
||||||
|
if (new Set(groessen).size === 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Alle Aufnahmen sind gleich groß – vermutlich zeigt jede die Anmeldeseite. Sitzung prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Fertig.");
|
console.log("Fertig.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
|
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
|
||||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const metadata = { title: "System" };
|
export const metadata = { title: "System" };
|
||||||
|
|
||||||
export default function SystemSeite() {
|
export default async function SystemSeite() {
|
||||||
|
const werte = await einstellungen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
|
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
|
||||||
<div className="app-content">
|
<div className="app-content">
|
||||||
<div className="container-fluid">
|
<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">
|
<dl className="row mb-0">
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
|
||||||
<dd className="col-8">{APP_VERSION}</dd>
|
<dd className="col-8">{APP_VERSION}</dd>
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
|
||||||
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
|
<dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
|
||||||
<dd className="col-8">Konfigurierbar ab #011</dd>
|
<dd className="col-8">
|
||||||
|
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
|
||||||
|
die Datenbank gelesen werden kann.
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
||||||
|
const geschwister =
|
||||||
|
host.clusterMitglieder.length > 0
|
||||||
|
? await prisma.host.findMany({
|
||||||
|
where: { hostname: { in: host.clusterMitglieder, mode: "insensitive" } },
|
||||||
|
select: { id: true, hostname: true, subStatus: true, letzterReportAm: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const meldende = new Map(geschwister.map((eintrag) => [eintrag.hostname.toLowerCase(), eintrag]));
|
||||||
|
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
|
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
|
||||||
const socketAbweichung =
|
const socketAbweichung =
|
||||||
@@ -113,6 +123,31 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<Zeile bezeichnung="Cluster / Node">
|
<Zeile bezeichnung="Cluster / Node">
|
||||||
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
|
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
|
||||||
</Zeile>
|
</Zeile>
|
||||||
|
{host.clusterMitglieder.length > 0 ? (
|
||||||
|
<Zeile bezeichnung={`Cluster-Knoten (${host.clusterMitglieder.length})`}>
|
||||||
|
<span className="d-inline-block text-start">
|
||||||
|
{host.clusterMitglieder.map((mitglied) => {
|
||||||
|
const eintrag = meldende.get(mitglied.toLowerCase());
|
||||||
|
return (
|
||||||
|
<span key={mitglied} className="d-block small">
|
||||||
|
{eintrag ? (
|
||||||
|
<Link href={`/hosts/${eintrag.id}`} className="text-decoration-none">
|
||||||
|
{mitglied}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
mitglied
|
||||||
|
)}{" "}
|
||||||
|
{eintrag ? (
|
||||||
|
<Badge farbe="success">meldet sich</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge farbe="warning">kein Agent</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Zeile>
|
||||||
|
) : null}
|
||||||
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
|
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
|
||||||
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
|
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
|
||||||
<Zeile bezeichnung="Sockets (lizenziert / physisch)">
|
<Zeile bezeichnung="Sockets (lizenziert / physisch)">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
|
|||||||
import { formatDatum } from "@/lib/format";
|
import { formatDatum } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
|
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);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (new URL(request.url).searchParams.get("format") === "pdf") {
|
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(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Host-Liste",
|
titel: "Host-Liste",
|
||||||
untertitel: `${alle.length} Hosts`,
|
untertitel: `${alle.length} Hosts`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
|
|||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
const gesamt = summen(laufzeiten);
|
const gesamt = summen(laufzeiten);
|
||||||
const proJahr = summenProJahr(laufzeiten);
|
const proJahr = summenProJahr(laufzeiten);
|
||||||
const datum = new Date().toISOString().slice(0, 10);
|
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(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: kunde.name,
|
titel: kunde.name,
|
||||||
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { csvAntwort, csvDatei } from "@/lib/csv";
|
import { csvAntwort, csvDatei } from "@/lib/csv";
|
||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
import {
|
import {
|
||||||
kundenListe,
|
kundenListe,
|
||||||
offeneRechnungspunkteProKunde,
|
offeneRechnungspunkteProKunde,
|
||||||
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
|
|||||||
const datum = new Date().toISOString().slice(0, 10);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Kundenübersicht",
|
titel: "Kundenübersicht",
|
||||||
untertitel: `${zeilen.length} Kunden`,
|
untertitel: `${zeilen.length} Kunden`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
|
|||||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
||||||
|
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
||||||
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
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 verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : 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)}`;
|
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,9 +155,9 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
werte={{
|
werte={{
|
||||||
nummer: einkauf?.nummer ?? null,
|
nummer: einkauf?.nummer ?? null,
|
||||||
datum: einkauf?.datum.toISOString() ?? null,
|
datum: einkauf?.datum.toISOString() ?? null,
|
||||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
|
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||||
nettoBetrag: einkaufBetrag,
|
nettoBetrag: einkaufBetrag,
|
||||||
ustSatz: Number(einkauf?.ustSatz ?? 19),
|
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
rabatt:
|
rabatt:
|
||||||
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
||||||
? null
|
? null
|
||||||
@@ -179,8 +186,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
erstellt: verkauf?.erstellt ?? false,
|
erstellt: verkauf?.erstellt ?? false,
|
||||||
nummer: verkauf?.nummer ?? null,
|
nummer: verkauf?.nummer ?? null,
|
||||||
datum: verkauf?.datum?.toISOString() ?? null,
|
datum: verkauf?.datum?.toISOString() ?? null,
|
||||||
nettoBetrag: verkaufBetrag,
|
nettoBetrag: verkaufVorschlag,
|
||||||
ustSatz: Number(verkauf?.ustSatz ?? 19),
|
ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
bezahlt: verkauf?.bezahlt ?? false,
|
bezahlt: verkauf?.bezahlt ?? false,
|
||||||
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
|
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
|
||||||
anhangId: verkauf?.anhangId ?? null,
|
anhangId: verkauf?.anhangId ?? null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
|
|||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
|
|||||||
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Rechnungsliste",
|
titel: "Rechnungsliste",
|
||||||
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
|
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
|
||||||
export async function GET(request: Request) {
|
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");
|
return skriptAntwort(inhalt, "proxledger-agent.sh");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers";
|
|||||||
import { clientIp } from "@/lib/ratelimit";
|
import { clientIp } from "@/lib/ratelimit";
|
||||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 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.
|
* 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) {
|
export async function POST(request: Request) {
|
||||||
const formular = await request.formData();
|
const formular = await request.formData();
|
||||||
const email = formular.get("email")?.toString().trim() ?? "";
|
const email = formular.get("email")?.toString().trim() ?? "";
|
||||||
@@ -24,6 +51,10 @@ export async function POST(request: Request) {
|
|||||||
return Response.redirect(adresse, 303);
|
return Response.redirect(adresse, 303);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!herkunftStimmt(request)) {
|
||||||
|
return zurueckZurAnmeldung("Die Anmeldung kam nicht von dieser Adresse.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !passwort) {
|
if (!email || !passwort) {
|
||||||
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
|
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
|
||||||
}
|
}
|
||||||
@@ -32,13 +63,17 @@ export async function POST(request: Request) {
|
|||||||
const kopfzeilen = await headers();
|
const kopfzeilen = await headers();
|
||||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||||
|
|
||||||
const token = await sitzungErzeugen({
|
const { sitzungsdauerStunden } = await einstellungen();
|
||||||
benutzerId: benutzer.id,
|
const token = await sitzungErzeugen(
|
||||||
email: benutzer.email,
|
{
|
||||||
name: benutzer.name,
|
benutzerId: benutzer.id,
|
||||||
herkunft: "passwort",
|
email: benutzer.email,
|
||||||
});
|
name: benutzer.name,
|
||||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
herkunft: "passwort",
|
||||||
|
},
|
||||||
|
sitzungsdauerStunden,
|
||||||
|
);
|
||||||
|
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
|
||||||
|
|
||||||
return Response.redirect(new URL(ziel, request.url), 303);
|
return Response.redirect(new URL(ziel, request.url), 303);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
|
|||||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
import { mitOidcAnmelden } from "@/server/auth/benutzer";
|
import { mitOidcAnmelden } from "@/server/auth/benutzer";
|
||||||
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
|
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
|
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
|
||||||
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
|
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
|
||||||
|
|
||||||
const token = await sitzungErzeugen({
|
const { sitzungsdauerStunden } = await systemEinstellungen();
|
||||||
benutzerId: benutzer.id,
|
const token = await sitzungErzeugen(
|
||||||
email: benutzer.email,
|
{
|
||||||
name: benutzer.name,
|
benutzerId: benutzer.id,
|
||||||
herkunft: "oidc",
|
email: benutzer.email,
|
||||||
});
|
name: benutzer.name,
|
||||||
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
|
herkunft: "oidc",
|
||||||
|
},
|
||||||
|
sitzungsdauerStunden,
|
||||||
|
);
|
||||||
|
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
|
||||||
|
|
||||||
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
|
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
|
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
|
||||||
export async function GET(request: Request) {
|
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");
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import { SignJWT, jwtVerify } from "jose";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const SITZUNGS_COOKIE = "pl_sitzung";
|
export const SITZUNGS_COOKIE = "pl_sitzung";
|
||||||
|
/** Rückfall, solange nichts in den Einstellungen steht (#025). */
|
||||||
export const SITZUNGSDAUER_STUNDEN = 12;
|
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 {
|
export interface Sitzung {
|
||||||
benutzerId: string;
|
benutzerId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -26,18 +32,21 @@ function schluessel(): Uint8Array {
|
|||||||
return new TextEncoder().encode(geheimnis);
|
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 })
|
return new SignJWT({ ...sitzung })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setSubject(sitzung.benutzerId)
|
.setSubject(sitzung.benutzerId)
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setIssuer("proxledger")
|
.setIssuer("proxledger")
|
||||||
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`)
|
.setExpirationTime(`${dauerStunden}h`)
|
||||||
.sign(schluessel());
|
.sign(schluessel());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
|
/** 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;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
|
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
|
||||||
@@ -47,6 +56,7 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
name: typeof payload.name === "string" ? payload.name : payload.email,
|
name: typeof payload.name === "string" ? payload.name : payload.email,
|
||||||
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
|
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
|
||||||
|
ausgestelltAm: new Date((payload.iat ?? 0) * 1000),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -64,12 +74,12 @@ export function ueberHttps(): boolean {
|
|||||||
return process.env.NODE_ENV === "production";
|
return process.env.NODE_ENV === "production";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cookieOptionen() {
|
export function cookieOptionen(dauerStunden: number = SITZUNGSDAUER_STUNDEN) {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
secure: ueberHttps(),
|
secure: ueberHttps(),
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
|
maxAge: dauerStunden * 3600,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,19 +23,62 @@ const OEFFENTLICH = [
|
|||||||
"/manifest.webmanifest",
|
"/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) {
|
export async function proxy(request: NextRequest) {
|
||||||
const pfad = request.nextUrl.pathname;
|
const pfad = request.nextUrl.pathname;
|
||||||
|
|
||||||
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
|
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);
|
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);
|
const ziel = new URL("/login", request.url);
|
||||||
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
|
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
|
||||||
return NextResponse.redirect(ziel);
|
return mitRichtlinie(request, NextResponse.redirect(ziel));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { alertsAbgleichen } from "@/server/alerts/abgleich";
|
import { alertsAbgleichen } from "@/server/alerts/abgleich";
|
||||||
import { REGELN } from "@/server/alerts/regeln";
|
import { REGELN } from "@/server/alerts/regeln";
|
||||||
import type { AktionsErgebnis } from "./kunden";
|
import type { AktionsErgebnis } from "./kunden";
|
||||||
@@ -15,6 +16,7 @@ function neuLaden() {
|
|||||||
|
|
||||||
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
|
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
|
||||||
export async function alertQuittieren(formular: FormData) {
|
export async function alertQuittieren(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
if (!id) return;
|
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. */
|
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
|
||||||
export async function alertSchliessen(formular: FormData) {
|
export async function alertSchliessen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ export async function alertSchliessen(formular: FormData) {
|
|||||||
|
|
||||||
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
|
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
|
||||||
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
|
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const ergebnis = await alertsAbgleichen();
|
const ergebnis = await alertsAbgleichen();
|
||||||
neuLaden();
|
neuLaden();
|
||||||
return {
|
return {
|
||||||
@@ -47,6 +51,7 @@ export async function warnregelnSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
for (const regel of REGELN) {
|
for (const regel of REGELN) {
|
||||||
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
|
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
|
||||||
const schwellwerte: Record<string, number | number[]> = {};
|
const schwellwerte: Record<string, number | number[]> = {};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { verschluesseln } from "@/lib/geheimnisse";
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
|
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
|
||||||
import { testNachricht } from "@/server/notifications/nachrichten";
|
import { testNachricht } from "@/server/notifications/nachrichten";
|
||||||
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
|
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||||
@@ -22,6 +23,7 @@ export async function emailKanalSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString() || null;
|
const id = formular.get("id")?.toString() || null;
|
||||||
const host = formular.get("host")?.toString().trim() ?? "";
|
const host = formular.get("host")?.toString().trim() ?? "";
|
||||||
const port = Number(formular.get("port") ?? 587);
|
const port = Number(formular.get("port") ?? 587);
|
||||||
@@ -78,6 +80,7 @@ export async function webhookKanalSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString() || null;
|
const id = formular.get("id")?.toString() || null;
|
||||||
const url = formular.get("url")?.toString().trim() ?? "";
|
const url = formular.get("url")?.toString().trim() ?? "";
|
||||||
|
|
||||||
@@ -116,6 +119,7 @@ export async function webhookKanalSpeichern(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function kanalLoeschen(formular: FormData) {
|
export async function kanalLoeschen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
@@ -126,6 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
|
|||||||
|
|
||||||
/** Verschickt eine Testnachricht über einen Kanal. */
|
/** Verschickt eine Testnachricht über einen Kanal. */
|
||||||
export async function testSenden(formular: FormData): Promise<void> {
|
export async function testSenden(formular: FormData): Promise<void> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
if (!id) return;
|
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). */
|
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
|
||||||
export async function zusammenfassungSpeichern(formular: FormData) {
|
export async function zusammenfassungSpeichern(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
|
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
|
||||||
|
|
||||||
await prisma.einstellung.upsert({
|
await prisma.einstellung.upsert({
|
||||||
@@ -154,6 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
|
|||||||
|
|
||||||
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
|
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
|
||||||
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
|
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
|
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
|
||||||
await zusammenfassungSenden(zeitraum);
|
await zusammenfassungSenden(zeitraum);
|
||||||
neuLaden();
|
neuLaden();
|
||||||
|
|||||||
@@ -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,6 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import type { AktionsErgebnis } from "./kunden";
|
import type { AktionsErgebnis } from "./kunden";
|
||||||
|
|
||||||
/** Speichert Notizen und Tags eines Hosts. */
|
/** Speichert Notizen und Tags eines Hosts. */
|
||||||
@@ -10,6 +11,7 @@ export async function hostNotizenSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const hostId = formular.get("hostId")?.toString();
|
const hostId = formular.get("hostId")?.toString();
|
||||||
if (!hostId) return { fehler: "Host nicht gefunden." };
|
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). */
|
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
|
||||||
export async function hostKundeSetzen(formular: FormData) {
|
export async function hostKundeSetzen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const hostId = formular.get("hostId")?.toString();
|
const hostId = formular.get("hostId")?.toString();
|
||||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||||
const standortId = formular.get("standortId")?.toString() || null;
|
const standortId = formular.get("standortId")?.toString() || null;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
||||||
import type { AktionsErgebnis } from "./kunden";
|
import type { AktionsErgebnis } from "./kunden";
|
||||||
|
|
||||||
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
||||||
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
||||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||||
const standortId = formular.get("standortId")?.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. */
|
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
|
||||||
export async function hostZuordnungAufheben(formular: FormData) {
|
export async function hostZuordnungAufheben(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const hostId = formular.get("hostId")?.toString();
|
const hostId = formular.get("hostId")?.toString();
|
||||||
if (!hostId) return;
|
if (!hostId) return;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
export interface AktionsErgebnis {
|
export interface AktionsErgebnis {
|
||||||
fehler?: string;
|
fehler?: string;
|
||||||
@@ -28,6 +29,7 @@ const kundeSchema = z.object({
|
|||||||
|
|
||||||
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
||||||
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = leerZuNull(formular.get("id"));
|
const id = leerZuNull(formular.get("id"));
|
||||||
const eingabe = kundeSchema.safeParse({
|
const eingabe = kundeSchema.safeParse({
|
||||||
name: formular.get("name")?.toString().trim() ?? "",
|
name: formular.get("name")?.toString().trim() ?? "",
|
||||||
@@ -66,6 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function kundeLoeschen(formular: FormData) {
|
export async function kundeLoeschen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export async function standortSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = leerZuNull(formular.get("id"));
|
const id = leerZuNull(formular.get("id"));
|
||||||
const eingabe = standortSchema.safeParse({
|
const eingabe = standortSchema.safeParse({
|
||||||
kundeId: formular.get("kundeId")?.toString() ?? "",
|
kundeId: formular.get("kundeId")?.toString() ?? "",
|
||||||
@@ -116,6 +120,7 @@ export async function standortSpeichern(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function standortLoeschen(formular: FormData) {
|
export async function standortLoeschen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
const kundeId = formular.get("kundeId")?.toString();
|
const kundeId = formular.get("kundeId")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
|
import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import {
|
import {
|
||||||
einkaufBezahltSetzen,
|
einkaufBezahltSetzen,
|
||||||
kundenrechnungErfassen,
|
kundenrechnungErfassen,
|
||||||
@@ -48,6 +49,7 @@ async function ausfuehren(arbeit: () => Promise<string | void>): Promise<Aktions
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function laufzeitBestaetigenAktion(formular: FormData) {
|
export async function laufzeitBestaetigenAktion(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("laufzeitId")?.toString();
|
const id = formular.get("laufzeitId")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
await laufzeitBestaetigen(id);
|
await laufzeitBestaetigen(id);
|
||||||
@@ -55,6 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function laufzeitVerwerfenAktion(formular: FormData) {
|
export async function laufzeitVerwerfenAktion(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("laufzeitId")?.toString();
|
const id = formular.get("laufzeitId")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
await laufzeitVerwerfen(id);
|
await laufzeitVerwerfen(id);
|
||||||
@@ -63,6 +66,7 @@ export async function laufzeitVerwerfenAktion(formular: FormData) {
|
|||||||
|
|
||||||
/** Häkchen aus der Rechnungstabelle. */
|
/** Häkchen aus der Rechnungstabelle. */
|
||||||
export async function statusUmschalten(formular: FormData): Promise<void> {
|
export async function statusUmschalten(formular: FormData): Promise<void> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||||
const feld = formular.get("feld")?.toString();
|
const feld = formular.get("feld")?.toString();
|
||||||
const wert = formular.get("wert")?.toString() === "true";
|
const wert = formular.get("wert")?.toString() === "true";
|
||||||
@@ -84,6 +88,7 @@ export async function proxmoxRechnungSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||||
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ export async function proxmoxRechnungSpeichern(
|
|||||||
datum: rechnungsdatum,
|
datum: rechnungsdatum,
|
||||||
faelligAm: datum(formular.get("faelligAm")),
|
faelligAm: datum(formular.get("faelligAm")),
|
||||||
nettoBetrag: netto,
|
nettoBetrag: netto,
|
||||||
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
|
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
|
||||||
rabatt: betrag(formular.get("rabatt")),
|
rabatt: betrag(formular.get("rabatt")),
|
||||||
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
|
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
|
||||||
bezahlt: formular.get("bezahlt") === "on",
|
bezahlt: formular.get("bezahlt") === "on",
|
||||||
@@ -121,6 +126,7 @@ export async function kundenrechnungSpeichern(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||||
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
||||||
|
|
||||||
@@ -133,7 +139,7 @@ export async function kundenrechnungSpeichern(
|
|||||||
nummer: formular.get("nummer")?.toString().trim() || null,
|
nummer: formular.get("nummer")?.toString().trim() || null,
|
||||||
datum: datum(formular.get("datum")),
|
datum: datum(formular.get("datum")),
|
||||||
nettoBetrag: betrag(formular.get("nettoBetrag")),
|
nettoBetrag: betrag(formular.get("nettoBetrag")),
|
||||||
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
|
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
|
||||||
bezahlt: formular.get("bezahlt") === "on",
|
bezahlt: formular.get("bezahlt") === "on",
|
||||||
bezahltAm: datum(formular.get("bezahltAm")),
|
bezahltAm: datum(formular.get("bezahltAm")),
|
||||||
anhangId,
|
anhangId,
|
||||||
@@ -151,6 +157,7 @@ export async function laufzeitAnlegenAktion(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const hostId = formular.get("hostId")?.toString();
|
const hostId = formular.get("hostId")?.toString();
|
||||||
const key = formular.get("key")?.toString().trim();
|
const key = formular.get("key")?.toString().trim();
|
||||||
const start = datum(formular.get("start"));
|
const start = datum(formular.get("start"));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { erzeugeToken } from "@/lib/tokens";
|
import { erzeugeToken } from "@/lib/tokens";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import type { AktionsErgebnis } from "./kunden";
|
import type { AktionsErgebnis } from "./kunden";
|
||||||
|
|
||||||
export interface TokenErgebnis extends AktionsErgebnis {
|
export interface TokenErgebnis extends AktionsErgebnis {
|
||||||
@@ -16,6 +17,7 @@ export async function enrollmentTokenErzeugen(
|
|||||||
_vorher: TokenErgebnis,
|
_vorher: TokenErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<TokenErgebnis> {
|
): Promise<TokenErgebnis> {
|
||||||
|
await sitzungErforderlich();
|
||||||
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
||||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||||
const standortId = formular.get("standortId")?.toString() || null;
|
const standortId = formular.get("standortId")?.toString() || null;
|
||||||
@@ -58,6 +60,7 @@ export async function enrollmentTokenErzeugen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function hostTokenWiderrufen(formular: FormData) {
|
export async function hostTokenWiderrufen(formular: FormData) {
|
||||||
|
await sitzungErforderlich();
|
||||||
const id = formular.get("id")?.toString();
|
const id = formular.get("id")?.toString();
|
||||||
const hostId = formular.get("hostId")?.toString();
|
const hostId = formular.get("hostId")?.toString();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
|
|||||||
import { AgentApiFehler } from "./service";
|
import { AgentApiFehler } from "./service";
|
||||||
|
|
||||||
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
||||||
export const AGENT_VERSION = "0.2.0";
|
export const AGENT_VERSION = "0.2.1";
|
||||||
|
|
||||||
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
||||||
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const reportSchema = z.object({
|
|||||||
ips: z.array(z.string().max(64)).max(32).nullish(),
|
ips: z.array(z.string().max(64)).max(32).nullish(),
|
||||||
clusterName: nullbarerText,
|
clusterName: nullbarerText,
|
||||||
nodeName: nullbarerText,
|
nodeName: nullbarerText,
|
||||||
|
clusterMitglieder: z.array(z.string().max(255)).max(64).nullish(),
|
||||||
kernel: nullbarerText,
|
kernel: nullbarerText,
|
||||||
uptimeSekunden: z.number().int().nonnegative().nullish(),
|
uptimeSekunden: z.number().int().nonnegative().nullish(),
|
||||||
cpuModell: nullbarerText,
|
cpuModell: nullbarerText,
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export async function verarbeiteReport(
|
|||||||
serverId: serverId ?? host.serverId,
|
serverId: serverId ?? host.serverId,
|
||||||
clusterName: eingabe.host.clusterName ?? host.clusterName,
|
clusterName: eingabe.host.clusterName ?? host.clusterName,
|
||||||
nodeName: eingabe.host.nodeName ?? host.nodeName,
|
nodeName: eingabe.host.nodeName ?? host.nodeName,
|
||||||
|
clusterMitglieder: eingabe.host.clusterMitglieder ?? host.clusterMitglieder,
|
||||||
ips: eingabe.host.ips ?? host.ips,
|
ips: eingabe.host.ips ?? host.ips,
|
||||||
sockets: eingabe.host.sockets ?? host.sockets,
|
sockets: eingabe.host.sockets ?? host.sockets,
|
||||||
cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
|
cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { einstellungen, onCalendar } from "@/server/einstellungen";
|
||||||
import { AGENT_VERSION } from "./http";
|
import { AGENT_VERSION } from "./http";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei
|
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
|
||||||
* Dashboard-Adresse und Agent-Version ein.
|
* Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
|
||||||
|
* Selbstaktualisierung).
|
||||||
*/
|
*/
|
||||||
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
|
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
|
||||||
const pfad = path.join(process.cwd(), "agent", datei);
|
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
|
return inhalt
|
||||||
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
|
.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;
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
return new URL(request.url).origin;
|
return new URL(request.url).origin;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
|||||||
hosts: hosts.map((host) => ({
|
hosts: hosts.map((host) => ({
|
||||||
id: host.id,
|
id: host.id,
|
||||||
hostname: host.hostname,
|
hostname: host.hostname,
|
||||||
|
clusterName: host.clusterName,
|
||||||
|
clusterMitglieder: host.clusterMitglieder,
|
||||||
kundeId: host.kundeId,
|
kundeId: host.kundeId,
|
||||||
kundeName: host.kunde?.name ?? null,
|
kundeName: host.kunde?.name ?? null,
|
||||||
subKey: host.subKey,
|
subKey: host.subKey,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface AlertKandidat {
|
|||||||
export interface HostDaten {
|
export interface HostDaten {
|
||||||
id: string;
|
id: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
|
clusterName: string | null;
|
||||||
|
clusterMitglieder: string[];
|
||||||
kundeId: string | null;
|
kundeId: string | null;
|
||||||
kundeName: string | null;
|
kundeName: string | null;
|
||||||
subKey: string | null;
|
subKey: string | null;
|
||||||
@@ -287,6 +289,39 @@ const hostOhneKunde: Regel = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clusterKnotenOhneAgent: Regel = {
|
||||||
|
name: "cluster-knoten-ohne-agent",
|
||||||
|
beschreibung: "Ein Cluster-Knoten meldet sich nicht beim Dashboard",
|
||||||
|
standardSchwellwerte: {},
|
||||||
|
pruefe(kontext) {
|
||||||
|
// Alle gemeldeten Hostnamen – ein Knoten gilt als bekannt, wenn er selbst meldet.
|
||||||
|
const meldende = new Set(kontext.hosts.map((host) => host.hostname.toLowerCase()));
|
||||||
|
|
||||||
|
const fehlende = new Map<string, { cluster: string; melder: HostDaten }>();
|
||||||
|
for (const host of kontext.hosts) {
|
||||||
|
if (!host.clusterName) continue;
|
||||||
|
for (const mitglied of host.clusterMitglieder) {
|
||||||
|
const name = mitglied.trim();
|
||||||
|
if (!name || meldende.has(name.toLowerCase())) continue;
|
||||||
|
if (!fehlende.has(name.toLowerCase())) {
|
||||||
|
fehlende.set(name.toLowerCase(), { cluster: host.clusterName, melder: host });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...fehlende.entries()].map(([name, eintrag]) => ({
|
||||||
|
fingerprint: `cluster-knoten-ohne-agent:${eintrag.cluster}:${name}`,
|
||||||
|
regel: "cluster-knoten-ohne-agent",
|
||||||
|
schwere: "WARNUNG" as AlertSchwere,
|
||||||
|
titel: "Cluster-Knoten ohne Agent",
|
||||||
|
beschreibung: `${name} gehört zum Cluster ${eintrag.cluster} (gemeldet von ${eintrag.melder.hostname}), meldet sich aber nicht – dort fehlt der Agent`,
|
||||||
|
hostId: eintrag.melder.id,
|
||||||
|
kundeId: eintrag.melder.kundeId,
|
||||||
|
laufzeitId: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const proxmoxRechnungFaellig: Regel = {
|
const proxmoxRechnungFaellig: Regel = {
|
||||||
name: "proxmox-rechnung-faellig",
|
name: "proxmox-rechnung-faellig",
|
||||||
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
|
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
|
||||||
@@ -378,6 +413,7 @@ export const REGELN: Regel[] = [
|
|||||||
serverIdGeaendert,
|
serverIdGeaendert,
|
||||||
keyMehrfach,
|
keyMehrfach,
|
||||||
hostOhneKunde,
|
hostOhneKunde,
|
||||||
|
clusterKnotenOhneAgent,
|
||||||
proxmoxRechnungFaellig,
|
proxmoxRechnungFaellig,
|
||||||
kundenrechnungFehlt,
|
kundenrechnungFehlt,
|
||||||
kundenrechnungUnbezahlt,
|
kundenrechnungUnbezahlt,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -3,9 +3,26 @@ import type { BenutzerModel } from "@/generated/prisma/models";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { RateLimit } from "@/lib/ratelimit";
|
import { RateLimit } from "@/lib/ratelimit";
|
||||||
import { protokolliere } from "@/server/audit";
|
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 {}
|
export class AnmeldeFehler extends Error {}
|
||||||
|
|
||||||
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
|
|||||||
passwort: string,
|
passwort: string,
|
||||||
quelle: string,
|
quelle: string,
|
||||||
): Promise<BenutzerModel> {
|
): Promise<BenutzerModel> {
|
||||||
|
const { anmeldeversuche } = await einstellungen();
|
||||||
const kennung = `${quelle}:${email.toLowerCase()}`;
|
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.");
|
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.
|
* 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> {
|
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
|
||||||
const normalisiert = email.toLowerCase().trim();
|
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 =
|
const vorhanden =
|
||||||
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
|
(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.");
|
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
|
const benutzer = vorhanden
|
||||||
? await prisma.benutzer.update({
|
? await prisma.benutzer.update({
|
||||||
where: { id: vorhanden.id },
|
where: { id: vorhanden.id },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -121,14 +121,17 @@ export function Bericht({
|
|||||||
untertitel,
|
untertitel,
|
||||||
children,
|
children,
|
||||||
quer = false,
|
quer = false,
|
||||||
|
firma,
|
||||||
}: {
|
}: {
|
||||||
titel: string;
|
titel: string;
|
||||||
untertitel?: string;
|
untertitel?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
quer?: boolean;
|
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", {
|
const erstellt = new Intl.DateTimeFormat("de-DE", {
|
||||||
timeZone: "Europe/Berlin",
|
timeZone: firma?.zeitzone || "Europe/Berlin",
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
|
|
||||||
@@ -138,8 +141,11 @@ export function Bericht({
|
|||||||
<View style={stile.kopf} fixed>
|
<View style={stile.kopf} fixed>
|
||||||
<Logo />
|
<Logo />
|
||||||
<View style={stile.kopfText}>
|
<View style={stile.kopfText}>
|
||||||
<Text style={stile.marke}>ProxLedger</Text>
|
<Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
|
||||||
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export class RechnungsFehler extends Error {}
|
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 (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
||||||
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
||||||
|
|
||||||
|
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
|
||||||
const nummer = eingabe.nummer.trim();
|
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)}`;
|
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: {
|
data: {
|
||||||
datum: eingabe.datum,
|
datum: eingabe.datum,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
ustSatz: eingabe.ustSatz ?? 19,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
rabatt: eingabe.rabatt ?? null,
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
@@ -94,7 +96,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
datum: eingabe.datum,
|
datum: eingabe.datum,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
nettoBetrag: eingabe.nettoBetrag,
|
nettoBetrag: eingabe.nettoBetrag,
|
||||||
ustSatz: eingabe.ustSatz ?? 19,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
rabatt: eingabe.rabatt ?? null,
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
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.");
|
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 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 bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
|
||||||
const betrag = eingabe.nettoBetrag ?? 0;
|
const betrag = eingabe.nettoBetrag ?? 0;
|
||||||
@@ -160,7 +163,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
|
|||||||
erstellt: eingabe.erstellt,
|
erstellt: eingabe.erstellt,
|
||||||
nummer,
|
nummer,
|
||||||
datum: eingabe.datum ?? null,
|
datum: eingabe.datum ?? null,
|
||||||
ustSatz: eingabe.ustSatz ?? 19,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||||
anhangId: eingabe.anhangId ?? null,
|
anhangId: eingabe.anhangId ?? null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Cron } from "croner";
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
|
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
|
||||||
|
import { altdatenLoeschen } from "@/server/aufraeumen";
|
||||||
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
|
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,7 @@ import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/
|
|||||||
const ZEITZONE = "Europe/Berlin";
|
const ZEITZONE = "Europe/Berlin";
|
||||||
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
|
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 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) {
|
function protokoll(nachricht: string) {
|
||||||
console.log(`[${new Date().toISOString()}] ${nachricht}`);
|
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() {
|
async function start() {
|
||||||
await warnregelnSicherstellen();
|
await warnregelnSicherstellen();
|
||||||
protokoll(
|
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.
|
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
|
||||||
@@ -72,6 +85,7 @@ async function start() {
|
|||||||
const aufgaben = [
|
const aufgaben = [
|
||||||
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
|
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
|
||||||
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
|
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
|
||||||
|
new Cron(AUFRAEUM_ZEITPLAN, { timezone: ZEITZONE }, aufraeumen),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
|
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
|
||||||
|
import { AGENT_VERSION } from "@/server/agent/http";
|
||||||
import { POST as reportPost } from "@/app/api/agent/v1/report/route";
|
import { POST as reportPost } from "@/app/api/agent/v1/report/route";
|
||||||
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
|
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
|
||||||
import { erzeugeToken } from "@/lib/tokens";
|
import { erzeugeToken } from "@/lib/tokens";
|
||||||
@@ -20,6 +21,36 @@ function befehlErsetzen(name: string, ausgabe: string) {
|
|||||||
chmodSync(datei, 0o755);
|
chmodSync(datei, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ahmt `proxmox-backup-manager` nach: Das Werkzeug beantwortet `version` anders als
|
||||||
|
* `subscription get`, und auf `--output-format json` antwortet es anders als ohne.
|
||||||
|
* Genau daran hängt die Auswertung im Agent.
|
||||||
|
*/
|
||||||
|
function pbsErsetzen(antworten: { json: string; tabelle: string; version: string }) {
|
||||||
|
const datei = path.join(stubVerzeichnis, "proxmox-backup-manager");
|
||||||
|
writeFileSync(
|
||||||
|
datei,
|
||||||
|
[
|
||||||
|
"#!/usr/bin/env bash",
|
||||||
|
'if [ "$1" = "version" ]; then',
|
||||||
|
"cat <<'VERSION'",
|
||||||
|
antworten.version,
|
||||||
|
"VERSION",
|
||||||
|
'elif [[ "$*" == *--output-format*json* ]]; then',
|
||||||
|
"cat <<'JSON'",
|
||||||
|
antworten.json,
|
||||||
|
"JSON",
|
||||||
|
"else",
|
||||||
|
"cat <<'TEXT'",
|
||||||
|
antworten.tabelle,
|
||||||
|
"TEXT",
|
||||||
|
"fi",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
chmodSync(datei, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
/** Führt den Agent mit den vorbereiteten Befehlen aus und liefert den Report. */
|
/** Führt den Agent mit den vorbereiteten Befehlen aus und liefert den Report. */
|
||||||
function reportErzeugen(): Record<string, never> | Record<string, unknown> {
|
function reportErzeugen(): Record<string, never> | Record<string, unknown> {
|
||||||
const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], {
|
const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], {
|
||||||
@@ -43,6 +74,23 @@ nextduedate: 2026-09-24
|
|||||||
sockets: 1
|
sockets: 1
|
||||||
level: c`;
|
level: c`;
|
||||||
|
|
||||||
|
// Echte Ausgaben von einem Proxmox Backup Server 4.2 ohne Subscription (Feldtest #023).
|
||||||
|
const PBS_TABELLE = `┌──────────┬──────────────────────────────────────────────────────────┐
|
||||||
|
│ Name │ Value │
|
||||||
|
╞══════════╪══════════════════════════════════════════════════════════╡
|
||||||
|
│ status │ notfound │
|
||||||
|
├──────────┼──────────────────────────────────────────────────────────┤
|
||||||
|
│ message │ There is no subscription key │
|
||||||
|
├──────────┼──────────────────────────────────────────────────────────┤
|
||||||
|
│ serverid │ 69AED6ABA8A34EBE9AE1075333BBF8FE │
|
||||||
|
├──────────┼──────────────────────────────────────────────────────────┤
|
||||||
|
│ url │ https://www.proxmox.com/en/proxmox-backup-server/pricing │
|
||||||
|
└──────────┴──────────────────────────────────────────────────────────┘`;
|
||||||
|
|
||||||
|
const PBS_VERSION = "proxmox-backup-server 4.2.6-1 running version: 4.2.1";
|
||||||
|
|
||||||
|
const PBS_JSON = `{"message":"There is no subscription key","serverid":"69AED6ABA8A34EBE9AE1075333BBF8FE","status":"notfound","url":"https://www.proxmox.com/en/proxmox-backup-server/pricing"}`;
|
||||||
|
|
||||||
describe("Agent-Skript", () => {
|
describe("Agent-Skript", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-"));
|
stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-"));
|
||||||
@@ -75,7 +123,7 @@ describe("Agent-Skript", () => {
|
|||||||
host: Record<string, unknown>;
|
host: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(report.agentVersion).toBe("0.2.0");
|
expect(report.agentVersion).toBe(AGENT_VERSION);
|
||||||
expect(report.reportId).toMatch(/.{8,}/);
|
expect(report.reportId).toMatch(/.{8,}/);
|
||||||
expect(report.erhobenAm).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
expect(report.erhobenAm).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||||
expect(report.produkt).toEqual({ typ: "pve", version: "pve-manager/8.3.2/dc2b2f4e" });
|
expect(report.produkt).toEqual({ typ: "pve", version: "pve-manager/8.3.2/dc2b2f4e" });
|
||||||
@@ -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", () => {
|
it("gibt die Version aus", () => {
|
||||||
const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim();
|
const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim();
|
||||||
expect(ausgabe).toBe("0.2.0");
|
expect(ausgabe).toBe(AGENT_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.runIf(datenbankVerfuegbar)("Zusammenspiel mit der Agent-API", () => {
|
describe.runIf(datenbankVerfuegbar)("Zusammenspiel mit der Agent-API", () => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
|
|||||||
|
|
||||||
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
||||||
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
||||||
expect(einstellungen).toHaveLength(10);
|
expect(einstellungen).toHaveLength(11);
|
||||||
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
||||||
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,12 +25,14 @@ describe("Sitzungen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sitzung = await sitzungLesen(token);
|
const sitzung = await sitzungLesen(token);
|
||||||
expect(sitzung).toEqual({
|
expect(sitzung).toMatchObject({
|
||||||
benutzerId: "b1",
|
benutzerId: "b1",
|
||||||
email: "admin@example.de",
|
email: "admin@example.de",
|
||||||
name: "Florian",
|
name: "Florian",
|
||||||
herkunft: "passwort",
|
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 () => {
|
it("weist ein verändertes oder fremd signiertes Token ab", async () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
|
|||||||
return {
|
return {
|
||||||
id: "h1",
|
id: "h1",
|
||||||
hostname: "pve-01",
|
hostname: "pve-01",
|
||||||
|
clusterName: null,
|
||||||
|
clusterMitglieder: [],
|
||||||
kundeId: "k1",
|
kundeId: "k1",
|
||||||
kundeName: "Brandl Elektrotechnik GmbH",
|
kundeName: "Brandl Elektrotechnik GmbH",
|
||||||
subKey: "pve1c-aaaa",
|
subKey: "pve1c-aaaa",
|
||||||
@@ -70,9 +72,9 @@ function ausRegel(k: RegelKontext, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Warnregeln", () => {
|
describe("Warnregeln", () => {
|
||||||
it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => {
|
it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => {
|
||||||
expect(REGELN).toHaveLength(10);
|
expect(REGELN).toHaveLength(11);
|
||||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(10);
|
expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Subscription läuft ab", () => {
|
describe("Subscription läuft ab", () => {
|
||||||
@@ -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", () => {
|
it("überspringt abgeschaltete Regeln", () => {
|
||||||
const k = kontext({
|
const k = kontext({
|
||||||
hosts: [host({ kundeId: null, kundeName: null })],
|
hosts: [host({ kundeId: null, kundeName: null })],
|
||||||
|
|||||||