Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc685980d8 | ||
|
|
db3d38aa3c | ||
|
|
d2ebc4e096 | ||
|
|
0d16dfd379 | ||
|
|
af38a4efce | ||
|
|
9c0cefcfe8 |
@@ -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
|
||||||
@@ -17,6 +17,23 @@ 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
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Symbole für Browser-Tab (SVG und ICO), iOS-Startbildschirm und Web-Manifest, erzeugt mit `npm run icons` aus den SVG-Vorlagen in `assets/` (#017)
|
||||||
|
|
||||||
## [0.12.0] – 2026-09-17
|
## [0.12.0] – 2026-09-17
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 7 h 08 min
|
**Gesamt:** 8 h 03 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
| #014 Deployment | 0 h 40 min |
|
| #014 Deployment | 0 h 40 min |
|
||||||
| #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 |
|
||||||
|
| #019 CI-Pipeline | 0 h 20 min |
|
||||||
|
| #021 Geheimnisse verschlüsseln | 0 h 20 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -45,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.0
|
**Version:** 0.13.1
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -88,6 +91,9 @@
|
|||||||
- Als v0.11.0 veröffentlicht statt 1.0.0: Feldtest, Handbuch, Favicons, CI und Verschlüsselung der Zugangsdaten stehen noch aus
|
- Als v0.11.0 veröffentlicht statt 1.0.0: Feldtest, Handbuch, Favicons, CI und Verschlüsselung der Zugangsdaten stehen noch aus
|
||||||
- 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
|
||||||
|
- #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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Fassung für 16 px: nur das P. -->
|
||||||
|
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M6 2h21a14 14 0 0 1 0 28H18v16H6zM18 13h8a3 3 0 0 1 0 6h-8z" fill="#1E2A44"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- Fassung für 32 px: eine Linie, kräftigere Formen. -->
|
||||||
|
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M7 3h20a13 13 0 0 1 0 26H17v16H7zM17 12h9a4 4 0 0 1 0 8h-9z" fill="#1E2A44"/>
|
||||||
|
<path d="M24 38h19" stroke="#17A2B8" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- Fassung für Startbildschirm-Symbole: Monogramm auf dunklem Grund mit Rand. -->
|
||||||
|
<svg viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="180" height="180" rx="34" fill="#1E2A44"/>
|
||||||
|
<g transform="translate(34 30) scale(2.3)">
|
||||||
|
<path fill-rule="evenodd" d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z" fill="#FFFFFF"/>
|
||||||
|
<path d="M22 36h20M22 43h14" stroke="#17A2B8" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- ProxLedger-Monogramm, Standardfassung (heller Grund). Quelle für alle abgeleiteten Dateien. -->
|
||||||
|
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z" fill="#1E2A44"/>
|
||||||
|
<path d="M22 36h20M22 43h14" stroke="#17A2B8" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 373 B |
@@ -1,23 +1,23 @@
|
|||||||
# #017 Favicon-Dateien erzeugen
|
# #017 Favicon-Dateien erzeugen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.12.1
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Das Monogramm liegt als React-Komponente vor. Es fehlen die abgeleiteten Dateien für Browser-Tab,
|
Das Monogramm liegt als React-Komponente vor. Es fehlen die abgeleiteten Dateien für Browser-Tab,
|
||||||
Startbildschirm und E-Mails.
|
Startbildschirm und E-Mails.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] `favicon.svg` (32-px-Variante mit einer Linie) unter `src/app/icon.svg`
|
- [x] `favicon.svg` (32-px-Variante mit einer Linie) unter `src/app/icon.svg`
|
||||||
- [ ] `favicon.ico` und PNG-Größen (16, 32, 180, 192, 512) erzeugen
|
- [x] `favicon.ico` und PNG-Größen (16, 32, 180, 192, 512) erzeugen
|
||||||
- [ ] `apple-touch-icon` über `apple-icon.tsx` (ImageResponse) oder als PNG
|
- [x] `apple-touch-icon` über `apple-icon.tsx` (ImageResponse) oder als PNG
|
||||||
- [ ] Erzeugung als Skript (`npm run icons`) festhalten, damit Änderungen am Logo nachziehbar sind
|
- [x] Erzeugung als Skript (`npm run icons`) festhalten, damit Änderungen am Logo nachziehbar sind
|
||||||
- [ ] Einfarbige Variante für PDF-Export und Rechnungen bereitstellen
|
- [x] Einfarbige Variante für PDF-Export und Rechnungen bereitstellen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Browser-Tab zeigt das Monogramm in Hell und Dunkel
|
- [x] Browser-Tab zeigt das Monogramm in Hell und Dunkel
|
||||||
- [ ] Beim Ablegen auf dem iOS-Startbildschirm erscheint das Logo ohne weißen Rand
|
- [x] Beim Ablegen auf dem iOS-Startbildschirm erscheint das Logo ohne weißen Rand
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#002
|
#002
|
||||||
@@ -25,3 +25,13 @@ Startbildschirm und E-Mails.
|
|||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
- Auf dem Entwicklungsrechner ist kein SVG-Konverter installiert (kein rsvg-convert, kein ImageMagick).
|
- Auf dem Entwicklungsrechner ist kein SVG-Konverter installiert (kein rsvg-convert, kein ImageMagick).
|
||||||
Entweder `sharp` als Entwicklungsabhängigkeit aufnehmen oder Next.js `ImageResponse` nutzen.
|
Entweder `sharp` als Entwicklungsabhängigkeit aufnehmen oder Next.js `ImageResponse` nutzen.
|
||||||
|
- Die SVG-Vorlagen liegen in `assets/`; `npm run icons` erzeugt daraus alle Dateien. Für 16 und 32 Pixel
|
||||||
|
kommen die vereinfachten Fassungen zum Einsatz (nur das P bzw. eine Linie), sonst verschwimmen die feinen
|
||||||
|
Linien.
|
||||||
|
- `favicon.ico` wird im Skript selbst zusammengesetzt (Kopf, Verzeichnis, PNG-Daten) – dafür braucht es
|
||||||
|
kein weiteres Werkzeug.
|
||||||
|
- Die Symbole für den Startbildschirm stehen auf dunklem Grund mit abgerundeten Ecken, weil iOS sonst
|
||||||
|
einen weißen Kasten zeigt.
|
||||||
|
- Dazu ein `manifest.webmanifest`; Symbole und Manifest sind bewusst ohne Anmeldung erreichbar, weil der
|
||||||
|
Browser sie vor der Anmeldung lädt.
|
||||||
|
- Die einfarbige Fassung für PDF und Rechnungen steckt bereits in der Logo-Komponente (Variante `mono`).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min |
|
| [#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 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
|
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
|
||||||
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
|
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
|
||||||
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | in Arbeit | v0.6.0 | 1 h | – |
|
| [#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 | offen | 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 08 min** |
|
| | **Summe** | | | **116 h** | **8 h 03 min** |
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.9.0",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.9.0",
|
"version": "0.12.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
@@ -1013,8 +1014,8 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -9117,8 +9118,8 @@
|
|||||||
"version": "0.35.4",
|
"version": "0.35.4",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -9167,8 +9168,8 @@
|
|||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.12.0",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
"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: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",
|
"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",
|
||||||
|
"pruefe:doku": "node scripts/pruefe-doku.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Erzeugt alle Symboldateien aus den SVG-Vorlagen in `assets/`.
|
||||||
|
*
|
||||||
|
* npm run icons
|
||||||
|
*
|
||||||
|
* Ergebnis:
|
||||||
|
* src/app/icon.svg – Symbol im Browser-Tab (moderne Browser)
|
||||||
|
* src/app/favicon.ico – Rückfall für ältere Browser (16, 32, 48 px)
|
||||||
|
* public/icon-192.png – Startbildschirm und Fortschrittsanzeigen
|
||||||
|
* public/icon-512.png
|
||||||
|
* src/app/apple-icon.png – iOS-Startbildschirm (180 px, mit Hintergrund)
|
||||||
|
*
|
||||||
|
* Die Größen 16 und 32 nutzen vereinfachte Vorlagen, weil die feinen Linien sonst verschwimmen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const WURZEL = process.cwd();
|
||||||
|
const ASSETS = path.join(WURZEL, "assets");
|
||||||
|
|
||||||
|
async function png(vorlage, groesse) {
|
||||||
|
const svg = await readFile(path.join(ASSETS, vorlage));
|
||||||
|
return sharp(svg, { density: 384 }).resize(groesse, groesse).png({ compressionLevel: 9 }).toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut eine ICO-Datei aus PNG-Bildern. Das Format ist schlicht: ein Kopf, je Bild ein
|
||||||
|
* Verzeichniseintrag, danach die Bilddaten – PNG als Inhalt ist seit Vista erlaubt.
|
||||||
|
*/
|
||||||
|
function icoBauen(bilder) {
|
||||||
|
const kopf = Buffer.alloc(6);
|
||||||
|
kopf.writeUInt16LE(0, 0); // reserviert
|
||||||
|
kopf.writeUInt16LE(1, 2); // Typ 1 = Symbol
|
||||||
|
kopf.writeUInt16LE(bilder.length, 4);
|
||||||
|
|
||||||
|
let versatz = 6 + bilder.length * 16;
|
||||||
|
const eintraege = [];
|
||||||
|
for (const bild of bilder) {
|
||||||
|
const eintrag = Buffer.alloc(16);
|
||||||
|
eintrag.writeUInt8(bild.groesse >= 256 ? 0 : bild.groesse, 0); // Breite (0 = 256)
|
||||||
|
eintrag.writeUInt8(bild.groesse >= 256 ? 0 : bild.groesse, 1); // Höhe
|
||||||
|
eintrag.writeUInt8(0, 2); // Farben in der Palette
|
||||||
|
eintrag.writeUInt8(0, 3); // reserviert
|
||||||
|
eintrag.writeUInt16LE(1, 4); // Farbebenen
|
||||||
|
eintrag.writeUInt16LE(32, 6); // Bits je Bildpunkt
|
||||||
|
eintrag.writeUInt32LE(bild.daten.length, 8);
|
||||||
|
eintrag.writeUInt32LE(versatz, 12);
|
||||||
|
eintraege.push(eintrag);
|
||||||
|
versatz += bild.daten.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat([kopf, ...eintraege, ...bilder.map((b) => b.daten)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(path.join(WURZEL, "public"), { recursive: true });
|
||||||
|
|
||||||
|
// Tab-Symbol: das SVG selbst, damit es in jeder Größe scharf bleibt.
|
||||||
|
const svg = await readFile(path.join(ASSETS, "logo-32.svg"));
|
||||||
|
await writeFile(path.join(WURZEL, "src", "app", "icon.svg"), svg);
|
||||||
|
console.log(" src/app/icon.svg");
|
||||||
|
|
||||||
|
const ico = icoBauen([
|
||||||
|
{ groesse: 16, daten: await png("logo-16.svg", 16) },
|
||||||
|
{ groesse: 32, daten: await png("logo-32.svg", 32) },
|
||||||
|
{ groesse: 48, daten: await png("logo-32.svg", 48) },
|
||||||
|
]);
|
||||||
|
await writeFile(path.join(WURZEL, "src", "app", "favicon.ico"), ico);
|
||||||
|
console.log(` src/app/favicon.ico (${(ico.length / 1024).toFixed(1)} KB)`);
|
||||||
|
|
||||||
|
for (const groesse of [192, 512]) {
|
||||||
|
const daten = await png("logo-kachel.svg", groesse);
|
||||||
|
await writeFile(path.join(WURZEL, "public", `icon-${groesse}.png`), daten);
|
||||||
|
console.log(` public/icon-${groesse}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apple = await png("logo-kachel.svg", 180);
|
||||||
|
await writeFile(path.join(WURZEL, "src", "app", "apple-icon.png"), apple);
|
||||||
|
console.log(" src/app/apple-icon.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Erzeuge Symbole aus assets/:");
|
||||||
|
main()
|
||||||
|
.then(() => console.log("Fertig."))
|
||||||
|
.catch((fehler) => {
|
||||||
|
console.error(fehler.message ?? fehler);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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 = {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- Fassung für 32 px: eine Linie, kräftigere Formen. -->
|
||||||
|
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M7 3h20a13 13 0 0 1 0 26H17v16H7zM17 12h9a4 4 0 0 1 0 8h-9z" fill="#1E2A44"/>
|
||||||
|
<path d="M24 38h19" stroke="#17A2B8" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
/** Angaben für den Startbildschirm, wenn das Dashboard als Verknüpfung abgelegt wird. */
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "ProxLedger – Proxmox Subscription Dashboard",
|
||||||
|
short_name: "ProxLedger",
|
||||||
|
description: "Subscription-Status aller Proxmox-Hosts im Blick",
|
||||||
|
lang: "de",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#F4F6F9",
|
||||||
|
theme_color: "#1E2A44",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,13 @@ const OEFFENTLICH = [
|
|||||||
"/api/gesundheit",
|
"/api/gesundheit",
|
||||||
"/install.sh",
|
"/install.sh",
|
||||||
"/agent.sh",
|
"/agent.sh",
|
||||||
|
// Symbole und Manifest: Der Browser holt sie auch vor der Anmeldung.
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
"/icon.svg",
|
"/icon.svg",
|
||||||
|
"/apple-icon.png",
|
||||||
|
"/icon-192.png",
|
||||||
|
"/icon-512.png",
|
||||||
|
"/manifest.webmanifest",
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
})());
|
})());
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||