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
+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}`;
}