Compare commits

..
4 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
39 changed files with 1533 additions and 42 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
.next
dist
.git
.env
.env.*
!.env.example
data
docs/anleitung/bilder
coverage
*.log
+16 -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,6 +15,15 @@ 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"
+1
View File
@@ -53,3 +53,4 @@ Thumbs.db
*.pem *.pem
/src/generated/prisma /src/generated/prisma
dist/
+25
View File
@@ -17,6 +17,31 @@ 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 ## [0.8.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+14 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 4 h 53 min **Gesamt:** 6 h 03 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -19,6 +19,8 @@
| #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 | | #012 Benachrichtigungen | 0 h 25 min |
| #013 Authentifizierung | 0 h 30 min |
| #014 Deployment | 0 h 40 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -32,12 +34,14 @@
| 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.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.8.0 **Version:** 0.10.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -71,8 +75,14 @@
- #012 abgeschlossen: SMTP (Microsoft 365), generischer Webhook mit Signatur, Zusammenfassung, Versandprotokoll, Testversand; keine Doppelmeldungen dank Prüfung gegen das Protokoll - #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 - Issue #021 angelegt: Zugangsdaten liegen noch unverschlüsselt in der Datenbank
- Meilenstein v0.8.0 abgeschlossen; 134 Tests - 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.
@@ -93,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.
@@ -100,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
- #013 Authentifizierung: OIDC über authentik, lokaler Admin, Audit-Log-Ansicht - #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 -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).
+4 -4
View File
@@ -16,13 +16,13 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#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 | erledigt | v0.8.0 | 6 h | 0 h 25 min | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min |
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | in Arbeit | 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 |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **4 h 53 min** | | | **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;
+36 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.7.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.7.0", "version": "0.9.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -17,8 +17,10 @@
"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", "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"
@@ -29,6 +31,7 @@
"@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",
@@ -7211,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",
@@ -7981,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",
@@ -8124,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",
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.8.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,8 +33,10 @@
"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", "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"
@@ -43,6 +47,7 @@
"@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>
</>
);
}
+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 />
+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>
);
}
+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");
}
+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 };
}
+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);
});
});