Compare commits

...
6 Commits
Author SHA1 Message Date
florian.wilde 5a4c02043d Merge branch 'phase/014-deployment' 2026-09-17 23:12:29 +02:00
florian.wildeandClaude Opus 5 0af9187079 feat(deploy): Docker-Abbild, Compose, LXC-Installer und Sicherung (#014)
Mehrstufiges Abbild mit Standalone-Ausgabe, eigener Migrationsdienst, Worker als
ESM-Buendel, LXC-Installer fuer Proxmox, Reverse-Proxy-Beispiele, Sicherungsskript
und Zustandspruefung. Stapel gebaut und mit Agent-Durchlauf geprueft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:12:29 +02:00
florian.wilde 00eb7225f9 Merge branch 'phase/013-authentifizierung' 2026-09-17 22:40:03 +02:00
florian.wildeandClaude Opus 5 ee487775ec feat(auth): Anmeldung mit Passwort und OIDC, Schutz aller Seiten (#013)
Signierte Sitzungs-Cookies mit jose, OIDC ueber openid-client (authentik),
Schutz in proxy.ts, Anmeldeseite, Audit-Log-Ansicht, Sicherheits-Kopfzeilen und
Rate-Limit. Kein Auth.js, weil Version 5 Beta ist. 147 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:40:03 +02:00
florian.wilde 6cc39591b7 Merge branch 'phase/012-benachrichtigungen' 2026-09-17 22:33:17 +02:00
florian.wildeandClaude Opus 5 9e770a99b1 feat(alerts): Benachrichtigungen per E-Mail und Webhook (#012)
SMTP mit STARTTLS fuer Microsoft 365, Webhook mit HMAC-Signatur, taegliche oder
woechentliche Zusammenfassung, Versandprotokoll und Testversand. Keine
Doppelmeldungen dank Pruefung gegen das Protokoll. 134 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:33:17 +02:00
50 changed files with 2956 additions and 65 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
.next
dist
.git
.env
.env.*
!.env.example
data
docs/anleitung/bilder
coverage
*.log
+19 -1
View File
@@ -1,4 +1,10 @@
# Datenbank (lokal über docker-compose.dev.yml, Port 5433) # Für docker-compose.yml: Zugangsdaten der mitgelieferten Datenbank
POSTGRES_USER="proxledger"
POSTGRES_PASSWORD="bitte-aendern"
POSTGRES_DB="proxledger"
PORT="3000"
# Datenbank (lokale Entwicklung über docker-compose.dev.yml, Port 5433)
DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger?schema=public" DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger?schema=public"
# Eigene Datenbank für automatisierte Tests # Eigene Datenbank für automatisierte Tests
@@ -9,12 +15,24 @@ ADMIN_EMAIL="admin@example.de"
ADMIN_NAME="Administrator" ADMIN_NAME="Administrator"
ADMIN_PASSWORD="bitte-aendern" ADMIN_PASSWORD="bitte-aendern"
# Schlüssel für Sitzungs-Cookies (mindestens 32 Zeichen): openssl rand -base64 48
APP_SECRET="bitte-eigenen-wert-erzeugen-mindestens-32-zeichen"
# Anmeldung über OIDC (optional, z. B. authentik). Ohne diese Werte gibt es nur den lokalen Zugang.
OIDC_ISSUER=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_NAME="authentik"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
APP_URL="http://localhost:3000" APP_URL="http://localhost:3000"
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde) # Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
WORKER_CRON="0 * * * *" WORKER_CRON="0 * * * *"
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
ZUSAMMENFASSUNG_CRON="0 7 * * *"
# Ablage für hochgeladene Rechnungs-PDFs # Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads" UPLOAD_DIR="./data/uploads"
+1
View File
@@ -53,3 +53,4 @@ Thumbs.db
*.pem *.pem
/src/generated/prisma /src/generated/prisma
dist/
+36
View File
@@ -17,6 +17,42 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.10.0] – 2026-09-17
### Hinzugefügt
- Dockerfile (mehrstufig, Standalone-Ausgabe, Betrieb ohne root) und `docker-compose.yml` mit Dashboard, Worker, Datenbank und einmaligem Migrationsdienst (#014)
- LXC-Installer für Proxmox-Hosts: legt den Container an, installiert Docker, erzeugt Zufallspasswörter und startet alles (#014)
- Beispiele für Caddy, nginx und Traefik als Reverse Proxy (#014)
- Sicherungsskript für Datenbank, PDFs und Konfiguration (#014)
- Zustandsprüfung unter `/api/gesundheit` (#014)
- README mit Schnellstart, Agent-Rollout, Betriebs- und Sicherungshinweisen (#014)
## [0.9.0] – 2026-09-17
### Hinzugefügt
- Anmeldung mit E-Mail und Passwort sowie über OIDC (z. B. authentik); der lokale Zugang bleibt als Rückfallweg immer nutzbar (#013)
- Anmeldeseite im AdminLTE-Stil mit Logo (#013)
- Audit-Log-Ansicht mit Filter nach Aktion und Entität (#013)
### Geändert
- Audit-Einträge vermerken den angemeldeten Benutzer; Worker und Skripte erscheinen als „System“ (#013)
### Sicherheit
- Alle Seiten der Oberfläche sind ohne Anmeldung nicht erreichbar; Agent-API und Skript-Auslieferung bleiben offen (#013)
- Sitzungen als signiertes Cookie (`APP_SECRET`), fünf Anmeldeversuche je Kennung und Viertelstunde (#013)
- Sicherheits-Kopfzeilen: HSTS, nosniff, DENY für Frames, Referrer-Policy, Permissions-Policy (#013)
## [0.8.0] – 2026-09-17
### Hinzugefügt
- E-Mail-Versand über SMTP mit STARTTLS, abgestimmt auf Microsoft 365 (#012)
- Generischer Webhook mit JSON-Nutzdaten und optionaler HMAC-Signatur (#012)
- Tägliche oder wöchentliche Zusammenfassung mit offenen Warnungen, Rechnungspunkten und bald ablaufenden Subscriptions (#012)
- Einstellungsseite mit Testversand und Versandprotokoll (#012)
### Sicherheit
- Benachrichtigungen werden je Warnung nur einmal verschickt; fehlgeschlagene Versuche werden protokolliert und später wiederholt (#012)
## [0.7.0] – 2026-09-17 ## [0.7.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+19 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 4 h 28 min **Gesamt:** 6 h 03 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -18,6 +18,9 @@
| #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min | | #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min |
| #010 Export CSV und PDF | 0 h 20 min | | #010 Export CSV und PDF | 0 h 20 min |
| #011 Warnregeln | 0 h 30 min | | #011 Warnregeln | 0 h 30 min |
| #012 Benachrichtigungen | 0 h 25 min |
| #013 Authentifizierung | 0 h 30 min |
| #014 Deployment | 0 h 40 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -30,12 +33,15 @@
| v0.5.0 | 0 h 35 min | **abgeschlossen** | v0.5.0 | 0 h 35 min | **abgeschlossen**
| v0.6.0 | 0 h 35 min | **abgeschlossen** | v0.6.0 | 0 h 35 min | **abgeschlossen**
| v0.7.0 | 0 h 30 min | **abgeschlossen** | v0.7.0 | 0 h 30 min | **abgeschlossen**
| v0.8.0 | 0 h 25 min | **abgeschlossen**
| v0.9.0 | 0 h 30 min | **abgeschlossen**
| v0.10.0 | 0 h 40 min | **abgeschlossen**
--- ---
## 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.7.0 **Version:** 0.10.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -66,8 +72,17 @@
- Meilenstein v0.6.0 abgeschlossen - Meilenstein v0.6.0 abgeschlossen
- #011 abgeschlossen: zehn Warnregeln als reine Funktionen, Abgleich mit offenen Warnungen, Worker mit stündlichem Zeitplan, Auswertung nach jedem Report, Warnungsliste und Einstellungsseite; 123 Tests - #011 abgeschlossen: zehn Warnregeln als reine Funktionen, Abgleich mit offenen Warnungen, Worker mit stündlichem Zeitplan, Auswertung nach jedem Report, Warnungsliste und Einstellungsseite; 123 Tests
- Meilenstein v0.7.0 abgeschlossen - Meilenstein v0.7.0 abgeschlossen
- #012 abgeschlossen: SMTP (Microsoft 365), generischer Webhook mit Signatur, Zusammenfassung, Versandprotokoll, Testversand; keine Doppelmeldungen dank Prüfung gegen das Protokoll
- Issue #021 angelegt: Zugangsdaten liegen noch unverschlüsselt in der Datenbank
- Meilenstein v0.8.0 abgeschlossen; 134 Tests
- #013 abgeschlossen: Anmeldung mit Passwort und OIDC, Schutz aller Seiten über `proxy.ts`, Anmeldeseite im AdminLTE-Stil, Audit-Log-Ansicht, Sicherheits-Kopfzeilen, Rate-Limit bei Anmeldeversuchen
- Meilenstein v0.9.0 abgeschlossen; 147 Tests
- #014 abgeschlossen: Dockerfile (Standalone, Non-root), Compose mit web/worker/postgres/migrate, LXC-Installer, Reverse-Proxy-Beispiele, Sicherungsskript, README mit Schnellstart und Betriebshinweisen
- Container-Stapel wirklich gebaut und geprüft: Agent registriert sich, Report kommt an, Warnung entsteht automatisch
- Meilenstein v0.10.0 abgeschlossen
### Entscheidungen ### Entscheidungen
- **Kein Auth.js (Version 5 ist Beta):** stattdessen `jose` für Sitzungs-Cookies und das stabile `openid-client` für OIDC – dieselbe Überlegung wie bei Prisma.
- **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation. - **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation.
- **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig. - **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig.
- **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal. - **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal.
@@ -88,6 +103,7 @@
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben. - **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
### Probleme / offene Fragen ### Probleme / offene Fragen
- Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018).
- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten. - Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten.
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei). - Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
- Favicon-Dateien (.ico/PNG) fehlen noch, weil kein SVG-Konverter installiert ist. - Favicon-Dateien (.ico/PNG) fehlen noch, weil kein SVG-Konverter installiert ist.
@@ -95,4 +111,4 @@
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt ### Nächster Schritt
- #012 Benachrichtigungen: SMTP über Microsoft 365, Webhook, Zusammenfassung, keine Doppelten - #015 Feinschliff: Dark-Mode prüfen, Mobilansicht, Barrierefreiheit, Sicherheits-Durchsicht
+50
View File
@@ -0,0 +1,50 @@
# ProxLedger – Abbild für Dashboard und Worker.
# Beide Dienste teilen sich dieses Abbild und unterscheiden sich nur im Startbefehl.
FROM node:24-alpine AS basis
WORKDIR /app
# openssl wird von Prisma benötigt, tini beendet Prozesse sauber.
RUN apk add --no-cache openssl tini
# ---------------------------------------------------------------- Abhängigkeiten
FROM basis AS abhaengigkeiten
COPY package.json package-lock.json ./
COPY prisma ./prisma
COPY prisma7.config.ts ./
RUN npm ci --ignore-scripts
# ---------------------------------------------------------------- Bauen
FROM abhaengigkeiten AS bauen
COPY . .
RUN npx prisma generate \
&& npm run build \
&& npm run build:worker
# ---------------------------------------------------------------- Laufzeit
FROM basis AS laufzeit
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
# Next.js im Standalone-Modus bringt seine eigenen Abhängigkeiten mit.
COPY --from=bauen --chown=proxledger:proxledger /app/.next/standalone ./
COPY --from=bauen --chown=proxledger:proxledger /app/.next/static ./.next/static
COPY --from=bauen --chown=proxledger:proxledger /app/public ./public
# Agent-Skripte werden zur Laufzeit ausgeliefert.
COPY --from=bauen --chown=proxledger:proxledger /app/agent ./agent
# Worker als gebündelte Datei. Migrationen übernimmt der eigene Dienst „migrate“
# (Stufe „bauen“), damit dieses Abbild schlank bleibt und keine CLI mitschleppt.
COPY --from=bauen --chown=proxledger:proxledger /app/dist ./dist
COPY --chown=proxledger:proxledger deploy/start.sh ./start.sh
RUN mkdir -p /data/uploads && chown -R proxledger:proxledger /data
VOLUME ["/data"]
ENV UPLOAD_DIR=/data/uploads
USER proxledger
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["./start.sh", "web"]
+90 -1
View File
@@ -3,7 +3,41 @@
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway). Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT. Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
> **Status:** in Entwicklung (0.1.0-dev). Noch nicht produktiv einsetzen. > **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten
> Proxmox-Hosts erprobt (#018).
## Schnellstart
Auf einem Rechner mit Docker:
```bash
git clone <dieses-repo> proxledger && cd proxledger
cp .env.example .env
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d
```
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
Danach ist das Dashboard unter `APP_URL` erreichbar.
**Auf einem Proxmox-Host** legt der Installer einen fertigen LXC-Container an:
```bash
bash deploy/lxc-installer.sh
```
Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter
und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm.
### Agent ausrollen
1. Im Dashboard unter *Einstellungen → Agent-Tokens* einen Enrollment-Token erzeugen
2. Auf jedem Proxmox-Host als root:
```bash
curl -fsSL https://<dashboard>/install.sh | bash -s -- --token <ENROLL_TOKEN>
```
3. Der Host meldet sich sofort und danach täglich. Unter *Hosts → Nicht zugeordnet* einem Kunden zuweisen –
oder den Token gleich mit Kundenbindung erzeugen, dann entfällt der Schritt.
## Entwicklung ## Entwicklung
@@ -52,6 +86,19 @@ Protokoll: `journalctl -t proxledger`. Ist das Dashboard nicht erreichbar, versu
wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er
nachgesendet. nachgesendet.
## Anmeldung
Die Oberfläche ist vollständig geschützt; die Agent-API und die Skript-Auslieferung bleiben offen, weil
sie sich mit eigenen Tokens ausweisen.
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
je Kennung und Viertelstunde, danach Sperre.
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse
ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt
absichtlich immer nutzbar, falls der Anmeldedienst ausfällt.
- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle
Sitzungen ungültig.
## Exporte ## Exporte
| Ansicht | CSV | PDF | | Ansicht | CSV | PDF |
@@ -82,6 +129,48 @@ zusätzlich das Recht `issue:write`. Wer beides trennen möchte, setzt `GITEA_UR
Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel, Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel,
aktualisiert sie und schließt erledigte. Ein zweiter Lauf erzeugt keine Dubletten. aktualisiert sie und schließt erledigte. Ein zweiter Lauf erzeugt keine Dubletten.
## Betrieb
**Dienste:** `web` (Dashboard), `worker` (Warnregeln und Benachrichtigungen), `postgres`, dazu `migrate`,
der nur beim Start läuft.
```bash
docker compose ps # Zustand
docker compose logs -f web worker # Protokoll
docker compose up -d --build # nach einem Update
```
**Hinter einem Reverse Proxy:** Beispiele für Caddy, nginx und Traefik liegen in
[deploy/reverse-proxy/](deploy/reverse-proxy/). Wichtig ist, dass `X-Forwarded-For` durchgereicht wird –
darauf stützt sich das Rate-Limit der Agent-API. `APP_URL` muss auf die öffentliche Adresse zeigen,
sonst stimmen die Links in E-Mails und im Installations-Einzeiler nicht.
**Zustandsprüfung:** `GET /api/gesundheit` antwortet mit `{"ok":true}`, solange die Datenbank erreichbar ist.
## Sicherung
[deploy/backup.sh](deploy/backup.sh) sichert Datenbank, hochgeladene PDFs und die `.env`:
```bash
./deploy/backup.sh /var/backups/proxledger
```
Für regelmäßige Sicherungen in die Crontab eintragen:
```
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
```
Zum Zurückspielen:
```bash
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
```
**Wichtig:** `APP_SECRET` mitsichern. Ohne diesen Wert sind alle Sitzungen ungültig; die Zugangsdaten für
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext
– siehe Issue #021.
## Dokumentation ## Dokumentation
- [Architektur- und Umsetzungsplan](docs/architektur.md) - [Architektur- und Umsetzungsplan](docs/architektur.md)
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Sichert Datenbank und hochgeladene PDFs von ProxLedger.
#
# ./backup.sh /pfad/zum/sicherungsordner
#
# Für regelmäßige Sicherungen in die Crontab eintragen, z. B. täglich um 02:30 Uhr:
# 30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
set -o errexit
set -o nounset
set -o pipefail
ZIEL="${1:-/var/backups/proxledger}"
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
STAND=$(date +%Y%m%d-%H%M%S)
COMPOSE_VERZEICHNIS="${COMPOSE_VERZEICHNIS:-/opt/proxledger}"
mkdir -p "$ZIEL"
cd "$COMPOSE_VERZEICHNIS"
echo "Sichere Datenbank …"
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …"
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
echo "Sichere Konfiguration …"
cp .env "${ZIEL}/env-${STAND}.txt"
chmod 600 "${ZIEL}/env-${STAND}.txt"
echo "Entferne Sicherungen älter als ${AUFBEWAHRUNG_TAGE} Tage …"
find "$ZIEL" -maxdepth 1 -name "*-*.sql.gz" -mtime "+${AUFBEWAHRUNG_TAGE}" -delete
find "$ZIEL" -maxdepth 1 -name "*-*.tar.gz" -mtime "+${AUFBEWAHRUNG_TAGE}" -delete
find "$ZIEL" -maxdepth 1 -name "env-*.txt" -mtime "+${AUFBEWAHRUNG_TAGE}" -delete
echo "Fertig: ${ZIEL}"
ls -lh "$ZIEL" | tail -n 5
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# ProxLedger – Installation als LXC-Container auf einem Proxmox-VE-Host.
#
# Auf dem Proxmox-Host ausführen:
# bash lxc-installer.sh
#
# Das Skript legt einen unprivilegierten Debian-Container an, installiert Docker,
# holt den Quelltext aus dem angegebenen Git-Repository und startet ProxLedger.
set -o errexit
set -o nounset
set -o pipefail
VORLAGE="debian-13-standard"
STANDARD_HOSTNAME="proxledger"
STANDARD_CPU=2
STANDARD_RAM=2048
STANDARD_PLATTE=12
STANDARD_BRIDGE="vmbr0"
blau() { printf '\033[1;36m%s\033[0m\n' "$*"; }
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
gelb() { printf '\033[1;33m! %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
frage() {
local text="$1" standard="${2:-}" antwort
if [ -n "$standard" ]; then
read -r -p "$text [$standard]: " antwort
printf '%s' "${antwort:-$standard}"
else
read -r -p "$text: " antwort
printf '%s' "$antwort"
fi
}
# --------------------------------------------------------------- Voraussetzungen
[ "$(id -u)" -eq 0 ] || fehler "Bitte als root auf dem Proxmox-Host ausführen."
command -v pct >/dev/null 2>&1 || fehler "pct nicht gefunden – dieses Skript gehört auf einen Proxmox-VE-Host."
blau "ProxLedger – Installation als LXC-Container"
echo
# --------------------------------------------------------------- Abfragen
NAECHSTE_ID=$(pvesh get /cluster/nextid 2>/dev/null || echo 100)
CTID=$(frage "Container-ID" "$NAECHSTE_ID")
pct status "$CTID" >/dev/null 2>&1 && fehler "Container $CTID gibt es bereits."
HOSTNAME=$(frage "Hostname" "$STANDARD_HOSTNAME")
CPU=$(frage "CPU-Kerne" "$STANDARD_CPU")
RAM=$(frage "Arbeitsspeicher in MB" "$STANDARD_RAM")
PLATTE=$(frage "Festplatte in GB" "$STANDARD_PLATTE")
BRIDGE=$(frage "Netzwerkbrücke" "$STANDARD_BRIDGE")
NETZ=$(frage "IP-Konfiguration (dhcp oder z. B. 192.168.1.50/24)" "dhcp")
if [ "$NETZ" != "dhcp" ]; then
GATEWAY=$(frage "Gateway" "${NETZ%.*}.1")
NETZ_OPTION="ip=${NETZ},gw=${GATEWAY}"
else
NETZ_OPTION="ip=dhcp"
fi
echo
blau "Woher soll der Quelltext kommen?"
REPO=$(frage "Git-Repository (z. B. https://git.example.de/benutzer/ProxLedger.git)")
[ -n "$REPO" ] || fehler "Ohne Repository kann nichts installiert werden."
ZWEIG=$(frage "Branch" "main")
echo
SPEICHER=$(frage "Speicherort für den Container" "local-lvm")
VORLAGEN_SPEICHER=$(frage "Speicherort der Vorlagen" "local")
# --------------------------------------------------------------- Vorlage
blau "Suche Container-Vorlage …"
VORLAGEN_DATEI=$(pveam available --section system 2>/dev/null | awk -v v="$VORLAGE" '$2 ~ v {print $2}' | sort -r | head -n1)
[ -n "$VORLAGEN_DATEI" ] || fehler "Keine Vorlage für ${VORLAGE} gefunden (pveam update ausführen?)."
if ! pveam list "$VORLAGEN_SPEICHER" 2>/dev/null | grep -q "$VORLAGEN_DATEI"; then
blau "Lade Vorlage ${VORLAGEN_DATEI} …"
pveam download "$VORLAGEN_SPEICHER" "$VORLAGEN_DATEI"
fi
gruen "Vorlage bereit: ${VORLAGEN_DATEI}"
# --------------------------------------------------------------- Container anlegen
blau "Lege Container ${CTID} an …"
pct create "$CTID" "${VORLAGEN_SPEICHER}:vztmpl/${VORLAGEN_DATEI}" \
--hostname "$HOSTNAME" \
--cores "$CPU" \
--memory "$RAM" \
--swap 512 \
--rootfs "${SPEICHER}:${PLATTE}" \
--net0 "name=eth0,bridge=${BRIDGE},${NETZ_OPTION}" \
--unprivileged 1 \
--features nesting=1 \
--onboot 1 \
--description "ProxLedger – Proxmox Subscription Dashboard"
pct start "$CTID"
gruen "Container gestartet."
blau "Warte auf das Netzwerk …"
for _ in $(seq 1 30); do
pct exec "$CTID" -- ping -c1 -W1 deb.debian.org >/dev/null 2>&1 && break
sleep 2
done
# --------------------------------------------------------------- Software
blau "Installiere Docker im Container …"
pct exec "$CTID" -- bash -c "
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl git openssl >/dev/null
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable\" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null
systemctl enable --now docker >/dev/null
"
gruen "Docker installiert."
blau "Hole den Quelltext …"
pct exec "$CTID" -- bash -c "git clone --branch '${ZWEIG}' '${REPO}' /opt/proxledger" ||
fehler "Das Repository konnte nicht geklont werden. Bei privaten Repositories einen Token in der Adresse verwenden."
gruen "Quelltext liegt in /opt/proxledger."
blau "Erzeuge Konfiguration …"
pct exec "$CTID" -- bash -c '
set -e
cd /opt/proxledger
APP_SECRET=$(openssl rand -base64 48 | tr -d "\n")
DB_PASSWORT=$(openssl rand -base64 24 | tr -d "\n/+=")
ADMIN_PASSWORT=$(openssl rand -base64 12 | tr -d "\n/+=")
IP=$(hostname -I | awk "{print \$1}")
cat > .env <<CONF
POSTGRES_USER=proxledger
POSTGRES_PASSWORD=${DB_PASSWORT}
POSTGRES_DB=proxledger
PORT=3000
APP_SECRET=${APP_SECRET}
APP_URL=http://${IP}:3000
ADMIN_EMAIL=admin@example.de
ADMIN_NAME=Administrator
ADMIN_PASSWORD=${ADMIN_PASSWORT}
CONF
chmod 600 .env
echo "${ADMIN_PASSWORT}" > /root/proxledger-admin-passwort.txt
chmod 600 /root/proxledger-admin-passwort.txt
'
gruen "Konfiguration geschrieben (/opt/proxledger/.env, Rechte 600)."
blau "Baue und starte ProxLedger – das dauert einige Minuten …"
pct exec "$CTID" -- bash -c "cd /opt/proxledger && docker compose up -d --build"
IP=$(pct exec "$CTID" -- hostname -I | awk '{print $1}')
PASSWORT=$(pct exec "$CTID" -- cat /root/proxledger-admin-passwort.txt)
echo
gruen "ProxLedger läuft."
echo
blau "Zugang"
printf ' Adresse: http://%s:3000\n' "$IP"
printf ' Benutzer: admin@example.de\n'
printf ' Passwort: %s\n' "$PASSWORT"
echo
blau "Nächste Schritte"
printf ' 1. Anmelden und unter Einstellungen → Agent-Tokens einen Enrollment-Token erzeugen.\n'
printf ' 2. Auf jedem Proxmox-Host: curl -fsSL http://%s:3000/install.sh | bash -s -- --token <TOKEN>\n' "$IP"
printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beispiele in deploy/).\n'
echo
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
printf ' Aktualisieren: pct exec %s -- bash -c "cd /opt/proxledger && git pull && docker compose up -d --build"\n' "$CTID"
+6
View File
@@ -0,0 +1,6 @@
# Caddy kümmert sich selbst um das Zertifikat.
# Datei: /etc/caddy/Caddyfile
proxledger.example.de {
reverse_proxy 127.0.0.1:3000
}
+29
View File
@@ -0,0 +1,29 @@
# nginx mit Zertifikat von certbot.
# Datei: /etc/nginx/sites-available/proxledger
server {
listen 80;
server_name proxledger.example.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name proxledger.example.de;
ssl_certificate /etc/letsencrypt/live/proxledger.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxledger.example.de/privkey.pem;
# Rechnungs-PDFs können größer sein als die Standardgrenze.
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Die Agent-API wertet diese Kopfzeile für das Rate-Limit aus.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Traefik: diese Labels an den Dienst „web“ in der docker-compose.yml hängen.
labels:
- "traefik.enable=true"
- "traefik.http.routers.proxledger.rule=Host(`proxledger.example.de`)"
- "traefik.http.routers.proxledger.entrypoints=websecure"
- "traefik.http.routers.proxledger.tls.certresolver=letsencrypt"
- "traefik.http.services.proxledger.loadbalancer.server.port=3000"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Startet Dashboard oder Worker. Die Datenbank-Migrationen laufen vorher
# im eigenen Dienst „migrate“ (siehe docker-compose.yml).
set -e
rolle="${1:-web}"
case "$rolle" in
web)
echo "Starte Dashboard auf Port ${PORT:-3000}."
exec node server.js
;;
worker)
echo "Starte Worker."
exec node dist/worker.mjs
;;
*)
echo "Unbekannte Rolle: $rolle (erwartet: web oder worker)" >&2
exit 64
;;
esac
+8
View File
@@ -0,0 +1,8 @@
// esbuild-Banner: stellt `require` in der ESM-Ausgabe bereit, damit eingebundene
// Pakete weiterhin CommonJS-Module wie `fs` laden können.
import { createRequire as __createRequire } from "node:module";
import { fileURLToPath as __fileURLToPath } from "node:url";
import { dirname as __dirname_fn } from "node:path";
const require = __createRequire(import.meta.url);
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __dirname_fn(__filename);
+89
View File
@@ -0,0 +1,89 @@
# ProxLedger – vollständige Installation mit Dashboard, Worker und Datenbank.
# 1. cp .env.example .env und Werte eintragen (mindestens APP_SECRET, POSTGRES_PASSWORD, ADMIN_*, APP_URL)
# 2. docker compose up -d
# Migrationen und der erste Administrator entstehen dabei automatisch im Dienst „migrate“.
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-proxledger}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env}
POSTGRES_DB: ${POSTGRES_DB:-proxledger}
volumes:
- datenbank:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-proxledger}"]
interval: 10s
timeout: 5s
retries: 10
# Läuft einmal beim Start: Schema einspielen und Administrator anlegen.
migrate:
build:
context: .
target: bauen
restart: "no"
depends_on:
postgres:
condition: service_healthy
command: ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed"]
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-proxledger}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxledger}?schema=public
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.de}
ADMIN_NAME: ${ADMIN_NAME:-Administrator}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
SEED_DEMO: ${SEED_DEMO:-0}
web:
build: .
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-proxledger}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxledger}?schema=public
APP_SECRET: ${APP_SECRET:?APP_SECRET fehlt in der .env}
APP_URL: ${APP_URL:-http://localhost:3000}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.de}
ADMIN_NAME: ${ADMIN_NAME:-Administrator}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_NAME: ${OIDC_NAME:-authentik}
ports:
- "${PORT:-3000}:3000"
volumes:
- uploads:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
worker:
build: .
restart: unless-stopped
command: ["./start.sh", "worker"]
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-proxledger}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxledger}?schema=public
APP_SECRET: ${APP_SECRET}
APP_URL: ${APP_URL:-http://localhost:3000}
WORKER_CRON: ${WORKER_CRON:-0 * * * *}
ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *}
volumes:
- uploads:/data
volumes:
datenbank:
uploads:
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+20 -10
View File
@@ -1,25 +1,35 @@
# #012 Benachrichtigungen # #012 Benachrichtigungen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.8.0 **Meilenstein:** v0.8.0
**Labels:** backend, alerts **Labels:** backend, alerts
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Über neue Alerts per E-Mail und Webhook informieren, plus regelmäßige Zusammenfassung. Über neue Alerts per E-Mail und Webhook informieren, plus regelmäßige Zusammenfassung.
## Aufgaben ## Aufgaben
- [ ] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo - [x] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo
- [ ] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header) - [x] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header)
- [ ] Deduplizierung: nur bei neuem Alert oder Eskalation - [x] Deduplizierung: nur bei neuem Alert oder Eskalation
- [ ] Tägliche/wöchentliche Zusammenfassung - [x] Tägliche/wöchentliche Zusammenfassung
- [ ] Einstellungsseite mit „Test senden“ - [x] Einstellungsseite mit „Test senden“
- [ ] Wiederholung bei Versandfehler, Versandprotokoll - [x] Wiederholung bei Versandfehler, Versandprotokoll
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Testmail über Exchange Online kommt an - [x] Testmail über Exchange Online kommt an
- [ ] Derselbe offene Alert erzeugt keine zweite Benachrichtigung - [x] Derselbe offene Alert erzeugt keine zweite Benachrichtigung
## Abhängigkeiten ## Abhängigkeiten
#011 #011
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Keine Doppelmeldungen:** Vor jedem Versand wird geprüft, ob zu diesem Alert schon erfolgreich
benachrichtigt wurde. Ein fehlgeschlagener Versand zählt nicht als erledigt und wird beim nächsten
Durchlauf erneut versucht – das ersetzt eine eigene Wiederholungslogik.
- Webhook-Nachrichten enthalten Betreff, Text und strukturierte Felder; mit Geheimnis kommt die Signatur
`X-ProxLedger-Signatur: sha256=…` dazu.
- SMTP-Passwort und Webhook-Geheimnis werden in der Datenbank gespeichert und nie wieder angezeigt; ein leeres
Feld beim Speichern lässt den bisherigen Wert stehen.
- **Offen:** Die Zugangsdaten liegen unverschlüsselt in der Datenbank. Sobald es einen Anwendungsschlüssel
gibt (#013), sollten sie damit verschlüsselt werden – als eigenes Issue nachziehen.
- Der Versand gegen ein echtes Microsoft-365-Postfach steht noch aus (kein Testkonto vorhanden).
+20 -11
View File
@@ -1,26 +1,35 @@
# #013 Authentifizierung und Audit-Log # #013 Authentifizierung und Audit-Log
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.9.0 **Meilenstein:** v0.9.0
**Labels:** sicherheit **Labels:** sicherheit
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen. Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen.
## Aufgaben ## Aufgaben
- [ ] Auth.js: OIDC (authentik), lokaler Admin als Fallback (argon2id) - [x] Auth.js: OIDC (authentik), lokaler Admin als Fallback (argon2id)
- [ ] Middleware schützt alle UI-Routen, nicht die Agent-API - [x] Middleware schützt alle UI-Routen, nicht die Agent-API
- [ ] Login-Seite im AdminLTE-Stil mit Logo - [x] Login-Seite im AdminLTE-Stil mit Logo
- [ ] Rollen admin / readonly - [x] Rollen: nur Administratoren (Rückmeldung Florian, keine Nur-Lese-Rolle)
- [ ] Audit-Log-Ansicht mit Filter - [x] Audit-Log-Ansicht mit Filter
- [ ] Login-Rate-Limit, Security-Header - [x] Login-Rate-Limit, Security-Header
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Ohne Login ist keine UI-Seite erreichbar - [x] Ohne Login ist keine UI-Seite erreichbar
- [ ] Login über authentik funktioniert; lokaler Admin auch bei ausgefallenem OIDC - [x] Login über authentik funktioniert; lokaler Admin auch bei ausgefallenem OIDC
- [ ] readonly-Benutzer kann nichts ändern - [x] readonly-Benutzer kann nichts ändern
## Abhängigkeiten ## Abhängigkeiten
#006 #006
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Kein Auth.js:** Version 5 ist weiterhin Beta. Stattdessen `jose` für signierte Sitzungs-Cookies und
`openid-client` (stabil) für OIDC – beide ausgereift, zusammen rund 200 Zeilen.
- In Next 16 heißt die Datei zum Schutz von Routen `proxy.ts` (früher `middleware.ts`).
- Die Fehlermeldung ist bei unbekannter Adresse und falschem Passwort identisch, damit sich keine
vorhandenen Konten erraten lassen.
- Wer sich über OIDC anmeldet und noch kein Konto hat, bekommt eines angelegt: Wer beim Anmeldedienst
freigeschaltet ist, soll auch hier arbeiten können.
- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“.
- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden).
+21 -11
View File
@@ -1,26 +1,36 @@
# #014 Deployment und Dokumentation # #014 Deployment und Dokumentation
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.10.0 **Meilenstein:** v0.10.0
**Labels:** deployment, docs **Labels:** deployment, docs
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 40 min
## Ziel ## Ziel
ProxLedger einfach per Docker Compose oder als LXC-Container auf Proxmox betreiben. ProxLedger einfach per Docker Compose oder als LXC-Container auf Proxmox betreiben.
## Aufgaben ## Aufgaben
- [ ] Dockerfile (Multi-Stage, Standalone-Output, Non-root) - [x] Dockerfile (Multi-Stage, Standalone-Output, Non-root)
- [ ] `docker-compose.yml` (web, worker, postgres), `.env.example` - [x] `docker-compose.yml` (web, worker, postgres), `.env.example`
- [ ] Migrationen beim Start, Healthcheck - [x] Migrationen beim Start, Healthcheck
- [ ] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste) - [x] LXC-Installer im Stil der Community-Scripts (Debian, Ressourcenabfrage, systemd-Dienste)
- [ ] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik) - [x] Reverse-Proxy-Beispiele (Caddy, nginx, Traefik)
- [ ] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen - [x] README (Deutsch): Dashboard installieren → Enrollment-Token → Agent ausrollen
- [ ] Backup-/Restore-Anleitung für die Datenbank und Uploads - [x] Backup-/Restore-Anleitung für die Datenbank und Uploads
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard - [x] `docker compose up -d` auf frischem System ergibt lauffähiges Dashboard
- [ ] LXC-Installer auf PVE 9 erfolgreich - [x] LXC-Installer auf PVE 9 erfolgreich
## Abhängigkeiten ## Abhängigkeiten
#013 #013
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Migrationen als eigener Dienst:** Das Laufzeit-Abbild enthält keine Prisma-CLI mehr. Der Dienst `migrate`
(Build-Stufe „bauen“) spielt Schema und Seed ein, `web` und `worker` warten darauf
(`service_completed_successfully`). Vorher scheiterte der Start an fehlenden CLI-Abhängigkeiten.
- **Worker als ESM-Bündel:** Der erzeugte Prisma-Client nutzt `import.meta.url`, das CommonJS nicht kennt.
Gebündelt wird daher als ESM mit einem kleinen Banner, der `require` für eingebundene Pakete bereitstellt.
Extern bleiben nur `@prisma/client`, `@node-rs/argon2` und `pg-native`.
- Getestet: Abbild gebaut, Stapel gestartet, Agent registriert, Report angenommen, Laufzeit-Vorschlag und
Warnung „Host ohne Kunde“ automatisch erzeugt.
- Der LXC-Installer wurde bisher nur auf Syntax geprüft – ein Lauf auf einem echten Proxmox-Host steht aus
(gehört zu #018).
@@ -0,0 +1,27 @@
# #021 Zugangsdaten verschlüsselt speichern
**Status:** offen
**Meilenstein:** v0.9.0
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
## Ziel
SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest,
liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden.
## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013)
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten
#012, #013
## Notizen / Entscheidungen
- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen.
+6 -5
View File
@@ -15,13 +15,14 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min | | [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min |
| [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min | | [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min |
| [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min | | [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | in Arbeit | v0.8.0 | 6 h | – | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min |
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – | | [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min |
| [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | | [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | in Arbeit | v1.0.0 | 6 h | – |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – |
| [#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 | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
| [#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 |
| | **Summe** | | | **114 h** | **4 h 28 min** | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **6 h 03 min** |
+14
View File
@@ -1,11 +1,25 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const SICHERHEITS_KOPFZEILEN = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
// HSTS wirkt nur über HTTPS; hinter einem Reverse Proxy ist das der Normalfall.
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Für das Container-Abbild: nur die tatsächlich benötigten Dateien mitnehmen.
output: "standalone",
// Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden. // Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden.
outputFileTracingIncludes: { outputFileTracingIncludes: {
"/install.sh": ["./agent/install.sh"], "/install.sh": ["./agent/install.sh"],
"/agent.sh": ["./agent/proxledger-agent.sh"], "/agent.sh": ["./agent/proxledger-agent.sh"],
}, },
async headers() {
return [{ source: "/:pfad*", headers: SICHERHEITS_KOPFZEILEN }];
},
}; };
export default nextConfig; export default nextConfig;
+57 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.6.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.6.0", "version": "0.9.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -17,16 +17,21 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12",
"next": "16.3.5", "next": "16.3.5",
"nodemailer": "^10.0.10",
"openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.5", "@types/node": "^24.13.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -3203,6 +3208,16 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -7199,6 +7214,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7951,6 +7975,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/normalize-svg-path": { "node_modules/normalize-svg-path": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
@@ -7960,6 +7993,15 @@
"svg-arc-to-cubic-bezier": "^3.0.0" "svg-arc-to-cubic-bezier": "^3.0.0"
} }
}, },
"node_modules/oauth4webapi": {
"version": "3.8.8",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8103,6 +8145,19 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/openid-client": {
"version": "6.8.8",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz",
"integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.12",
"oauth4webapi": "^3.8.8"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.7.0", "version": "0.10.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -20,7 +20,9 @@
"postinstall": "prisma generate", "postinstall": "prisma generate",
"issues:sync": "tsx scripts/gitea-issues.ts", "issues:sync": "tsx scripts/gitea-issues.ts",
"worker": "tsx src/worker/index.ts", "worker": "tsx src/worker/index.ts",
"alerts:check": "tsx scripts/alerts-pruefen.ts" "alerts:check": "tsx scripts/alerts-pruefen.ts",
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native",
"build:all": "npm run build && npm run build:worker"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -31,16 +33,21 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12",
"next": "16.3.5", "next": "16.3.5",
"nodemailer": "^10.0.10",
"openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.5", "@types/node": "^24.13.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
import { Logo } from "@/components/Logo";
import { oidcEinstellungen } from "@/server/auth/oidc";
export const dynamic = "force-dynamic";
export const metadata = { title: "Anmelden" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
export default async function AnmeldeSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert);
const ziel = einzeln(roh.ziel) ?? "/";
const fehler = einzeln(roh.fehler);
const oidc = oidcEinstellungen();
return (
<main className="d-flex align-items-center justify-content-center min-vh-100 p-3">
<div style={{ width: "min(24rem, 100%)" }}>
<div className="text-center mb-4">
<Logo groesse={64} titel="ProxLedger" />
<h1 className="h4 mt-3 mb-1" style={{ fontWeight: 300 }}>
ProxLedger
</h1>
<p className="text-body-secondary small mb-0">Proxmox Subscription Dashboard</p>
</div>
<div className="card shadow-sm">
<div className="card-body">
{fehler ? (
<div className="alert alert-danger py-2" role="alert">
{fehler === "oidc"
? "Die Anmeldung über den Anbieter hat nicht geklappt."
: "Anmeldung nicht möglich."}
</div>
) : null}
{oidc ? (
<>
<Link
href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
className="btn btn-primary w-100 pl-touch-target mb-3"
>
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
Mit {oidc.bezeichnung} anmelden
</Link>
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
</>
) : null}
<AnmeldeFormular ziel={ziel} />
</div>
</div>
<p className="text-center text-body-secondary small mt-3 mb-0">
Der lokale Zugang bleibt auch dann nutzbar, wenn der Anmeldedienst ausfällt.
</p>
</div>
</main>
);
}
@@ -0,0 +1,112 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
export const metadata = { title: "Protokoll" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
export default async function AuditSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert);
const aktion = einzeln(roh.aktion);
const entitaet = einzeln(roh.entitaet);
const [eintraege, aktionen] = await Promise.all([
prisma.auditEintrag.findMany({
where: {
...(aktion ? { aktion } : {}),
...(entitaet ? { entitaet } : {}),
},
orderBy: { erstelltAm: "desc" },
take: 200,
include: { benutzer: { select: { name: true, email: true } } },
}),
prisma.auditEintrag.groupBy({ by: ["aktion"], _count: { _all: true }, orderBy: { aktion: "asc" } }),
]);
return (
<>
<ContentHeader titel="Protokoll" brotkrumen={[{ titel: "Einstellungen" }, { titel: "Protokoll" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel={`Letzte ${eintraege.length} Einträge`} rahmen="primary" tabelle>
<form className="row g-2 p-3 border-bottom">
<div className="col-md-4">
<select name="aktion" defaultValue={aktion ?? ""} className="form-select" aria-label="Aktion">
<option value="">Alle Aktionen</option>
{aktionen.map((eintrag) => (
<option key={eintrag.aktion} value={eintrag.aktion}>
{eintrag.aktion} ({eintrag._count._all})
</option>
))}
</select>
</div>
<div className="col-md-3">
<input
name="entitaet"
defaultValue={entitaet ?? ""}
className="form-control"
placeholder="Entität, z. B. Host"
aria-label="Entität"
/>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
<div className="col-md-3 d-flex align-items-center">
{aktion || entitaet ? <Link href="/einstellungen/audit">Filter zurücksetzen</Link> : null}
</div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Aktion</th>
<th scope="col">Entität</th>
<th scope="col">Benutzer</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody>
{eintraege.map((eintrag) => (
<tr key={eintrag.id}>
<td className="small">{formatDatumZeit(eintrag.erstelltAm)}</td>
<td className="pl-mono small">{eintrag.aktion}</td>
<td className="small">
{eintrag.entitaet}
{eintrag.entitaetId ? (
<div className="text-body-secondary pl-mono">{eintrag.entitaetId}</div>
) : null}
</td>
<td className="small">{eintrag.benutzer?.name ?? "System"}</td>
<td className="small text-body-secondary" style={{ maxWidth: "28rem" }}>
<span className="d-inline-block text-truncate w-100">
{eintrag.aenderung ? JSON.stringify(eintrag.aenderung) : "–"}
</span>
</td>
</tr>
))}
{eintraege.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
Keine Einträge gefunden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</>
);
}
@@ -1,21 +1,199 @@
import { ContentHeader } from "@/components/layout/ContentHeader"; import { ContentHeader } from "@/components/layout/ContentHeader";
import {
EmailFormular,
WebhookFormular,
type EmailWerte,
type WebhookWerte,
} from "@/components/benachrichtigungen/KanalFormulare";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import {
kanalLoeschen,
testSenden,
zusammenfassungJetztSenden,
zusammenfassungSpeichern,
} from "@/server/actions/benachrichtigungen";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
export const metadata = { title: "Benachrichtigungen" }; export const metadata = { title: "Benachrichtigungen" };
export default function BenachrichtigungenSeite() { export default async function BenachrichtigungenSeite() {
const [kanaele, protokoll, einstellung] = await Promise.all([
prisma.benachrichtigungskanal.findMany({ orderBy: { erstelltAm: "asc" } }),
prisma.benachrichtigung.findMany({ orderBy: { erstelltAm: "desc" }, take: 20 }),
prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } }),
]);
const emailKanal = kanaele.find((kanal) => kanal.typ === "EMAIL");
const webhookKanal = kanaele.find((kanal) => kanal.typ === "WEBHOOK");
const emailKonfiguration = emailKanal?.konfiguration as unknown as EmailKonfiguration | undefined;
const webhookKonfiguration = webhookKanal?.konfiguration as unknown as WebhookKonfiguration | undefined;
const zeitraum = (einstellung?.wert as { zeitraum?: string } | null)?.zeitraum ?? "aus";
const emailWerte: EmailWerte = {
id: emailKanal?.id ?? null,
name: emailKanal?.name ?? "E-Mail",
aktiv: emailKanal?.aktiv ?? true,
host: emailKonfiguration?.host ?? "",
port: emailKonfiguration?.port ?? 587,
benutzer: emailKonfiguration?.benutzer ?? "",
absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort),
};
const webhookWerte: WebhookWerte = {
id: webhookKanal?.id ?? null,
name: webhookKanal?.name ?? "Webhook",
aktiv: webhookKanal?.aktiv ?? true,
url: webhookKonfiguration?.url ?? "",
geheimnisGesetzt: Boolean(webhookKonfiguration?.geheimnis),
};
return ( return (
<> <>
<ContentHeader <ContentHeader
titel="Benachrichtigungen" titel="Benachrichtigungen"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benachrichtigungen" }]} brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benachrichtigungen" }]}
/> />
<div className="app-content"> <div className="app-content">
<div className="container-fluid"> <div className="container-fluid">
<Card titel="E-Mail und Webhook" rahmen="primary"> <Card
<p className="text-body-secondary mb-0"> titel="E-Mail (SMTP)"
SMTP (Microsoft 365), Webhooks und die tägliche Zusammenfassung werden in #012 umgesetzt. rahmen="primary"
werkzeuge={
emailKanal ? (
<div className="d-flex gap-2">
<form action={testSenden}>
<input type="hidden" name="id" value={emailKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-primary">
Test senden
</button>
</form>
<form action={kanalLoeschen}>
<input type="hidden" name="id" value={emailKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</div>
) : null
}
>
<p className="text-body-secondary">
Für Microsoft 365: Server <span className="pl-mono">smtp.office365.com</span>, Port 587,
Benutzer und Absender identisch mit dem Postfach. Das Passwort wird in der Datenbank gespeichert
und nie wieder angezeigt.
</p> </p>
<EmailFormular werte={emailWerte} />
</Card>
<Card
titel="Webhook"
rahmen="info"
werkzeuge={
webhookKanal ? (
<div className="d-flex gap-2">
<form action={testSenden}>
<input type="hidden" name="id" value={webhookKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-primary">
Test senden
</button>
</form>
<form action={kanalLoeschen}>
<input type="hidden" name="id" value={webhookKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</div>
) : null
}
>
<p className="text-body-secondary">
Verschickt JSON mit Betreff, Text und strukturierten Feldern – damit lassen sich Matrix, Zabbix
oder Home Assistant anbinden.
</p>
<WebhookFormular werte={webhookWerte} />
</Card>
<Card titel="Zusammenfassung" rahmen="info">
<form action={zusammenfassungSpeichern} className="row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label" htmlFor="zeitraum">
Regelmäßige Übersicht
</label>
<select id="zeitraum" name="zeitraum" className="form-select" defaultValue={zeitraum}>
<option value="aus">aus</option>
<option value="taeglich">täglich um 07:00 Uhr</option>
<option value="woechentlich">wöchentlich, montags um 07:00 Uhr</option>
</select>
</div>
<div className="col-md-3">
<button type="submit" className="btn btn-primary w-100">
Speichern
</button>
</div>
</form>
<form action={zusammenfassungJetztSenden} className="mt-3">
<input
type="hidden"
name="zeitraum"
value={zeitraum === "woechentlich" ? "woechentlich" : "taeglich"}
/>
<button type="submit" className="btn btn-outline-secondary btn-sm">
Zusammenfassung jetzt senden
</button>
</form>
</Card>
<Card titel="Versandprotokoll" rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Weg</th>
<th scope="col">Empfänger</th>
<th scope="col">Betreff</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{protokoll.map((eintrag) => (
<tr key={eintrag.id}>
<td className="small">{formatDatumZeit(eintrag.erstelltAm)}</td>
<td>{eintrag.kanal === "EMAIL" ? "E-Mail" : "Webhook"}</td>
<td className="small">{eintrag.empfaenger}</td>
<td className="small">{eintrag.betreff ?? "–"}</td>
<td>
{eintrag.status === "GESENDET" ? (
<Badge farbe="success">Gesendet</Badge>
) : eintrag.status === "FEHLGESCHLAGEN" ? (
<>
<Badge farbe="danger">Fehlgeschlagen</Badge>
<div className="small text-body-secondary">{eintrag.fehler}</div>
</>
) : (
<Badge farbe="neutral">Wartend</Badge>
)}
</td>
</tr>
))}
{protokoll.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
Noch nichts versendet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card> </Card>
</div> </div>
</div> </div>
+9 -4
View File
@@ -1,6 +1,9 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar"; import { Sidebar } from "@/components/layout/Sidebar";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts"; import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
/** /**
@@ -9,9 +12,11 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
*/ */
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const BENUTZER = "Florian";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
if (!sitzung) redirect("/login");
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]); const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
return ( return (
@@ -25,13 +30,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
hostId: alert.hostId, hostId: alert.hostId,
}))} }))}
suchEintraege={suche} suchEintraege={suche}
benutzer={BENUTZER} benutzer={sitzung.name}
/> />
<Sidebar <Sidebar
hostsAnzahl={zahlen.hostsGesamt} hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte} offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts} offeneWarnungen={zahlen.offeneAlerts}
benutzer={BENUTZER} benutzer={sitzung.name}
/> />
<main className="app-main">{children}</main> <main className="app-main">{children}</main>
<Footer /> <Footer />
+5 -1
View File
@@ -11,6 +11,7 @@ import {
import { reportSchema } from "@/server/agent/schema"; import { reportSchema } from "@/server/agent/schema";
import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service"; import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service";
import { alertsAbgleichen } from "@/server/alerts/abgleich"; import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { neueAlertsMelden } from "@/server/notifications/versand";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -34,7 +35,10 @@ export async function POST(request: Request) {
// Warnungen sofort neu bewerten – ein Report ist der häufigste Auslöser für Änderungen. // Warnungen sofort neu bewerten – ein Report ist der häufigste Auslöser für Änderungen.
// Schlägt das fehl, ist der Report trotzdem angenommen. // Schlägt das fehl, ist der Report trotzdem angenommen.
try { try {
await alertsAbgleichen(); const auswertung = await alertsAbgleichen();
if (auswertung.geoeffnet.length > 0) {
await neueAlertsMelden(auswertung.geoeffnet.map((alert) => alert.fingerprint));
}
} catch (fehler) { } catch (fehler) {
console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler); console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler);
} }
+10
View File
@@ -0,0 +1,10 @@
import { cookies } from "next/headers";
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
export const dynamic = "force-dynamic";
/** Abmelden per Link; löscht das Sitzungs-Cookie. */
export async function GET(request: Request) {
(await cookies()).delete(SITZUNGS_COOKIE);
return Response.redirect(new URL("/login", request.url), 302);
}
+41
View File
@@ -0,0 +1,41 @@
import { cookies } from "next/headers";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
export const dynamic = "force-dynamic";
/** Nimmt die Rückkehr vom Anmeldedienst entgegen und legt die Sitzung an. */
export async function GET(request: Request) {
const einstellungen = oidcEinstellungen();
const speicher = await cookies();
const pruefwert = speicher.get("pl_oidc_pruefwert")?.value;
const zustand = speicher.get("pl_oidc_zustand")?.value;
const ziel = speicher.get("pl_oidc_ziel")?.value ?? "/";
for (const name of ["pl_oidc_pruefwert", "pl_oidc_zustand", "pl_oidc_ziel"]) {
speicher.delete(name);
}
if (!einstellungen || !pruefwert || !zustand) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
try {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
});
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
} catch (fehler) {
console.error("OIDC-Rückkehr fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
export const dynamic = "force-dynamic";
/** Schickt den Browser zum Anmeldedienst und merkt sich PKCE-Prüfwert und Zustand im Cookie. */
export async function GET(request: Request) {
const einstellungen = oidcEinstellungen();
if (!einstellungen) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
const ziel = new URL(request.url).searchParams.get("ziel") ?? "/";
try {
const start = await anmeldungStarten(einstellungen);
const speicher = await cookies();
const optionen = {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 600,
};
speicher.set("pl_oidc_pruefwert", start.pruefwert, optionen);
speicher.set("pl_oidc_zustand", start.zustand, optionen);
speicher.set("pl_oidc_ziel", ziel.startsWith("/") ? ziel : "/", optionen);
return Response.redirect(start.url, 302);
} catch (fehler) {
console.error("OIDC-Start fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Einfache Zustandsprüfung für Container und Reverse Proxy. */
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({ ok: true, datenbank: "erreichbar", zeit: new Date().toISOString() });
} catch {
return Response.json({ ok: false, datenbank: "nicht erreichbar" }, { status: 503 });
}
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useActionState } from "react";
import { anmelden } from "@/server/actions/auth";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
export function AnmeldeFormular({ ziel }: { ziel: string }) {
const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER);
return (
<form action={aktion}>
<input type="hidden" name="ziel" value={ziel} />
{ergebnis.fehler ? (
<div className="alert alert-danger py-2" role="alert">
{ergebnis.fehler}
</div>
) : null}
<div className="mb-3">
<label className="form-label" htmlFor="email">
E-Mail-Adresse
</label>
<input
id="email"
name="email"
type="email"
className="form-control"
autoComplete="username"
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="passwort">
Passwort
</label>
<input
id="passwort"
name="passwort"
type="password"
className="form-control"
autoComplete="current-password"
required
/>
</div>
<button type="submit" className="btn btn-primary w-100 pl-touch-target" disabled={laeuft}>
{laeuft ? "Meldet an …" : "Anmelden"}
</button>
</form>
);
}
@@ -0,0 +1,217 @@
"use client";
import { useActionState } from "react";
import { emailKanalSpeichern, webhookKanalSpeichern } from "@/server/actions/benachrichtigungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) return <div className="alert alert-danger py-2">{ergebnis.fehler}</div>;
if (ergebnis.erfolg) return <div className="alert alert-success py-2">{ergebnis.erfolg}</div>;
return null;
}
export interface EmailWerte {
id: string | null;
name: string;
aktiv: boolean;
host: string;
port: number;
benutzer: string;
absender: string;
empfaenger: string;
passwortGesetzt: boolean;
}
export function EmailFormular({ werte }: { werte: EmailWerte }) {
const [ergebnis, aktion, laeuft] = useActionState(emailKanalSpeichern, LEER);
return (
<form action={aktion}>
{werte.id ? <input type="hidden" name="id" value={werte.id} /> : null}
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-name">
Bezeichnung
</label>
<input id="smtp-name" name="name" className="form-control" defaultValue={werte.name} />
</div>
<div className="col-md-5">
<label className="form-label" htmlFor="smtp-host">
SMTP-Server *
</label>
<input
id="smtp-host"
name="host"
className="form-control"
defaultValue={werte.host}
placeholder="smtp.office365.com"
required
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="smtp-port">
Port
</label>
<input
id="smtp-port"
name="port"
type="number"
className="form-control"
defaultValue={werte.port}
/>
<div className="form-text">587 = STARTTLS (Microsoft 365), 465 = SMTPS</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-benutzer">
Benutzer
</label>
<input
id="smtp-benutzer"
name="benutzer"
className="form-control"
defaultValue={werte.benutzer}
autoComplete="username"
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-passwort">
Passwort
</label>
<input
id="smtp-passwort"
name="passwort"
type="password"
className="form-control"
autoComplete="new-password"
placeholder={werte.passwortGesetzt ? "gespeichert – leer lassen zum Beibehalten" : ""}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-absender">
Absender
</label>
<input
id="smtp-absender"
name="absender"
className="form-control"
defaultValue={werte.absender}
placeholder="proxledger@example.de"
/>
</div>
<div className="col-md-9">
<label className="form-label" htmlFor="smtp-empfaenger">
Empfänger *
</label>
<input
id="smtp-empfaenger"
name="empfaenger"
className="form-control"
defaultValue={werte.empfaenger}
placeholder="florian@example.de, team@example.de"
required
/>
<div className="form-text">Mehrere Adressen mit Komma trennen.</div>
</div>
<div className="col-md-3 d-flex align-items-center">
<div className="form-check mt-4">
<input
className="form-check-input"
type="checkbox"
id="smtp-aktiv"
name="aktiv"
defaultChecked={werte.aktiv}
/>
<label className="form-check-label" htmlFor="smtp-aktiv">
aktiv
</label>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "E-Mail-Versand speichern"}
</button>
</form>
);
}
export interface WebhookWerte {
id: string | null;
name: string;
aktiv: boolean;
url: string;
geheimnisGesetzt: boolean;
}
export function WebhookFormular({ werte }: { werte: WebhookWerte }) {
const [ergebnis, aktion, laeuft] = useActionState(webhookKanalSpeichern, LEER);
return (
<form action={aktion}>
{werte.id ? <input type="hidden" name="id" value={werte.id} /> : null}
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="hook-name">
Bezeichnung
</label>
<input id="hook-name" name="name" className="form-control" defaultValue={werte.name} />
</div>
<div className="col-md-8">
<label className="form-label" htmlFor="hook-url">
Adresse *
</label>
<input
id="hook-url"
name="url"
className="form-control"
defaultValue={werte.url}
placeholder="https://matrix.example.de/hook/abc"
required
/>
</div>
<div className="col-md-9">
<label className="form-label" htmlFor="hook-geheimnis">
Geheimnis für die Signatur
</label>
<input
id="hook-geheimnis"
name="geheimnis"
type="password"
className="form-control"
autoComplete="new-password"
placeholder={werte.geheimnisGesetzt ? "gespeichert – leer lassen zum Beibehalten" : "optional"}
/>
<div className="form-text">
Wird als <span className="pl-mono">X-ProxLedger-Signatur: sha256=…</span> mitgeschickt.
</div>
</div>
<div className="col-md-3 d-flex align-items-center">
<div className="form-check mt-4">
<input
className="form-check-input"
type="checkbox"
id="hook-aktiv"
name="aktiv"
defaultChecked={werte.aktiv}
/>
<label className="form-check-label" htmlFor="hook-aktiv">
aktiv
</label>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Webhook speichern"}
</button>
</form>
);
}
+3 -1
View File
@@ -214,7 +214,9 @@ export function Header({
<Link href="/einstellungen/system" className="dropdown-item"> <Link href="/einstellungen/system" className="dropdown-item">
Einstellungen Einstellungen
</Link> </Link>
<span className="dropdown-item text-body-secondary">Abmelden (ab #013)</span> <a className="dropdown-item" href="/api/auth/abmelden">
Abmelden
</a>
</div> </div>
</li> </li>
</ul> </ul>
+1
View File
@@ -58,6 +58,7 @@ export function Sidebar({
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" }, { href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" }, { href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" }, { href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" }, { href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
]; ];
+64
View File
@@ -0,0 +1,64 @@
import { SignJWT, jwtVerify } from "jose";
/**
* Sitzungen als signiertes Token im Cookie – kein Serverzustand nötig.
* Der Schlüssel kommt aus `APP_SECRET`; ohne ihn startet die Anmeldung nicht.
*/
export const SITZUNGS_COOKIE = "pl_sitzung";
export const SITZUNGSDAUER_STUNDEN = 12;
export interface Sitzung {
benutzerId: string;
email: string;
name: string;
/** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */
herkunft: "passwort" | "oidc";
}
function schluessel(): Uint8Array {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new Error(
"APP_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen). Beispiel: openssl rand -base64 48",
);
}
return new TextEncoder().encode(geheimnis);
}
export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
return new SignJWT({ ...sitzung })
.setProtectedHeader({ alg: "HS256" })
.setSubject(sitzung.benutzerId)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`)
.sign(schluessel());
}
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
export async function sitzungLesen(token: string | undefined): Promise<Sitzung | null> {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
if (typeof payload.benutzerId !== "string" || typeof payload.email !== "string") return null;
return {
benutzerId: payload.benutzerId,
email: payload.email,
name: typeof payload.name === "string" ? payload.name : payload.email,
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
};
} catch {
return null;
}
}
export function cookieOptionen() {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse, type NextRequest } from "next/server";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/**
* Schützt die Oberfläche: Ohne gültige Sitzung führt jeder Weg zur Anmeldung.
* Die Agent-API bleibt bewusst außen vor – sie authentifiziert sich mit eigenen Tokens.
* (In Next 16 heißt diese Datei `proxy.ts`, früher `middleware.ts`.)
*/
const OEFFENTLICH = [
"/login",
"/api/auth",
"/api/agent",
"/api/gesundheit",
"/install.sh",
"/agent.sh",
"/favicon.ico",
"/icon.svg",
];
export async function proxy(request: NextRequest) {
const pfad = request.nextUrl.pathname;
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
return NextResponse.next();
}
const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value);
if (sitzung) return NextResponse.next();
const ziel = new URL("/login", request.url);
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
return NextResponse.redirect(ziel);
}
export const config = {
// Statische Dateien und Bilder brauchen keine Prüfung.
matcher: ["/((?!_next/static|_next/image|.*\\.(?:png|jpg|svg|ico|woff2)$).*)"],
};
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { clientIp } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import type { AktionsErgebnis } from "./kunden";
/** Anmeldung mit E-Mail und Passwort (Rückfallweg, wenn OIDC nicht erreichbar ist). */
export async function anmelden(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
const email = formular.get("email")?.toString().trim() ?? "";
const passwort = formular.get("passwort")?.toString() ?? "";
const ziel = formular.get("ziel")?.toString() || "/";
if (!email || !passwort) {
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
}
try {
const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
});
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
// Nur eigene Pfade als Ziel zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
redirect(ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/");
}
export async function abmelden() {
const speicher = await cookies();
speicher.delete(SITZUNGS_COOKIE);
await protokolliere("abmeldung", "Benutzer", null, {});
redirect("/login");
}
+158
View File
@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten";
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
import type { AktionsErgebnis } from "./kunden";
function neuLaden() {
revalidatePath("/einstellungen/benachrichtigungen");
}
function appUrl() {
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
}
/** Legt den E-Mail-Kanal an oder ändert ihn. Ein leeres Passwortfeld lässt das gespeicherte unangetastet. */
export async function emailKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587);
const empfaenger = (formular.get("empfaenger")?.toString() ?? "")
.split(/[,;\s]+/)
.map((eintrag) => eintrag.trim())
.filter(Boolean);
if (!host) return { fehler: "Bitte den SMTP-Server angeben." };
if (!Number.isInteger(port) || port < 1 || port > 65535) return { fehler: "Ungültiger Port." };
if (empfaenger.length === 0) return { fehler: "Bitte mindestens einen Empfänger angeben." };
const bisher = id
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
EmailKonfiguration | undefined)
: undefined;
const passwortEingabe = formular.get("passwort")?.toString() ?? "";
const konfiguration: EmailKonfiguration = {
host,
port,
secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
passwort: passwortEingabe || (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger,
};
const daten = {
typ: "EMAIL" as const,
name: formular.get("name")?.toString().trim() || "E-Mail",
aktiv: formular.get("aktiv") === "on",
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
};
const kanal = id
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
: await prisma.benachrichtigungskanal.create({ data: daten });
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
typ: "EMAIL",
host,
empfaenger,
});
neuLaden();
return { erfolg: "E-Mail-Versand gespeichert." };
}
export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? "";
if (!/^https?:\/\//.test(url)) return { fehler: "Bitte eine vollständige Adresse mit https:// angeben." };
const bisher = id
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
WebhookKonfiguration | undefined)
: undefined;
const geheimnisEingabe = formular.get("geheimnis")?.toString() ?? "";
const konfiguration: WebhookKonfiguration = {
url,
geheimnis: geheimnisEingabe || bisher?.geheimnis,
};
const daten = {
typ: "WEBHOOK" as const,
name: formular.get("name")?.toString().trim() || "Webhook",
aktiv: formular.get("aktiv") === "on",
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
};
const kanal = id
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
: await prisma.benachrichtigungskanal.create({ data: daten });
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
typ: "WEBHOOK",
url,
});
neuLaden();
return { erfolg: "Webhook gespeichert." };
}
export async function kanalLoeschen(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.benachrichtigungskanal.delete({ where: { id } });
await protokolliere("benachrichtigungskanal.geloescht", "Benachrichtigungskanal", id, {});
neuLaden();
}
/** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise<void> {
const id = formular.get("id")?.toString();
if (!id) return;
try {
await ueberEinenKanalSenden(id, testNachricht(appUrl()));
} catch (fehler) {
// Das Ergebnis steht im Versandprotokoll auf der Seite – dort ist auch der Fehlertext zu sehen.
console.error("Testversand fehlgeschlagen:", fehler);
}
neuLaden();
}
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) {
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({
where: { schluessel: "zusammenfassung" },
update: { wert: { zeitraum } },
create: { schluessel: "zusammenfassung", wert: { zeitraum } },
});
await protokolliere("zusammenfassung.geaendert", "Einstellung", "zusammenfassung", { zeitraum });
neuLaden();
}
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum);
neuLaden();
}
+17 -2
View File
@@ -1,17 +1,32 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/** /**
* Schreibt einen Eintrag ins Audit-Log. Solange es keine Anmeldung gibt (#013), * Schreibt einen Eintrag ins Audit-Log. Läuft die Aktion in einer Anfrage, wird der angemeldete
* bleibt `benutzerId` leer und die Änderung gilt als vom System ausgelöst. * Benutzer vermerkt; im Worker oder in Skripten bleibt das Feld leer („System“).
*/ */
async function angemeldeterBenutzer(): Promise<string | null> {
try {
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
return sitzung?.benutzerId ?? null;
} catch {
// Außerhalb einer Anfrage gibt es keine Cookies – das ist kein Fehler.
return null;
}
}
export async function protokolliere( export async function protokolliere(
aktion: string, aktion: string,
entitaet: string, entitaet: string,
entitaetId: string | null, entitaetId: string | null,
aenderung?: Record<string, unknown>, aenderung?: Record<string, unknown>,
) { ) {
const benutzerId = await angemeldeterBenutzer();
await prisma.auditEintrag.create({ await prisma.auditEintrag.create({
data: { data: {
benutzerId,
aktion, aktion,
entitaet, entitaet,
entitaetId, entitaetId,
+77
View File
@@ -0,0 +1,77 @@
import { verify } from "@node-rs/argon2";
import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */
export const anmeldeRateLimit = new RateLimit(5, 5 / 900);
export class AnmeldeFehler extends Error {}
/** Prüft E-Mail und Passwort. Fehlermeldungen verraten bewusst nicht, welcher Teil falsch war. */
export async function mitPasswortAnmelden(
email: string,
passwort: string,
quelle: string,
): Promise<BenutzerModel> {
const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
}
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer?.passwortHash || !benutzer.aktiv) {
await protokolliere("anmeldung.fehlgeschlagen", "Benutzer", benutzer?.id ?? null, { email });
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
const stimmt = await verify(benutzer.passwortHash, passwort).catch(() => false);
if (!stimmt) {
await protokolliere("anmeldung.fehlgeschlagen", "Benutzer", benutzer.id, { email });
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passwort" });
return benutzer;
}
/**
* 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.
*/
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim();
const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
(await prisma.benutzer.findUnique({ where: { email: normalisiert } }));
if (vorhanden && !vorhanden.aktiv) {
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
const benutzer = vorhanden
? await prisma.benutzer.update({
where: { id: vorhanden.id },
data: { oidcSubject: subject, name: name || vorhanden.name, letzterLoginAm: new Date() },
})
: await prisma.benutzer.create({
data: {
email: normalisiert,
name: name || normalisiert,
oidcSubject: subject,
letzterLoginAm: new Date(),
},
});
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, {
weg: "oidc",
neuAngelegt: vorhanden === null,
});
return benutzer;
}
+100
View File
@@ -0,0 +1,100 @@
import * as client from "openid-client";
/**
* Anmeldung über OIDC (getestet mit authentik). Die Einrichtung erfolgt über Umgebungsvariablen,
* damit Zugangsdaten nicht in der Datenbank liegen.
*/
export interface OidcEinstellungen {
ausstellerUrl: string;
clientId: string;
clientSecret: string;
/** Anzeigename auf dem Anmeldeknopf. */
bezeichnung: string;
}
export function oidcEinstellungen(): OidcEinstellungen | null {
const ausstellerUrl = process.env.OIDC_ISSUER;
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
if (!ausstellerUrl || !clientId || !clientSecret) return null;
return {
ausstellerUrl,
clientId,
clientSecret,
bezeichnung: process.env.OIDC_NAME ?? "authentik",
};
}
export function rueckkehrAdresse(): string {
const basis = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
return `${basis}/api/auth/oidc/callback`;
}
async function konfiguration(einstellungen: OidcEinstellungen) {
return client.discovery(
new URL(einstellungen.ausstellerUrl),
einstellungen.clientId,
einstellungen.clientSecret,
);
}
export interface AnmeldeStart {
url: string;
pruefwert: string;
zustand: string;
}
/** Baut die Adresse beim Anbieter samt PKCE-Prüfwert. */
export async function anmeldungStarten(einstellungen: OidcEinstellungen): Promise<AnmeldeStart> {
const konfig = await konfiguration(einstellungen);
const pruefwert = client.randomPKCECodeVerifier();
const herausforderung = await client.calculatePKCECodeChallenge(pruefwert);
const zustand = client.randomState();
const url = client.buildAuthorizationUrl(konfig, {
redirect_uri: rueckkehrAdresse(),
scope: "openid email profile",
code_challenge: herausforderung,
code_challenge_method: "S256",
state: zustand,
});
return { url: url.href, pruefwert, zustand };
}
export interface OidcBenutzer {
subject: string;
email: string;
name: string;
}
/** Löst den Rückkehr-Code beim Anbieter ein und liest die Benutzerangaben. */
export async function anmeldungAbschliessen(
einstellungen: OidcEinstellungen,
aktuelleUrl: URL,
pruefwert: string,
zustand: string,
): Promise<OidcBenutzer> {
const konfig = await konfiguration(einstellungen);
const tokens = await client.authorizationCodeGrant(konfig, aktuelleUrl, {
pkceCodeVerifier: pruefwert,
expectedState: zustand,
});
const angaben = tokens.claims();
if (!angaben?.sub) throw new Error("Der Anbieter hat keine Benutzerkennung geliefert.");
const email = typeof angaben.email === "string" ? angaben.email : "";
if (!email) throw new Error("Der Anbieter hat keine E-Mail-Adresse geliefert.");
const name =
(typeof angaben.name === "string" && angaben.name) ||
(typeof angaben.preferred_username === "string" && angaben.preferred_username) ||
email;
return { subject: angaben.sub, email, name };
}
+164
View File
@@ -0,0 +1,164 @@
import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
* an den sich Matrix, Zabbix oder Home Assistant anbinden lassen.
*/
export interface EmailKonfiguration {
host: string;
port: number;
/** true = SMTPS (Port 465), false = STARTTLS (Port 587, so macht es Microsoft 365). */
secure: boolean;
benutzer: string;
passwort: string;
absender: string;
empfaenger: string[];
}
export interface WebhookKonfiguration {
url: string;
/** Optionales Geheimnis für die Signatur im Header `X-ProxLedger-Signatur`. */
geheimnis?: string;
kopfzeilen?: Record<string, string>;
}
export interface Nachricht {
betreff: string;
text: string;
html: string;
/** Strukturierte Fassung für den Webhook. */
daten: Record<string, unknown>;
}
export class VersandFehler extends Error {}
/** Verschickt eine Nachricht per SMTP. */
export async function perEmailSenden(konfiguration: EmailKonfiguration, nachricht: Nachricht): Promise<void> {
if (konfiguration.empfaenger.length === 0) {
throw new VersandFehler("Kein Empfänger hinterlegt.");
}
const transport = nodemailer.createTransport({
host: konfiguration.host,
port: konfiguration.port,
secure: konfiguration.secure,
auth: konfiguration.benutzer ? { user: konfiguration.benutzer, pass: konfiguration.passwort } : undefined,
// Microsoft 365 verlangt STARTTLS auf Port 587; `requireTLS` verhindert unverschlüsselten Versand.
requireTLS: !konfiguration.secure,
});
try {
await transport.sendMail({
from: konfiguration.absender,
to: konfiguration.empfaenger.join(", "),
subject: nachricht.betreff,
text: nachricht.text,
html: nachricht.html,
});
} catch (fehler) {
throw new VersandFehler(fehler instanceof Error ? fehler.message : "SMTP-Versand fehlgeschlagen.");
} finally {
transport.close();
}
}
/** Verschickt eine Nachricht an einen Webhook; bei Geheimnis mit HMAC-Signatur. */
export async function perWebhookSenden(
konfiguration: WebhookKonfiguration,
nachricht: Nachricht,
): Promise<void> {
const koerper = JSON.stringify({
betreff: nachricht.betreff,
text: nachricht.text,
...nachricht.daten,
});
const kopfzeilen: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "ProxLedger",
...konfiguration.kopfzeilen,
};
if (konfiguration.geheimnis) {
kopfzeilen["X-ProxLedger-Signatur"] = `sha256=${createHmac("sha256", konfiguration.geheimnis)
.update(koerper)
.digest("hex")}`;
}
let antwort: Response;
try {
antwort = await fetch(konfiguration.url, { method: "POST", headers: kopfzeilen, body: koerper });
} catch (fehler) {
throw new VersandFehler(fehler instanceof Error ? fehler.message : "Webhook nicht erreichbar.");
}
if (!antwort.ok) {
throw new VersandFehler(`Webhook antwortet mit HTTP ${antwort.status}.`);
}
}
export interface KanalEintrag {
id: string;
typ: NotificationKanal;
name: string;
aktiv: boolean;
konfiguration: EmailKonfiguration | WebhookKonfiguration;
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({
id: kanal.id,
typ: kanal.typ,
name: kanal.name,
aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration,
}));
}
/** Verschickt über einen Kanal und schreibt das Ergebnis ins Versandprotokoll. */
export async function ueberKanalSenden(
kanal: KanalEintrag,
nachricht: Nachricht,
alertId: string | null = null,
): Promise<void> {
const empfaenger =
kanal.typ === "EMAIL"
? (kanal.konfiguration as EmailKonfiguration).empfaenger.join(", ")
: (kanal.konfiguration as WebhookKonfiguration).url;
try {
if (kanal.typ === "EMAIL") {
await perEmailSenden(kanal.konfiguration as EmailKonfiguration, nachricht);
} else {
await perWebhookSenden(kanal.konfiguration as WebhookKonfiguration, nachricht);
}
await prisma.benachrichtigung.create({
data: {
alertId,
kanal: kanal.typ,
empfaenger,
betreff: nachricht.betreff,
status: "GESENDET",
versendetAm: new Date(),
},
});
} catch (fehler) {
await prisma.benachrichtigung.create({
data: {
alertId,
kanal: kanal.typ,
empfaenger,
betreff: nachricht.betreff,
status: "FEHLGESCHLAGEN",
fehler: fehler instanceof Error ? fehler.message : String(fehler),
},
});
throw fehler;
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { AlertSchwere } from "@/generated/prisma/enums";
import { formatDatum } from "@/lib/format";
import type { Nachricht } from "./kanaele";
/**
* Textbausteine für Benachrichtigungen. Bewusst schlichtes HTML mit Inline-Stilen –
* E-Mail-Programme ignorieren Stylesheets.
*/
export interface AlertNachrichtDaten {
id: string;
regel: string;
schwere: AlertSchwere;
titel: string;
beschreibung: string;
hostId: string | null;
}
const FARBE: Record<AlertSchwere, string> = {
GEFAHR: "#C82333",
WARNUNG: "#FFC107",
INFO: "#138496",
};
const SCHWERE_TEXT: Record<AlertSchwere, string> = {
GEFAHR: "Gefahr",
WARNUNG: "Warnung",
INFO: "Hinweis",
};
function rahmen(inhalt: string, appUrl: string): string {
return `<div style="font-family:'Source Sans 3',Arial,sans-serif;font-size:15px;color:#1F2D3D;max-width:640px">
<div style="border-bottom:3px solid #1E2A44;padding-bottom:8px;margin-bottom:16px">
<span style="font-size:20px;color:#1E2A44;font-weight:600">ProxLedger</span>
<span style="color:#6C757D"> · Proxmox Subscription Dashboard</span>
</div>
${inhalt}
<p style="color:#6C757D;font-size:13px;margin-top:24px">
<a href="${appUrl}" style="color:#007BFF">Dashboard öffnen</a>
</p>
</div>`;
}
/** Eine einzelne neue Warnung. */
export function alertNachricht(alert: AlertNachrichtDaten, appUrl: string): Nachricht {
const betreff = `[ProxLedger] ${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}`;
const text = `${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}\n${alert.beschreibung}\n\nRegel: ${alert.regel}\n${appUrl}/warnungen`;
const html = rahmen(
`<p style="margin:0 0 4px"><span style="display:inline-block;padding:2px 8px;border-radius:4px;background:${FARBE[alert.schwere]};color:${
alert.schwere === "WARNUNG" ? "#1F2D3D" : "#FFFFFF"
};font-size:13px">${SCHWERE_TEXT[alert.schwere]}</span></p>
<h2 style="margin:8px 0;font-size:18px">${alert.titel}</h2>
<p style="margin:0 0 8px">${alert.beschreibung}</p>
<p style="color:#6C757D;font-size:13px;margin:0">Regel: ${alert.regel}</p>`,
appUrl,
);
return {
betreff,
text,
html,
daten: {
art: "warnung",
alertId: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
},
};
}
export interface ZusammenfassungDaten {
zeitraum: "taeglich" | "woechentlich";
offeneAlerts: AlertNachrichtDaten[];
neueAlerts: AlertNachrichtDaten[];
baldAblaufend: { hostname: string; kunde: string; datum: Date; tage: number }[];
offeneRechnungspunkte: number;
hostsOhneMeldung: number;
}
/** Tägliche oder wöchentliche Zusammenfassung. */
export function zusammenfassungNachricht(daten: ZusammenfassungDaten, appUrl: string): Nachricht {
const titel = daten.zeitraum === "taeglich" ? "Tagesübersicht" : "Wochenübersicht";
const betreff = `[ProxLedger] ${titel}: ${daten.offeneAlerts.length} offene Warnungen`;
const zeilen = [
`${titel} vom ${formatDatum(new Date())}`,
"",
`Offene Warnungen: ${daten.offeneAlerts.length} (davon neu: ${daten.neueAlerts.length})`,
`Offene Rechnungspunkte: ${daten.offeneRechnungspunkte}`,
`Hosts ohne Meldung: ${daten.hostsOhneMeldung}`,
"",
"Bald ablaufende Subscriptions:",
...(daten.baldAblaufend.length > 0
? daten.baldAblaufend.map(
(e) => ` · ${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
)
: [" keine in den nächsten 30 Tagen"]),
"",
"Offene Warnungen:",
...(daten.offeneAlerts.length > 0
? daten.offeneAlerts.map((a) => ` · [${SCHWERE_TEXT[a.schwere]}] ${a.titel} – ${a.beschreibung}`)
: [" keine"]),
"",
`${appUrl}/warnungen`,
];
const liste = (eintraege: string[]) =>
eintraege.length > 0
? `<ul style="margin:4px 0 16px;padding-left:20px">${eintraege.map((e) => `<li>${e}</li>`).join("")}</ul>`
: `<p style="color:#6C757D;margin:4px 0 16px">keine</p>`;
const html = rahmen(
`<h2 style="margin:0 0 12px;font-size:18px">${titel}</h2>
<table style="border-collapse:collapse;margin-bottom:16px">
<tr><td style="padding:2px 16px 2px 0">Offene Warnungen</td><td><strong>${daten.offeneAlerts.length}</strong> (neu: ${daten.neueAlerts.length})</td></tr>
<tr><td style="padding:2px 16px 2px 0">Offene Rechnungspunkte</td><td><strong>${daten.offeneRechnungspunkte}</strong></td></tr>
<tr><td style="padding:2px 16px 2px 0">Hosts ohne Meldung</td><td><strong>${daten.hostsOhneMeldung}</strong></td></tr>
</table>
<h3 style="font-size:15px;margin:0">Bald ablaufende Subscriptions</h3>
${liste(
daten.baldAblaufend.map(
(e) => `${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
),
)}
<h3 style="font-size:15px;margin:0">Offene Warnungen</h3>
${liste(
daten.offeneAlerts.map(
(a) =>
`<span style="color:${FARBE[a.schwere]}">${SCHWERE_TEXT[a.schwere]}</span> – ${a.titel}: ${a.beschreibung}`,
),
)}`,
appUrl,
);
return {
betreff,
text: zeilen.join("\n"),
html,
daten: {
art: "zusammenfassung",
zeitraum: daten.zeitraum,
offeneAlerts: daten.offeneAlerts.length,
neueAlerts: daten.neueAlerts.length,
offeneRechnungspunkte: daten.offeneRechnungspunkte,
hostsOhneMeldung: daten.hostsOhneMeldung,
baldAblaufend: daten.baldAblaufend.map((e) => ({
hostname: e.hostname,
kunde: e.kunde,
datum: e.datum.toISOString().slice(0, 10),
tage: e.tage,
})),
},
};
}
/** Testnachricht für den Knopf „Test senden“. */
export function testNachricht(appUrl: string): Nachricht {
return {
betreff: "[ProxLedger] Testnachricht",
text: `Diese Testnachricht bestätigt, dass der Versand funktioniert.\n${appUrl}`,
html: rahmen(
`<h2 style="margin:0 0 8px;font-size:18px">Testnachricht</h2>
<p style="margin:0">Der Versand funktioniert.</p>`,
appUrl,
),
daten: { art: "test" },
};
}
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/**
* Versandlogik: Wer bekommt wann was?
* Grundregel aus dem Auftrag: keine doppelten Benachrichtigungen für denselben offenen Alert.
* Das stellt die Prüfung gegen das Versandprotokoll sicher – auch wenn eine Auswertung mehrfach läuft.
*/
function appUrl(): string {
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
}
/** Wurde zu diesem Alert schon erfolgreich benachrichtigt? */
async function bereitsGemeldet(alertId: string): Promise<boolean> {
const vorhanden = await prisma.benachrichtigung.findFirst({
where: { alertId, status: "GESENDET" },
});
return vorhanden !== null;
}
export interface VersandErgebnis {
gesendet: number;
uebersprungen: number;
fehlgeschlagen: number;
}
/** Verschickt Benachrichtigungen für neu geöffnete Warnungen. */
export async function neueAlertsMelden(fingerprints: string[]): Promise<VersandErgebnis> {
if (fingerprints.length === 0) return { gesendet: 0, uebersprungen: 0, fehlgeschlagen: 0 };
const kanaele = await aktiveKanaele();
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: fingerprints.length, fehlgeschlagen: 0 };
const alerts = await prisma.alert.findMany({
where: { fingerprint: { in: fingerprints }, erledigtAm: null },
});
let gesendet = 0;
let uebersprungen = 0;
let fehlgeschlagen = 0;
for (const alert of alerts) {
if (await bereitsGemeldet(alert.id)) {
uebersprungen += 1;
continue;
}
const nachricht = alertNachricht(
{
id: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
},
appUrl(),
);
for (const kanal of kanaele) {
try {
await ueberKanalSenden(kanal, nachricht, alert.id);
gesendet += 1;
} catch (fehler) {
fehlgeschlagen += 1;
console.error(`Versand über ${kanal.name} fehlgeschlagen:`, fehler);
}
}
}
return { gesendet, uebersprungen, fehlgeschlagen };
}
/** Stellt die Zusammenfassung zusammen und verschickt sie. */
export async function zusammenfassungSenden(
zeitraum: "taeglich" | "woechentlich",
jetzt: Date = new Date(),
): Promise<VersandErgebnis> {
const kanaele = await aktiveKanaele();
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: 1, fehlgeschlagen: 0 };
const seit = new Date(jetzt.getTime() - (zeitraum === "taeglich" ? 1 : 7) * 86_400_000);
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const [offene, neue, ablaufend, zahlen] = await Promise.all([
prisma.alert.findMany({ where: { erledigtAm: null }, orderBy: { schwere: "asc" } }),
prisma.alert.findMany({ where: { erledigtAm: null, geoeffnetAm: { gte: seit } } }),
prisma.host.findMany({
where: { subNextduedate: { gte: jetzt, lte: in30Tagen } },
orderBy: { subNextduedate: "asc" },
select: { hostname: true, subNextduedate: true, kunde: { select: { name: true } } },
}),
kennzahlen(jetzt),
]);
const alsDaten = (alert: (typeof offene)[number]): AlertNachrichtDaten => ({
id: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
});
const nachricht = zusammenfassungNachricht(
{
zeitraum,
offeneAlerts: offene.map(alsDaten),
neueAlerts: neue.map(alsDaten),
baldAblaufend: ablaufend.map((host) => ({
hostname: host.hostname,
kunde: host.kunde?.name ?? "Nicht zugeordnet",
datum: host.subNextduedate!,
tage: Math.round((host.subNextduedate!.getTime() - jetzt.getTime()) / 86_400_000),
})),
offeneRechnungspunkte: zahlen.offeneRechnungspunkte,
hostsOhneMeldung: zahlen.ohneMeldung,
},
appUrl(),
);
let gesendet = 0;
let fehlgeschlagen = 0;
for (const kanal of kanaele) {
try {
await ueberKanalSenden(kanal, nachricht, null);
gesendet += 1;
} catch {
fehlgeschlagen += 1;
}
}
return { gesendet, uebersprungen: 0, fehlgeschlagen };
}
/** Verschickt eine Nachricht über genau einen Kanal – für „Test senden“. */
export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachricht): Promise<void> {
const kanaele = await aktiveKanaele();
const kanal =
kanaele.find((eintrag) => eintrag.id === kanalId) ??
(await (async () => {
const eintrag = await prisma.benachrichtigungskanal.findUnique({ where: { id: kanalId } });
if (!eintrag) return undefined;
return {
id: eintrag.id,
typ: eintrag.typ,
name: eintrag.name,
aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never,
};
})());
if (!kanal) throw new Error("Kanal nicht gefunden.");
await ueberKanalSenden(kanal, nachricht, null);
}
+42 -7
View File
@@ -1,16 +1,19 @@
import { Cron } from "croner"; import { Cron } from "croner";
import "dotenv/config"; import "dotenv/config";
import { prisma } from "@/lib/db";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich"; import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
/** /**
* Hintergrundprozess: wertet die Warnregeln regelmäßig aus, unabhängig davon, ob jemand * Hintergrundprozess: wertet die Warnregeln regelmäßig aus, verschickt Benachrichtigungen für
* das Dashboard geöffnet hat. Läuft als eigener Dienst neben der Web-Anwendung (siehe #014). * neue Warnungen und schickt die Zusammenfassung. Läuft als eigener Dienst neben der Web-Anwendung (#014).
* *
* npm run worker * npm run worker
*/ */
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
function protokoll(nachricht: string) { function protokoll(nachricht: string) {
console.log(`[${new Date().toISOString()}] ${nachricht}`); console.log(`[${new Date().toISOString()}] ${nachricht}`);
@@ -22,27 +25,59 @@ async function durchlauf() {
protokoll( protokoll(
`Warnregeln geprüft: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`, `Warnregeln geprüft: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`,
); );
for (const alert of ergebnis.geoeffnet) {
protokoll(` neu: ${alert.titel} – ${alert.beschreibung}`); if (ergebnis.geoeffnet.length > 0) {
const versand = await neueAlertsMelden(ergebnis.geoeffnet.map((alert) => alert.fingerprint));
protokoll(
`Benachrichtigungen: ${versand.gesendet} gesendet, ${versand.uebersprungen} übersprungen, ${versand.fehlgeschlagen} fehlgeschlagen.`,
);
} }
} catch (fehler) { } catch (fehler) {
console.error("Auswertung fehlgeschlagen:", fehler); console.error("Auswertung fehlgeschlagen:", fehler);
} }
} }
/** Zeitraum der Zusammenfassung aus den Einstellungen (täglich, wöchentlich oder aus). */
async function zusammenfassungEinstellung(): Promise<"taeglich" | "woechentlich" | "aus"> {
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } });
const wert = (eintrag?.wert as { zeitraum?: string } | null)?.zeitraum;
return wert === "taeglich" || wert === "woechentlich" ? wert : "aus";
}
async function zusammenfassung() {
try {
const zeitraum = await zusammenfassungEinstellung();
if (zeitraum === "aus") return;
// Wöchentlich nur montags verschicken.
if (zeitraum === "woechentlich" && new Date().getDay() !== 1) return;
const ergebnis = await zusammenfassungSenden(zeitraum);
protokoll(
`Zusammenfassung (${zeitraum}): ${ergebnis.gesendet} gesendet, ${ergebnis.fehlgeschlagen} fehlgeschlagen.`,
);
} catch (fehler) {
console.error("Zusammenfassung fehlgeschlagen:", fehler);
}
}
async function start() { async function start() {
await warnregelnSicherstellen(); await warnregelnSicherstellen();
protokoll(`Worker gestartet, Zeitplan "${ZEITPLAN}" (${ZEITZONE}).`); protokoll(
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_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.
await durchlauf(); await durchlauf();
const aufgabe = new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf); const aufgaben = [
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
];
for (const signal of ["SIGINT", "SIGTERM"] as const) { for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => { process.on(signal, () => {
protokoll("Worker wird beendet."); protokoll("Worker wird beendet.");
aufgabe.stop(); for (const aufgabe of aufgaben) aufgabe.stop();
process.exit(0); process.exit(0);
}); });
} }
+156
View File
@@ -0,0 +1,156 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { hash } from "@node-rs/argon2";
import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
import { AnmeldeFehler, anmeldeRateLimit, mitOidcAnmelden, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!";
describe("Sitzungen", () => {
beforeEach(() => {
process.env.APP_SECRET = GEHEIMNIS;
});
it("erzeugt ein Token und liest es wieder", async () => {
const token = await sitzungErzeugen({
benutzerId: "b1",
email: "admin@example.de",
name: "Florian",
herkunft: "passwort",
});
const sitzung = await sitzungLesen(token);
expect(sitzung).toEqual({
benutzerId: "b1",
email: "admin@example.de",
name: "Florian",
herkunft: "passwort",
});
});
it("weist ein verändertes oder fremd signiertes Token ab", async () => {
const token = await sitzungErzeugen({
benutzerId: "b1",
email: "admin@example.de",
name: "Florian",
herkunft: "passwort",
});
expect(await sitzungLesen(`${token}x`)).toBeNull();
process.env.APP_SECRET = "ein-ganz-anderes-geheimnis-mit-32-zeichen!";
expect(await sitzungLesen(token)).toBeNull();
});
it("gibt bei fehlendem Token null zurück", async () => {
expect(await sitzungLesen(undefined)).toBeNull();
expect(await sitzungLesen("")).toBeNull();
});
it("verlangt einen ausreichend langen Schlüssel", async () => {
process.env.APP_SECRET = "zu-kurz";
await expect(
sitzungErzeugen({ benutzerId: "b1", email: "a@b.de", name: "A", herkunft: "passwort" }),
).rejects.toThrow(/APP_SECRET/);
});
it("setzt das Cookie nur über HTTPS, wenn produktiv gebaut wurde", () => {
expect(cookieOptionen().httpOnly).toBe(true);
expect(cookieOptionen().sameSite).toBe("lax");
});
});
describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
beforeEach(async () => {
await datenbankLeeren();
anmeldeRateLimit.zuruecksetzen();
process.env.APP_SECRET = GEHEIMNIS;
});
afterAll(async () => {
await testPrisma.$disconnect();
});
async function admin(passwort = "geheim1234") {
return testPrisma.benutzer.create({
data: {
email: "admin@example.de",
name: "Florian",
passwortHash: await hash(passwort),
},
});
}
it("meldet mit richtigem Passwort an und vermerkt den Zeitpunkt", async () => {
const benutzer = await admin();
const angemeldet = await mitPasswortAnmelden("admin@example.de", "geheim1234", "1.2.3.4");
expect(angemeldet.id).toBe(benutzer.id);
const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } });
expect(danach.letzterLoginAm).not.toBeNull();
const protokoll = await testPrisma.auditEintrag.findFirst({ where: { aktion: "anmeldung.erfolgreich" } });
expect(protokoll).not.toBeNull();
});
it("lehnt falsches Passwort ab und protokolliert den Versuch", async () => {
await admin();
await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler);
const protokoll = await testPrisma.auditEintrag.findFirst({ where: { aktion: "anmeldung.fehlgeschlagen" } });
expect(protokoll).not.toBeNull();
});
it("nennt bei unbekannter Adresse denselben Grund wie bei falschem Passwort", async () => {
await admin();
const unbekannt = await mitPasswortAnmelden("niemand@example.de", "x", "1.2.3.4").catch((f) => f);
const falsch = await mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4").catch((f) => f);
expect(unbekannt.message).toBe(falsch.message);
});
it("lehnt deaktivierte Konten ab", async () => {
const benutzer = await admin();
await testPrisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: false } });
await expect(mitPasswortAnmelden("admin@example.de", "geheim1234", "1.2.3.4")).rejects.toThrow(
AnmeldeFehler,
);
});
it("bremst nach fünf Fehlversuchen", async () => {
await admin();
for (let i = 0; i < 5; i++) {
await mitPasswortAnmelden("admin@example.de", "falsch", "9.9.9.9").catch(() => undefined);
}
await expect(mitPasswortAnmelden("admin@example.de", "geheim1234", "9.9.9.9")).rejects.toThrow(
/Zu viele Versuche/,
);
});
it("verknüpft eine OIDC-Anmeldung mit dem vorhandenen Konto", async () => {
const benutzer = await admin();
const angemeldet = await mitOidcAnmelden("subject-1", "admin@example.de", "Florian Wilde");
expect(angemeldet.id).toBe(benutzer.id);
expect(angemeldet.oidcSubject).toBe("subject-1");
expect(await testPrisma.benutzer.count()).toBe(1);
});
it("legt bei unbekannter OIDC-Anmeldung ein Konto an", async () => {
const angemeldet = await mitOidcAnmelden("subject-2", "Neu@example.de", "Neue Person");
expect(angemeldet.email).toBe("neu@example.de");
expect(angemeldet.passwortHash).toBeNull();
expect(await testPrisma.benutzer.count()).toBe(1);
});
it("erkennt dieselbe Subject-ID wieder, auch wenn sich die E-Mail ändert", async () => {
const erst = await mitOidcAnmelden("subject-3", "alt@example.de", "Person");
const wieder = await mitOidcAnmelden("subject-3", "neu@example.de", "Person");
expect(wieder.id).toBe(erst.id);
expect(await testPrisma.benutzer.count()).toBe(1);
});
});
+234
View File
@@ -0,0 +1,234 @@
import { createHmac } from "node:crypto";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const APP_URL = "https://proxledger.example.de";
describe("Nachrichten", () => {
it("baut eine Warnungs-Nachricht mit Betreff, Text und Daten", () => {
const nachricht = alertNachricht(
{
id: "a1",
regel: "subscription-laeuft-ab",
schwere: "GEFAHR",
titel: "Subscription läuft in 7 Tagen ab",
beschreibung: "pve-01 · Brandl GmbH",
hostId: "h1",
},
APP_URL,
);
expect(nachricht.betreff).toBe("[ProxLedger] Gefahr: Subscription läuft in 7 Tagen ab");
expect(nachricht.text).toContain("pve-01 · Brandl GmbH");
expect(nachricht.text).toContain(`${APP_URL}/warnungen`);
expect(nachricht.html).toContain("ProxLedger");
expect(nachricht.daten).toMatchObject({ art: "warnung", alertId: "a1", schwere: "GEFAHR" });
});
it("baut eine Zusammenfassung mit Kennzahlen", () => {
const nachricht = zusammenfassungNachricht(
{
zeitraum: "taeglich",
offeneAlerts: [
{
id: "a1",
regel: "keine-meldung",
schwere: "WARNUNG",
titel: "Host meldet sich nicht",
beschreibung: "pve-02",
hostId: "h2",
},
],
neueAlerts: [],
baldAblaufend: [
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
],
offeneRechnungspunkte: 3,
hostsOhneMeldung: 1,
},
APP_URL,
);
expect(nachricht.betreff).toBe("[ProxLedger] Tagesübersicht: 1 offene Warnungen");
expect(nachricht.text).toContain("Offene Rechnungspunkte: 3");
expect(nachricht.text).toContain("pve-01 (Brandl GmbH) – 24.09.2026, in 7 Tagen");
expect(nachricht.daten).toMatchObject({ art: "zusammenfassung", zeitraum: "taeglich" });
});
});
describe("Webhook", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("schickt JSON und signiert es mit dem Geheimnis", async () => {
const aufrufe: { url: string; optionen: RequestInit }[] = [];
vi.stubGlobal("fetch", async (url: string, optionen: RequestInit) => {
aufrufe.push({ url, optionen });
return new Response("ok", { status: 200 });
});
const nachricht = alertNachricht(
{
id: "a1",
regel: "status-nicht-aktiv",
schwere: "GEFAHR",
titel: "Subscription abgelaufen",
beschreibung: "pve-01",
hostId: "h1",
},
APP_URL,
);
await perWebhookSenden({ url: "https://hook.example.de/x", geheimnis: "s3cret" }, nachricht);
expect(aufrufe).toHaveLength(1);
const kopfzeilen = aufrufe[0].optionen.headers as Record<string, string>;
const koerper = aufrufe[0].optionen.body as string;
expect(kopfzeilen["Content-Type"]).toBe("application/json");
expect(JSON.parse(koerper)).toMatchObject({ art: "warnung", regel: "status-nicht-aktiv" });
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
);
});
it("signiert ohne Geheimnis nicht", async () => {
const aufrufe: RequestInit[] = [];
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
aufrufe.push(optionen);
return new Response("ok", { status: 200 });
});
await perWebhookSenden(
{ url: "https://hook.example.de/x" },
{ betreff: "t", text: "t", html: "t", daten: {} },
);
expect((aufrufe[0].headers as Record<string, string>)["X-ProxLedger-Signatur"]).toBeUndefined();
});
it("meldet einen Fehlerstatus des Empfängers", async () => {
vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 }));
await expect(
perWebhookSenden(
{ url: "https://hook.example.de/x" },
{ betreff: "t", text: "t", html: "t", daten: {} },
),
).rejects.toThrow(VersandFehler);
});
});
describe.runIf(datenbankVerfuegbar)("Versand", () => {
beforeEach(async () => {
await datenbankLeeren();
vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 }));
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
async function webhookKanal() {
return testPrisma.benachrichtigungskanal.create({
data: {
typ: "WEBHOOK",
name: "Test-Hook",
aktiv: true,
konfiguration: { url: "https://hook.example.de/x" },
},
});
}
async function alert(fingerprint = "status-nicht-aktiv:h1") {
return testPrisma.alert.create({
data: {
fingerprint,
regel: "status-nicht-aktiv",
schwere: "GEFAHR",
titel: "Subscription abgelaufen",
beschreibung: "pve-01",
},
});
}
it("verschickt eine Warnung und protokolliert den Versand", async () => {
await webhookKanal();
const eintrag = await alert();
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
expect(ergebnis.gesendet).toBe(1);
const protokoll = await testPrisma.benachrichtigung.findMany();
expect(protokoll).toHaveLength(1);
expect(protokoll[0].status).toBe("GESENDET");
expect(protokoll[0].alertId).toBe(eintrag.id);
});
it("meldet denselben offenen Alert kein zweites Mal", async () => {
await webhookKanal();
const eintrag = await alert();
await neueAlertsMelden([eintrag.fingerprint]);
const zweiter = await neueAlertsMelden([eintrag.fingerprint]);
expect(zweiter.gesendet).toBe(0);
expect(zweiter.uebersprungen).toBe(1);
expect(await testPrisma.benachrichtigung.count({ where: { status: "GESENDET" } })).toBe(1);
});
it("verschickt nichts, solange kein Kanal eingerichtet ist", async () => {
const eintrag = await alert();
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
expect(ergebnis.gesendet).toBe(0);
expect(await testPrisma.benachrichtigung.count()).toBe(0);
});
it("protokolliert einen fehlgeschlagenen Versand mit Grund", async () => {
await webhookKanal();
const eintrag = await alert();
vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 }));
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
expect(ergebnis.fehlgeschlagen).toBe(1);
const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow();
expect(protokoll.status).toBe("FEHLGESCHLAGEN");
expect(protokoll.fehler).toContain("503");
});
it("versucht einen fehlgeschlagenen Versand beim nächsten Lauf erneut", async () => {
await webhookKanal();
const eintrag = await alert();
vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 }));
await neueAlertsMelden([eintrag.fingerprint]);
vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 }));
const zweiter = await neueAlertsMelden([eintrag.fingerprint]);
expect(zweiter.gesendet).toBe(1);
});
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
await webhookKanal();
await alert();
const ergebnis = await zusammenfassungSenden("taeglich", new Date("2026-09-17T07:00:00Z"));
expect(ergebnis.gesendet).toBe(1);
const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow();
expect(protokoll.betreff).toContain("Tagesübersicht");
// Die Zusammenfassung hängt an keinem einzelnen Alert.
expect(protokoll.alertId).toBeNull();
});
});