Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b0751c16 | ||
|
|
7775b6211f | ||
|
|
94f1eb4e6f | ||
|
|
1bfd549838 | ||
|
|
52466eee3f |
@@ -17,6 +17,29 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.15.0] – 2026-09-18
|
||||
|
||||
### Hinzugefügt
|
||||
- `docs/sicherheit.md`: Zuordnung aller Maßnahmen zu den OWASP Top 10, samt der Punkte, die bewusst offen bleiben (#024)
|
||||
- Content-Security-Policy mit frischem Nonce je Anfrage (#024)
|
||||
- Sitzungen lassen sich widerrufen: gesperrtes Konto oder neues Passwort beendet laufende Sitzungen sofort (#024)
|
||||
- Benutzerverwaltung auf der Kommandozeile: `npm run benutzer -- liste|sperren|entsperren|passwort` (#024)
|
||||
|
||||
### Behoben
|
||||
- Server Actions verließen sich allein auf den Schutz in `proxy.ts`; jede der 27 Aktionen prüft die Sitzung jetzt selbst (#024)
|
||||
- Die Anmeldung prüfte die Herkunft des Formulars nicht – damit war Login-CSRF möglich (#024)
|
||||
- `npm audit` meldete vier hohe Schwachstellen in Beiwerk von Prisma (`mysql2`, `deepmerge-ts`); über `overrides` angehoben, jetzt ohne Befund (#024)
|
||||
|
||||
## [0.14.1] – 2026-09-18
|
||||
|
||||
### Behoben
|
||||
- Auf Proxmox Backup Server las der Agent **keine einzige Angabe** aus: `proxmox-backup-manager subscription get` gibt eine Rahmentabelle aus, keine `feld: wert`-Zeilen. Jetzt wird für PBS `--output-format json` genutzt, und die Tabelle wird als Rückfall ausgewertet (#023)
|
||||
- Die PBS-Version enthielt die laufende Fassung ein zweites Mal (#023)
|
||||
- Der Agent meldete eine „neue Version“, auch wenn die installierte neuer war als die des Dashboards (#023)
|
||||
|
||||
### Geändert
|
||||
- Agent-Version 0.2.1; die Tests prüfen die Versionsangabe jetzt gegen die Konstante des Dashboards statt gegen eine abgeschriebene Zahl (#023)
|
||||
|
||||
## [0.14.0] – 2026-09-18
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 9 h 08 min
|
||||
**Gesamt:** 10 h 28 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -28,6 +28,8 @@
|
||||
| #021 Geheimnisse verschlüsseln | 0 h 20 min |
|
||||
| #018 Feldtest auf echten Hosts | 0 h 40 min |
|
||||
| #022 Cluster-Abgleich | 0 h 25 min |
|
||||
| #023 Feldtest PBS | 0 h 35 min |
|
||||
| #024 OWASP-Härtung | 0 h 45 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -50,7 +52,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.14.0
|
||||
**Version:** 0.15.0
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -133,6 +135,13 @@
|
||||
- #022 abgeschlossen: Cluster-Abgleich – jeder Agent meldet die Mitglieder, das Dashboard warnt bei Knoten ohne Agent
|
||||
- **Fehler im eigenen Werkzeug gefunden:** Alle Handbuch-Bilder waren in Wahrheit die Anmeldeseite. Firefox verwirft eine selbst angelegte Cookie-Datei stillschweigend. Jetzt läuft ein lokaler Vermittler mit, und das Skript bricht ab, wenn alle Aufnahmen gleich groß sind.
|
||||
|
||||
- Feldtest auf einem echten Proxmox Backup Server (#023): der Agent hätte dort **nichts** gelesen – PBS gibt eine Rahmentabelle aus, kein `feld: wert`. Behoben, mit den echten Ausgaben als Testfälle
|
||||
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
|
||||
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
|
||||
|
||||
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
|
||||
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
|
||||
|
||||
### Nächster Schritt
|
||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||
- Für 1.0.0 fehlen noch: Feldtest auf PBS und PMG, Uninstall auf einem echten Host
|
||||
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
AGENT_VERSION="0.2.0"
|
||||
AGENT_VERSION="0.2.1"
|
||||
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
||||
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
||||
SPOOL_MAXIMUM=30
|
||||
@@ -106,15 +106,24 @@ produkt_erkennen() {
|
||||
}
|
||||
|
||||
subscription_ausgabe() {
|
||||
local ausgabe=""
|
||||
case "$1" in
|
||||
# PVE kennt --output-format nicht (geprueft auf 9.2), liefert aber "feld: wert".
|
||||
pve) pvesubscription get 2>/dev/null ;;
|
||||
pbs) proxmox-backup-manager subscription get 2>/dev/null ;;
|
||||
# PBS gibt ohne --output-format eine Rahmentabelle aus, aus der sich schlecht lesen laesst.
|
||||
# JSON kann es (geprueft auf 4.2); aeltere Fassungen faengt die Tabellenauswertung ab.
|
||||
pbs)
|
||||
ausgabe=$(proxmox-backup-manager subscription get --output-format json 2>/dev/null)
|
||||
[ -n "$ausgabe" ] || ausgabe=$(proxmox-backup-manager subscription get 2>/dev/null)
|
||||
printf '%s' "$ausgabe"
|
||||
;;
|
||||
pmg) pmgsubscription get 2>/dev/null ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Liest ein Feld aus der Subscription-Ausgabe. Unterstützt "feld: wert" und JSON.
|
||||
# Liest ein Feld aus der Subscription-Ausgabe. Unterstuetzt "feld: wert", JSON und die
|
||||
# Rahmentabelle von PBS ("| feld | wert |" mit Unicode-Strichen).
|
||||
feld_lesen() {
|
||||
local feld="$1" text="$2" wert
|
||||
wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1)
|
||||
@@ -122,6 +131,15 @@ feld_lesen() {
|
||||
wert=$(printf '%s\n' "$text" |
|
||||
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
|
||||
fi
|
||||
if [ -z "$wert" ]; then
|
||||
# Tabellenzeile: Trennzeichen ist U+2502; awk zerlegt daran und liefert die zweite Spalte.
|
||||
wert=$(printf '%s\n' "$text" |
|
||||
awk -F'\xe2\x94\x82' -v feld="$feld" '
|
||||
NF >= 3 {
|
||||
name = $2; gsub(/^[ \t]+|[ \t]+$/, "", name)
|
||||
if (name == feld) { wert = $3; gsub(/^[ \t]+|[ \t]+$/, "", wert); print wert; exit }
|
||||
}')
|
||||
fi
|
||||
# Umschließende Anführungszeichen und Leerzeichen entfernen.
|
||||
wert=${wert%\"}
|
||||
wert=${wert#\"}
|
||||
@@ -133,7 +151,9 @@ produkt_version() {
|
||||
# Der Kernel wird separat gemeldet, deshalb hier abschneiden.
|
||||
case "$1" in
|
||||
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 ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
@@ -395,8 +415,15 @@ aktion_test() {
|
||||
[ "$(printf '%s' "$antwort" | tail -n1)" = "200" ]
|
||||
}
|
||||
|
||||
# Ist $1 eine neuere Fassung als $2? `sort -V` vergleicht Versionsnummern der Reihe nach.
|
||||
version_neuer() {
|
||||
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
|
||||
}
|
||||
|
||||
version_pruefen() {
|
||||
if [ -n "${NEUESTE_VERSION:-}" ] && [ "$NEUESTE_VERSION" != "$AGENT_VERSION" ]; then
|
||||
# Nur melden, wenn das Dashboard wirklich eine neuere Fassung kennt. Sonst warnt ein Agent,
|
||||
# der einer Testinstallation vorauseilt, gegen sich selbst (im Feldtest #023 aufgefallen).
|
||||
if [ -n "${NEUESTE_VERSION:-}" ] && version_neuer "$NEUESTE_VERSION" "$AGENT_VERSION"; then
|
||||
protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})."
|
||||
if [ "$AUTO_UPDATE" = "yes" ]; then
|
||||
aktion_update
|
||||
|
||||
@@ -113,6 +113,12 @@ Der Installer erkennt selbst, ob es sich um Proxmox VE, Backup Server oder Mail
|
||||
registriert den Host, legt die Konfiguration unter `/etc/proxledger/agent.conf` (Rechte 600) ab,
|
||||
richtet einen systemd-Timer ein und schickt sofort den ersten Report.
|
||||
|
||||
> **Stand der Erprobung:** Proxmox VE (9.2, mit und ohne Subscription, einzeln und im Cluster) und
|
||||
> Proxmox Backup Server (4.2, ohne Subscription) sind auf echten Hosts geprüft. Für **Mail Gateway**
|
||||
> stand bisher kein Host zur Verfügung – dort beruht die Auswertung noch auf Annahmen, und bei PBS hat
|
||||
> sich genau so eine Annahme als falsch erwiesen. Bitte den ersten PMG-Host nach der Installation
|
||||
> kurz gegenprüfen (`proxledger-agent --dry-run` zeigt, was gemeldet würde).
|
||||
|
||||
Nützliche Befehle auf dem Host:
|
||||
|
||||
| Befehl | Zweck |
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 248 KiB |
@@ -15,7 +15,7 @@ auf echten Hosts laufen.
|
||||
- [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
|
||||
`pmgsubscription get` mit den Annahmen im Skript abgleichen
|
||||
- [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null)
|
||||
- [x] Timer und Spool-Nachsendung geprüft; Uninstall steht noch aus (Agents laufen vorerst weiter)
|
||||
- [x] Timer, Spool-Nachsendung und Uninstall geprüft (Uninstall am 18.09.2026 auf dem PBS-Host, siehe #023)
|
||||
- [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
@@ -59,4 +59,5 @@ funktioniert (Report gepuffert bei unerreichbarem Dashboard, beim nächsten Lauf
|
||||
sonst dauert ein Test des Zwischenspeichers zehneinhalb Minuten.
|
||||
6. **Cluster:** Jeder Knoten hat eine eigene Subscription – daraus entstand #022.
|
||||
|
||||
**Noch offen:** PBS und PMG (keine Hosts verfügbar), Uninstall auf einem echten Host.
|
||||
**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,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.
|
||||
@@ -26,4 +26,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#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 |
|
||||
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
|
||||
| | **Summe** | | | **119 h** | **9 h 08 min** |
|
||||
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
|
||||
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
|
||||
| | **Summe** | | | **125 h** | **10 h 28 min** |
|
||||
|
||||
@@ -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",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxledger",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
@@ -5071,13 +5071,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
|
||||
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/rebeccastevens"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
@@ -5133,16 +5143,6 @@
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
@@ -7820,24 +7820,25 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"version": "3.24.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
"denque": "^2.1.0",
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"long": "^5.2.1",
|
||||
"lru.min": "^1.0.0",
|
||||
"named-placeholders": "^1.1.3",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.2"
|
||||
"iconv-lite": "^0.7.3",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
@@ -9059,12 +9060,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -9314,14 +9309,20 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
|
||||
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -26,7 +26,8 @@
|
||||
"lasttest": "tsx scripts/lasttest.ts",
|
||||
"screenshots": "node scripts/screenshots.mjs",
|
||||
"icons": "node scripts/icons.mjs",
|
||||
"pruefe:doku": "node scripts/pruefe-doku.mjs"
|
||||
"pruefe:doku": "node scripts/pruefe-doku.mjs",
|
||||
"benutzer": "tsx scripts/benutzer.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
@@ -65,5 +66,9 @@
|
||||
"description": "Proxmox Subscription Dashboard",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"overrides": {
|
||||
"mysql2": "^3.24.4",
|
||||
"deepmerge-ts": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenUngueltigAb" TIMESTAMP(3);
|
||||
@@ -456,6 +456,8 @@ model Benutzer {
|
||||
oidcSubject String? @unique
|
||||
aktiv Boolean @default(true)
|
||||
letzterLoginAm DateTime?
|
||||
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
|
||||
sitzungenUngueltigAb DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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());
|
||||
@@ -9,6 +9,32 @@ export const dynamic = "force-dynamic";
|
||||
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
|
||||
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
|
||||
*/
|
||||
/**
|
||||
* Prüft, ob das Formular von der eigenen Oberfläche kam.
|
||||
*
|
||||
* Ohne diese Prüfung könnte eine fremde Seite ein Anmeldeformular auf dieses Dashboard abschicken
|
||||
* und das Opfer unbemerkt in ein untergeschobenes Konto anmelden (Login-CSRF, OWASP A01).
|
||||
* Server Actions in Next.js prüfen das selbst; dieser Endpunkt ist ein gewöhnliches Formular
|
||||
* und muss es daher selbst tun.
|
||||
*/
|
||||
export function herkunftStimmt(request: Request): boolean {
|
||||
const herkunft = request.headers.get("origin");
|
||||
// Formulare ohne Origin (etwa curl oder alte Browser) werden nicht ausgeschlossen –
|
||||
// der Schutz richtet sich gegen fremde Seiten, die immer einen Origin senden.
|
||||
if (!herkunft) return true;
|
||||
|
||||
const erlaubt = new Set<string>();
|
||||
if (process.env.APP_URL) erlaubt.add(new URL(process.env.APP_URL).host);
|
||||
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
||||
if (gastgeber) erlaubt.add(gastgeber);
|
||||
|
||||
try {
|
||||
return erlaubt.has(new URL(herkunft).host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formular = await request.formData();
|
||||
const email = formular.get("email")?.toString().trim() ?? "";
|
||||
@@ -24,6 +50,10 @@ export async function POST(request: Request) {
|
||||
return Response.redirect(adresse, 303);
|
||||
}
|
||||
|
||||
if (!herkunftStimmt(request)) {
|
||||
return zurueckZurAnmeldung("Die Anmeldung kam nicht von dieser Adresse.");
|
||||
}
|
||||
|
||||
if (!email || !passwort) {
|
||||
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import { SignJWT, jwtVerify } from "jose";
|
||||
export const SITZUNGS_COOKIE = "pl_sitzung";
|
||||
export const SITZUNGSDAUER_STUNDEN = 12;
|
||||
|
||||
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
|
||||
export interface GeleseneSitzung extends Sitzung {
|
||||
ausgestelltAm: Date;
|
||||
}
|
||||
|
||||
export interface Sitzung {
|
||||
benutzerId: string;
|
||||
email: string;
|
||||
@@ -37,7 +42,7 @@ export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
|
||||
}
|
||||
|
||||
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
|
||||
export async function sitzungLesen(token: string | undefined): Promise<Sitzung | null> {
|
||||
export async function sitzungLesen(token: string | undefined): Promise<GeleseneSitzung | null> {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
|
||||
@@ -47,6 +52,7 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
|
||||
email: payload.email,
|
||||
name: typeof payload.name === "string" ? payload.name : payload.email,
|
||||
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
|
||||
ausgestelltAm: new Date((payload.iat ?? 0) * 1000),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -23,19 +23,62 @@ const OEFFENTLICH = [
|
||||
"/manifest.webmanifest",
|
||||
];
|
||||
|
||||
/**
|
||||
* Content-Security-Policy je Anfrage, mit frischem Nonce.
|
||||
*
|
||||
* `strict-dynamic` heißt: Nur Skripte mit diesem Nonce dürfen laufen (und was sie selbst nachladen).
|
||||
* Damit läuft eingeschleustes `<script>` aus der Datenbank oder einer Proxmox-Ausgabe nicht mehr –
|
||||
* die zweite Verteidigungslinie hinter der Maskierung durch React (OWASP A03).
|
||||
*
|
||||
* `style-src` erlaubt bewusst `unsafe-inline`: Bootstrap und AdminLTE setzen Stile direkt am Element,
|
||||
* und ein Stil kann keinen Code ausführen.
|
||||
*/
|
||||
function richtlinie(nonce: string): string {
|
||||
const entwicklung = process.env.NODE_ENV !== "production";
|
||||
return [
|
||||
"default-src 'self'",
|
||||
// In der Entwicklung braucht der Hot-Reload eval; im Betrieb nicht.
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
// Verbindungen nur zur eigenen Herkunft; in der Entwicklung zusätzlich der Hot-Reload.
|
||||
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
...(entwicklung ? [] : ["upgrade-insecure-requests"]),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function mitRichtlinie(request: NextRequest, antwort?: NextResponse): NextResponse {
|
||||
const nonce = crypto.randomUUID().replaceAll("-", "");
|
||||
const csp = richtlinie(nonce);
|
||||
|
||||
// Next.js versieht seine eigenen Skripte mit diesem Nonce, sobald es ihn in der Anfrage findet.
|
||||
const kopfzeilen = new Headers(request.headers);
|
||||
kopfzeilen.set("x-nonce", nonce);
|
||||
kopfzeilen.set("Content-Security-Policy", csp);
|
||||
|
||||
const ergebnis = antwort ?? NextResponse.next({ request: { headers: kopfzeilen } });
|
||||
ergebnis.headers.set("Content-Security-Policy", csp);
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pfad = request.nextUrl.pathname;
|
||||
|
||||
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
|
||||
return NextResponse.next();
|
||||
return mitRichtlinie(request);
|
||||
}
|
||||
|
||||
const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value);
|
||||
if (sitzung) return NextResponse.next();
|
||||
if (sitzung) return mitRichtlinie(request);
|
||||
|
||||
const ziel = new URL("/login", request.url);
|
||||
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
|
||||
return NextResponse.redirect(ziel);
|
||||
return mitRichtlinie(request, NextResponse.redirect(ziel));
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { alertsAbgleichen } from "@/server/alerts/abgleich";
|
||||
import { REGELN } from "@/server/alerts/regeln";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
@@ -15,6 +16,7 @@ function neuLaden() {
|
||||
|
||||
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
|
||||
export async function alertQuittieren(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -25,6 +27,7 @@ export async function alertQuittieren(formular: FormData) {
|
||||
|
||||
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
|
||||
export async function alertSchliessen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -35,6 +38,7 @@ export async function alertSchliessen(formular: FormData) {
|
||||
|
||||
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
|
||||
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const ergebnis = await alertsAbgleichen();
|
||||
neuLaden();
|
||||
return {
|
||||
@@ -47,6 +51,7 @@ export async function warnregelnSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
for (const regel of REGELN) {
|
||||
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
|
||||
const schwellwerte: Record<string, number | number[]> = {};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
|
||||
import { testNachricht } from "@/server/notifications/nachrichten";
|
||||
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||
@@ -22,6 +23,7 @@ export async function emailKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const host = formular.get("host")?.toString().trim() ?? "";
|
||||
const port = Number(formular.get("port") ?? 587);
|
||||
@@ -78,6 +80,7 @@ export async function webhookKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const url = formular.get("url")?.toString().trim() ?? "";
|
||||
|
||||
@@ -116,6 +119,7 @@ export async function webhookKanalSpeichern(
|
||||
}
|
||||
|
||||
export async function kanalLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -126,6 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
|
||||
|
||||
/** Verschickt eine Testnachricht über einen Kanal. */
|
||||
export async function testSenden(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -140,6 +145,7 @@ export async function testSenden(formular: FormData): Promise<void> {
|
||||
|
||||
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
|
||||
export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
|
||||
|
||||
await prisma.einstellung.upsert({
|
||||
@@ -154,6 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
|
||||
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
|
||||
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
|
||||
await zusammenfassungSenden(zeitraum);
|
||||
neuLaden();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Speichert Notizen und Tags eines Hosts. */
|
||||
@@ -10,6 +11,7 @@ export async function hostNotizenSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return { fehler: "Host nicht gefunden." };
|
||||
|
||||
@@ -39,6 +41,7 @@ export async function hostNotizenSpeichern(
|
||||
|
||||
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
|
||||
export async function hostKundeSetzen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
||||
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
@@ -30,6 +32,7 @@ export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData
|
||||
|
||||
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
|
||||
export async function hostZuordnungAufheben(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
fehler?: string;
|
||||
@@ -28,6 +29,7 @@ const kundeSchema = z.object({
|
||||
|
||||
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
||||
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = kundeSchema.safeParse({
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
@@ -66,6 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
||||
}
|
||||
|
||||
export async function kundeLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -91,6 +94,7 @@ export async function standortSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = standortSchema.safeParse({
|
||||
kundeId: formular.get("kundeId")?.toString() ?? "",
|
||||
@@ -116,6 +120,7 @@ export async function standortSpeichern(
|
||||
}
|
||||
|
||||
export async function standortLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
einkaufBezahltSetzen,
|
||||
kundenrechnungErfassen,
|
||||
@@ -48,6 +49,7 @@ async function ausfuehren(arbeit: () => Promise<string | void>): Promise<Aktions
|
||||
}
|
||||
|
||||
export async function laufzeitBestaetigenAktion(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("laufzeitId")?.toString();
|
||||
if (!id) return;
|
||||
await laufzeitBestaetigen(id);
|
||||
@@ -55,6 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
|
||||
}
|
||||
|
||||
export async function laufzeitVerwerfenAktion(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("laufzeitId")?.toString();
|
||||
if (!id) return;
|
||||
await laufzeitVerwerfen(id);
|
||||
@@ -63,6 +66,7 @@ export async function laufzeitVerwerfenAktion(formular: FormData) {
|
||||
|
||||
/** Häkchen aus der Rechnungstabelle. */
|
||||
export async function statusUmschalten(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||
const feld = formular.get("feld")?.toString();
|
||||
const wert = formular.get("wert")?.toString() === "true";
|
||||
@@ -84,6 +88,7 @@ export async function proxmoxRechnungSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
||||
|
||||
@@ -121,6 +126,7 @@ export async function kundenrechnungSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const laufzeitId = formular.get("laufzeitId")?.toString();
|
||||
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
|
||||
|
||||
@@ -151,6 +157,7 @@ export async function laufzeitAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
const key = formular.get("key")?.toString().trim();
|
||||
const start = datum(formular.get("start"));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { erzeugeToken } from "@/lib/tokens";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
export interface TokenErgebnis extends AktionsErgebnis {
|
||||
@@ -16,6 +17,7 @@ export async function enrollmentTokenErzeugen(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
@@ -58,6 +60,7 @@ export async function enrollmentTokenErzeugen(
|
||||
}
|
||||
|
||||
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -67,6 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
}
|
||||
|
||||
export async function hostTokenWiderrufen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
const id = formular.get("id")?.toString();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
|
||||
import { AgentApiFehler } from "./service";
|
||||
|
||||
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
||||
export const AGENT_VERSION = "0.2.0";
|
||||
export const AGENT_VERSION = "0.2.1";
|
||||
|
||||
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
||||
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
|
||||
import { AGENT_VERSION } from "@/server/agent/http";
|
||||
import { POST as reportPost } from "@/app/api/agent/v1/report/route";
|
||||
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
|
||||
import { erzeugeToken } from "@/lib/tokens";
|
||||
@@ -20,6 +21,36 @@ function befehlErsetzen(name: string, ausgabe: string) {
|
||||
chmodSync(datei, 0o755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ahmt `proxmox-backup-manager` nach: Das Werkzeug beantwortet `version` anders als
|
||||
* `subscription get`, und auf `--output-format json` antwortet es anders als ohne.
|
||||
* Genau daran hängt die Auswertung im Agent.
|
||||
*/
|
||||
function pbsErsetzen(antworten: { json: string; tabelle: string; version: string }) {
|
||||
const datei = path.join(stubVerzeichnis, "proxmox-backup-manager");
|
||||
writeFileSync(
|
||||
datei,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
'if [ "$1" = "version" ]; then',
|
||||
"cat <<'VERSION'",
|
||||
antworten.version,
|
||||
"VERSION",
|
||||
'elif [[ "$*" == *--output-format*json* ]]; then',
|
||||
"cat <<'JSON'",
|
||||
antworten.json,
|
||||
"JSON",
|
||||
"else",
|
||||
"cat <<'TEXT'",
|
||||
antworten.tabelle,
|
||||
"TEXT",
|
||||
"fi",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
chmodSync(datei, 0o755);
|
||||
}
|
||||
|
||||
/** Führt den Agent mit den vorbereiteten Befehlen aus und liefert den Report. */
|
||||
function reportErzeugen(): Record<string, never> | Record<string, unknown> {
|
||||
const ausgabe = execFileSync("bash", [AGENT, "--dry-run"], {
|
||||
@@ -43,6 +74,23 @@ nextduedate: 2026-09-24
|
||||
sockets: 1
|
||||
level: c`;
|
||||
|
||||
// Echte Ausgaben von einem Proxmox Backup Server 4.2 ohne Subscription (Feldtest #023).
|
||||
const PBS_TABELLE = `┌──────────┬──────────────────────────────────────────────────────────┐
|
||||
│ Name │ Value │
|
||||
╞══════════╪══════════════════════════════════════════════════════════╡
|
||||
│ status │ notfound │
|
||||
├──────────┼──────────────────────────────────────────────────────────┤
|
||||
│ message │ There is no subscription key │
|
||||
├──────────┼──────────────────────────────────────────────────────────┤
|
||||
│ serverid │ 69AED6ABA8A34EBE9AE1075333BBF8FE │
|
||||
├──────────┼──────────────────────────────────────────────────────────┤
|
||||
│ url │ https://www.proxmox.com/en/proxmox-backup-server/pricing │
|
||||
└──────────┴──────────────────────────────────────────────────────────┘`;
|
||||
|
||||
const PBS_VERSION = "proxmox-backup-server 4.2.6-1 running version: 4.2.1";
|
||||
|
||||
const PBS_JSON = `{"message":"There is no subscription key","serverid":"69AED6ABA8A34EBE9AE1075333BBF8FE","status":"notfound","url":"https://www.proxmox.com/en/proxmox-backup-server/pricing"}`;
|
||||
|
||||
describe("Agent-Skript", () => {
|
||||
beforeEach(() => {
|
||||
stubVerzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-agent-test-"));
|
||||
@@ -75,7 +123,7 @@ describe("Agent-Skript", () => {
|
||||
host: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(report.agentVersion).toBe("0.2.0");
|
||||
expect(report.agentVersion).toBe(AGENT_VERSION);
|
||||
expect(report.reportId).toMatch(/.{8,}/);
|
||||
expect(report.erhobenAm).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
expect(report.produkt).toEqual({ typ: "pve", version: "pve-manager/8.3.2/dc2b2f4e" });
|
||||
@@ -144,6 +192,39 @@ 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(
|
||||
@@ -169,7 +250,7 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
|
||||
|
||||
it("gibt die Version aus", () => {
|
||||
const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim();
|
||||
expect(ausgabe).toBe("0.2.0");
|
||||
expect(ausgabe).toBe(AGENT_VERSION);
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Zusammenspiel mit der Agent-API", () => {
|
||||
|
||||
@@ -25,12 +25,14 @@ describe("Sitzungen", () => {
|
||||
});
|
||||
|
||||
const sitzung = await sitzungLesen(token);
|
||||
expect(sitzung).toEqual({
|
||||
expect(sitzung).toMatchObject({
|
||||
benutzerId: "b1",
|
||||
email: "admin@example.de",
|
||||
name: "Florian",
|
||||
herkunft: "passwort",
|
||||
});
|
||||
// Der Ausstellungszeitpunkt entscheidet, ob ein Widerruf die Sitzung trifft (#024).
|
||||
expect(sitzung?.ausgestelltAm.getTime()).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it("weist ein verändertes oder fremd signiertes Token ab", async () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||