feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens

Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus
Laufzettel übernommen, weil dort derselbe Stack erprobt ist.

Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das
helle Theme setzt dieselben Namen neu. Amber und Rot sind dort
abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide
Themes gegen 4,5:1.

Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank,
Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf
1440 px und 390 px.

Closes #001

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:23:41 +02:00
co-authored by Claude Opus 5.5
parent c3211d0fe2
commit 871bb3a77c
75 changed files with 10155 additions and 15 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+41
View File
@@ -0,0 +1,41 @@
# ── Datenbank ────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
# Nur für Tests; wird vor jedem Test geleert.
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy_test
# ── Geheimnisse ──────────────────────────────────────────────────────────────
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
# Erzeugen: openssl rand -base64 32 – NIE ändern, sonst sind gespeicherte Geheimnisse unlesbar.
PROXDEPLOY_SCHLUESSEL=
# Signiert die Sitzungscookies. Erzeugen: openssl rand -base64 48
SITZUNG_GEHEIMNIS=
# ── Adresse und Reverse-Proxy ────────────────────────────────────────────────
# Öffentliche Adresse, wie Browser und CLI sie sehen (ohne Schrägstrich am Ende).
APP_URL=http://localhost:3000
# true hinter einem Reverse-Proxy: X-Forwarded-For/-Proto werden ausgewertet.
TRUST_PROXY=false
# ── Erster Administrator ─────────────────────────────────────────────────────
# Wird nur angelegt, solange es noch keinen Benutzer gibt. Danach wirkungslos.
ADMIN_BENUTZER=admin
ADMIN_PASSWORT=
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
OIDC_AKTIV=false
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_ANZEIGENAME=SSO
# Gruppen-Claim und Gruppennamen für die Rollen.
OIDC_GRUPPEN_CLAIM=groups
OIDC_GRUPPE_ADMIN=proxdeploy-admins
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
# ── Worker ───────────────────────────────────────────────────────────────────
# Wie viele Aufträge gleichzeitig laufen dürfen.
WORKER_PARALLEL=4
# ── Logs ─────────────────────────────────────────────────────────────────────
# trace | debug | info | warn | error
LOG_LEVEL=info
+1
View File
@@ -0,0 +1 @@
pnpm exec commitlint --edit "$1"
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+1
View File
@@ -0,0 +1 @@
24
+12
View File
@@ -0,0 +1,12 @@
.next
node_modules
coverage
docs
*.md
pnpm-lock.yaml
src/generated
prisma/migrations
cli/
catalog/
test-results
public/brand
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 110
}
+7
View File
@@ -10,6 +10,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- Architektur- und Umsetzungsplan (`docs/architektur.md`) mit Prüfung der Proxmox-API 8.x/9.x und der Image-Quellen (#001) - Architektur- und Umsetzungsplan (`docs/architektur.md`) mit Prüfung der Proxmox-API 8.x/9.x und der Image-Quellen (#001)
- Issues #001–#020 für die sechs Etappen und den Feldtest (#001) - Issues #001–#020 für die sechs Etappen und den Feldtest (#001)
- Grundgerüst: Next.js 16, Prisma 7, Tailwind 4 mit den ProxDeploy-Farben als Tokens, dunkles und helles Theme (#001)
- Anmeldung mit lokalem Benutzer; der erste Administrator entsteht aus `ADMIN_BENUTZER`/`ADMIN_PASSWORT` (#001)
- Seitenleiste mit Wortmarke, auf dem Handy als Schublade; Übersichtsseite (#001)
- `/api/v1/gesundheit` und `/api/v1/ich` (#001)
### Geändert ### Geändert
@@ -18,3 +22,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Entfernt ### Entfernt
### Sicherheit ### Sicherheit
- Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001)
- Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001)
- Das Audit-Log ist per Datenbank-Trigger nur anhängbar (#001)
+105
View File
@@ -0,0 +1,105 @@
# Konventionen für ProxDeploy
Diese Datei ist verbindlich. Wer hier etwas ändert, ändert es auch in `docs/architektur.md`.
## Sprache
**Deutsch, überall wo ein Mensch liest oder benennt** – auch Bezeichner: Funktionen, Variablen,
Prisma-Modelle und -Felder, Enum-Werte, Fehlerklassen, Dateinamen, Kommentare, Testbeschreibungen,
Commit-Betreffs. **Umlaute in Bezeichnern ASCII-gefaltet** (`geaendertAm`, `pruefen`), in Texten für
Menschen richtig geschrieben („Verbindung prüfen“).
Englisch bleiben: vom Framework vorgegebene Namen (`GET`, `route.ts`, `page.tsx`, `proxy.ts`),
Fachjargon (`Token`, `OIDC`, `VMID`, `Node`, `Storage`, `Bridge`, `Template`, `cloud-init`) und die
**YAML-Schlüssel im Katalog** (`catalog/**`) – die sind ein Austauschformat aus der Vorgabe.
## Namensabbildung zur Vorgabe
| Vorgabe | Modell / Begriff | Vorgabe | Modell / Begriff |
|---|---|---|---|
| User | `Benutzer` | Deployment / Job | `Auftrag` (Art `AUSROLLEN`, `TEMPLATE_BAUEN`, `AUFRAEUMEN`) |
| API token (CLI) | `ApiToken` | Phase | `Phase` |
| SSH key | `SshSchluessel` | Job log line | `Logeintrag` |
| Server | `Server` | VMID lock | `VmidReservierung` |
| Node | `knoten` (Feld) | Proxmox template | `Template` |
| Distribution | `Distribution` | Preset | `Vorlage` |
| Profile | `Profil` | Audit log | `Protokoll` |
| Setting | `Einstellung` | Key provider | `SchluesselQuelle` |
API-Pfade sind deutsch: `/api/v1/server`, `/api/v1/auftraege`, `/api/v1/vorlagen`.
## Schichtung
```
src/engine/* Deploy-Logik, frameworkfrei: kein Next, kein Prisma, kein React.
src/server/services/* Regeln und Schreiboperationen; rufen Engine und Prisma.
src/server/auth/* Sitzung, Tokens, Rechte (RECHTE + darf()).
src/app/api/v1/* Route Handler: Recht prüfen, zod, Service. Sonst nichts.
src/components/* Darstellung, keine Geschäftslogik.
src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier.
src/worker/* Hintergrundprozess (pg-boss).
```
- **Web-GUI und CLI nutzen dieselbe REST-API.** Keine Logik nur für eine Oberfläche.
- **Phasentexte kommen aus der Engine.** Web und CLI rendern Name, Status, Detail, Dauer, Ursache,
Hinweis – sie erfinden keinen Text.
- **Jede Seite und jeder Route Handler prüft sein Recht selbst** (`seiteSchuetzen`,
`rechtErforderlich`). `tests/zugang.test.ts` prüft das strukturell; Ausnahmen stehen dort mit
Begründung. Rolle und Aktiv-Status werden je Anfrage aus der DB gelesen.
- **Client-Komponenten importieren nichts aus `src/server/*`.**
- **Fehler an Aufrufer** nur als `AnwendungsFehler` (Status, Code, Nachricht, Hinweis); Format
`{ fehler: { code, nachricht, hinweis?, felder? } }`.
- **zod nur über `@/lib/validierung`** (deutsche Meldungen; ESLint erzwingt das).
- **Datenbankregeln** (Trigger, CHECK) stehen in den Migrationen und namentlich in
`tests/datenbankregeln.test.ts`.
## Werkzeuge und Versionen
Node 24 über nvm (`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"`), pnpm 12. Exakt gepinnt:
`next@16.4.0`, `react@19.3.0`, `prisma`/`@prisma/client`/`@prisma/adapter-pg` **`7.10.0`**
(der `latest`-Tag von prisma zeigt auf einen 8.0-RC), `vitest@5.0.1`, `@playwright/test@1.63.0`,
`pino@10.4.0`. Obergrenzen: **eslint 9.x** (eslint-plugin-react aus eslint-config-next kann 10 nicht),
**typescript ~6.0.3** (typescript-eslint 8 erlaubt < 6.1).
Next 16: `proxy.ts` statt middleware; `cookies()`, `headers()`, `params`, `searchParams` sind async.
Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff
über den Lazy-Proxy `src/lib/db.ts`.
## Vor jedem Commit
```bash
pnpm db:up
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
```
Ohne laufende Datenbank überspringt `pnpm test` die Datenbanktests und sagt es auf stderr.
## Oberfläche prüfen – immer im gebauten Stand
```bash
pnpm build && PORT=3310 APP_URL=http://127.0.0.1:3310 pnpm start &
pnpm test:e2e
```
`next dev` hydriert in dieser Umgebung nicht verlässlich; ein Screenshot beweist dann nichts. Die
Abläufe prüfen Desktop (1440 px) und Handy (**390 px, kein horizontales Scrollen**).
## Git, Versionen, Zeiterfassung
- Branch `main`; je Issue/Etappe `phase/NNN-kurzname`, nach Abschluss `--ff-only` nach `main`.
- Conventional Commits, deutscher Betreff ≤ 72 Zeichen, Körper erklärt das Warum, Fußzeile
`Closes #NNN` bzw. `Refs #NNN`. Identität `Florian Wilde <wilde.florian@gmail.com>`.
- SemVer; jede Etappe ein annotierter Tag `vX.Y.Z`. **1.0.0 erst nach dem Feldtest** auf echtem
Proxmox 8.x und 9.x (#020).
- `CHANGELOG.md` (Keep a Changelog, deutsch) und `DEVLOG.md` (Zeit je Issue/Meilenstein aus den
Commit-Zeitstempeln, Sitzungen mit „Erledigt“ und „Entscheidungen“) werden mit jedem Issue gepflegt;
`docs/issues/*.md` tragen Status und tatsächliche Zeit.
## Gestaltung
Farben nur als Tokens aus `src/app/globals.css` (`bg-panel`, `text-muted`, `border-linie`,
`text-akzent`, `text-laeuft`, `text-fehler`); keine rohen Hexwerte in Komponenten. Ausnahmen:
`themeColor` in `src/app/layout.tsx` (verträgt keine CSS-Variablen) und die Brand-Dateien unter
`public/brand/`. Kein Proxmox-Orange. Status immer Symbol + Farbe + Text (`<Status>`).
Karten mit 1-px-Linie, Radius 14 px, keine Schatten, keine Verläufe. Kontrast ≥ 4,5:1 in beiden Themes
(`tests/kontrast.test.ts`). Icons: Lucide.
+7 -2
View File
@@ -2,13 +2,13 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 0 h 25 min (laufende Sitzung) **Gesamt:** 0 h 50 min (laufende Sitzung)
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten. Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
| #001 Projekt-Setup | 0 h 25 min (laufend) | | #001 Projekt-Setup | 0 h 50 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -26,6 +26,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- Proxmox-API 8.x/9.x gegen API-Schema 9.2, Quellen der stable-8-Zweige und Release Notes geprüft (Ergebnis: `docs/architektur.md` Abschnitt 3) - Proxmox-API 8.x/9.x gegen API-Schema 9.2, Quellen der stable-8-Zweige und Release Notes geprüft (Ergebnis: `docs/architektur.md` Abschnitt 3)
- Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft - Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft
- Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt - Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt
- Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
### Entscheidungen ### Entscheidungen
- **VM-Provisionierung immer über den Guest-Agent:** funktioniert ohne SSH und liefert Live-Fortschritt. Snippets braucht es nur beim Template-Bau. - **VM-Provisionierung immer über den Guest-Agent:** funktioniert ohne SSH und liefert Live-Fortschritt. Snippets braucht es nur beim Template-Bau.
@@ -34,4 +36,7 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- **Template-Bau per API erst ab PVE 8.4** (`download-url` mit `content=import`); davor über SSH. - **Template-Bau per API erst ab PVE 8.4** (`download-url` mit `content=import`); davor über SSH.
- **Planer und Ausführung getrennt:** der Dry-Run zeigt exakt den Plan, den der worker danach abarbeitet. - **Planer und Ausführung getrennt:** der Dry-Run zeigt exakt den Plan, den der worker danach abarbeitet.
- **CLI in Go** mit aus OpenAPI generiertem Client. - **CLI in Go** mit aus OpenAPI generiertem Client.
- **Konfiguration aus Laufzettel übernommen** (gleicher Stack, Versionen dort bereits erprobt); Next auf 16.4.0 wie ProxLedger (Sicherheitsstand).
- **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern.
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe). - **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
+11
View File
@@ -0,0 +1,11 @@
// Conventional Commits. Betreff auf Deutsch, hoechstens 72 Zeichen (siehe CLAUDE.md).
const konfiguration = {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [2, "always", 72],
"subject-case": [0],
"body-max-line-length": [0],
},
};
export default konfiguration;
+26
View File
@@ -0,0 +1,26 @@
# Nur für die Entwicklung: PostgreSQL auf eigenem Port (5437), damit die Datenbanken
# anderer Projekte frei bleiben. Niemals im Betrieb verwenden.
name: proxdeploy-dev
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: proxdeploy
POSTGRES_PASSWORD: proxdeploy
POSTGRES_DB: proxdeploy
ports:
- "127.0.0.1:${DEV_DB_PORT:-5437}:5432"
volumes:
- datenbank-dev:/var/lib/postgresql/data
- ./scripts/testdatenbank.sql:/docker-entrypoint-initdb.d/testdatenbank.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U proxdeploy"]
interval: 5s
timeout: 5s
retries: 10
volumes:
datenbank-dev:
+18 -12
View File
@@ -1,27 +1,33 @@
# #001 Projekt-Setup # #001 Projekt-Setup
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** chore, infrastruktur **Labels:** chore, infrastruktur
**Geschätzt:** 4 h **Tatsächlich:** – **Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
## Ziel ## Ziel
Lauffähiges Grundgerüst mit Projektorganisation, Design-Tokens und Prüfwerkzeugen. Lauffähiges Grundgerüst mit Projektorganisation, Design-Tokens und Prüfwerkzeugen.
## Aufgaben ## Aufgaben
- [ ] Git, `.gitignore`, CHANGELOG, DEVLOG, Issues, `docs/architektur.md`, `CLAUDE.md` mit Namensabbildung - [x] Git, `.gitignore`, CHANGELOG, DEVLOG, Issues, `docs/architektur.md`, `CLAUDE.md` mit Namensabbildung
- [ ] Next.js 16, TypeScript strict, Tailwind 4 mit Tokens aus der Farbtabelle (dunkel + hell) - [x] Next.js 16, TypeScript strict, Tailwind 4 mit Tokens aus der Farbtabelle (dunkel + hell)
- [ ] Prisma 7 + Docker-Compose für Entwicklung (Postgres), Vitest mit Test-DB, ESLint/Prettier - [x] Prisma 7 + Docker-Compose für Entwicklung (Postgres auf 5437 mit Testdatenbank), Vitest mit Test-DB, ESLint/Prettier, commitlint + husky
- [ ] Versionen exakt pinnen und begründen; Build/Tests laufen in Docker (lokal kein Node) - [x] Versionen exakt pinnen und begründen (`CLAUDE.md`)
- [ ] Grundlayout: Seitenleiste mit Wortmarke, Server-Umschalter, Theme-Umschalter, `<Status>`-Komponente - [x] Grundlayout: Seitenleiste mit Wortmarke, Theme-Umschalter, `<Status>`-Komponente, Handy-Schublade
- [ ] Lokale Anmeldung (erster Admin aus `.env`), Sitzungs-Cookie, `/api/v1/gesundheit` - [x] Lokale Anmeldung (erster Admin aus `.env`), Sitzungs-Cookie, `/api/v1/gesundheit`, `/api/v1/ich`
- [ ] pino-Logger (JSON), `.env.example` - [x] pino-Logger (JSON), `.env.example`
- [x] AES-256-GCM-Helfer (vorgezogen aus #002, weil der Start ihn prüft)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `lint`, `typecheck`, `test`, `build` grün im Container - [x] `lint`, `typecheck`, `format:pruefen`, `test` grün (60 Tests, davon 15 gegen die Datenbank)
- [ ] Anmeldung funktioniert im gebauten Stand; keine Secrets im Repo - [x] Anmeldung funktioniert im gebauten Stand; Playwright: 8 Abläufe auf 1440 px und 390 px grün
- [x] Keine Secrets im Repo
## Abhängigkeiten ## Abhängigkeiten
– –
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Node läuft über nvm (nicht systemweit) – Docker ist für die Builds nicht nötig.
- Server-Umschalter in der Seitenleiste folgt mit #002, wenn es Server gibt.
- Navigationseinträge tragen `ab: "#NNN"` und erscheinen erst, wenn ihr Issue fertig ist.
- Anmeldesperre: 10 Fehlversuche je Benutzername und Adresse in 15 min, im Speicher.
- Das Protokoll ist per Trigger nur anhängbar; nur das Nullsetzen von `benutzerId` beim Löschen eines Benutzers ist erlaubt.
+1 -1
View File
@@ -4,7 +4,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich | | Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| [#001](001-projekt-setup.md) | Projekt-Setup | offen | v0.1.0 | 4 h | – | | [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | offen | v0.1.0 | 6 h | – | | [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | offen | v0.1.0 | 6 h | – |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – | | [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – | | [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
+47
View File
@@ -0,0 +1,47 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import prettier from "eslint-config-prettier/flat";
export default defineConfig([
...nextVitals,
...nextTs,
prettier,
{
rules: {
// Zod wird in src/lib/validierung.ts auf deutsche Meldungen eingestellt.
// Ein direkter Import aus "zod" umginge das, und die englischen
// Standardsaetze landeten in der Oberflaeche.
"no-restricted-imports": [
"error",
{
paths: [
{
name: "zod",
message:
'Bitte `import { z } from "@/lib/validierung"` benutzen – dort sind die deutschen Meldungen eingestellt.',
},
],
},
],
},
},
{
// Die Datei, die Zod einstellt, muss es selbst importieren duerfen.
files: ["src/lib/validierung.ts"],
rules: { "no-restricted-imports": "off" },
},
globalIgnores([
".next/**",
"out/**",
"build/**",
"coverage/**",
"next-env.d.ts",
// Erzeugter Prisma-Client.
"src/generated/**",
"test-results/**",
"playwright-report/**",
"dist/**",
"cli/**",
]),
]);
+21
View File
@@ -0,0 +1,21 @@
import type { NextConfig } from "next";
/** Feste Sicherheitskopfzeilen; die CSP setzt src/proxy.ts je Anfrage mit Nonce. */
const SICHERHEITS_KOPFZEILEN = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
];
const konfiguration: NextConfig = {
// Schlankes Laufzeit-Abbild für Dockerfile und LXC-Installer.
output: "standalone",
// ssh2 bringt optionale native Teile mit, die der Bundler nicht anfassen soll.
serverExternalPackages: ["ssh2", "pg-boss", "pino"],
async headers() {
return [{ source: "/:pfad*", headers: SICHERHEITS_KOPFZEILEN }];
},
};
export default konfiguration;
+78
View File
@@ -0,0 +1,78 @@
{
"name": "proxdeploy",
"version": "0.1.0-dev",
"private": true,
"type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
"packageManager": "pnpm@12.6.0",
"engines": {
"node": ">=24"
},
"scripts": {
"build": "next build",
"db:down": "docker compose -f docker-compose.dev.yml down",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
"dev": "next dev",
"format": "prettier --write .",
"format:pruefen": "prettier --check .",
"lint": "eslint",
"postinstall": "prisma generate",
"prepare": "husky",
"start": "next start",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0",
"@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"clsx": "^2.1.1",
"jose": "^6.2.12",
"lucide-react": "1.47.0",
"next": "16.4.0",
"pg": "^8.23.0",
"pino": "10.4.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.13.6",
"@types/pg": "^8.23.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"dotenv": "^17.4.2",
"esbuild": "^0.27.0",
"eslint": "^9.39.5",
"eslint-config-next": "16.4.0",
"eslint-config-prettier": "^10.1.8",
"husky": "^9.1.7",
"lint-staged": "^17.5.1",
"prettier": "^3.9.9",
"prisma": "7.10.0",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.13",
"typescript": "~6.0.3",
"vite": "8.3.0",
"vitest": "5.0.1"
},
"lint-staged": {
"*.{ts,tsx,mts,mjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,css,yml}": [
"prettier --write"
]
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Ende-zu-Ende-Abläufe – immer gegen den **gebauten** Stand (`pnpm build && pnpm start`), weil
* `next dev` hier nicht verlässlich hydriert und ein Screenshot dann nichts beweist.
*/
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3310";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env["CI"]),
reporter: "list",
use: {
baseURL: APP_URL,
trace: "retain-on-failure",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
projects: [
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
// 390 px ist die Abnahmebreite fürs Handy (kein horizontales Scrollen).
{ name: "handy", use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } } },
],
});
+7489
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# pnpm 12 fuehrt Build-Skripte von Abhaengigkeiten nur nach ausdruecklicher Freigabe aus.
# Nur diese vier brauchen eines; alles andere laeuft ohne. Erweitern mit `pnpm approve-builds`.
allowBuilds:
"@prisma/engines": true
esbuild: true
prisma: true
unrs-resolver: true
+6
View File
@@ -0,0 +1,6 @@
// Tailwind 4 wird ueber sein PostCSS-Plugin eingebunden; eine tailwind.config.js gibt es nicht mehr.
const konfiguration = {
plugins: { "@tailwindcss/postcss": {} },
};
export default konfiguration;
+8
View File
@@ -0,0 +1,8 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: { url: process.env["DATABASE_URL"] },
});
@@ -0,0 +1,85 @@
-- CreateEnum
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'OPERATOR');
-- CreateEnum
CREATE TYPE "Quelle" AS ENUM ('WEB', 'CLI', 'API', 'SYSTEM');
-- CreateTable
CREATE TABLE "Benutzer" (
"id" TEXT NOT NULL,
"benutzername" TEXT NOT NULL,
"anzeigename" TEXT NOT NULL,
"email" TEXT,
"passwortHash" TEXT,
"oidcSubjekt" TEXT,
"rolle" "Rolle" NOT NULL DEFAULT 'OPERATOR',
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"standards" JSONB,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Protokoll" (
"id" BIGSERIAL NOT NULL,
"zeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"benutzerId" TEXT,
"quelle" "Quelle" NOT NULL,
"aktion" TEXT NOT NULL,
"ziel" TEXT,
"details" JSONB,
"ip" TEXT,
CONSTRAINT "Protokoll_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Einstellung" (
"schluessel" TEXT NOT NULL,
"wert" JSONB NOT NULL,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("schluessel")
);
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_benutzername_key" ON "Benutzer"("benutzername");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_oidcSubjekt_key" ON "Benutzer"("oidcSubjekt");
-- CreateIndex
CREATE INDEX "Protokoll_zeit_idx" ON "Protokoll"("zeit");
-- CreateIndex
CREATE INDEX "Protokoll_benutzerId_zeit_idx" ON "Protokoll"("benutzerId", "zeit");
-- AddForeignKey
ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Regeln, die auch gelten, wenn jemand an der Service-Schicht vorbei schreibt.
-- Das Audit-Log ist nur anhängbar. Eine Ausnahme braucht es nicht: auch das Löschen
-- eines Benutzers setzt benutzerId per FK auf NULL, und das ist ein UPDATE – das
-- erlaubt der Trigger nur für genau diese Spalte.
CREATE FUNCTION "protokoll_nur_anhaengen"() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'UPDATE'
AND NEW."benutzerId" IS NULL
AND (NEW.id, NEW.zeit, NEW.quelle, NEW.aktion, NEW.ziel, NEW.details, NEW.ip)
IS NOT DISTINCT FROM (OLD.id, OLD.zeit, OLD.quelle, OLD.aktion, OLD.ziel, OLD.details, OLD.ip) THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Das Protokoll ist nur anhängbar (%).', TG_OP USING ERRCODE = 'P0001';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "protokoll_nur_anhaengen"
BEFORE UPDATE OR DELETE ON "Protokoll"
FOR EACH ROW EXECUTE FUNCTION "protokoll_nur_anhaengen"();
ALTER TABLE "Benutzer"
ADD CONSTRAINT "benutzer_benutzername_format"
CHECK ("benutzername" ~ '^[a-z0-9][a-z0-9._-]{1,62}$');
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+63
View File
@@ -0,0 +1,63 @@
// Datenmodell von ProxDeploy. Namensabbildung zur Vorgabe: siehe CLAUDE.md.
// Modelle kommen mit dem Issue dazu, das sie braucht – der Plan in docs/architektur.md
// zeigt das Zielbild.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum Rolle {
ADMIN
OPERATOR
}
enum Quelle {
WEB
CLI
API
SYSTEM
}
model Benutzer {
id String @id @default(cuid())
benutzername String @unique
anzeigename String
email String?
/// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden.
passwortHash String?
oidcSubjekt String? @unique
rolle Rolle @default(OPERATOR)
aktiv Boolean @default(true)
/// Persönliche Standardwerte im Assistenten.
standards Json?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
protokoll Protokoll[]
}
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
model Protokoll {
id BigInt @id @default(autoincrement())
zeit DateTime @default(now())
benutzerId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
quelle Quelle
aktion String
ziel String?
details Json?
ip String?
@@index([zeit])
@@index([benutzerId, zeit])
}
model Einstellung {
schluessel String @id
wert Json
geaendertAm DateTime @updatedAt
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

+1
View File
@@ -0,0 +1 @@
CREATE DATABASE proxdeploy_test OWNER proxdeploy;
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Seitenleiste } from "@/components/seitenleiste";
import { NAVIGATION } from "@/lib/navigation";
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
import { versionText } from "@/lib/version";
import { darf } from "@/server/auth/rechte";
import { aktuellerBenutzer } from "@/server/auth/zugang";
/**
* Hülle aller angemeldeten Seiten. Sie ist das **zweite** Netz – jede Seite prüft ihr Recht
* selbst, weil Next dieses Layout bei Navigation innerhalb der Gruppe nicht neu rendert.
*/
export default async function AppHuelle({ children }: { children: React.ReactNode }) {
const benutzer = await aktuellerBenutzer();
if (!benutzer) redirect("/anmelden");
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
const eintraege = NAVIGATION.filter((eintrag) => !eintrag.ab && darf(benutzer.rolle, eintrag.recht));
return (
<div className="lg:flex">
<Seitenleiste
eintraege={eintraege}
benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }}
version={versionText()}
theme={theme}
serverUmschalter={null}
/>
<main id="inhalt" className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-10">
<div className="mx-auto max-w-6xl">{children}</div>
</main>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { seiteSchuetzen } from "@/server/auth/zugang";
export const metadata: Metadata = { title: "Übersicht" };
export default async function Uebersicht() {
const benutzer = await seiteSchuetzen("lesen", "/");
return (
<div className="flex flex-col gap-6">
<header>
<p className="text-muted text-sm">Willkommen, {benutzer.anzeigename}</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Übersicht</h1>
</header>
<div className="grid gap-6 lg:grid-cols-2">
<Karte titel="Server">
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
<p className="text-muted mt-2 text-sm">
Server lassen sich anlegen, sobald die Server-Verwaltung (#002) fertig ist.
</p>
</Karte>
<Karte titel="Letzte Deployments">
<Status art="offen">Noch keine Deployments.</Status>
</Karte>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
export function Anmeldeformular({ ziel }: { ziel: string }) {
const router = useRouter();
const [fehler, setFehler] = useState<string | null>(null);
const [sendet, setSendet] = useState(false);
async function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
const daten = new FormData(ereignis.currentTarget);
setSendet(true);
setFehler(null);
try {
const antwort = await fetch("/api/v1/auth/anmelden", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ benutzername: daten.get("benutzername"), passwort: daten.get("passwort") }),
});
if (!antwort.ok) {
const inhalt = (await antwort.json().catch(() => null)) as {
fehler?: { nachricht?: string; hinweis?: string };
} | null;
setFehler(
[inhalt?.fehler?.nachricht ?? "Anmeldung fehlgeschlagen.", inhalt?.fehler?.hinweis]
.filter(Boolean)
.join(" "),
);
return;
}
router.replace(ziel);
router.refresh();
} catch {
setFehler("ProxDeploy ist nicht erreichbar.");
} finally {
setSendet(false);
}
}
const feld =
"bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 placeholder:text-muted focus:border-akzent";
return (
<form
onSubmit={absenden}
className="bg-panel border-linie rounded-karte flex flex-col gap-4 border p-6"
noValidate
>
<div className="flex flex-col gap-1.5">
<label htmlFor="benutzername" className="text-sm font-medium">
Benutzername
</label>
<input
id="benutzername"
name="benutzername"
autoComplete="username"
required
autoFocus
className={feld}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="passwort" className="text-sm font-medium">
Passwort
</label>
<input
id="passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
className={feld}
/>
</div>
<div aria-live="polite" className="min-h-5 text-sm">
{fehler ? <Status art="fehler">{fehler}</Status> : null}
</div>
<Knopf type="submit" disabled={sendet}>
{sendet ? "Anmelden …" : "Anmelden"}
</Knopf>
</form>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { aktuellerBenutzer } from "@/server/auth/zugang";
import { Anmeldeformular } from "./anmeldeformular";
export const metadata: Metadata = { title: "Anmelden" };
/** Nur relative Ziele innerhalb der Anwendung – sonst wäre `?ziel=` eine offene Weiterleitung. */
function sicheresZiel(ziel: string | string[] | undefined): string {
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
}
export default async function Anmelden({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const ziel = sicheresZiel((await searchParams)["ziel"]);
if (await aktuellerBenutzer()) redirect(ziel);
return (
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
<Symbol className="h-14 w-14" />
<h1 className="text-2xl">
<Wortmarke blinkt />
</h1>
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
</div>
<Anmeldeformular ziel={ziel} />
</div>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { SITZUNG_COOKIE } from "@/lib/sitzung";
import { api } from "@/server/api";
import { anfrageAufrufer } from "@/server/auth/zugang";
import { protokollieren } from "@/server/services/protokoll";
/** Öffentlich: Abmelden muss auch mit abgelaufener Sitzung gehen. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await anfrageAufrufer(anfrage);
if (aufrufer) {
await protokollieren({
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Abgemeldet",
ip: aufruferAdresse(anfrage.headers),
});
}
const antwort = NextResponse.json({ ok: true });
antwort.cookies.delete(SITZUNG_COOKIE);
return antwort;
});
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { aufruferAdresse, istHttps } from "@/lib/adressen";
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { mitPasswortAnmelden } from "@/server/services/anmeldung";
const Anmeldung = z.object({
benutzername: z.string().trim().min(1, "Benutzername fehlt.").max(64),
passwort: z.string().min(1, "Passwort fehlt.").max(1024),
});
/** Öffentlich (Proxy-Ausnahme) – sonst könnte sich niemand anmelden. */
export const POST = api(async (anfrage: Request) => {
const eingabe = Anmeldung.parse(await jsonLesen(anfrage));
const benutzer = await mitPasswortAnmelden(
eingabe.benutzername,
eingabe.passwort,
aufruferAdresse(anfrage.headers),
);
const antwort = NextResponse.json({ benutzername: benutzer.benutzername, rolle: benutzer.rolle });
antwort.cookies.set(
SITZUNG_COOKIE,
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "passwort" }),
{
httpOnly: true,
sameSite: "lax",
secure: istHttps(anfrage.headers, anfrage.url),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
},
);
return antwort;
});
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { VERSION } from "@/lib/version";
/** Healthcheck für Docker und Reverse-Proxy. Öffentlich, verrät nur die Version. */
export async function GET(): Promise<Response> {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: "ok", version: VERSION });
} catch {
return NextResponse.json({ status: "datenbank_nicht_erreichbar", version: VERSION }, { status: 503 });
}
}
export const dynamic = "force-dynamic";
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { VERSION } from "@/lib/version";
import { api } from "@/server/api";
import { RECHTE, darf, type Recht } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang";
/** Wer bin ich, was darf ich – für die CLI (`proxdeploy login`) und die GUI. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
const rechte = (Object.keys(RECHTE) as Recht[]).filter((recht) => darf(benutzer.rolle, recht));
return NextResponse.json({
benutzername: benutzer.benutzername,
anzeigename: benutzer.anzeigename,
rolle: benutzer.rolle,
rechte,
version: VERSION,
});
});
+92
View File
@@ -0,0 +1,92 @@
@import "tailwindcss";
/*
* Farben von ProxDeploy – die einzige Stelle mit Hexwerten. Komponenten greifen nur über die
* Tailwind-Klassen (bg-panel, text-muted, border-linie …) oder var(--…) darauf zu.
*
* Dunkel ist der Standard. Das helle Theme setzt dieselben Namen neu, damit keine Komponente
* zwei Varianten kennen muss. Amber und Rot sind auf Hell abgedunkelt: die Originaltöne
* erreichen auf #F4F6F8 keine 4,5:1 (tests/kontrast.test.ts prüft beide Themes).
*/
:root,
[data-theme="dunkel"] {
--farbe-grund: #11161c; /* Ink */
--farbe-panel: #1a2129;
--farbe-panel-hoch: #212a34; /* Hover, Eingabefelder */
--farbe-linie: #2a333d;
--farbe-muted: #8b98a5;
--farbe-text: #e6edf3; /* Paper */
--farbe-akzent: #2dd4bf; /* Teal */
--farbe-akzent-text: #11161c; /* Text auf Akzentfläche */
--farbe-laeuft: #f5b841; /* Amber */
--farbe-fehler: #f87171;
--farbe-fokus: #2dd4bf;
color-scheme: dark;
}
[data-theme="hell"] {
--farbe-grund: #f4f6f8;
--farbe-panel: #ffffff;
--farbe-panel-hoch: #eef1f4;
--farbe-linie: #d5dbe1;
--farbe-muted: #4f5b66;
--farbe-text: #11161c;
--farbe-akzent: #0f766e; /* Teal dunkel */
--farbe-akzent-text: #ffffff;
--farbe-laeuft: #8a5a00;
--farbe-fehler: #b91c1c;
--farbe-fokus: #0f766e;
color-scheme: light;
}
@theme inline {
--color-grund: var(--farbe-grund);
--color-panel: var(--farbe-panel);
--color-panel-hoch: var(--farbe-panel-hoch);
--color-linie: var(--farbe-linie);
--color-muted: var(--farbe-muted);
--color-text: var(--farbe-text);
--color-akzent: var(--farbe-akzent);
--color-akzent-text: var(--farbe-akzent-text);
--color-laeuft: var(--farbe-laeuft);
--color-fehler: var(--farbe-fehler);
--font-sans: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", monospace;
--radius-karte: 14px;
}
html {
background: var(--farbe-grund);
color: var(--farbe-text);
}
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Fokus immer sichtbar, aber nur bei Tastaturbedienung. */
:focus-visible {
outline: 2px solid var(--farbe-fokus);
outline-offset: 2px;
border-radius: 6px;
}
@keyframes pd-cursor {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: no-preference) {
.pd-cursor-blinkt {
animation: pd-cursor 1.1s steps(1) infinite;
}
}
+17
View File
@@ -0,0 +1,17 @@
import Link from "next/link";
import { Status } from "@/components/status";
/** Ziel von `seiteSchuetzen`, wenn die Rolle nicht reicht. Zeigt nichts außer dem Hinweis. */
export default function KeinRecht() {
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center gap-4 px-4">
<Status art="fehler" className="text-lg font-semibold">
Dafür fehlt die Berechtigung.
</Status>
<p className="text-muted">Ein Administrator kann die Rolle ändern.</p>
<Link href="/" className="text-akzent underline underline-offset-4">
Zur Übersicht
</Link>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import "@fontsource-variable/space-grotesk";
import "@fontsource-variable/jetbrains-mono";
import "./globals.css";
import type { Metadata, Viewport } from "next";
import { cookies } from "next/headers";
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
export const metadata: Metadata = {
title: { default: "ProxDeploy", template: "%s · ProxDeploy" },
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
icons: { icon: "/brand/symbol.svg" },
};
// themeColor verträgt keine CSS-Variablen; Ink ist der Hintergrund des dunklen Themes.
export const viewport: Viewport = { themeColor: "#11161C", width: "device-width", initialScale: 1 };
export default async function Wurzel({ children }: { children: React.ReactNode }) {
// Das Theme steht im Cookie, damit der Server gleich richtig rendert und nichts aufblitzt.
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
return (
<html lang="de" data-theme={theme}>
<body className="bg-grund text-text min-h-dvh">{children}</body>
</html>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { clsx } from "clsx";
/** Panel-Karte: 1-px-Rahmen in Linienfarbe, Radius 14 px, keine Schatten. */
export function Karte({
titel,
aktion,
children,
className,
}: {
titel?: React.ReactNode;
aktion?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={clsx("bg-panel border-linie rounded-karte border", className)}>
{titel ? (
<header className="border-linie flex items-center justify-between gap-3 border-b px-5 py-3.5">
<h2 className="text-base font-semibold">{titel}</h2>
{aktion}
</header>
) : null}
<div className="p-5">{children}</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { clsx } from "clsx";
type Variante = "primaer" | "sekundaer" | "gefahr";
const VARIANTEN: Record<Variante, string> = {
primaer: "bg-akzent text-akzent-text hover:opacity-90",
sekundaer: "border border-linie bg-panel-hoch text-text hover:border-muted",
gefahr: "border border-fehler text-fehler hover:bg-fehler/10",
};
/** Ein echter `<button>`; 40 px hoch, am Handy 44 px (Tippfläche). */
export function Knopf({
variante = "primaer",
className,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variante?: Variante }) {
return (
<button
type="button"
{...rest}
className={clsx(
"inline-flex h-11 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition sm:h-10",
"disabled:cursor-not-allowed disabled:opacity-50",
VARIANTEN[variante],
className,
)}
/>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { clsx } from "clsx";
import {
BookOpen,
FileStack,
History,
LayoutDashboard,
LogOut,
Menu,
Rocket,
Server,
Settings,
Layers,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { istAktiv, type NavigationsEintrag } from "@/lib/navigation";
import type { Theme } from "@/lib/theme";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { ThemeUmschalter } from "@/components/theme-umschalter";
const SYMBOLE: Record<
NavigationsEintrag["symbol"],
React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>
> = {
uebersicht: LayoutDashboard,
ausrollen: Rocket,
verlauf: History,
templates: Layers,
vorlagen: FileStack,
katalog: BookOpen,
server: Server,
einstellungen: Settings,
};
type Eigenschaften = {
eintraege: readonly NavigationsEintrag[];
benutzer: { anzeigename: string; rolle: string };
version: string;
theme: Theme;
serverUmschalter: React.ReactNode;
};
export function Seitenleiste({ eintraege, benutzer, version, theme, serverUmschalter }: Eigenschaften) {
const pfad = usePathname();
const router = useRouter();
const [offen, setOffen] = useState(false);
async function abmelden() {
await fetch("/api/v1/auth/abmelden", { method: "POST" });
router.replace("/anmelden");
router.refresh();
}
const inhalt = (
<div className="flex h-full flex-col gap-6 p-4">
<Link href="/" className="flex items-center gap-2.5 px-2 pt-1" onClick={() => setOffen(false)}>
<Symbol className="h-8 w-8 shrink-0" />
<Wortmarke className="text-lg" />
</Link>
{serverUmschalter}
<nav aria-label="Hauptnavigation" className="flex-1">
<ul className="flex flex-col gap-0.5">
{eintraege.map((eintrag) => {
const Icon = SYMBOLE[eintrag.symbol];
const aktiv = istAktiv(eintrag.pfad, pfad);
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={clsx(
"flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition sm:h-10",
aktiv ? "bg-panel-hoch text-text" : "text-muted hover:text-text hover:bg-panel-hoch/60",
)}
>
<span className={aktiv ? "text-akzent" : undefined}>
<Icon size={18} aria-hidden={true} />
</span>
{eintrag.titel}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-linie flex flex-col gap-3 border-t pt-4">
<div className="flex items-center justify-between gap-2 px-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{benutzer.anzeigename}</p>
<p className="text-muted text-xs">{benutzer.rolle === "ADMIN" ? "Administrator" : "Operator"}</p>
</div>
<div className="flex items-center">
<ThemeUmschalter anfang={theme} />
<button
type="button"
onClick={abmelden}
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
aria-label="Abmelden"
title="Abmelden"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</div>
<p className="text-muted px-2 font-mono text-xs">{version}</p>
</div>
</div>
);
return (
<>
{/* Handy: Kopfleiste mit Menüknopf, Navigation als Schublade. */}
<div className="border-linie bg-panel sticky top-0 z-30 flex h-14 items-center justify-between border-b px-4 lg:hidden">
<Link href="/" className="flex items-center gap-2">
<Symbol className="h-7 w-7" />
<Wortmarke />
</Link>
<button
type="button"
onClick={() => setOffen(true)}
className="inline-flex h-11 w-11 items-center justify-center rounded-lg"
aria-label="Navigation öffnen"
aria-expanded={offen}
aria-controls="seitenleiste"
>
<Menu size={22} aria-hidden="true" />
</button>
</div>
{offen ? (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
aria-hidden="true"
onClick={() => setOffen(false)}
/>
) : null}
<aside
id="seitenleiste"
className={clsx(
"border-linie bg-panel fixed inset-y-0 left-0 z-50 w-72 border-r transition-transform lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
type="button"
onClick={() => setOffen(false)}
className="absolute top-3 right-3 inline-flex h-11 w-11 items-center justify-center rounded-lg lg:hidden"
aria-label="Navigation schließen"
>
<X size={20} aria-hidden="true" />
</button>
{inhalt}
</aside>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { clsx } from "clsx";
/**
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
* Dieselben Symbole wie in der CLI-Ausgabe.
*/
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
export const STATUS_SYMBOL: Record<StatusArt, string> = {
erledigt: "✓",
laeuft: "◐",
fehler: "✗",
offen: "○",
};
const STATUS_FARBE: Record<StatusArt, string> = {
erledigt: "text-akzent",
laeuft: "text-laeuft",
fehler: "text-fehler",
offen: "text-muted",
};
export function Status({
art,
children,
className,
}: {
art: StatusArt;
children: React.ReactNode;
className?: string;
}) {
return (
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
<span aria-hidden="true" className="font-mono">
{STATUS_SYMBOL[art]}
</span>
<span>{children}</span>
</span>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useState } from "react";
import { THEME_COOKIE, type Theme } from "@/lib/theme";
export function ThemeUmschalter({ anfang }: { anfang: Theme }) {
const [theme, setTheme] = useState<Theme>(anfang);
const naechstes: Theme = theme === "dunkel" ? "hell" : "dunkel";
function umschalten() {
document.documentElement.dataset["theme"] = naechstes;
document.cookie = `${THEME_COOKIE}=${naechstes}; path=/; max-age=31536000; samesite=lax`;
setTheme(naechstes);
}
return (
<button
type="button"
onClick={umschalten}
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
aria-label={naechstes === "hell" ? "Helles Theme einschalten" : "Dunkles Theme einschalten"}
title={naechstes === "hell" ? "Helles Theme" : "Dunkles Theme"}
>
{theme === "dunkel" ? <Sun size={18} aria-hidden="true" /> : <Moon size={18} aria-hidden="true" />}
</button>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { clsx } from "clsx";
/**
* Wortmarke nach Branding-Vorgabe: „Prox“ normal, „Deploy“ fett in Akzentfarbe, dahinter ein
* Block-Cursor. Die Schrift ist immer JetBrains Mono.
*/
export function Wortmarke({ className, blinkt = false }: { className?: string; blinkt?: boolean }) {
return (
<span className={clsx("font-mono tracking-tight whitespace-nowrap", className)}>
<span className="text-text">Prox</span>
<span className="text-akzent font-bold">Deploy</span>
<span
aria-hidden="true"
className={clsx("bg-akzent ml-[0.12em] inline-block h-[0.95em] w-[0.5em] align-[-0.12em]", {
"pd-cursor-blinkt": blinkt,
})}
/>
</span>
);
}
/** Symbol „Launch-Node“ als Inline-SVG, damit es die Theme-Farben übernimmt. */
export function Symbol({ className, titel }: { className?: string; titel?: string }) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
className={className}
role={titel ? "img" : undefined}
aria-hidden={titel ? undefined : true}
>
{titel ? <title>{titel}</title> : null}
<path
d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z"
stroke="var(--farbe-text)"
strokeWidth="3.5"
strokeLinejoin="round"
/>
<path
d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5"
stroke="var(--farbe-akzent)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M24 47 H40" stroke="var(--farbe-akzent)" strokeWidth="4" strokeLinecap="round" />
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
*/
export async function register(): Promise<void> {
if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
const { schluesselPruefen } = await import("@/lib/geheimnisse");
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger");
try {
schluesselPruefen();
await ersterAdminFallsLeer();
} catch (fehler) {
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Adressen der Anwendung nach außen.
*
* Maßgeblich ist APP_URL, nicht die eingehende Anfrage: hinter dem Reverse-Proxy trägt die
* Anfrage den internen Host und oft das falsche Schema. Die Herkunft der Anfrage dient nur als
* Notnagel, damit die Anwendung ohne APP_URL nicht stehen bleibt.
*/
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
const basis = process.env["APP_URL"]?.trim();
if (basis) {
try {
return new URL(pfad, basis);
} catch {
// Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren.
}
}
return new URL(pfad, ersatzHerkunft);
}
/**
* Ob die Verbindung des Browsers HTTPS ist. Hinter einem Reverse-Proxy (TRUST_PROXY=true) zählt
* X-Forwarded-Proto, sonst das Schema der Anfrage. Davon hängt das `Secure`-Attribut des
* Sitzungscookies ab: fest gesetzt, scheitert die Anmeldung über reines HTTP stillschweigend
* (in ProxLedger 0.11.1 passiert).
*/
export function istHttps(kopfzeilen: Headers, anfrageUrl: string): boolean {
if (process.env["TRUST_PROXY"] === "true") {
const weitergeleitet = kopfzeilen.get("x-forwarded-proto")?.split(",")[0]?.trim();
if (weitergeleitet) return weitergeleitet === "https";
}
return new URL(anfrageUrl).protocol === "https:";
}
/**
* Adresse des Aufrufers für Protokoll und Anmeldesperre. Ohne vertrauten Proxy sind alle
* Kopfzeilen vom Aufrufer frei wählbar, dann bleibt die Adresse lieber leer als erfunden.
*/
export function aufruferAdresse(kopfzeilen: Headers): string | null {
if (process.env["TRUST_PROXY"] !== "true") return null;
return kopfzeilen.get("x-forwarded-for")?.split(",")[0]?.trim() || kopfzeilen.get("x-real-ip");
}
+39
View File
@@ -0,0 +1,39 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Zugriff auf die Datenbank.
*
* Der Client wird erst beim ersten Zugriff gebaut. Dadurch laeuft `next build`
* ohne gesetzte DATABASE_URL durch, obwohl Module den Import auf oberster Ebene
* haben. In der Entwicklung liegt die Instanz an globalThis, damit Hot Reload
* nicht bei jedem Speichern eine neue Verbindung aufbaut.
*/
function clientBauen(): PrismaClient {
const url = process.env["DATABASE_URL"];
if (!url) {
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
}
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
}
const globalerZwischenspeicher = globalThis as typeof globalThis & {
proxdeployPrisma?: PrismaClient;
};
let instanz: PrismaClient | undefined;
function instanzHolen(): PrismaClient {
if (process.env["NODE_ENV"] !== "production") {
globalerZwischenspeicher.proxdeployPrisma ??= clientBauen();
return globalerZwischenspeicher.proxdeployPrisma;
}
instanz ??= clientBauen();
return instanz;
}
export const prisma = new Proxy({} as PrismaClient, {
get(_ziel, eigenschaft, empfaenger) {
return Reflect.get(instanzHolen(), eigenschaft, empfaenger);
},
});
+99
View File
@@ -0,0 +1,99 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
/**
* Verschlüsselt Geheimnisse, die ProxDeploy im Klartext wieder braucht: Proxmox-Token-Secrets,
* SSH-Privatschlüssel, das OIDC-Client-Secret. Anmeldepasswörter und CLI-Tokens werden dagegen
* gehasht und nie entschlüsselt.
*
* AES-256-GCM, weil es neben der Vertraulichkeit auch die Unverfälschtheit bezeugt: ein
* veränderter Geheimtext wirft beim Entschlüsseln, statt still Unsinn zu liefern.
*
* Der Schlüssel kommt **direkt** aus `PROXDEPLOY_SCHLUESSEL` (32 Byte, base64) und wird nicht aus
* einem Passwort abgeleitet: Er ist ohnehin zufällig erzeugt, und eine Ableitung hätte nur ein
* festes Salz im Code gebraucht, ohne etwas zu schützen.
*/
const ALGORITHMUS = "aes-256-gcm";
const IV_LAENGE = 12;
const AUTH_TAG_LAENGE = 16;
const SCHLUESSEL_LAENGE = 32;
/** Versionspräfix, damit ein späterer Schlüsseltausch alte Werte noch erkennt. */
const FORMAT_VERSION = "v1";
export class GeheimnisFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) {
super(nachricht, optionen);
this.name = "GeheimnisFehler";
}
}
let zwischenspeicher: { quelle: string; schluessel: Buffer } | undefined;
function schluesselHolen(): Buffer {
const quelle = process.env["PROXDEPLOY_SCHLUESSEL"]?.trim() ?? "";
if (zwischenspeicher?.quelle === quelle) return zwischenspeicher.schluessel;
const schluessel = Buffer.from(quelle, "base64");
// Die tatsächliche Länge steht bewusst nicht in der Meldung – sie könnte in einem Log landen.
if (schluessel.length !== SCHLUESSEL_LAENGE) {
throw new GeheimnisFehler(
"PROXDEPLOY_SCHLUESSEL fehlt oder ist kein base64-kodierter 32-Byte-Schlüssel. " +
"Erzeugen mit: openssl rand -base64 32",
);
}
zwischenspeicher = { quelle, schluessel };
return schluessel;
}
/** Prüft beim Start, ob der Schlüssel brauchbar ist, statt erst beim ersten Server-Zugriff. */
export function schluesselPruefen(): void {
schluesselHolen();
}
export function verschluesseln(klartext: string): string {
const schluessel = schluesselHolen();
// Je Aufruf ein neuer IV. Derselbe IV zweimal mit demselben Schlüssel bricht GCM vollständig.
const iv = randomBytes(IV_LAENGE);
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
// base64 enthält keinen Punkt, er taugt also als Trenner.
return [
FORMAT_VERSION,
iv.toString("base64"),
chiffre.getAuthTag().toString("base64"),
geheim.toString("base64"),
].join(".");
}
export function entschluesseln(geheimtext: string): string {
const teile = geheimtext.split(".");
const [version, ivTeil, tagTeil, datenTeil] = teile;
if (teile.length !== 4 || !version || !ivTeil || !tagTeil || datenTeil === undefined) {
throw new GeheimnisFehler("Geheimtext hat ein unbekanntes Format.");
}
if (version !== FORMAT_VERSION) {
throw new GeheimnisFehler(`Geheimtext trägt die unbekannte Version „${version}“.`);
}
const iv = Buffer.from(ivTeil, "base64");
const authTag = Buffer.from(tagTeil, "base64");
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
throw new GeheimnisFehler("Geheimtext ist unvollständig: IV oder Auth-Tag haben die falsche Länge.");
}
const schluessel = schluesselHolen();
try {
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
entschluessler.setAuthTag(authTag);
// `final` prüft das Auth-Tag und wirft bei verändertem Text oder falschem Schlüssel.
return Buffer.concat([
entschluessler.update(Buffer.from(datenTeil, "base64")),
entschluessler.final(),
]).toString("utf8");
} catch (ursache) {
throw new GeheimnisFehler(
"Geheimtext ließ sich nicht entschlüsseln. Entweder wurde der Wert verändert, oder " +
"PROXDEPLOY_SCHLUESSEL ist nicht mehr derselbe wie beim Verschlüsseln.",
{ cause: ursache },
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import pino from "pino";
/**
* Strukturierte Logs (JSON auf stdout) für den Betrieb. Geheimnisse werden an den bekannten
* Stellen geschwärzt – zusätzlich zur Regel, sie gar nicht erst in Log-Objekte zu geben.
*/
export const logger = pino({
level: process.env["LOG_LEVEL"] ?? "info",
base: { dienst: process.env["PROXDEPLOY_DIENST"] ?? "app" },
messageKey: "nachricht",
timestamp: pino.stdTimeFunctions.isoTime,
redact: {
paths: [
"passwort",
"*.passwort",
"tokenGeheimnis",
"*.tokenGeheimnis",
"sshPrivat",
"*.sshPrivat",
"authorization",
"*.authorization",
"headers.cookie",
],
censor: "[geschwärzt]",
},
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Navigation der Seitenleiste. Jeder Eintrag nennt sein Recht als Schlüssel aus RECHTE
* (`src/server/auth/rechte.ts`); die Hülle blendet aus, was die Rolle nicht darf.
* Hier nur Text und Pfade, damit Client-Komponenten es importieren dürfen.
*/
export type NavigationsEintrag = {
pfad: string;
titel: string;
symbol:
| "uebersicht"
| "ausrollen"
| "verlauf"
| "templates"
| "vorlagen"
| "katalog"
| "server"
| "einstellungen";
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
ab?: string;
};
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen", ab: "#002" },
{
pfad: "/einstellungen",
titel: "Einstellungen",
symbol: "einstellungen",
recht: "eigeneZugaenge",
ab: "#015",
},
];
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
export function istAktiv(eintrag: string, pfad: string): boolean {
if (eintrag === "/") return pfad === "/";
return pfad === eintrag || pfad.startsWith(`${eintrag}/`);
}
+32
View File
@@ -0,0 +1,32 @@
import { hash, verify } from "@node-rs/argon2";
/**
* Anmeldepasswörter: argon2id mit den Vorgaben der Bibliothek (OWASP-konform).
* Wird nie entschlüsselt – das unterscheidet es von `geheimnisse.ts`.
*/
export const PASSWORT_MINDESTLAENGE = 12;
export async function passwortHashen(passwort: string): Promise<string> {
return await hash(passwort);
}
export async function passwortPruefen(passwortHash: string, passwort: string): Promise<boolean> {
try {
return await verify(passwortHash, passwort);
} catch {
// Ein kaputter Hash in der Datenbank ist kein Grund, die Anmeldung abstürzen zu lassen.
return false;
}
}
/**
* Ein fester Hash für den Fall „Benutzer gibt es nicht“: so dauert eine Anmeldung mit
* unbekanntem Namen genauso lange wie eine mit falschem Passwort, und die Antwortzeit verrät
* nicht, welche Benutzernamen existieren.
*/
let attrappe: Promise<string> | undefined;
export async function attrappenPruefung(passwort: string): Promise<false> {
attrappe ??= hash("proxdeploy-attrappe-ohne-bedeutung");
await passwortPruefen(await attrappe, passwort);
return false;
}
+86
View File
@@ -0,0 +1,86 @@
import { SignJWT, jwtVerify } from "jose";
/**
* Das signierte Sitzungscookie der Web-GUI.
*
* Framework-frei, damit es ohne Next und ohne Datenbank testbar ist. Im Token steht nur, wer
* angemeldet ist und wie. Rolle und Aktiv-Status stehen bewusst **nicht** darin – sie werden je
* Anfrage aus der Datenbank gelesen, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
*/
export const SITZUNG_COOKIE = "pd_sitzung";
/** Ein Arbeitstag. */
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
export type SitzungsHerkunft = "passwort" | "oidc";
export type Sitzung = {
benutzerId: string;
herkunft: SitzungsHerkunft;
ausgestelltAm: number;
};
const AUSSTELLER = "proxdeploy";
const ZIELGRUPPE = "proxdeploy-web";
const ALGORITHMUS = "HS256";
const MINDESTLAENGE = 32;
export class SitzungsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "SitzungsFehler";
}
}
let zwischenspeicher: { quelle: string; schluessel: Uint8Array } | undefined;
function schluesselHolen(): Uint8Array {
const quelle = process.env["SITZUNG_GEHEIMNIS"] ?? "";
if (quelle.length < MINDESTLAENGE) {
throw new SitzungsFehler(
`SITZUNG_GEHEIMNIS fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
"Erzeugen mit: openssl rand -base64 48",
);
}
if (zwischenspeicher?.quelle !== quelle) {
zwischenspeicher = { quelle, schluessel: new TextEncoder().encode(quelle) };
}
return zwischenspeicher.schluessel;
}
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
return await new SignJWT({ herkunft: daten.herkunft })
.setProtectedHeader({ alg: ALGORITHMUS })
.setSubject(daten.benutzerId)
.setIssuer(AUSSTELLER)
.setAudience(ZIELGRUPPE)
.setIssuedAt()
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
.sign(schluesselHolen());
}
/** Gibt undefined zurück, wenn das Token fehlt, abgelaufen, verfälscht oder fremd ist. */
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
// Vor dem `try`: ein fehlendes Geheimnis ist ein Konfigurationsfehler, kein „nicht angemeldet“.
const schluessel = schluesselHolen();
if (!jwt) return undefined;
try {
const { payload } = await jwtVerify(jwt, schluessel, {
algorithms: [ALGORITHMUS],
issuer: AUSSTELLER,
audience: ZIELGRUPPE,
});
const { sub, iat, herkunft } = payload;
if (
typeof sub !== "string" ||
sub.length === 0 ||
typeof iat !== "number" ||
(herkunft !== "passwort" && herkunft !== "oidc")
) {
return undefined;
}
return { benutzerId: sub, herkunft, ausgestelltAm: iat };
} catch {
return undefined;
}
}
+7
View File
@@ -0,0 +1,7 @@
export const THEME_COOKIE = "pd_theme";
export type Theme = "dunkel" | "hell";
/** Dunkel ist der Standard; alles Unbekannte im Cookie wird zu dunkel. */
export function themeAusCookie(wert: string | undefined): Theme {
return wert === "hell" ? "hell" : "dunkel";
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
/**
* Zod mit deutschen Standardmeldungen.
*
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
* englische Saetze heraus („Invalid input: expected string“), und die landen
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
* ab, woran niemand gedacht hat.
*
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
*/
z.config(z.locales.de());
export { z };
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
export { ZodError } from "zod";
export type { ZodIssue, ZodType } from "zod";
+10
View File
@@ -0,0 +1,10 @@
/**
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
*/
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0-dev";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string {
return GIT_SHA ? `v${VERSION} · ${GIT_SHA}` : `v${VERSION}`;
}
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { SITZUNG_COOKIE } from "@/lib/sitzung";
/**
* Next 16: die frühere middleware.ts. Sie setzt die Content-Security-Policy mit Nonce und ist
* ein billiges Tor: ohne Sitzungscookie geht es zur Anmeldung. **Geprüft wird das Cookie hier
* nicht** – das tun Seiten und Route Handler selbst gegen die Datenbank.
*/
export const OEFFENTLICH: readonly string[] = [
"/anmelden",
"/api/v1/auth",
"/api/v1/gesundheit",
"/brand",
"/manifest.webmanifest",
];
export function istOeffentlich(pfad: string): boolean {
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
}
/**
* Im Entwicklungsmodus braucht React `unsafe-eval` und Fast Refresh `ws:` – ohne beides hydriert
* die Seite nicht. Der gebaute Stand bekommt die strenge Fassung (tests/sicherheitsrichtlinie).
*/
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data:`,
`font-src 'self'`,
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
].join("; ");
}
export function proxy(anfrage: NextRequest): NextResponse {
const pfad = anfrage.nextUrl.pathname;
// Die CLI meldet sich per Bearer-Token an; das prüft der Route Handler.
const hatToken = anfrage.headers.get("authorization")?.startsWith("Bearer ") ?? false;
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE) && !hatToken) {
// API bekommt 401 statt Umleitung: fetch folgte ihr sonst und hielte die Anmeldeseite für Daten.
if (pfad.startsWith("/api/")) {
return NextResponse.json(
{ fehler: { code: "nicht_angemeldet", nachricht: "Nicht angemeldet." } },
{ status: 401 },
);
}
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
const ziel = pfad + anfrage.nextUrl.search;
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
return NextResponse.redirect(anmelden);
}
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const kopfzeilen = new Headers(anfrage.headers);
kopfzeilen.set("x-nonce", nonce);
kopfzeilen.set("x-pfad", pfad);
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
antwort.headers.set(
"Content-Security-Policy",
sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production"),
);
return antwort;
}
export const config = {
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|ico|woff2?)$).*)"],
};
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { ZodError } from "@/lib/validierung";
import { logger } from "@/lib/logger";
import { AnwendungsFehler } from "@/server/fehler";
/**
* Einheitliche Fehlerantwort der REST-API – Web-GUI und CLI lesen dasselbe Format:
* `{ fehler: { code, nachricht, hinweis?, felder? } }`.
*/
export type FehlerAntwort = {
fehler: { code: string; nachricht: string; hinweis?: string; felder?: Record<string, string> };
};
export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
if (fehler instanceof AnwendungsFehler) {
return NextResponse.json(
{
fehler: {
code: fehler.code,
nachricht: fehler.message,
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
},
},
{ status: fehler.status },
);
}
if (fehler instanceof ZodError) {
const felder: Record<string, string> = {};
for (const problem of fehler.issues) {
const pfad = problem.path.join(".") || "_";
felder[pfad] ??= problem.message;
}
return NextResponse.json(
{
fehler: {
code: "ungueltig",
nachricht: "Die Eingaben sind nicht vollständig oder ungültig.",
felder,
},
},
{ status: 400 },
);
}
logger.error({ err: fehler }, "Unerwarteter Fehler in der API");
return NextResponse.json({ fehler: { code: "intern", nachricht: "Interner Fehler." } }, { status: 500 });
}
/** Umhüllt einen Route Handler: wandelt geworfene Fehler in das einheitliche Format. */
export function api<A extends unknown[]>(
handler: (...argumente: A) => Promise<Response>,
): (...argumente: A) => Promise<Response> {
return async (...argumente: A) => {
try {
return await handler(...argumente);
} catch (fehler) {
return fehlerAntwort(fehler);
}
};
}
/** Liest einen JSON-Rumpf; ein kaputter Rumpf ist ein 400, kein 500. */
export async function jsonLesen(anfrage: Request): Promise<unknown> {
try {
return await anfrage.json();
} catch {
throw new AnwendungsFehler(400, "kein_json", "Der Anfragerumpf ist kein gültiges JSON.");
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { Rolle } from "@/generated/prisma/enums";
/**
* Wer was darf – die einzige Stelle. Seiten, Route Handler und Navigation fragen `darf()`;
* niemand vergleicht selbst mit einer Rolle.
*
* Admin: Server, Katalog, Benutzer, Einstellungen. Operator: ansehen und ausrollen.
*/
export const RECHTE = {
lesen: ["ADMIN", "OPERATOR"],
ausrollen: ["ADMIN", "OPERATOR"],
vorlagenPflegen: ["ADMIN", "OPERATOR"],
eigeneZugaenge: ["ADMIN", "OPERATOR"],
serverVerwalten: ["ADMIN"],
katalogVerwalten: ["ADMIN"],
benutzerVerwalten: ["ADMIN"],
einstellungenVerwalten: ["ADMIN"],
protokollLesen: ["ADMIN"],
} as const satisfies Record<string, readonly Rolle[]>;
export type Recht = keyof typeof RECHTE;
export function darf(rolle: Rolle, recht: Recht): boolean {
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
}
+61
View File
@@ -0,0 +1,61 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Benutzer } from "@/generated/prisma/client";
import type { Quelle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rechte";
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
/**
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
* Token. Jede Seite und jeder Route Handler prüft selbst – das Layout ist nur das zweite Netz,
* weil Next es bei Navigation innerhalb desselben Layouts nicht neu rendert.
*/
export type Aufrufer = { benutzer: Benutzer; quelle: Quelle };
async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | null> {
const sitzung = await sitzungLesen(jwt);
if (!sitzung) return null;
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
return benutzer?.aktiv ? benutzer : null;
}
/** Für Server Components. */
export async function aktuellerBenutzer(): Promise<Benutzer | null> {
const speicher = await cookies();
return await benutzerAusSitzung(speicher.get(SITZUNG_COOKIE)?.value);
}
/** Für Seiten: leitet zur Anmeldung um oder zeigt „kein Recht“. */
export async function seiteSchuetzen(recht: Recht, pfad: string): Promise<Benutzer> {
const benutzer = await aktuellerBenutzer();
if (!benutzer) redirect(`/anmelden?ziel=${encodeURIComponent(pfad)}`);
if (!darf(benutzer.rolle, recht)) redirect("/kein-recht");
return benutzer;
}
function cookieAusKopf(kopf: string | null, name: string): string | undefined {
if (!kopf) return undefined;
for (const teil of kopf.split(";")) {
const [schluessel, ...wert] = teil.trim().split("=");
if (schluessel === name) return decodeURIComponent(wert.join("="));
}
return undefined;
}
/**
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
* landen hier, damit kein Handler zwei Wege kennen muss.
*/
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
return benutzer ? { benutzer, quelle: "WEB" } : null;
}
export async function rechtErforderlich(anfrage: Request, recht: Recht): Promise<Aufrufer> {
const aufrufer = await anfrageAufrufer(anfrage);
if (!aufrufer) throw nichtAngemeldet();
if (!darf(aufrufer.benutzer.rolle, recht)) throw keinRecht();
return aufrufer;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Fehler, die bis zum Aufrufer durchgereicht werden dürfen. Alles andere wird als
* „Interner Fehler“ gemeldet und nur ins Log geschrieben.
*
* `hinweis` sagt, was zu tun ist – Web und CLI zeigen ihn unter der Meldung an.
*/
export class AnwendungsFehler extends Error {
constructor(
readonly status: number,
readonly code: string,
nachricht: string,
readonly hinweis?: string,
) {
super(nachricht);
this.name = "AnwendungsFehler";
}
}
export const nichtAngemeldet = () => new AnwendungsFehler(401, "nicht_angemeldet", "Nicht angemeldet.");
export const keinRecht = () =>
new AnwendungsFehler(
403,
"kein_recht",
"Dafür fehlt die Berechtigung.",
"Ein Administrator kann die Rolle ändern.",
);
export const nichtGefunden = (was: string) =>
new AnwendungsFehler(404, "nicht_gefunden", `${was} gibt es nicht.`);
+105
View File
@@ -0,0 +1,105 @@
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { attrappenPruefung, passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
import { AnwendungsFehler } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/**
* Sperre gegen Durchprobieren: höchstens 10 Fehlversuche je Benutzername und Adresse in 15 min.
* Bewusst im Speicher: ProxDeploy läuft als eine Instanz, und ein Neustart, der die Zähler
* leert, ist für einen Angreifer kein Gewinn – er müsste den Dienst dafür neu starten können.
*/
const FENSTER_MS = 15 * 60 * 1000;
const HOECHSTENS = 10;
const fehlversuche = new Map<string, number[]>();
function versucheZaehlen(schluessel: string, jetzt: number): number {
const liste = (fehlversuche.get(schluessel) ?? []).filter((t) => jetzt - t < FENSTER_MS);
fehlversuche.set(schluessel, liste);
return liste.length;
}
/** Nur für Tests. */
export function sperrenZuruecksetzen(): void {
fehlversuche.clear();
}
export async function mitPasswortAnmelden(
benutzername: string,
passwort: string,
ip: string | null,
): Promise<Benutzer> {
const name = benutzername.trim().toLowerCase();
const schluessel = `${name}|${ip ?? "-"}`;
const jetzt = Date.now();
if (versucheZaehlen(schluessel, jetzt) >= HOECHSTENS) {
throw new AnwendungsFehler(
429,
"zu_viele_versuche",
"Zu viele Fehlversuche.",
"In 15 Minuten erneut versuchen.",
);
}
const benutzer = await prisma.benutzer.findUnique({ where: { benutzername: name } });
const passt =
benutzer?.passwortHash && benutzer.aktiv
? await passwortPruefen(benutzer.passwortHash, passwort)
: await attrappenPruefung(passwort);
if (!benutzer || !passt) {
fehlversuche.get(schluessel)?.push(jetzt);
await protokollieren({
benutzerId: benutzer?.id ?? null,
quelle: "WEB",
aktion: "Anmeldung fehlgeschlagen",
ziel: `benutzer:${name}`,
ip,
});
// Eine Meldung für alle Fälle – sie verrät nicht, ob es den Benutzer gibt.
throw new AnwendungsFehler(401, "anmeldung_fehlgeschlagen", "Benutzername oder Passwort ist falsch.");
}
fehlversuche.delete(schluessel);
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
return benutzer;
}
/**
* Legt beim ersten Start den Administrator aus ADMIN_BENUTZER/ADMIN_PASSWORT an – nur solange die
* Tabelle leer ist. Danach sind die Variablen wirkungslos, damit ein vergessener Eintrag in der
* .env kein zweites Hintertürchen ist.
*/
export async function ersterAdminFallsLeer(): Promise<void> {
if ((await prisma.benutzer.count()) > 0) return;
const benutzername = process.env["ADMIN_BENUTZER"]?.trim().toLowerCase() || "admin";
const passwort = process.env["ADMIN_PASSWORT"] ?? "";
if (passwort.length < PASSWORT_MINDESTLAENGE) {
logger.warn(
`Es gibt noch keinen Benutzer. ADMIN_PASSWORT setzen (mindestens ${PASSWORT_MINDESTLAENGE} Zeichen) und neu starten.`,
);
return;
}
let benutzer: Benutzer;
try {
benutzer = await prisma.benutzer.create({
data: {
benutzername,
anzeigename: "Administrator",
rolle: "ADMIN",
passwortHash: await passwortHashen(passwort),
},
});
} catch (fehler) {
// Ein zweiter Prozess war schneller (app und worker starten gleichzeitig) – das ist in Ordnung.
if ((fehler as { code?: string }).code === "P2002") return;
throw fehler;
}
await protokollieren({
benutzerId: benutzer.id,
quelle: "SYSTEM",
aktion: "Erster Administrator angelegt",
});
logger.info({ benutzername }, "Erster Administrator angelegt");
}
+30
View File
@@ -0,0 +1,30 @@
import type { Quelle } from "@/generated/prisma/enums";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export type ProtokollEintrag = {
benutzerId: string | null;
quelle: Quelle;
/** Lesbar und deutsch, z. B. „Server angelegt“. */
aktion: string;
/** Maschinenlesbar, z. B. „server:rz1“. */
ziel?: string;
details?: Prisma.InputJsonValue;
ip?: string | null;
};
type Client = Pick<typeof prisma, "protokoll">;
/** Schreibt einen Audit-Eintrag; in einer Transaktion mit `tx` als Client aufrufen. */
export async function protokollieren(eintrag: ProtokollEintrag, client: Client = prisma): Promise<void> {
await client.protokoll.create({
data: {
benutzerId: eintrag.benutzerId,
quelle: eintrag.quelle,
aktion: eintrag.aktion,
ziel: eintrag.ziel ?? null,
details: eintrag.details,
ip: eintrag.ip ?? null,
},
});
}
+102
View File
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it } from "vitest";
import { POST as anmeldenPost } from "@/app/api/v1/auth/anmelden/route";
import { GET as ichGet } from "@/app/api/v1/ich/route";
import { passwortHashen } from "@/lib/passwort";
import { ersterAdminFallsLeer, sperrenZuruecksetzen } from "@/server/services/anmeldung";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
function anmeldung(benutzername: string, passwort: string): Request {
return new Request("http://localhost/api/v1/auth/anmelden", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ benutzername, passwort }),
});
}
describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
beforeEach(async () => {
await datenbankLeeren();
sperrenZuruecksetzen();
await testPrisma.benutzer.create({
data: {
benutzername: "florian",
anzeigename: "Florian",
rolle: "OPERATOR",
passwortHash: await passwortHashen("richtiges-passwort"),
},
});
});
it("setzt bei richtigem Passwort ein HttpOnly-Cookie und /ich kennt den Benutzer", async () => {
const antwort = await anmeldenPost(anmeldung("Florian", "richtiges-passwort"));
expect(antwort.status).toBe(200);
const cookie = antwort.headers.get("set-cookie") ?? "";
expect(cookie).toMatch(/pd_sitzung=/);
expect(cookie).toMatch(/HttpOnly/i);
// Über http (ohne Proxy) darf das Cookie nicht Secure sein, sonst schickt der Browser es nie zurück.
expect(cookie).not.toMatch(/Secure/i);
const ich = await ichGet(
new Request("http://localhost/api/v1/ich", { headers: { cookie: cookie.split(";")[0]! } }),
);
expect(ich.status).toBe(200);
expect(await ich.json()).toMatchObject({ benutzername: "florian", rolle: "OPERATOR" });
});
it("meldet falsches Passwort und unbekannten Benutzer gleich", async () => {
const falsch = await anmeldenPost(anmeldung("florian", "falsch"));
const unbekannt = await anmeldenPost(anmeldung("niemand", "falsch"));
expect(falsch.status).toBe(401);
expect(await falsch.json()).toEqual(await unbekannt.json());
});
it("weist deaktivierte Benutzer ab", async () => {
await testPrisma.benutzer.update({ where: { benutzername: "florian" }, data: { aktiv: false } });
expect((await anmeldenPost(anmeldung("florian", "richtiges-passwort"))).status).toBe(401);
});
it("sperrt nach zehn Fehlversuchen, auch für das richtige Passwort", async () => {
for (let i = 0; i < 10; i += 1) await anmeldenPost(anmeldung("florian", "falsch"));
const antwort = await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
expect(antwort.status).toBe(429);
expect((await antwort.json()).fehler.hinweis).toMatch(/15 Minuten/);
});
it("protokolliert Erfolg und Fehlschlag", async () => {
await anmeldenPost(anmeldung("florian", "falsch"));
await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
const aktionen = (await testPrisma.protokoll.findMany({ orderBy: { id: "asc" } })).map((e) => e.aktion);
expect(aktionen).toEqual(["Anmeldung fehlgeschlagen", "Angemeldet"]);
});
it("antwortet auf kaputtes JSON mit 400 statt 500", async () => {
const antwort = await anmeldenPost(
new Request("http://localhost/api/v1/auth/anmelden", { method: "POST", body: "{kaputt" }),
);
expect(antwort.status).toBe(400);
});
it("/ich ohne Sitzung ist 401", async () => {
expect((await ichGet(new Request("http://localhost/api/v1/ich"))).status).toBe(401);
});
});
describe.runIf(datenbankVerfuegbar)("Erster Administrator", () => {
beforeEach(datenbankLeeren);
it("wird nur angelegt, solange es keinen Benutzer gibt", async () => {
process.env["ADMIN_BENUTZER"] = "Admin";
process.env["ADMIN_PASSWORT"] = "ein-langes-startpasswort";
await ersterAdminFallsLeer();
await ersterAdminFallsLeer();
const alle = await testPrisma.benutzer.findMany();
expect(alle).toHaveLength(1);
expect(alle[0]).toMatchObject({ benutzername: "admin", rolle: "ADMIN" });
});
it("legt bei zu kurzem Passwort niemanden an", async () => {
process.env["ADMIN_PASSWORT"] = "kurz";
await ersterAdminFallsLeer();
expect(await testPrisma.benutzer.count()).toBe(0);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Regeln, die in der Datenbank selbst stehen – sie gelten auch, wenn jemand an der
* Service-Schicht vorbei schreibt. Jede ist hier namentlich aufgeführt.
*/
describe.runIf(datenbankVerfuegbar)("Datenbankregeln", () => {
beforeEach(datenbankLeeren);
it("protokoll_nur_anhaengen: Einträge lassen sich weder ändern noch löschen", async () => {
const eintrag = await testPrisma.protokoll.create({ data: { quelle: "SYSTEM", aktion: "Test" } });
await expect(
testPrisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "Gefälscht" } }),
).rejects.toThrow(/nur anhängbar/);
await expect(testPrisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/nur anhängbar/);
});
it("protokoll_nur_anhaengen: das Löschen eines Benutzers lässt seine Einträge stehen", async () => {
const benutzer = await testPrisma.benutzer.create({ data: { benutzername: "weg", anzeigename: "Weg" } });
await testPrisma.protokoll.create({
data: { quelle: "WEB", aktion: "Angemeldet", benutzerId: benutzer.id },
});
await testPrisma.benutzer.delete({ where: { id: benutzer.id } });
const eintraege = await testPrisma.protokoll.findMany();
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.benutzerId).toBeNull();
});
it("benutzer_benutzername_format: nur Kleinbuchstaben, Ziffern, . _ -", async () => {
await expect(
testPrisma.benutzer.create({ data: { benutzername: "Florian Wilde", anzeigename: "x" } }),
).rejects.toThrow(/benutzer_benutzername_format/);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { expect, test, type Page } from "@playwright/test";
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
async function anmelden(page: Page) {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await page.getByLabel("Benutzername").fill(BENUTZER);
await page.getByLabel("Passwort").fill(PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
async function keinQuerscrollen(page: Page) {
const breiten = await page.evaluate(() => ({
inhalt: document.documentElement.scrollWidth,
fenster: window.innerWidth,
}));
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
}
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
await page.goto("/anmelden");
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
await page.getByLabel("Benutzername").fill("e2e-unbekannt");
await page.getByLabel("Passwort").fill("falsch-falsch");
await page.getByRole("button", { name: "Anmelden" }).click();
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
await expect(page.getByText("✗")).toBeVisible();
});
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
await anmelden(page);
await keinQuerscrollen(page);
await page.screenshot({ path: `test-results/uebersicht-${test.info().project.name}.png`, fullPage: true });
if (test.info().project.name === "handy") {
await page.getByRole("button", { name: "Navigation öffnen" }).click();
}
await page.getByRole("button", { name: "Abmelden" }).click();
await expect(page).toHaveURL(/\/anmelden/);
});
test("Theme-Wechsel bleibt nach dem Neuladen erhalten", async ({ page }) => {
await anmelden(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "dunkel");
if (test.info().project.name === "handy") {
await page.getByRole("button", { name: "Navigation öffnen" }).click();
}
await page.getByRole("button", { name: "Helles Theme einschalten" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
await page.screenshot({
path: `test-results/uebersicht-hell-${test.info().project.name}.png`,
fullPage: true,
});
});
test("Anmeldeseite ohne Querscrollen", async ({ page }) => {
await page.goto("/anmelden");
await keinQuerscrollen(page);
});
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from "vitest";
import { entschluesseln, GeheimnisFehler, verschluesseln } from "@/lib/geheimnisse";
const ursprung = process.env["PROXDEPLOY_SCHLUESSEL"];
afterEach(() => {
process.env["PROXDEPLOY_SCHLUESSEL"] = ursprung;
});
describe("Geheimnisse (AES-256-GCM)", () => {
it("verschlüsselt und entschlüsselt verlustfrei, auch Umlaute und Zeilenumbrüche", () => {
const klar = "-----BEGIN OPENSSH PRIVATE KEY-----\nÄÖÜ ß\n-----END-----\n";
expect(entschluesseln(verschluesseln(klar))).toBe(klar);
});
it("erzeugt bei gleichem Klartext jedes Mal einen anderen Geheimtext (neuer IV)", () => {
expect(verschluesseln("geheim")).not.toBe(verschluesseln("geheim"));
});
it("trägt das Versionspräfix v1", () => {
expect(verschluesseln("x").startsWith("v1.")).toBe(true);
});
it("wirft bei verändertem Geheimtext", () => {
const teile = verschluesseln("geheim").split(".");
const daten = Buffer.from(teile[3]!, "base64");
daten[0] = daten[0]! ^ 0xff;
teile[3] = daten.toString("base64");
expect(() => entschluesseln(teile.join("."))).toThrow(GeheimnisFehler);
});
it("wirft mit anderem Schlüssel", () => {
const geheim = verschluesseln("geheim");
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 9).toString("base64");
expect(() => entschluesseln(geheim)).toThrow(/nicht mehr derselbe/);
});
it("verlangt genau 32 Byte Schlüssel und nennt den Befehl zum Erzeugen", () => {
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(16).toString("base64");
expect(() => verschluesseln("x")).toThrow(/openssl rand -base64 32/);
});
it("lehnt unbekannte Formate und Versionen ab", () => {
expect(() => entschluesseln("klartext")).toThrow(/unbekanntes Format/);
expect(() => entschluesseln("v9.a.b.c")).toThrow(/unbekannte Version/);
});
});
+18
View File
@@ -0,0 +1,18 @@
import "dotenv/config";
/**
* Spielt die Migrationen in die Testdatenbank ein. Ohne TEST_DATABASE_URL überspringen sich die
* Datenbanktests – und sagen das laut, damit ein grüner Lauf nicht mehr verspricht, als er prüft.
*/
export default async function aufbau(): Promise<void> {
const url = process.env["TEST_DATABASE_URL"];
if (!url) {
console.warn("\n TEST_DATABASE_URL fehlt – Datenbanktests werden ÜBERSPRUNGEN. Start: pnpm db:up\n");
return;
}
const { execFileSync } = await import("node:child_process");
execFileSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
stdio: "inherit",
env: { ...process.env, DATABASE_URL: url },
});
}
+28
View File
@@ -0,0 +1,28 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const url = process.env["TEST_DATABASE_URL"];
// In CI darf nichts still übersprungen werden – dort lieber hart abbrechen.
if (!url && process.env["CI"]) {
throw new Error("TEST_DATABASE_URL fehlt. In CI dürfen die Datenbanktests nicht übersprungen werden.");
}
export const datenbankVerfuegbar = Boolean(url);
export const testPrisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5437/proxdeploy_test" }),
});
/**
* Leert alle Tabellen. Das Protokoll ist per Trigger nur anhängbar – TRUNCATE löst
* Zeilen-Trigger nicht aus und ist deshalb der einzige Weg, es zu leeren.
*/
export async function datenbankLeeren(): Promise<void> {
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
`;
if (tabellen.length === 0) return;
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
await testPrisma.$executeRawUnsafe(`TRUNCATE ${liste} RESTART IDENTITY CASCADE`);
}
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* Kontrast mindestens 4,5:1 (WCAG AA) für Text auf Grund und Panel – in beiden Themes.
* Liest die Tokens direkt aus globals.css, damit eine Farbänderung hier auffällt.
*/
const css = readFileSync("src/app/globals.css", "utf8");
function block(selektor: string): Record<string, string> {
const anfang = css.indexOf(selektor);
const inhalt = css.slice(css.indexOf("{", anfang) + 1, css.indexOf("}", anfang));
return Object.fromEntries(
[...inhalt.matchAll(/--farbe-([a-z-]+):\s*(#[0-9a-f]{6})/gi)].map((t) => [t[1]!, t[2]!]),
);
}
function leuchtdichte(hex: string): number {
const kanal = (i: number) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * kanal(1) + 0.7152 * kanal(3) + 0.0722 * kanal(5);
}
export function kontrast(a: string, b: string): number {
const [hell, dunkel] = [leuchtdichte(a), leuchtdichte(b)].sort((x, y) => y - x) as [number, number];
return (hell + 0.05) / (dunkel + 0.05);
}
const THEMES = { dunkel: block('[data-theme="dunkel"]'), hell: block('[data-theme="hell"]') };
const TEXTFARBEN = ["text", "muted", "akzent", "laeuft", "fehler"];
describe("Kontrast der Theme-Farben", () => {
for (const [name, farben] of Object.entries(THEMES)) {
for (const flaeche of ["grund", "panel", "panel-hoch"]) {
it.each(TEXTFARBEN)(`${name}: %s auf ${flaeche} ≥ 4,5:1`, (textfarbe) => {
expect(kontrast(farben[textfarbe]!, farben[flaeche]!)).toBeGreaterThanOrEqual(4.5);
});
}
it(`${name}: Text auf Akzentfläche ≥ 4,5:1`, () => {
expect(kontrast(farben["akzent-text"]!, farben["akzent"]!)).toBeGreaterThanOrEqual(4.5);
});
}
});
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
if (process.env["TEST_DATABASE_URL"]) {
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
}
// Feste Testgeheimnisse, damit kein Test eine .env verlangt.
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 7).toString("base64");
process.env["SITZUNG_GEHEIMNIS"] = "test-sitzungsgeheimnis-mit-mindestens-32-zeichen";
process.env["LOG_LEVEL"] = "silent";
process.env["TRUST_PROXY"] = "false";
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { sicherheitsrichtlinie } from "@/proxy";
describe("Content-Security-Policy", () => {
it("ist im gebauten Stand streng", () => {
const csp = sicherheitsrichtlinie("abc", false);
expect(csp).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
expect(csp).not.toContain("unsafe-eval");
expect(csp).not.toContain("ws:");
expect(csp).toContain("frame-ancestors 'none'");
});
it("lockert nur im Entwicklungsmodus", () => {
expect(sicherheitsrichtlinie("abc", true)).toContain("'unsafe-eval'");
});
});
+31
View File
@@ -0,0 +1,31 @@
import { SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
describe("Sitzungscookie", () => {
it("liest ein selbst ausgestelltes Token zurück", async () => {
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
expect(await sitzungLesen(jwt)).toMatchObject({ benutzerId: "b1", herkunft: "passwort" });
});
it("verwirft fehlende, verfälschte und fremd signierte Tokens", async () => {
expect(await sitzungLesen(undefined)).toBeUndefined();
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
expect(await sitzungLesen(jwt.slice(0, -2) + "xx")).toBeUndefined();
const fremd = await new SignJWT({ herkunft: "passwort" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("b1")
.setIssuer("proxdeploy")
.setAudience("proxdeploy-web")
.setIssuedAt()
.setExpirationTime("1h")
.sign(new TextEncoder().encode("ein-ganz-anderes-geheimnis-mit-genug-zeichen"));
expect(await sitzungLesen(fremd)).toBeUndefined();
});
it("enthält keine Rolle – die kommt je Anfrage aus der Datenbank", async () => {
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "oidc" });
const inhalt = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
expect(inhalt).not.toHaveProperty("rolle");
});
});
+53
View File
@@ -0,0 +1,53 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { istOeffentlich, OEFFENTLICH } from "@/proxy";
/**
* Strukturelle Prüfung: jede Seite und jeder Route Handler prüft sein Recht selbst.
* Ausnahmen stehen hier namentlich mit Begründung – ein neuer Handler ohne Prüfung soll
* jemanden zwingen, sie aufzuschreiben.
*/
const AUSNAHMEN: Record<string, string> = {
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
"src/app/anmelden/page.tsx": "Anmeldeseite",
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
};
function dateien(verzeichnis: string): string[] {
return readdirSync(verzeichnis).flatMap((name) => {
const pfad = join(verzeichnis, name);
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
});
}
const wurzel = process.cwd();
const kandidaten = dateien(join(wurzel, "src/app"))
.map((pfad) => relative(wurzel, pfad))
.filter((pfad) => pfad.endsWith("/page.tsx") || pfad.endsWith("/route.ts"));
describe("Zugangsprüfung", () => {
it.each(kandidaten.filter((pfad) => !(pfad in AUSNAHMEN)))("%s prüft sein Recht", (pfad) => {
const quelltext = readFileSync(join(wurzel, pfad), "utf8");
expect(quelltext).toMatch(/seiteSchuetzen\(|rechtErforderlich\(/);
});
it("jede Ausnahme gibt es noch", () => {
for (const pfad of Object.keys(AUSNAHMEN)) expect(kandidaten).toContain(pfad);
});
it("öffentliche Pfade sind genau die bekannten", () => {
expect(OEFFENTLICH).toEqual([
"/anmelden",
"/api/v1/auth",
"/api/v1/gesundheit",
"/brand",
"/manifest.webmanifest",
]);
expect(istOeffentlich("/api/v1/auth/anmelden")).toBe(true);
expect(istOeffentlich("/api/v1/authentifizieren")).toBe(false);
expect(istOeffentlich("/server")).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.mts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", "cli", "dist"]
}
+16
View File
@@ -0,0 +1,16 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
test: {
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"],
environment: "node",
globalSetup: ["tests/global-setup.ts"],
setupFiles: ["tests/setup-env.ts"],
// Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen.
fileParallelism: false,
},
});