diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a007c4..5cb6b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.20.1] – 2026-10-03 + +### Behoben +- **Abmelden führte auf `0.0.0.0:3000`** und damit ins Leere. Next.js macht Weiterleitungen aus Server Actions serverseitig absolut und nahm dafür die Lausch-Adresse des Servers. Betroffen waren Abmelden, die Sitzungsprüfung und das Löschen eines Kunden (#033) +- Eine als Lausch-Adresse eingetragene `APP_URL` (`0.0.0.0`, `::`) wird jetzt übergangen statt übernommen (#033) +- Die LXC-Installation brach mit *404 – Could not fetch install/proxledger-install.sh* ab: Ein per curl geladenes ct-Skript kennt seine Herkunft nicht und suchte bei den community-scripts (#032) + +### Geändert +- Vorgabe für den Arbeitsspeicher des LXC von 2 auf 4 GB – `next build` scheitert sonst während der Installation am Speicher (#032) + ## [0.20.0] – 2026-10-03 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index a41409f..0948c73 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 16 h 08 min +**Gesamt:** 16 h 58 min | Issue | Zeit | |---|---| @@ -37,6 +37,8 @@ | #029 Einrichtungsdokumentation | 0 h 40 min | | #030 netbird Reverse Proxy | 0 h 20 min | | #031 Update für beide Varianten | 0 h 45 min | +| #032 LXC-Installation korrigiert | 0 h 20 min | +| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min | | Meilenstein | Zeit | |---|---| @@ -59,7 +61,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.20.0 +**Version:** 0.20.1 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -173,6 +175,10 @@ - Update- und Sicherungsskript für beide Installationsarten (#031). Beim Durchspielen zwei Fehler gefunden: Die Zustandsprüfung des Web-Containers konnte nie gelingen (Next bindet an `HOSTNAME`, Docker setzt dort den Containernamen), und eine fehlgeschlagene Sicherung hielt die Aktualisierung nicht auf - Lehre: Ein Skript, das nie unter echten Bedingungen gelaufen ist, ist eine Behauptung. Erst der Durchlauf gegen einen laufenden Stapel hat beide Fehler gezeigt +- Erster echter Einsatz durch Florian – und prompt zwei Fehler, die kein Test gezeigt hatte (#032, #033) +- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger +- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß + ### Nächster Schritt - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) diff --git a/deploy/community-scripts/install/proxledger-install.sh b/deploy/community-scripts/install/proxledger-install.sh index 39f2239..27fdb81 100755 --- a/deploy/community-scripts/install/proxledger-install.sh +++ b/deploy/community-scripts/install/proxledger-install.sh @@ -40,7 +40,13 @@ msg_info "Setting up ProxLedger" $STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger cd /opt/proxledger -IP_ADRESSE=$(hostname -I | awk '{print $1}') +# Erreichbare Adresse des Containers ermitteln. `hostname -I` liefert sie in aller Regel; +# notfalls hilft die Route nach draussen. 0.0.0.0 waere eine Lausch-Adresse und im Browser nutzlos. +IP_ADRESSE=$(hostname -I 2>/dev/null | awk '{print $1}') +if [ -z "$IP_ADRESSE" ] || [ "$IP_ADRESSE" = "0.0.0.0" ]; then + IP_ADRESSE=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {print $7; exit}') +fi +[ -n "$IP_ADRESSE" ] || IP_ADRESSE="localhost" cat </opt/proxledger/.env DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public" APP_SECRET="${APP_SECRET}" @@ -107,6 +113,8 @@ msg_ok "Created Services" { echo "ProxLedger" echo "Adresse: http://${IP_ADRESSE}:3000" + echo " (Im Journal steht 0.0.0.0:3000 - das ist nur die Lausch-Adresse," + echo " im Browser gehoert die obige IP oder der spaetere DNS-Name hinein.)" echo "Benutzer: admin@proxledger.local" echo "Passwort: ${ADMIN_PASS}" echo diff --git a/docs/issues/032-lxc-installation-korrekturen.md b/docs/issues/032-lxc-installation-korrekturen.md new file mode 100644 index 0000000..2a5abf7 --- /dev/null +++ b/docs/issues/032-lxc-installation-korrekturen.md @@ -0,0 +1,27 @@ +# #032 LXC-Installation: Herkunft der Skripte und Speicher +**Status:** erledigt +**Meilenstein:** v0.20.1 +**Labels:** deployment +**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min + +## Auslöser +Erster echter Lauf auf `hv-rz5-pve-02`: Der Container entstand, dann brach es ab mit + +``` +curl: (22) The requested URL returned error: 404 +✖️ Could not fetch install/proxledger-install.sh +📁 Looked in: https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main +``` + +## Ursache +Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Das Gerüst setzt dann +die Standardadresse der community-scripts ein und sucht dort `install/proxledger-install.sh`. + +## Behebung +- Das ct-Skript setzt `COMMUNITY_SCRIPTS_URL` selbst auf die eigene Gitea-Adresse (für Forks und + andere Zweige weiterhin überschreibbar). +- Vorgabe für den Arbeitsspeicher von 2 auf **4 GB** angehoben: `next build` braucht während der + Installation deutlich mehr als 2 GB und wäre nach zehn Minuten am Speicher gescheitert. +- Die ermittelte Adresse wird geprüft; `hostname -I` ohne Ergebnis fällt auf die Route nach außen + zurück, und in den Zugangsdaten steht der Hinweis, dass `0.0.0.0` aus dem Journal keine + aufrufbare Adresse ist. diff --git a/docs/issues/033-weiterleitung-lauschadresse.md b/docs/issues/033-weiterleitung-lauschadresse.md new file mode 100644 index 0000000..6f9b860 --- /dev/null +++ b/docs/issues/033-weiterleitung-lauschadresse.md @@ -0,0 +1,35 @@ +# #033 Abmelden führte auf die Lausch-Adresse +**Status:** erledigt +**Meilenstein:** v0.20.1 +**Labels:** bug, deployment +**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min + +## Auslöser +Im Docker-Betrieb führte **Abmelden** auf `0.0.0.0:3000/login` – eine Adresse, die kein Browser +aufrufen kann (`ERR_ADDRESS_INVALID`). + +## Ursache +Hausgemacht, und zwar in #031: Für die Zustandsprüfung des Containers wurde `ENV HOSTNAME=0.0.0.0` +gesetzt, damit der Server auf allen Schnittstellen lauscht. Next.js macht eine Weiterleitung aus +einer Server Action aber **serverseitig absolut** und nimmt dafür genau diese Adresse. Vorher stand +dort der Containername (`http://0a5963a591df:3000/`) – derselbe Fehler, nur weniger auffällig. + +Betroffen waren alle Weiterleitungen, die nicht über eine `Request` liefen: Abmelden, die Prüfung in +`sitzungErforderlich` und das Löschen eines Kunden. + +## Behebung +- Neuer Helfer `eigenerPfad()` in `src/lib/adresse.ts`: baut die Adresse aus Einstellung, `APP_URL` + oder den Kopfzeilen der Anfrage – nie aus der Lausch-Adresse. +- `oeffentlicheBasis()` übergeht unbrauchbare Angaben (`0.0.0.0`, `::`) jetzt ausdrücklich, statt sie + zu übernehmen. Eine falsch eingetragene Adresse macht das Dashboard damit nicht unbenutzbar. +- Zwei zusätzliche Tests. + +## Prüfung +Gegen einen Docker-Stapel durchgespielt: Mit `APP_URL=http://0.0.0.0:3000` in der `.env` – also der +Fehlkonfiguration – leitet die Anwendung jetzt auf den tatsächlich aufgerufenen Namen +(`http://localhost:3003/login`) statt auf die Lausch-Adresse. + +## Lehre +Der Fix für die Zustandsprüfung hat einen zweiten Fehler sichtbar gemacht, der vorher nur anders +aussah. Weiterleitungen gehören grundsätzlich aus der **konfigurierten** Adresse gebaut, nicht aus +dem, was der Server zufällig über sich selbst weiß. diff --git a/docs/issues/README.md b/docs/issues/README.md index 1432b0a..631bcec 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -35,4 +35,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#029](029-betriebsdokumentation.md) | Einrichtungsdokumentation | erledigt | v0.19.0 | 3 h | 0 h 40 min | | [#030](030-netbird-reverse-proxy.md) | netbird Reverse Proxy in die Anleitung | erledigt | v0.19.1 | 1 h | 0 h 20 min | | [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min | -| | **Summe** | | | **149 h** | **16 h 08 min** | +| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min | +| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min | +| | **Summe** | | | **151 h** | **16 h 58 min** | diff --git a/package.json b/package.json index 5bdaa78..98b2a49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.20.0", + "version": "0.20.1", "private": true, "scripts": { "dev": "next dev", diff --git a/src/lib/adresse.ts b/src/lib/adresse.ts index eeb4641..56bb3eb 100644 --- a/src/lib/adresse.ts +++ b/src/lib/adresse.ts @@ -9,31 +9,34 @@ * sich fälschen; für eine Weiterleitung auf die eigene Seite ist das unkritisch, als erste Wahl aber * unnötig. */ -export function oeffentlicheBasis(request: Request, eingetragen?: string): URL { - if (eingetragen) { - try { - return new URL(eingetragen); - } catch { - // Unbrauchbarer Eintrag: weiter mit den übrigen Quellen. - } - } +/** + * Lausch-Adressen taugen nicht als Ziel für einen Browser: `0.0.0.0` heißt „alle Schnittstellen“. + * Steht so etwas in der Konfiguration, wird es übergangen statt eine unerreichbare Seite zu bauen. + */ +const UNBRAUCHBAR = new Set(["0.0.0.0", "::", "[::]", "", "0"]); - if (process.env.APP_URL) { - try { - return new URL(process.env.APP_URL); - } catch { - // ebenso - } +function brauchbar(adresse: string | undefined): URL | null { + if (!adresse) return null; + try { + const ergebnis = new URL(adresse); + return UNBRAUCHBAR.has(ergebnis.hostname) ? null : ergebnis; + } catch { + return null; } +} + +export function oeffentlicheBasis(request: Request, eingetragen?: string): URL { + const ausEinstellung = brauchbar(eingetragen); + if (ausEinstellung) return ausEinstellung; + + const ausUmgebung = brauchbar(process.env.APP_URL); + if (ausUmgebung) return ausUmgebung; const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); if (gastgeber) { const schema = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", ""); - try { - return new URL(`${schema}://${gastgeber}`); - } catch { - // ebenso - } + const ausKopfzeile = brauchbar(`${schema}://${gastgeber}`); + if (ausKopfzeile) return ausKopfzeile; } return new URL(request.url); @@ -43,3 +46,19 @@ export function oeffentlicheBasis(request: Request, eingetragen?: string): URL { export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL { return new URL(pfad, oeffentlicheBasis(request, eingetragen)); } + +/** + * Dasselbe für Server Actions und Seiten, die keine `Request` zur Hand haben. + * + * Nötig, weil Next.js eine Weiterleitung aus einer Server Action serverseitig absolut macht – und + * dafür die Adresse nimmt, auf die der Server lauscht. Im Container ist das `0.0.0.0` (oder der + * Containername); im Browser landet man damit auf einer Seite, die es nicht gibt. + */ +export async function eigenerPfad(pfad: string, eingetragen?: string): Promise { + const { headers } = await import("next/headers"); + const kopfzeilen = await headers(); + + // Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt. + const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen); + return new URL(pfad, basis).toString(); +} diff --git a/src/server/actions/auth.ts b/src/server/actions/auth.ts index dc1495a..af434e1 100644 --- a/src/server/actions/auth.ts +++ b/src/server/actions/auth.ts @@ -2,12 +2,16 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import { eigenerPfad } from "@/lib/adresse"; import { SITZUNGS_COOKIE } from "@/lib/sitzung"; import { protokolliere } from "@/server/audit"; +import { einstellungen } from "@/server/einstellungen"; /** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */ export async function abmelden() { (await cookies()).delete(SITZUNGS_COOKIE); await protokolliere("abmeldung", "Benutzer", null, {}); - redirect("/login"); + + const { dashboardUrl } = await einstellungen(); + redirect(await eigenerPfad("/login", dashboardUrl)); } diff --git a/src/server/actions/kunden.ts b/src/server/actions/kunden.ts index d5bdc00..434f370 100644 --- a/src/server/actions/kunden.ts +++ b/src/server/actions/kunden.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { prisma } from "@/lib/db"; import { protokolliere } from "@/server/audit"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { eigenerPfad } from "@/lib/adresse"; export interface AktionsErgebnis { fehler?: string; @@ -79,7 +80,7 @@ export async function kundeLoeschen(formular: FormData) { revalidatePath("/kunden"); revalidatePath("/hosts/nicht-zugeordnet"); - redirect("/kunden"); + redirect(await eigenerPfad("/kunden")); } const standortSchema = z.object({ diff --git a/src/server/auth/sitzungspflicht.ts b/src/server/auth/sitzungspflicht.ts index 3cb0e26..26758ba 100644 --- a/src/server/auth/sitzungspflicht.ts +++ b/src/server/auth/sitzungspflicht.ts @@ -2,6 +2,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { cache } from "react"; import type { Rolle } from "@/generated/prisma/enums"; +import { eigenerPfad } from "@/lib/adresse"; import { prisma } from "@/lib/db"; import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung"; import { darf, type Recht } from "./rollen"; @@ -68,10 +69,10 @@ export const angemeldet = cache(async (): Promise = */ export async function sitzungErforderlich(recht?: Recht): Promise { const benutzer = await angemeldet(); - if (!benutzer) redirect("/login?fehler=Bitte+anmelden."); + if (!benutzer) redirect(await eigenerPfad("/login?fehler=Bitte+anmelden.")); if (recht && !darf(benutzer.rolle, recht)) { - redirect("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung."); + redirect(await eigenerPfad("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung.")); } return benutzer; diff --git a/tests/sicherheit.test.ts b/tests/sicherheit.test.ts index bee03ce..84be2eb 100644 --- a/tests/sicherheit.test.ts +++ b/tests/sicherheit.test.ts @@ -100,6 +100,19 @@ describe("Öffentliche Adresse", () => { expect(eigeneAdresse(anfrageMit({}), "/").host).toBe("0a5963a591df:3000"); }); + it("übergeht eine Lausch-Adresse in der Konfiguration", () => { + // 0.0.0.0 heißt „alle Schnittstellen“ – als Ziel im Browser ist das unbrauchbar. + // Genau so stand es nach einer Installation in der .env und führte beim Abmelden ins Leere. + const anfrage = anfrageMit({ host: "proxledger.firma.de" }); + expect(eigeneAdresse(anfrage, "/login", "http://0.0.0.0:3000").host).toBe("proxledger.firma.de"); + }); + + it("übergeht auch eine Lausch-Adresse aus der Umgebung", () => { + vi.stubEnv("APP_URL", "http://0.0.0.0:3000"); + const anfrage = anfrageMit({ host: "proxledger.firma.de" }); + expect(eigeneAdresse(anfrage, "/login").host).toBe("proxledger.firma.de"); + }); + it("übergeht einen unbrauchbaren Eintrag, statt zu scheitern", () => { const anfrage = anfrageMit({ "x-forwarded-host": "dashboard.kunde.de" }); expect(eigeneAdresse(anfrage, "/", "keine-adresse").host).toBe("dashboard.kunde.de");