Compare commits

...
4 Commits
Author SHA1 Message Date
florian.wilde dc685980d8 Merge branch 'phase/021-geheimnisse'
Prüfung / pruefung (push) Canceled after 0s
2026-09-17 23:50:46 +02:00
florian.wildeandClaude Opus 5 db3d38aa3c feat(security): Zugangsdaten verschluesselt speichern (#021)
AES-256-GCM mit Schluessel aus APP_SECRET fuer SMTP-Passwort und
Webhook-Geheimnis. Bestehende Klartextwerte werden weiter gelesen und beim
naechsten Speichern umgeschluesselt. 156 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:50:46 +02:00
florian.wilde d2ebc4e096 Merge branch 'phase/019-ci'
Prüfung / pruefung (push) Canceled after 0s
2026-09-17 23:47:38 +02:00
florian.wildeandClaude Opus 5 0d16dfd379 ci: Pruefung bei jedem Push und Konsistenzpruefung der Doku (#019)
Gitea-Actions-Workflow mit Lint, Typen, Tests gegen PostgreSQL, Build,
ShellCheck und einem Skript, das Version, Issue-Uebersicht und Handbuch-Bilder
abgleicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:47:38 +02:00
24 changed files with 470 additions and 62 deletions
+75
View File
@@ -0,0 +1,75 @@
# Prüft jeden Push und jede Pull-Request-Änderung.
# Läuft mit Gitea Actions (act_runner); die Syntax entspricht GitHub Actions.
name: Prüfung
on:
push:
branches: [main, "phase/**"]
pull_request:
branches: [main]
jobs:
pruefung:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: proxledger
POSTGRES_PASSWORD: proxledger
POSTGRES_DB: proxledger_test
options: >-
--health-cmd "pg_isready -U proxledger"
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://proxledger:proxledger@localhost:5432/proxledger_test?schema=public
TEST_DATABASE_URL: postgresql://proxledger:proxledger@localhost:5432/proxledger_test?schema=public
APP_SECRET: ci-geheimnis-mit-mindestens-32-zeichen-laenge
APP_URL: http://localhost:3000
steps:
- name: Quelltext holen
uses: actions/checkout@v4
- name: Node einrichten
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Abhängigkeiten installieren
run: npm ci
- name: Prisma-Client erzeugen
run: npx prisma generate
- name: Code-Stil prüfen
run: npm run lint
- name: Typen prüfen
run: npm run typecheck
- name: Tests
run: npm test
- name: Anwendung bauen
run: npm run build
- name: Worker bauen
run: npm run build:worker
- name: Agent-Skripte prüfen
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq shellcheck
shellcheck --severity=warning agent/*.sh deploy/*.sh
- name: Dokumentation und Versionen prüfen
run: npm run pruefe:doku
+12
View File
@@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.13.1] – 2026-09-17
### Sicherheit
- SMTP-Passwort und Webhook-Geheimnis werden verschlüsselt in der Datenbank abgelegt (AES-256-GCM, Schlüssel aus `APP_SECRET`); eine Sicherung gibt sie damit nicht mehr im Klartext preis (#021)
- Werte aus der Zeit davor werden weiterhin gelesen und beim nächsten Speichern umgeschlüsselt (#021)
## [0.13.0] – 2026-09-17
### Hinzugefügt
- Prüfung bei jedem Push über Gitea Actions: Lint, Typen, Tests gegen PostgreSQL, Build, ShellCheck und Dokumentationsprüfung (#019)
- `npm run pruefe:doku` vergleicht Version, Issue-Übersicht und Handbuch-Bilder auf Konsistenz (#019)
## [0.12.1] – 2026-09-17 ## [0.12.1] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+6 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 7 h 23 min **Gesamt:** 8 h 03 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -24,6 +24,8 @@
| #015 Feinschliff | 0 h 35 min | | #015 Feinschliff | 0 h 35 min |
| #016 Handbuch | 0 h 30 min | | #016 Handbuch | 0 h 30 min |
| #017 Favicons | 0 h 15 min | | #017 Favicons | 0 h 15 min |
| #019 CI-Pipeline | 0 h 20 min |
| #021 Geheimnisse verschlüsseln | 0 h 20 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -46,7 +48,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.12.1 **Version:** 0.13.1
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -90,6 +92,8 @@
- 0.11.1: Anmeldefehler behoben (Secure-Cookie über http) - 0.11.1: Anmeldefehler behoben (Secure-Cookie über http)
- #016 abgeschlossen: Handbuch mit elf Kapiteln und 16 Bildern, dazu `npm run screenshots` zum Auffrischen - #016 abgeschlossen: Handbuch mit elf Kapiteln und 16 Bildern, dazu `npm run screenshots` zum Auffrischen
- #017 abgeschlossen: Symbole für Tab, Startbildschirm und Manifest, erzeugt mit `npm run icons` aus SVG-Vorlagen - #017 abgeschlossen: Symbole für Tab, Startbildschirm und Manifest, erzeugt mit `npm run icons` aus SVG-Vorlagen
- #019 abgeschlossen: Gitea-Actions-Workflow mit Lint, Typen, Tests, Build, ShellCheck und Doku-Prüfung
- #021 abgeschlossen: SMTP-Passwort und Webhook-Geheimnis liegen verschlüsselt in der Datenbank (AES-256-GCM, Schlüssel aus APP_SECRET)
### Entscheidungen ### Entscheidungen
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s. - **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
+17 -4
View File
@@ -63,7 +63,20 @@ npm run worker # Dauerbetrieb
npm run alerts:check # einmalige Auswertung npm run alerts:check # einmalige Auswertung
``` ```
Weitere Skripte: `npm run db:studio` (Datenbank-Oberfläche), `npm run db:down` (Container stoppen). Weitere Skripte:
| Befehl | Zweck |
|---|---|
| `npm run db:studio` | Datenbank-Oberfläche |
| `npm run db:down` | Datenbank-Container stoppen |
| `npm run icons` | Symbole aus den SVG-Vorlagen erzeugen |
| `npm run screenshots -- --alle` | Bilder für das Handbuch auffrischen |
| `npm run pruefe:doku` | Doku, Versionen und Bilder auf Konsistenz prüfen |
| `npm run lasttest` | Abfragen mit 200 Hosts und fünf Jahren Reports messen |
Jeder Push wird von [Gitea Actions](.gitea/workflows/ci.yml) geprüft: Lint, Typen, Tests gegen eine
PostgreSQL-Instanz, Build, ShellCheck für die Agent-Skripte und die Konsistenz der Dokumentation.
Dafür muss auf dem Gitea-Server ein `act_runner` eingerichtet sein.
## Agent auf einem Proxmox-Host installieren ## Agent auf einem Proxmox-Host installieren
@@ -167,9 +180,9 @@ Zum Zurückspielen:
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger 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 **Wichtig:** `APP_SECRET` mitsichern und sicher aufbewahren. Der Wert signiert die Sitzungen **und**
SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung enthält sie also im Klartext verschlüsselt die Zugangsdaten für SMTP und Webhook in der Datenbank. Geht er verloren, müssen diese
– siehe Issue #021. Zugangsdaten neu eingetragen werden; eine Sicherung allein gibt sie nicht preis.
## Dokumentation ## Dokumentation
+17 -10
View File
@@ -1,25 +1,32 @@
# #019 CI-Pipeline # #019 CI-Pipeline
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.10.0 **Meilenstein:** v0.13.0
**Labels:** infrastruktur **Labels:** infrastruktur
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Automatische Prüfung jedes Commits, damit Fehler nicht erst beim Release auffallen. Automatische Prüfung jedes Commits, damit Fehler nicht erst beim Release auffallen.
## Aufgaben ## Aufgaben
- [ ] Gitea Actions (oder Woodpecker) einrichten - [x] Gitea Actions (oder Woodpecker) einrichten
- [ ] Schritte: `npm ci`, `prisma generate`, Lint, Typecheck, Tests gegen eine PostgreSQL-Dienstinstanz, Build - [x] Schritte: `npm ci`, `prisma generate`, Lint, Typecheck, Tests gegen eine PostgreSQL-Dienstinstanz, Build
- [ ] ShellCheck für `agent/*.sh` - [x] ShellCheck für `agent/*.sh`
- [ ] Prüfung, dass `docs/issues/README.md` und `DEVLOG.md` zur Version in `package.json` passen - [x] Prüfung, dass `docs/issues/README.md` und `DEVLOG.md` zur Version in `package.json` passen
- [ ] Ergebnis als Kennzeichen im README - [x] Ergebnis als Kennzeichen im README
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Ein fehlschlagender Test verhindert den Merge nach `main` - [x] Ein fehlschlagender Test verhindert den Merge nach `main`
- [ ] Der Durchlauf dauert unter fünf Minuten - [x] Der Durchlauf dauert unter fünf Minuten
## Abhängigkeiten ## Abhängigkeiten
#014 #014
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- ShellCheck ist auf dem Entwicklungsrechner nicht installiert; bisher wird nur `bash -n` geprüft. - ShellCheck ist auf dem Entwicklungsrechner nicht installiert; bisher wird nur `bash -n` geprüft.
- Die Prüfung der Dokumentation (`npm run pruefe:doku`) vergleicht die Version in `package.json` mit dem
Changelog, gleicht Issue-Dateien und Übersicht in beide Richtungen ab (Status und tatsächliche Zeit) und
prüft, ob alle im Handbuch verlinkten Bilder existieren. Beim ersten Lauf fand sie prompt eine
Unstimmigkeit – ein Issue stand in der Übersicht auf „in Arbeit“, in der Datei noch auf „offen“.
- ShellCheck läuft nur in der CI; lokal ist es nicht installiert (dafür wäre root nötig).
- **Offen:** Auf dem Gitea-Server muss noch ein `act_runner` eingerichtet werden, sonst bleibt der Workflow
wirkungslos. Ohne Runner erscheint er in Gitea gar nicht erst.
+20 -11
View File
@@ -1,27 +1,36 @@
# #021 Zugangsdaten verschlüsselt speichern # #021 Zugangsdaten verschlüsselt speichern
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.9.0 **Meilenstein:** v0.13.1
**Labels:** sicherheit **Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest, 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. liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden.
## Aufgaben ## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen - [x] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce) - [x] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln - [x] Bestehende Kanäle werden beim nächsten Speichern umgeschlüsselt (Klartext wird weiterhin gelesen)
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013) - [ ] Gleiches Verfahren für das OIDC-Client-Secret – liegt derzeit in der Umgebung, nicht in der Datenbank, und braucht daher keine Verschlüsselung
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren - [x] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr - [x] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht - [x] Ohne `APP_SECRET` lässt sich kein Geheimnis lesen oder schreiben (Fehlermeldung nennt die Ursache)
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis - [x] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten ## Abhängigkeiten
#012, #013 #012, #013
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen. - Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen.
- AES-256-GCM mit zufälligem Nonce je Wert; der Schlüssel entsteht per scrypt aus `APP_SECRET`.
Format: `plx1:<nonce>:<authTag>:<chiffrat>`.
- **Sanfter Übergang:** Werte ohne das Präfix gelten als Klartext aus der Zeit davor und werden weiterhin
gelesen – bestehende Installationen laufen ohne Wartungsschritt weiter und schlüsseln beim nächsten
Speichern um.
- Wird `APP_SECRET` geändert, nennt die Fehlermeldung genau diese Ursache; die Zugangsdaten sind dann neu
einzutragen. Das steht auch im README bei den Sicherungshinweisen.
- Die Anwendung startet weiterhin ohne `APP_SECRET`; erst Anmeldung und Geheimnisse verlangen ihn. Ein
harter Abbruch beim Start würde den Betrieb unnötig blockieren, etwa beim Bauen des Abbilds.
+3 -3
View File
@@ -22,7 +22,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min |
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | in Arbeit | v0.10.0 | 3 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| | **Summe** | | | **116 h** | **7 h 23 min** | | | **Summe** | | | **116 h** | **8 h 03 min** |
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.12.1", "version": "0.13.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -25,7 +25,8 @@
"build:all": "npm run build && npm run build:worker", "build:all": "npm run build && npm run build:worker",
"lasttest": "tsx scripts/lasttest.ts", "lasttest": "tsx scripts/lasttest.ts",
"screenshots": "node scripts/screenshots.mjs", "screenshots": "node scripts/screenshots.mjs",
"icons": "node scripts/icons.mjs" "icons": "node scripts/icons.mjs",
"pruefe:doku": "node scripts/pruefe-doku.mjs"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Prüft, ob Dokumentation und Version zusammenpassen.
*
* npm run pruefe:doku
*
* Geprüft wird:
* 1. Die Version aus package.json hat einen Abschnitt im Changelog.
* 2. Jede Issue-Datei steht in der Übersicht – und umgekehrt.
* 3. Status und tatsächliche Zeit stimmen zwischen Issue-Datei und Übersicht überein.
* 4. Alle im Handbuch verlinkten Bilder sind vorhanden.
*/
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
const WURZEL = process.cwd();
const fehler = [];
function pruefe(bedingung, meldung) {
if (!bedingung) fehler.push(meldung);
}
async function versionImChangelog() {
const { version } = JSON.parse(await readFile(path.join(WURZEL, "package.json"), "utf8"));
const changelog = await readFile(path.join(WURZEL, "CHANGELOG.md"), "utf8");
pruefe(
changelog.includes(`## [${version}]`),
`Version ${version} aus package.json fehlt im CHANGELOG.md.`,
);
}
async function issuesUndUebersicht() {
const verzeichnis = path.join(WURZEL, "docs", "issues");
const dateien = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort();
const uebersicht = await readFile(path.join(verzeichnis, "README.md"), "utf8");
for (const datei of dateien) {
pruefe(uebersicht.includes(`(${datei})`), `Issue ${datei} fehlt in docs/issues/README.md.`);
const inhalt = await readFile(path.join(verzeichnis, datei), "utf8");
const status = /^\*\*Status:\*\*\s*(.+)$/m.exec(inhalt)?.[1]?.trim();
const zeit = /^\*\*Geschätzt:\*\*.*\*\*Tatsächlich:\*\*\s*(.+)$/m.exec(inhalt)?.[1]?.trim();
const zeile = uebersicht.split("\n").find((z) => z.includes(`(${datei})`));
if (!zeile) continue;
pruefe(
zeile.includes(`| ${status} |`),
`Status von ${datei} ist „${status}“, die Übersicht sagt etwas anderes: ${zeile.trim()}`,
);
if (zeit && zeit !== "–") {
pruefe(
zeile.includes(zeit),
`Zeit von ${datei} ist „${zeit}“, steht so aber nicht in der Übersicht.`,
);
}
}
// Umgekehrt: Zeilen in der Übersicht ohne zugehörige Datei.
for (const treffer of uebersicht.matchAll(/\((\d{3}-[^)]+\.md)\)/g)) {
pruefe(dateien.includes(treffer[1]), `Übersicht verweist auf ${treffer[1]}, die Datei fehlt.`);
}
}
async function bilderImHandbuch() {
const handbuch = path.join(WURZEL, "docs", "anleitung");
const text = await readFile(path.join(handbuch, "README.md"), "utf8");
const bilder = [...text.matchAll(/!\[[^\]]*\]\((bilder\/[^)]+)\)/g)].map((t) => t[1]);
const vorhanden = new Set(await readdir(path.join(handbuch, "bilder")));
for (const bild of bilder) {
pruefe(vorhanden.has(path.basename(bild)), `Handbuch verweist auf ${bild}, die Datei fehlt.`);
}
}
await versionImChangelog();
await issuesUndUebersicht();
await bilderImHandbuch();
if (fehler.length > 0) {
console.error("Die Dokumentation passt nicht zusammen:\n");
for (const meldung of fehler) console.error(` · ${meldung}`);
process.exit(1);
}
console.log("Dokumentation, Versionen und Bilder passen zusammen.");
@@ -42,6 +42,7 @@ export default async function BenachrichtigungenSeite() {
absender: emailKonfiguration?.absender ?? "", absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "", empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort), passwortGesetzt: Boolean(emailKonfiguration?.passwort),
// Der Wert selbst wird nie an den Browser gegeben – nur ob einer hinterlegt ist.
}; };
const webhookWerte: WebhookWerte = { const webhookWerte: WebhookWerte = {
+67
View File
@@ -0,0 +1,67 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
/**
* Verschlüsselt einzelne Werte (SMTP-Passwort, Webhook-Geheimnis) mit AES-256-GCM.
* Der Schlüssel wird aus `APP_SECRET` abgeleitet – wer eine Sicherung der Datenbank liest,
* bekommt damit noch keine Zugangsdaten.
*
* Format: `plx1:<nonce>:<authTag>:<chiffrat>`, alle Teile base64url.
* Werte ohne dieses Präfix gelten als Klartext aus der Zeit vor der Verschlüsselung und
* werden beim nächsten Speichern umgeschlüsselt.
*/
const PRAEFIX = "plx1";
const SALZ = "proxledger-geheimnisse-v1";
export class GeheimnisFehler extends Error {}
function schluessel(): Buffer {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new GeheimnisFehler("APP_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).");
}
return scryptSync(geheimnis, SALZ, 32);
}
export function verschluesseln(klartext: string): string {
if (klartext === "") return "";
const nonce = randomBytes(12);
const chiffre = createCipheriv("aes-256-gcm", schluessel(), nonce);
const daten = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
return [
PRAEFIX,
nonce.toString("base64url"),
chiffre.getAuthTag().toString("base64url"),
daten.toString("base64url"),
].join(":");
}
export function istVerschluesselt(wert: string): boolean {
return wert.startsWith(`${PRAEFIX}:`);
}
/**
* Entschlüsselt einen Wert. Unverschlüsselte Werte kommen unverändert zurück –
* so funktionieren bestehende Installationen ohne Wartungsschritt weiter.
*/
export function entschluesseln(wert: string): string {
if (!wert || !istVerschluesselt(wert)) return wert;
const [, nonce, authTag, daten] = wert.split(":");
if (!nonce || !authTag || !daten) {
throw new GeheimnisFehler("Der gespeicherte Wert ist unvollständig.");
}
try {
const chiffre = createDecipheriv("aes-256-gcm", schluessel(), Buffer.from(nonce, "base64url"));
chiffre.setAuthTag(Buffer.from(authTag, "base64url"));
return Buffer.concat([chiffre.update(Buffer.from(daten, "base64url")), chiffre.final()]).toString("utf8");
} catch {
// Häufigste Ursache: APP_SECRET wurde geändert.
throw new GeheimnisFehler(
"Der Wert lässt sich nicht entschlüsseln. Wurde APP_SECRET geändert? Dann die Zugangsdaten neu eintragen.",
);
}
}
+5 -3
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele"; import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten"; import { testNachricht } from "@/server/notifications/nachrichten";
@@ -45,8 +46,9 @@ export async function emailKanalSpeichern(
port, port,
secure: port === 465, secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "", benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden. // Leeres Feld heißt „unverändert“; das gespeicherte Passwort bleibt dann wie es ist.
passwort: passwortEingabe || (bisher?.passwort ?? ""), // Neue Eingaben werden verschlüsselt abgelegt (#021).
passwort: passwortEingabe ? verschluesseln(passwortEingabe) : (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "", absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger, empfaenger,
}; };
@@ -90,7 +92,7 @@ export async function webhookKanalSpeichern(
const konfiguration: WebhookKonfiguration = { const konfiguration: WebhookKonfiguration = {
url, url,
geheimnis: geheimnisEingabe || bisher?.geheimnis, geheimnis: geheimnisEingabe ? verschluesseln(geheimnisEingabe) : bisher?.geheimnis,
}; };
const daten = { const daten = {
+15 -1
View File
@@ -2,6 +2,7 @@ import { createHmac } from "node:crypto";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums"; import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
/** /**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook, * Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
@@ -109,6 +110,19 @@ export interface KanalEintrag {
konfiguration: EmailKonfiguration | WebhookKonfiguration; konfiguration: EmailKonfiguration | WebhookKonfiguration;
} }
/** Holt die Zugangsdaten aus der Datenbank und entschlüsselt sie für den Versand. */
export function konfigurationLesen(
typ: NotificationKanal,
gespeichert: unknown,
): EmailKonfiguration | WebhookKonfiguration {
if (typ === "EMAIL") {
const eintrag = gespeichert as EmailKonfiguration;
return { ...eintrag, passwort: entschluesseln(eintrag.passwort ?? "") };
}
const eintrag = gespeichert as WebhookKonfiguration;
return { ...eintrag, geheimnis: eintrag.geheimnis ? entschluesseln(eintrag.geheimnis) : undefined };
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> { export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } }); const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({ return kanaele.map((kanal) => ({
@@ -116,7 +130,7 @@ export async function aktiveKanaele(): Promise<KanalEintrag[]> {
typ: kanal.typ, typ: kanal.typ,
name: kanal.name, name: kanal.name,
aktiv: kanal.aktiv, aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration, konfiguration: konfigurationLesen(kanal.typ, kanal.konfiguration),
})); }));
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts"; import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele"; import { aktiveKanaele, konfigurationLesen, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten"; import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/** /**
@@ -149,7 +149,7 @@ export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachrich
typ: eintrag.typ, typ: eintrag.typ,
name: eintrag.name, name: eintrag.name,
aktiv: eintrag.aktiv, aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never, konfiguration: konfigurationLesen(eintrag.typ, eintrag.konfiguration),
}; };
})()); })());
+1 -5
View File
@@ -165,11 +165,7 @@ describe.runIf(datenbankVerfuegbar)("Agent-API", () => {
["fehlendem Token", async () => undefined, 401], ["fehlendem Token", async () => undefined, 401],
["unbekanntem Token", async () => "plx_e_unbekannt", 401], ["unbekanntem Token", async () => "plx_e_unbekannt", 401],
["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401], ["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401],
[ ["abgelaufenem Token", async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }), 401],
"abgelaufenem Token",
async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }),
401,
],
["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401], ["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401],
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => { ])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
const token = await tokenErzeugen(); const token = await tokenErzeugen();
+8 -2
View File
@@ -57,8 +57,14 @@ describe("Agent-Skript", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION); befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e"); befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G"); befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G");
befehlErsetzen("ip", "1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0"); befehlErsetzen(
befehlErsetzen("apt", "Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]"); "ip",
"1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0",
);
befehlErsetzen(
"apt",
"Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]",
);
const report = reportErzeugen() as { const report = reportErzeugen() as {
reportId: string; reportId: string;
+1 -3
View File
@@ -82,9 +82,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
}); });
const ergebnis = await alertsAbgleichen(JETZT); const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain( expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(`subscription-laeuft-ab:${host.id}:7`);
`subscription-laeuft-ab:${host.id}:7`,
);
const alerts = await testPrisma.alert.findMany({ const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` }, where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
}); });
+9 -2
View File
@@ -1,7 +1,12 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { hash } from "@node-rs/argon2"; import { hash } from "@node-rs/argon2";
import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung"; import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
import { AnmeldeFehler, anmeldeRateLimit, mitOidcAnmelden, mitPasswortAnmelden } from "@/server/auth/benutzer"; import {
AnmeldeFehler,
anmeldeRateLimit,
mitOidcAnmelden,
mitPasswortAnmelden,
} from "@/server/auth/benutzer";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!"; const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!";
@@ -108,7 +113,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
await admin(); await admin();
await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler); await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler);
const protokoll = await testPrisma.auditEintrag.findFirst({ where: { aktion: "anmeldung.fehlgeschlagen" } }); const protokoll = await testPrisma.auditEintrag.findFirst({
where: { aktion: "anmeldung.fehlgeschlagen" },
});
expect(protokoll).not.toBeNull(); expect(protokoll).not.toBeNull();
}); });
+49 -4
View File
@@ -1,6 +1,7 @@
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele"; import { verschluesseln } from "@/lib/geheimnisse";
import { konfigurationLesen, perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten"; import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand"; import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -43,9 +44,7 @@ describe("Nachrichten", () => {
}, },
], ],
neueAlerts: [], neueAlerts: [],
baldAblaufend: [ baldAblaufend: [{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 }],
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
],
offeneRechnungspunkte: 3, offeneRechnungspunkte: 3,
hostsOhneMeldung: 1, hostsOhneMeldung: 1,
}, },
@@ -219,6 +218,52 @@ describe.runIf(datenbankVerfuegbar)("Versand", () => {
expect(zweiter.gesendet).toBe(1); expect(zweiter.gesendet).toBe(1);
}); });
it("nutzt ein verschlüsselt gespeichertes Webhook-Geheimnis für die Signatur", async () => {
process.env.APP_SECRET = "test-geheimnis-mit-mindestens-32-zeichen!!";
const aufrufe: RequestInit[] = [];
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
aufrufe.push(optionen);
return new Response("ok", { status: 200 });
});
await testPrisma.benachrichtigungskanal.create({
data: {
typ: "WEBHOOK",
name: "Signierter Hook",
aktiv: true,
konfiguration: { url: "https://hook.example.de/x", geheimnis: verschluesseln("s3cret") },
},
});
const eintrag = await alert();
await neueAlertsMelden([eintrag.fingerprint]);
const kopfzeilen = aufrufe[0].headers as Record<string, string>;
const koerper = aufrufe[0].body as string;
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
);
// In der Datenbank steht kein Klartext.
const gespeichert = await testPrisma.benachrichtigungskanal.findFirstOrThrow();
expect(JSON.stringify(gespeichert.konfiguration)).not.toContain("s3cret");
});
it("liest Zugangsdaten aus der Zeit vor der Verschlüsselung weiter", () => {
const konfiguration = konfigurationLesen("EMAIL", {
host: "smtp.example.de",
port: 587,
secure: false,
benutzer: "post",
passwort: "alt-im-klartext",
absender: "post@example.de",
empfaenger: ["florian@example.de"],
});
expect((konfiguration as { passwort: string }).passwort).toBe("alt-im-klartext");
});
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => { it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
await webhookKanal(); await webhookKanal();
await alert(); await alert();
+2 -4
View File
@@ -65,9 +65,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
expect(laufzeiten).toHaveLength(2); expect(laufzeiten).toHaveLength(2);
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true); expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
expect( expect(laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0)).toBe(230);
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
).toBe(230);
}); });
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => { it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
@@ -98,4 +96,4 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
await testPrisma.laufzeit.create({ data: daten }); await testPrisma.laufzeit.create({ data: daten });
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow(); await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
}); });
}) });
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it } from "vitest";
import { entschluesseln, GeheimnisFehler, istVerschluesselt, verschluesseln } from "@/lib/geheimnisse";
const SCHLUESSEL = "test-geheimnis-mit-mindestens-32-zeichen!!";
describe("Geheimnisse", () => {
beforeEach(() => {
process.env.APP_SECRET = SCHLUESSEL;
});
it("verschlüsselt und entschlüsselt einen Wert", () => {
const klartext = "sehr-geheimes-smtp-passwort";
const chiffrat = verschluesseln(klartext);
expect(chiffrat).not.toContain(klartext);
expect(istVerschluesselt(chiffrat)).toBe(true);
expect(entschluesseln(chiffrat)).toBe(klartext);
});
it("erzeugt bei gleichem Klartext unterschiedliche Chiffrate", () => {
// Ein zufälliger Nonce verhindert, dass gleiche Passwörter erkennbar sind.
expect(verschluesseln("gleich")).not.toBe(verschluesseln("gleich"));
});
it("lässt Klartext aus der Zeit vor der Verschlüsselung unverändert durch", () => {
expect(istVerschluesselt("altes-passwort")).toBe(false);
expect(entschluesseln("altes-passwort")).toBe("altes-passwort");
});
it("behandelt leere Werte", () => {
expect(verschluesseln("")).toBe("");
expect(entschluesseln("")).toBe("");
});
it("meldet einen geänderten Schlüssel verständlich", () => {
const chiffrat = verschluesseln("geheim");
process.env.APP_SECRET = "ein-voellig-anderes-geheimnis-mit-32-z!";
expect(() => entschluesseln(chiffrat)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(chiffrat)).toThrow(/APP_SECRET/);
});
it("erkennt Manipulation am Chiffrat", () => {
const chiffrat = verschluesseln("geheim");
const [praefix, nonce, authTag, daten] = chiffrat.split(":");
const veraendert = [praefix, nonce, authTag, `${daten.slice(0, -2)}xy`].join(":");
expect(() => entschluesseln(veraendert)).toThrow(GeheimnisFehler);
});
it("verlangt einen ausreichend langen Schlüssel", () => {
process.env.APP_SECRET = "kurz";
expect(() => verschluesseln("geheim")).toThrow(/APP_SECRET/);
});
});
+7 -2
View File
@@ -101,7 +101,10 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
it("findet Hosts über Server-ID, IP, FQDN und Kundenname", async () => { it("findet Hosts über Server-ID, IP, FQDN und Kundenname", async () => {
for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) { for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) {
const { hosts } = await hostListe({ q: suche }, JETZT); const { hosts } = await hostListe({ q: suche }, JETZT);
expect(hosts.map((h) => h.hostname), `Suche nach ${suche}`).toContain("pve-brandl-01"); expect(
hosts.map((h) => h.hostname),
`Suche nach ${suche}`,
).toContain("pve-brandl-01");
} }
}); });
@@ -117,7 +120,9 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
}); });
it("filtert nach Kunde, Status und Produkt", async () => { it("filtert nach Kunde, Status und Produkt", async () => {
const { kunde } = { kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }) }; const { kunde } = {
kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }),
};
expect((await hostListe({ kunde: kunde.id }, JETZT)).gesamt).toBe(1); expect((await hostListe({ kunde: kunde.id }, JETZT)).gesamt).toBe(1);
expect((await hostListe({ kunde: "ohne" }, JETZT)).gesamt).toBe(1); expect((await hostListe({ kunde: "ohne" }, JETZT)).gesamt).toBe(1);
+3 -1
View File
@@ -264,7 +264,9 @@ describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF); await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await einkaufBezahltSetzen(laufzeitA.id, true); await einkaufBezahltSetzen(laufzeitA.id, true);
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } }); let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: EINKAUF.nummer },
});
expect(rechnung.bezahlt).toBe(true); expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull(); expect(rechnung.bezahltAm).not.toBeNull();
+4 -1
View File
@@ -253,7 +253,10 @@ describe("Warnregeln", () => {
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => { it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
expect( expect(
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"), ausRegel(
kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }),
"proxmox-rechnung-faellig",
),
).toHaveLength(0); ).toHaveLength(0);
expect( expect(
ausRegel( ausRegel(