diff --git a/CHANGELOG.md b/CHANGELOG.md index 85db83b..db0a8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.11.1] – 2026-09-17 + +### Behoben +- Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite: Das Sitzungs-Cookie erhielt im Produktionsbau immer das Merkmal `Secure` und wurde über `http://` vom Browser verworfen. Maßgeblich ist jetzt die tatsächliche Adresse (`APP_URL`) (#013) + +### Geändert +- Die Anmeldung läuft über ein gewöhnliches Formular statt über eine Server-Action; damit funktioniert sie auch ohne JavaScript (#013) + ## [0.11.0] – 2026-09-17 ### Geändert diff --git a/DEVLOG.md b/DEVLOG.md index 6c05316..b4bf20f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -43,7 +43,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.11.0 +**Version:** 0.11.1 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -109,6 +109,7 @@ - **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben. ### Probleme / offene Fragen +- Von Florian im Betrieb gemeldet: Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite. Ursache: `Secure`-Cookie über `http://`. Behoben in 0.11.1. - Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018). - Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten. - Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei). diff --git a/docs/anleitung/bilder/audit.png b/docs/anleitung/bilder/audit.png new file mode 100644 index 0000000..059f3a7 Binary files /dev/null and b/docs/anleitung/bilder/audit.png differ diff --git a/docs/anleitung/bilder/benachrichtigungen.png b/docs/anleitung/bilder/benachrichtigungen.png new file mode 100644 index 0000000..059f3a7 Binary files /dev/null and b/docs/anleitung/bilder/benachrichtigungen.png differ diff --git a/docs/anleitung/bilder/dashboard-dunkel.png b/docs/anleitung/bilder/dashboard-dunkel.png index 610fbf6..7fdb908 100644 Binary files a/docs/anleitung/bilder/dashboard-dunkel.png and b/docs/anleitung/bilder/dashboard-dunkel.png differ diff --git a/docs/anleitung/bilder/dashboard.png b/docs/anleitung/bilder/dashboard.png index 04138a6..059f3a7 100644 Binary files a/docs/anleitung/bilder/dashboard.png and b/docs/anleitung/bilder/dashboard.png differ diff --git a/docs/anleitung/bilder/host-detail.png b/docs/anleitung/bilder/host-detail.png index 6e3b22f..059f3a7 100644 Binary files a/docs/anleitung/bilder/host-detail.png and b/docs/anleitung/bilder/host-detail.png differ diff --git a/docs/anleitung/bilder/hosts-dunkel.png b/docs/anleitung/bilder/hosts-dunkel.png index 260ab48..7fdb908 100644 Binary files a/docs/anleitung/bilder/hosts-dunkel.png and b/docs/anleitung/bilder/hosts-dunkel.png differ diff --git a/docs/anleitung/bilder/hosts.png b/docs/anleitung/bilder/hosts.png index 3506b8c..059f3a7 100644 Binary files a/docs/anleitung/bilder/hosts.png and b/docs/anleitung/bilder/hosts.png differ diff --git a/docs/anleitung/bilder/jahresbahn.png b/docs/anleitung/bilder/jahresbahn.png index e26321b..059f3a7 100644 Binary files a/docs/anleitung/bilder/jahresbahn.png and b/docs/anleitung/bilder/jahresbahn.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png index beae084..059f3a7 100644 Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/mobil-dashboard.png b/docs/anleitung/bilder/mobil-dashboard.png index 9e5e1e6..c7a3b77 100644 Binary files a/docs/anleitung/bilder/mobil-dashboard.png and b/docs/anleitung/bilder/mobil-dashboard.png differ diff --git a/docs/anleitung/bilder/mobil-hosts.png b/docs/anleitung/bilder/mobil-hosts.png index aeca5fc..c7a3b77 100644 Binary files a/docs/anleitung/bilder/mobil-hosts.png and b/docs/anleitung/bilder/mobil-hosts.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index bbe2081..059f3a7 100644 Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ diff --git a/docs/anleitung/bilder/rechnungen-dunkel.png b/docs/anleitung/bilder/rechnungen-dunkel.png index 02db255..7fdb908 100644 Binary files a/docs/anleitung/bilder/rechnungen-dunkel.png and b/docs/anleitung/bilder/rechnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index dad0a21..059f3a7 100644 Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png index a56abc6..059f3a7 100644 Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/anleitung/bilder/warnregeln.png b/docs/anleitung/bilder/warnregeln.png index 87c7ad5..059f3a7 100644 Binary files a/docs/anleitung/bilder/warnregeln.png and b/docs/anleitung/bilder/warnregeln.png differ diff --git a/docs/anleitung/bilder/warnungen-dunkel.png b/docs/anleitung/bilder/warnungen-dunkel.png index fc6c1c8..7fdb908 100644 Binary files a/docs/anleitung/bilder/warnungen-dunkel.png and b/docs/anleitung/bilder/warnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/warnungen.png b/docs/anleitung/bilder/warnungen.png index d0341df..059f3a7 100644 Binary files a/docs/anleitung/bilder/warnungen.png and b/docs/anleitung/bilder/warnungen.png differ diff --git a/docs/issues/013-authentifizierung.md b/docs/issues/013-authentifizierung.md index 6776084..0fe100f 100644 --- a/docs/issues/013-authentifizierung.md +++ b/docs/issues/013-authentifizierung.md @@ -33,3 +33,8 @@ Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen. freigeschaltet ist, soll auch hier arbeiten können. - Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“. - **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden). +- **Nachtrag (Fehler aus dem Betrieb):** Das Sitzungs-Cookie bekam `Secure` anhand von `NODE_ENV`. Ein + produktiv gebautes Dashboard über `http://` (lokal oder im LAN, etwa nach der LXC-Installation) setzte + damit ein Cookie, das der Browser verwirft – jeder Klick führte zurück zur Anmeldung. Jetzt entscheidet + das Schema in `APP_URL`. Die Anmeldung läuft außerdem über ein normales Formular statt über eine + Server-Action und ist damit ohne JavaScript nutzbar und mit `curl` prüfbar. diff --git a/package.json b/package.json index 792feb7..41aefe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.11.0", + "version": "0.11.1", "private": true, "scripts": { "dev": "next dev", @@ -23,7 +23,8 @@ "alerts:check": "tsx scripts/alerts-pruefen.ts", "build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native", "build:all": "npm run build && npm run build:worker", - "lasttest": "tsx scripts/lasttest.ts" + "lasttest": "tsx scripts/lasttest.ts", + "screenshots": "node scripts/screenshots.mjs" }, "dependencies": { "@node-rs/argon2": "^2.2.1", diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs new file mode 100644 index 0000000..2d2da30 --- /dev/null +++ b/scripts/screenshots.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/). + * + * npm run screenshots # heller Modus, Desktop + * npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht + * + * Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten. + * Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt + * und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { SignJWT } from "jose"; +import "dotenv/config"; + +const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000"; +const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); +const ALLE = process.argv.includes("--alle"); + +// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen. +const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots"); + +const SEITEN = [ + { name: "dashboard", pfad: "/", titel: "Dashboard" }, + { name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" }, + { name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" }, + { name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" }, + { name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" }, + { name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" }, + { name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" }, + { name: "warnungen", pfad: "/warnungen", titel: "Warnungen" }, + { name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" }, + { name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" }, + { name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" }, + { name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" }, +]; + +async function sitzungsToken() { + const geheimnis = process.env.APP_SECRET; + if (!geheimnis || geheimnis.length < 32) { + throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder."); + } + + return new SignJWT({ + benutzerId: "screenshots", + email: process.env.ADMIN_EMAIL ?? "admin@example.de", + name: process.env.ADMIN_NAME ?? "Florian", + herkunft: "passwort", + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject("screenshots") + .setIssuedAt() + .setIssuer("proxledger") + .setExpirationTime("1h") + .sign(new TextEncoder().encode(geheimnis)); +} + +/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */ +function profilVorbereiten(name, token, dunkel) { + const verzeichnis = path.join(PROFIL_BASIS, name); + rmSync(verzeichnis, { recursive: true, force: true }); + mkdirSync(verzeichnis, { recursive: true }); + + writeFileSync( + path.join(verzeichnis, "user.js"), + [ + `user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`, + `user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`, + 'user_pref("browser.shell.checkDefaultBrowser", false);', + "", + ].join("\n"), + ); + + const { hostname, protocol } = new URL(BASIS); + const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite")); + datenbank.exec(` + CREATE TABLE moz_cookies ( + id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT, + host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER, + isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0, + sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0, + isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0, + CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes) + )`); + + const jetzt = Date.now(); + datenbank + .prepare( + `INSERT INTO moz_cookies + (originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime) + VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`, + ) + .run( + token, + hostname, + Math.floor(jetzt / 1000) + 3600, + jetzt * 1000, + jetzt * 1000, + protocol === "https:" ? 1 : 0, + protocol === "https:" ? 2 : 1, + jetzt * 1000, + ); + datenbank.close(); + + return verzeichnis; +} + +function aufnehmen(profil, pfad, datei, breite, hoehe) { + execFileSync( + "firefox", + [ + "--profile", + profil, + "--headless", + "--window-size", + `${breite},${hoehe}`, + "--screenshot", + datei, + `${BASIS}${pfad}`, + ], + { stdio: "ignore", timeout: 120_000 }, + ); +} + +async function main() { + const antwort = await fetch(`${BASIS}/login`).catch(() => null); + if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`); + + mkdirSync(ZIEL, { recursive: true }); + const token = await sitzungsToken(); + + const hell = profilVorbereiten("hell", token, false); + console.log(`Bilder nach ${ZIEL}`); + + // Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich. + aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900); + console.log(" login.png"); + + for (const seite of SEITEN) { + aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150); + console.log(` ${seite.name}.png – ${seite.titel}`); + } + + if (ALLE) { + const dunkel = profilVorbereiten("dunkel", token, true); + for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { + const eintrag = SEITEN.find((s) => s.name === seite); + aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150); + console.log(` ${seite}-dunkel.png`); + } + + for (const seite of ["dashboard", "hosts"]) { + const eintrag = SEITEN.find((s) => s.name === seite); + aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500); + console.log(` mobil-${seite}.png`); + } + } + + if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true }); + console.log("Fertig."); +} + +main().catch((fehler) => { + console.error(fehler.message ?? fehler); + process.exit(1); +}); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index d3212f9..c2c9274 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -30,9 +30,7 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc