Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a4026fb44 | ||
|
|
9f75b9b015 | ||
|
|
0e48f1e3fa | ||
|
|
476ecb3be1 | ||
|
|
173d6d2e61 | ||
|
|
409a02561d | ||
|
|
8119aaad66 | ||
|
|
ee36f78cc3 | ||
|
|
38e674de34 | ||
|
|
b745910afa | ||
|
|
840e902b26 | ||
|
|
5e5028c53d | ||
|
|
83320f0443 | ||
|
|
f076f49631 | ||
|
|
8e5bf74a30 | ||
|
|
02845ed717 | ||
|
|
3657b60d16 | ||
|
|
f86a328b3c | ||
|
|
b2664de62f | ||
|
|
dfb2118bac | ||
|
|
335fc47496 | ||
|
|
e570d30019 | ||
|
|
c5293f5915 | ||
|
|
c1177067e6 | ||
|
|
e50fb30ab3 | ||
|
|
97f6450f1f | ||
|
|
ca2af0c2fc | ||
|
|
9c873a4069 | ||
|
|
09bb98235b | ||
|
|
4bcf3daedd | ||
|
|
94d5edbc6c | ||
|
|
92210e9099 | ||
|
|
6dfa85381a | ||
|
|
11e514dd25 | ||
|
|
9f1eb189db | ||
|
|
585173622d | ||
|
|
9c6c6a1c35 | ||
|
|
2195418867 | ||
|
|
ddca903687 | ||
|
|
eaaabd8e75 | ||
|
|
6317b731a2 | ||
|
|
b6fdd4f64a | ||
|
|
2d886ac062 | ||
|
|
2f45e33e20 | ||
|
|
7d8e5c9172 | ||
|
|
6c3729bae8 | ||
|
|
ffc80a8fcf | ||
|
|
d416f3869a | ||
|
|
19b0751c16 | ||
|
|
7775b6211f |
@@ -24,7 +24,8 @@ OIDC_CLIENT_ID=""
|
|||||||
OIDC_CLIENT_SECRET=""
|
OIDC_CLIENT_SECRET=""
|
||||||
OIDC_NAME="authentik"
|
OIDC_NAME="authentik"
|
||||||
|
|
||||||
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
|
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails).
|
||||||
|
# Lässt sich später unter Einstellungen → System überschreiben.
|
||||||
APP_URL="http://localhost:3000"
|
APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
|
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
|
||||||
@@ -33,6 +34,9 @@ WORKER_CRON="0 * * * *"
|
|||||||
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
|
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
|
||||||
ZUSAMMENFASSUNG_CRON="0 7 * * *"
|
ZUSAMMENFASSUNG_CRON="0 7 * * *"
|
||||||
|
|
||||||
|
# Zeitplan fürs Aufräumen alter Reports und Protokolleinträge (Fristen: Einstellungen → System)
|
||||||
|
AUFRAEUM_CRON="30 3 * * *"
|
||||||
|
|
||||||
# Ablage für hochgeladene Rechnungs-PDFs
|
# Ablage für hochgeladene Rechnungs-PDFs
|
||||||
UPLOAD_DIR="./data/uploads"
|
UPLOAD_DIR="./data/uploads"
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,228 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [1.0.0] – 2026-10-23
|
||||||
|
|
||||||
|
Erste stabile Fassung. Kein neuer Funktionsumfang gegenüber 0.27.1 – was fehlte, war Erprobung, und
|
||||||
|
die ist erbracht:
|
||||||
|
|
||||||
|
- **Ein Lizenzjahr vollständig durchlaufen.** Für `hv-pve-01` und `hv-pve-02` (Robert Hofmann GmbH):
|
||||||
|
Warnung vor dem Ablauf am 17.10.2026, Verlängerung, Proxmox-Rechnung 291017 mit Rabatt je Position,
|
||||||
|
Kundenrechnung, beide bezahlt. Die Folgelaufzeiten stehen auf 17.10.2027.
|
||||||
|
- **Proxmox Backup Server mit aktiver Lizenz bestätigt.** Drei PBS-Hosts melden Key und Ablaufdatum.
|
||||||
|
- **Proxmox VE** ist seit dem Feldtest im September bestätigt: mit und ohne Subscription, einzeln und
|
||||||
|
im Cluster, mit und ohne QDevice.
|
||||||
|
- **Wiederherstellung geprobt** (#045): Daten zerstört, zurückgespielt, geprüft.
|
||||||
|
|
||||||
|
Im Betrieb seit dem 03.10.2026 mit 16 Hosts, vier Kunden und echten Rechnungen.
|
||||||
|
|
||||||
|
**Weiterhin ungeprüft:** Proxmox Mail Gateway – dort wird derzeit kein Host betrieben; der Zweig ist
|
||||||
|
im Handbuch als ungeprüft gekennzeichnet (#023). Ebenso steht die Wiederherstellungsprobe für die
|
||||||
|
Installation als Dienst noch aus; geprobt ist der Docker-Weg.
|
||||||
|
|
||||||
|
## [0.27.1] – 2026-10-23
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- **Next.js auf 16.4.2.** Für 16.0.0 bis 16.4.0 wurden drei Lücken gemeldet, zwei davon **kritisch**: Remote Code Execution über die Bildoptimierung mit SVG-Dateien (GHSA-2464-fhr4-w9xw), Remote Code Execution in `use cache` (GHSA-673q-mjm6-r5xr) und eine Blockade über Server Actions (GHSA-p3m5-59mp-jv5r)
|
||||||
|
- ProxLedger nutzt weder `next/image` noch `use cache`; die Blockade über Server Actions betraf die Installation dagegen unmittelbar. **Wer öffentlich erreichbar ist, sollte sofort aktualisieren.**
|
||||||
|
|
||||||
|
## [0.27.0] – 2026-10-10
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `deploy/restore.sh`: spielt Datenbank **und** hochgeladene PDFs aus einer Sicherung zurück, startet die Dienste und prüft die Erreichbarkeit (#045)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Sicherungen ließen sich nur in eine leere Datenbank zurückspielen** – im Ernstfall also gerade nicht. `pg_dump` läuft jetzt mit `--clean --if-exists` (#045)
|
||||||
|
- Für die hochgeladenen PDFs gab es keinen beschriebenen Weg zurück (#045)
|
||||||
|
|
||||||
|
### Dokumentation
|
||||||
|
- `docs/betrieb/sicherung.md` beschreibt den **geprobten** Ablauf: Daten zerstört, zurückgespielt, geprüft (#045)
|
||||||
|
|
||||||
|
## [0.26.1] – 2026-10-10
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- `sharp` auf 0.35.5 angehoben: Für ältere Fassungen wurde eine als hoch eingestufte Lücke in der mitgelieferten librsvg gemeldet (CVE-2026-96889). ProxLedger nutzt `sharp` nur beim Erzeugen der Symbole, nicht zur Laufzeit – die Fassung gehört trotzdem aktualisiert
|
||||||
|
- `source-map-js` über `overrides` auf 1.2.2 angehoben (Denial of Service, kam über postcss und vite mit)
|
||||||
|
- Next.js auf 16.4.0
|
||||||
|
|
||||||
|
## [0.26.0] – 2026-10-04
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Kennzeichen **Eigenbedarf** am Kunden: für eigene Hosts entfallen Kundenrechnung, die zugehörigen Warnungen, die Aufgabe „Kundenrechnung erstellen“ und die Marge – die Proxmox-Rechnung wird weiterhin geführt (#044)
|
||||||
|
|
||||||
|
## [0.25.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Laufzeit von Hand anlegen** – für Hosts, die (noch) kein Ablaufdatum melden, und für Nachträge (#043)
|
||||||
|
- **Laufzeit bearbeiten**: Key, Beginn, Ende und Notiz lassen sich nachträglich ändern (#043)
|
||||||
|
- **Laufzeiten zusammenführen**, wenn neben einer erfassten Laufzeit ein Vorschlag mit abweichendem Datum steht; die Rechnungen wandern mit (#043)
|
||||||
|
- Kundenansicht: Laufzeit und Host verlinkt, dazu Marge und Rabatt je Zeile (#043)
|
||||||
|
|
||||||
|
## [0.24.1] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Cluster mit QDevice meldeten falsche Knotennamen** (`A,V,NMW`, `NR`) und das QDevice als zusätzlichen Knoten: `pvecm nodes` hat dann eine Spalte mehr. Die Namensspalte wird jetzt aus der Kopfzeile bestimmt (#042)
|
||||||
|
- Agent-Version 0.2.2
|
||||||
|
|
||||||
|
## [0.24.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Dreizehnte Warnregel: Mehrere Hosts desselben Kunden tragen denselben Namen – mit IP-Adressen zum Unterscheiden (#041)
|
||||||
|
- Hosts lassen sich **stilllegen**: Sie verschwinden aus Listen, Zahlen, Suche und Warnungen, ihre Laufzeiten und Rechnungen bleiben erhalten (#041)
|
||||||
|
- Endgültiges Löschen für Hosts, an denen keine Laufzeit hängt (#041)
|
||||||
|
- Schalter „stillgelegte Hosts zeigen“ in der Host-Liste (#041)
|
||||||
|
|
||||||
|
## [0.23.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
|
||||||
|
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
|
||||||
|
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
|
||||||
|
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
|
||||||
|
|
||||||
|
## [0.22.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Auf dem Telefon führte kein Weg zu den Rechnungsformularen**: Im Reiter *Rechnungen* der Host-Detailansicht war die Laufzeit nicht verlinkt, und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar (#039)
|
||||||
|
- Der Reiter *Rechnungen* rechnete den Einkauf noch ohne Rabatt (#039)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Der Reiter *Rechnungen* nutzt auf dem Telefon das Kartenlayout (#039)
|
||||||
|
|
||||||
|
## [0.22.1] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
|
||||||
|
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
|
||||||
|
|
||||||
|
## [0.22.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- **Der Rabatt gehört jetzt zur Rechnungsposition statt zur Rechnung** – so, wie Proxmox abrechnet: je Subscription-Key eine Zeile mit Listenpreis und eine mit „Client Discount“. Nur so bleibt er bei einer Sammelrechnung dem richtigen Host zugeordnet (#037)
|
||||||
|
- Der Rechnungsbetrag ist die Summe der Positionen nach Abzug ihrer Rabatte; Marge und Summen rechnen mit dem tatsächlich gezahlten Betrag (#037)
|
||||||
|
- Eigener Steuersatz für den Einkauf, Vorgabe 0 % wegen Reverse Charge (#037)
|
||||||
|
- Das Betragsfeld heißt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird (#037)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Zwölfte Warnregel `kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen – der Moment, an dem man selbst zahlt (#037)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Der Rabatt wurde bisher **nirgends abgezogen**, weder in der Summe noch in der Marge (#037)
|
||||||
|
|
||||||
|
## [0.21.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Standort eines Hosts lässt sich in der Detailansicht setzen; die Auswahl richtet sich nach dem gewählten Kunden (#036)
|
||||||
|
- Breite Tabellen werden auf dem Telefon zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts abgeschnitten zu sein (#035)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Jede Kundenzuweisung löschte den Standort des Hosts**, weil das Formular ihn nicht mitsendete, die Aktion ihn aber las (#036)
|
||||||
|
- Ein Standort, der zu einem anderen Kunden gehört, wird nicht mehr gespeichert (#036)
|
||||||
|
- Kartenköpfe mit Knöpfen liefen auf dem Telefon über den Rand (#035)
|
||||||
|
- Das Kontrollkästchen für den Rechnungsstatus wurde auf dem Telefon zum 44 Pixel hohen leeren Kasten (#035)
|
||||||
|
|
||||||
|
## [0.20.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Das Seitenmenü ließ sich auf dem Handy nicht schließen.** Es gab weder eine Fläche zum Antippen noch ein Schließen beim Navigieren – das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht einbinden (#034)
|
||||||
|
- Die Dashboard-Tabelle war auf dem Telefon rechts abgeschnitten; sie zeigt dort jetzt Host und Ablauf, den Kunden klein darunter (#034)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schließen-Knopf im Menükopf, Schließen per Escape, und der Inhalt hinter dem offenen Menü scrollt nicht mehr mit (#034)
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- `deploy/update.sh` und `deploy/backup.sh` erkennen die Installationsart selbst und beherrschen jetzt auch die Installation als Dienst (Node und PostgreSQL direkt im Container) – nicht mehr nur Docker (#031)
|
||||||
|
- Eine fehlgeschlagene Sicherung bricht die Aktualisierung ab, bevor etwas verändert wird (#031)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Die Zustandsprüfung des Web-Containers konnte nie gelingen: Next bindet im Standalone-Modus an die Adresse aus `HOSTNAME`, und Docker setzt dort den Containernamen – der Server lauschte nur auf der Container-IP. `web` blieb dauerhaft auf „health: starting“ (#031)
|
||||||
|
|
||||||
|
## [0.19.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Next.js auf 16.3.8 angehoben. Für 16.2.0 bis 16.3.5 wurde eine als **kritisch** eingestufte Lücke in `next/og` gemeldet (Remote Code Execution, GHSA-vcvr-r3jv-pc5j). ProxLedger nutzt `next/og` nicht, war also nicht angreifbar – die Fassung gehört trotzdem aktualisiert, bevor etwas produktiv läuft
|
||||||
|
- `@simplewebauthn/server` auf 14.0.3
|
||||||
|
|
||||||
|
## [0.19.1] – 2026-09-19
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Anleitung für den netbird Reverse Proxy (Beta): Dienst anlegen, Zertifikat, private Dienste – samt Warnung, dass eine Anmeldehürde vor dem Dienst auch die Agents aussperrt (#030)
|
||||||
|
|
||||||
|
## [0.19.0] – 2026-09-19
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `docs/betrieb/`: Inbetriebnahme, Reverse Proxy (nginx Proxy Manager und netbird), OIDC mit authentik und Sicherung – jeweils Schritt für Schritt (#029)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Die OIDC-Rückkehradresse berücksichtigte die im Dashboard eingetragene Adresse nicht, sondern nur `APP_URL` (#029)
|
||||||
|
- Beim Einlösen des OIDC-Codes wurde hinter einem Reverse Proxy die interne Adresse verwendet; der Tausch wäre am Anbieter gescheitert (#029)
|
||||||
|
|
||||||
|
## [0.18.0] – 2026-09-19
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Drei Rollen: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Hosts und Rechnungen – ohne Einkaufspreise und Marge – und dürfen Notizen schreiben sowie Warnungen quittieren (#027)
|
||||||
|
- Benutzerverwaltung unter *Einstellungen → Benutzer*: anlegen, Rolle ändern, sperren, Passwort setzen, löschen (#027)
|
||||||
|
- Anmeldung mit Passkeys (WebAuthn), gleichwertig neben Passwort und OIDC; eigene Seite *Meine Passkeys* (#028)
|
||||||
|
- Einstellung, welche Rolle automatisch angelegte OIDC-Konten bekommen (#027)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Neu über OIDC angelegte Konten hätten die Vorgabe des Datenmodells geerbt und wären Administratoren geworden; Vorgabe ist jetzt *Techniker* (#027)
|
||||||
|
- Das Screenshot-Skript meldete sich mit einem erfundenen Konto an – seit der Sitzungsprüfung gegen die Datenbank landete es auf der Anmeldeseite (#027)
|
||||||
|
|
||||||
|
## [0.17.0] – 2026-09-19
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `deploy/update.sh`: sichert, baut neu, migriert und prüft, ob das Dashboard wieder antwortet (#026)
|
||||||
|
- Installationsskripte nach Art der Proxmox VE Helper-Scripts unter `deploy/community-scripts/` – Container mit Node und PostgreSQL, ohne Docker (#026)
|
||||||
|
- `docs/roadmap.md`: was bis 1.0.0 fehlt und was danach lohnt (#026)
|
||||||
|
- Zustandsprüfung für den Worker im Docker-Stack (#026)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Nach der Anmeldung führte die Weiterleitung im Container auf den internen Containernamen (`http://<container-id>:3000`) statt auf die erreichbare Adresse – hinter einem Reverse Proxy landete man damit im Nichts. Beim Prüfen des Docker-Stapels aufgefallen (#026)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- README auf den tatsächlichen Stand gebracht: Der Feldtest ist erledigt, der Hinweis „noch nicht erprobt“ war überholt (#026)
|
||||||
|
|
||||||
|
## [0.16.0] – 2026-09-18
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- *Einstellungen → System*: Dashboard-Adresse, Melderhythmus und Selbstaktualisierung der Agents, kaufmännische Vorgaben, Sicherheitsgrenzen, Firmenangaben und Zeitzone (#025)
|
||||||
|
- Die Dashboard-Adresse und der Melderhythmus landen im ausgelieferten Installations-Einzeiler und im systemd-Timer (#025)
|
||||||
|
- OIDC lässt sich auf bestimmte E-Mail-Domänen begrenzen; das automatische Anlegen unbekannter Konten ist abschaltbar (#025)
|
||||||
|
- Nächtliches Aufräumen nach einstellbaren Aufbewahrungsfristen für Reports und Protokoll – der jüngste Report jedes Hosts bleibt erhalten (#025)
|
||||||
|
- Firmenangaben erscheinen im Kopf der PDF-Ausgaben (#025)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Steuersatz, Zahlungsziel und Aufschlag kommen aus den Einstellungen statt aus fest eingetragenen 19 % (#025)
|
||||||
|
|
||||||
|
## [0.15.0] – 2026-09-18
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `docs/sicherheit.md`: Zuordnung aller Maßnahmen zu den OWASP Top 10, samt der Punkte, die bewusst offen bleiben (#024)
|
||||||
|
- Content-Security-Policy mit frischem Nonce je Anfrage (#024)
|
||||||
|
- Sitzungen lassen sich widerrufen: gesperrtes Konto oder neues Passwort beendet laufende Sitzungen sofort (#024)
|
||||||
|
- Benutzerverwaltung auf der Kommandozeile: `npm run benutzer -- liste|sperren|entsperren|passwort` (#024)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Server Actions verließen sich allein auf den Schutz in `proxy.ts`; jede der 27 Aktionen prüft die Sitzung jetzt selbst (#024)
|
||||||
|
- Die Anmeldung prüfte die Herkunft des Formulars nicht – damit war Login-CSRF möglich (#024)
|
||||||
|
- `npm audit` meldete vier hohe Schwachstellen in Beiwerk von Prisma (`mysql2`, `deepmerge-ts`); über `overrides` angehoben, jetzt ohne Befund (#024)
|
||||||
|
|
||||||
## [0.14.1] – 2026-09-18
|
## [0.14.1] – 2026-09-18
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 9 h 43 min
|
**Gesamt:** 25 h 28 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -29,6 +29,28 @@
|
|||||||
| #018 Feldtest auf echten Hosts | 0 h 40 min |
|
| #018 Feldtest auf echten Hosts | 0 h 40 min |
|
||||||
| #022 Cluster-Abgleich | 0 h 25 min |
|
| #022 Cluster-Abgleich | 0 h 25 min |
|
||||||
| #023 Feldtest PBS | 0 h 35 min |
|
| #023 Feldtest PBS | 0 h 35 min |
|
||||||
|
| #024 OWASP-Härtung | 0 h 45 min |
|
||||||
|
| #025 Einstellungen | 1 h 10 min |
|
||||||
|
| #026 Auslieferung | 0 h 40 min |
|
||||||
|
| #027 Rollen und Benutzerverwaltung | 1 h 20 min |
|
||||||
|
| #028 Passkeys | 0 h 45 min |
|
||||||
|
| #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 |
|
||||||
|
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
||||||
|
| #035 Tabellen als Karten | 0 h 50 min |
|
||||||
|
| #036 Standort am Host | 0 h 30 min |
|
||||||
|
| #037 Rabatt je Position | 0 h 55 min |
|
||||||
|
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
||||||
|
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
||||||
|
| #040 Schlagworte sichtbar | 0 h 40 min |
|
||||||
|
| #041 Doppelte Hosts | 0 h 50 min |
|
||||||
|
| #042 QDevice im Cluster | 0 h 20 min |
|
||||||
|
| #043 Laufzeiten bearbeiten | 1 h 05 min |
|
||||||
|
| #044 Eigenbedarf | 0 h 40 min |
|
||||||
|
| #045 Wiederherstellung geprobt | 0 h 50 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -51,7 +73,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.14.1
|
**Version:** 1.0.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -138,6 +160,88 @@
|
|||||||
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
|
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
|
||||||
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
|
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
|
||||||
|
|
||||||
|
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
|
||||||
|
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
|
||||||
|
|
||||||
|
- Einstellungen im Dashboard (#025): vier Bereiche, alle Werte wirken auch wirklich – der Melderhythmus zum Beispiel bis in den erzeugten systemd-Timer hinein, live geprüft
|
||||||
|
- Dabei die OIDC-Lücke aus #024 geschlossen: Domänenliste und Schalter gegen automatisch angelegte Konten
|
||||||
|
- Bewusst nicht gebaut: „stumm nach X Tagen“ (steht schon bei den Warnregeln) und die Währung in der Browseransicht
|
||||||
|
|
||||||
|
- Auslieferung finalisiert (#026): Docker-Weg mit Update-Skript, dazu Skripte nach Art der Helper-Scripts für einen LXC ohne Docker
|
||||||
|
- Recherche dazu: Das Gerüst der community-scripts liegt inzwischen in einem eigenen Repository, und neue Skripte gehören zuerst nach ProxmoxVED. Eine Einreichung setzt öffentlichen Quelltext voraus – deshalb vorerst intern
|
||||||
|
- Roadmap geschrieben: Bis 1.0.0 fehlt kein Code mehr, sondern Erprobung (PMG, PBS mit Lizenz, ein Monat Dauerbetrieb, einmal eine Sicherung zurückspielen)
|
||||||
|
|
||||||
|
- Rollen und Benutzerverwaltung (#027): Die Einschränkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben. Sechs Tests prüfen, dass ein Kundenkonto weder fremde Hosts noch Einkaufspreise sieht
|
||||||
|
- Dabei aufgefallen: Neu angelegte OIDC-Konten hätten die Modellvorgabe ADMIN geerbt – behoben, mit eigener Einstellung und Test
|
||||||
|
- Passkeys (#028): passwortlos, mehrere je Konto, Aufgabe in einem kurzlebigen Cookie. Nur über HTTPS nutzbar – die Seite sagt es, statt still zu scheitern
|
||||||
|
|
||||||
|
- Einrichtungsdokumentation (#029): Beim Aufschreiben des OIDC-Wegs fielen zwei Fehler auf, die nur hinter einem Reverse Proxy auftreten – Rückkehradresse und Code-Einlösung nahmen die interne Adresse. Beides behoben
|
||||||
|
- Erkenntnis: Eine Anleitung Schritt für Schritt aufzuschreiben findet Fehler, die kein Test findet, weil sie nur in einer bestimmten Betriebsform auftreten
|
||||||
|
|
||||||
|
- netbird Reverse Proxy (#030) in die Anleitung aufgenommen – Florians Hinweis, dass die Beta gut läuft. Beim Durchgehen der Möglichkeiten fiel der Fallstrick auf: Eine Anmeldehürde vor dem Dienst trifft auch `/api/agent` und legt alle Agents still
|
||||||
|
|
||||||
|
### 03.10.2026 – Prüfung vor dem Einsatz
|
||||||
|
- `npm audit` vor der Freigabe erneut laufen lassen: In den zwei Wochen seit dem letzten Stand kam eine kritische Meldung zu Next.js dazu (RCE in `next/og`). Wir nutzen `next/og` nicht, waren also nicht angreifbar – angehoben auf 16.3.8, Tests und Build grün
|
||||||
|
- Lehre für den Betrieb: Vor jedem Produktivgang und danach regelmäßig `npm audit` prüfen, nicht nur bei Änderungen am Code
|
||||||
|
|
||||||
|
- 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ß
|
||||||
|
|
||||||
|
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
|
||||||
|
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
|
||||||
|
|
||||||
|
- Breite Tabellen werden auf dem Telefon zu Karten (#035); dabei fiel auf, dass `pl-touch-target` am Kontrollkästchen selbst saß und es zum leeren Kasten aufblies
|
||||||
|
- Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las
|
||||||
|
- Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle
|
||||||
|
|
||||||
|
- Eine echte Proxmox-Rechnung hat drei Fehler im Rechnungsteil aufgedeckt (#037): Rabatt an der falschen Ebene, nirgends abgezogen, und 19 % USt als Vorgabe, wo Reverse Charge gilt
|
||||||
|
- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel
|
||||||
|
- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen
|
||||||
|
|
||||||
|
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
|
||||||
|
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
|
||||||
|
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
|
||||||
|
|
||||||
|
- #039: Auf dem Telefon führte kein Weg zu den Rechnungsformularen – die Laufzeit war nicht verlinkt und die Spalte erst ab 992 px sichtbar
|
||||||
|
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
|
||||||
|
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
|
||||||
|
|
||||||
|
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
|
||||||
|
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
|
||||||
|
|
||||||
|
- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden
|
||||||
|
- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt
|
||||||
|
|
||||||
|
- QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice
|
||||||
|
- Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen
|
||||||
|
|
||||||
|
- Laufzeiten (#043): Anlegen gab es nur als Server-Aktion ohne Oberfläche, Ändern und Zusammenführen gar nicht. Dabei tritt der Doppelfall zwangsläufig auf, sobald das gemeldete Ablaufdatum von der Bestellung abweicht
|
||||||
|
- Beim Zusammenführen muss die Quelle ihre Rechnungsposition zuerst loslassen – die Datenbank lässt eine Position nur an einer Laufzeit zu. Der Test hat die falsche Reihenfolge sofort gezeigt
|
||||||
|
|
||||||
|
- Eigenbedarf (#044): Eigene Hosts brauchen einen Kunden, sonst fehlen sie in jeder Auswertung – als gewöhnlicher Kunde erzeugen sie aber Aufgaben, die es nicht gibt, und eine negative Marge
|
||||||
|
- Die Einkaufsseite bleibt vollständig: Eigene Hosts kosten echtes Geld
|
||||||
|
|
||||||
|
### 10.10.2026 – Wartungsblick
|
||||||
|
- `npm audit` turnusmäßig geprüft: zwei neue hohe Meldungen (`sharp`/librsvg, `source-map-js`). Beide angehoben, Tests und Build grün
|
||||||
|
- Produktivinstanz antwortet: `https://proxledger.wilde-ste.de/api/gesundheit` meldet Datenbank erreichbar
|
||||||
|
|
||||||
|
- Wiederherstellung geprobt (#045): Daten zerstört, zurückgespielt, geprüft. Zwei Lücken kamen erst dabei heraus – ohne `--clean` ließ sich nur in eine leere Datenbank zurückspielen, und für die PDFs gab es gar keinen Weg
|
||||||
|
- Damit ist einer der vier Punkte bis 1.0.0 erledigt. Offen bleiben PMG, PBS mit Lizenz und ein vollständiger Lizenzjahr-Durchlauf (17.10.)
|
||||||
|
|
||||||
|
### 23.10.2026 – Wartungsblick
|
||||||
|
- `npm audit`: drei neue Meldungen zu Next.js ≤ 16.4.0, zwei davon kritisch (RCE über Bildoptimierung und `use cache`, dazu eine Blockade über Server Actions). Auf 16.4.2 angehoben, alles grün
|
||||||
|
- Geprüft, was davon ProxLedger trifft: `next/image` und `use cache` werden nicht genutzt, Server Actions dagegen überall – die Blockade war also echt
|
||||||
|
- Erinnerung an mich: Zwei Wartungsblicke in Folge haben hohe bis kritische Meldungen gefunden. Der wöchentliche Lauf in der CI ist überfällig
|
||||||
|
|
||||||
|
### 23.10.2026 – 1.0.0
|
||||||
|
- Florian hat den Beleg geliefert: `hv-pve-01` und `hv-pve-02` sind verlängert, beide Rechnungen bezahlt, die Folgelaufzeiten stehen auf 17.10.2027. Dazu drei PBS-Hosts mit aktiver Lizenz
|
||||||
|
- PMG wird zurückgestellt – kein Host im Betrieb. Die Lücke bleibt im Handbuch benannt, statt sie wegzudefinieren
|
||||||
|
- Damit ist 1.0.0 gerechtfertigt: nicht weil nichts mehr fehlt, sondern weil das, was fehlte, erprobt wurde
|
||||||
|
|
||||||
### Nächster Schritt
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #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)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ RUN npx prisma generate \
|
|||||||
FROM basis AS laufzeit
|
FROM basis AS laufzeit
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
# Ohne diese Zeile nimmt Next den von Docker gesetzten HOSTNAME als Bindeadresse und lauscht
|
||||||
|
# nur auf der Container-IP. Die Zustandsprüfung über 127.0.0.1 schlägt dann immer fehl.
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
|
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,18 @@
|
|||||||
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
|
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
|
||||||
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
|
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
|
||||||
|
|
||||||
> **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten
|
> **Status: 1.0.0 – im produktiven Einsatz.** Seit dem 03.10.2026 mit echten Daten in Betrieb; ein
|
||||||
> Proxmox-Hosts erprobt (#018).
|
> Lizenzjahr ist vollständig durchlaufen (Warnung → Verlängerung → Einkauf → Kundenrechnung → bezahlt).
|
||||||
|
> Erprobt auf Proxmox VE 9.2 mit und ohne Subscription, einzeln und im Cluster, sowie auf Proxmox
|
||||||
|
> Backup Server 4.2 mit und ohne Lizenz. **Ungeprüft:** Mail Gateway – dort wird derzeit kein Host
|
||||||
|
> betrieben. Was als Nächstes ansteht, steht in [docs/roadmap.md](docs/roadmap.md).
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
Auf einem Rechner mit Docker:
|
Auf einem Rechner mit Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <dieses-repo> proxledger && cd proxledger
|
git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
|
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -20,14 +23,27 @@ docker compose up -d
|
|||||||
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
|
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
|
||||||
Danach ist das Dashboard unter `APP_URL` erreichbar.
|
Danach ist das Dashboard unter `APP_URL` erreichbar.
|
||||||
|
|
||||||
**Auf einem Proxmox-Host** legt der Installer einen fertigen LXC-Container an:
|
Aktualisieren geht mit einem Befehl – er sichert vorher, baut neu, migriert und prüft zum Schluss,
|
||||||
|
ob das Dashboard wieder antwortet:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy/lxc-installer.sh
|
bash deploy/update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter
|
**Auf einem Proxmox-Host** gibt es zwei Wege zu einem fertigen LXC-Container:
|
||||||
und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm.
|
|
||||||
|
```bash
|
||||||
|
bash deploy/lxc-installer.sh # eigener Installer: Container mit Docker darin
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nach Art der Proxmox VE Helper-Scripts: Container mit Node und PostgreSQL direkt darin
|
||||||
|
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Der zweite Weg folgt den Konventionen von [community-scripts.org](https://community-scripts.org) und
|
||||||
|
braucht kein Docker; Einzelheiten und die Voraussetzungen für eine Einreichung stehen in
|
||||||
|
[deploy/community-scripts/README.md](deploy/community-scripts/README.md).
|
||||||
|
|
||||||
### Agent ausrollen
|
### Agent ausrollen
|
||||||
|
|
||||||
@@ -105,13 +121,38 @@ Die Oberfläche ist vollständig geschützt; die Agent-API und die Skript-Auslie
|
|||||||
sie sich mit eigenen Tokens ausweisen.
|
sie sich mit eigenen Tokens ausweisen.
|
||||||
|
|
||||||
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
|
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
|
||||||
je Kennung und Viertelstunde, danach Sperre.
|
je Kennung und Viertelstunde, danach Sperre (einstellbar unter *Einstellungen → System*).
|
||||||
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse
|
Benutzer sperren oder Passwort setzen: `npm run benutzer -- sperren <email>`.
|
||||||
|
- **Passkey:** passwortlos über WebAuthn, mehrere je Konto, Verwaltung unter *Meine Passkeys*. Setzt HTTPS voraus (außer `localhost`).
|
||||||
|
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen – Schritt für Schritt in [docs/betrieb/authentik.md](docs/betrieb/authentik.md). Rückkehradresse
|
||||||
ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt
|
ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt
|
||||||
absichtlich immer nutzbar, falls der Anmeldedienst ausfällt.
|
absichtlich immer nutzbar, falls der Anmeldedienst ausfällt. Unter *Einstellungen → System* lässt sich
|
||||||
|
einschränken, welche E-Mail-Domänen zugelassen sind und ob unbekannte Konten angelegt werden dürfen.
|
||||||
- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle
|
- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle
|
||||||
Sitzungen ungültig.
|
Sitzungen ungültig.
|
||||||
|
|
||||||
|
## Einrichtung im Betrieb
|
||||||
|
|
||||||
|
| Anleitung | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| [Inbetriebnahme](docs/betrieb/inbetriebnahme.md) | von null zum meldenden Host, in der richtigen Reihenfolge |
|
||||||
|
| [Reverse Proxy](docs/betrieb/reverse-proxy.md) | HTTPS mit nginx Proxy Manager, netbird Reverse Proxy (Beta) oder netbird pur |
|
||||||
|
| [authentik](docs/betrieb/authentik.md) | Anmeldung über OIDC, samt Fehlerbildern |
|
||||||
|
| [Sicherung](docs/betrieb/sicherung.md) | was gesichert wird – und wie man es zurückspielt |
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
Die meisten Betriebswerte stehen unter *Einstellungen → System* und wirken sofort: Dashboard-Adresse,
|
||||||
|
Melderhythmus und Selbstaktualisierung der Agents, Steuersatz, Zahlungsziel und Aufschlag,
|
||||||
|
Sitzungsdauer und Anmeldeversuche, OIDC-Beschränkungen, Aufbewahrungsfristen sowie Firmenangaben und
|
||||||
|
Zeitzone für die PDF-Ausgaben.
|
||||||
|
|
||||||
|
In der Umgebung bleiben nur `APP_SECRET` und `DATABASE_URL` – beide werden gebraucht, bevor die
|
||||||
|
Anwendung die Datenbank lesen kann.
|
||||||
|
|
||||||
|
Wie die Anwendung gegen die OWASP Top 10 dasteht (einschließlich der Punkte, die bewusst offen
|
||||||
|
bleiben), steht in [docs/sicherheit.md](docs/sicherheit.md).
|
||||||
|
|
||||||
## Exporte
|
## Exporte
|
||||||
|
|
||||||
| Ansicht | CSV | PDF |
|
| Ansicht | CSV | PDF |
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ set -o nounset
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
|
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
|
||||||
|
# Melderhythmus und Streuung setzt das Dashboard beim Ausliefern ein (Einstellungen → System).
|
||||||
|
# Wird das Skript unverändert aus dem Quelltext ausgeführt, bleiben die Platzhalter stehen;
|
||||||
|
# dann gelten die Vorgaben.
|
||||||
|
TIMER_ONCALENDAR="@@TIMER_ONCALENDAR@@"
|
||||||
|
TIMER_STREUUNG="@@TIMER_STREUUNG@@"
|
||||||
|
case "$TIMER_ONCALENDAR" in *@@*) TIMER_ONCALENDAR="daily" ;; esac
|
||||||
|
case "$TIMER_STREUUNG" in *@@*) TIMER_STREUUNG="4h" ;; esac
|
||||||
TOKEN=""
|
TOKEN=""
|
||||||
CA_CERT=""
|
CA_CERT=""
|
||||||
AUTO_UPDATE="no"
|
AUTO_UPDATE="@@AUTO_UPDATE@@"
|
||||||
|
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
|
||||||
AKTION="install"
|
AKTION="install"
|
||||||
|
|
||||||
CONFIG_VERZEICHNIS="/etc/proxledger"
|
CONFIG_VERZEICHNIS="/etc/proxledger"
|
||||||
@@ -202,14 +210,14 @@ ProtectSystem=full
|
|||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
UNIT
|
UNIT
|
||||||
|
|
||||||
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT'
|
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Täglicher ProxLedger-Report
|
Description=ProxLedger-Report
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=daily
|
OnCalendar=${TIMER_ONCALENDAR}
|
||||||
# Verteilt die Meldungen aller Hosts über vier Stunden.
|
# Verteilt die Meldungen aller Hosts über diesen Zeitraum.
|
||||||
RandomizedDelaySec=4h
|
RandomizedDelaySec=${TIMER_STREUUNG}
|
||||||
Persistent=true
|
Persistent=true
|
||||||
Unit=proxledger.service
|
Unit=proxledger.service
|
||||||
|
|
||||||
@@ -219,7 +227,7 @@ UNIT
|
|||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now proxledger.timer >/dev/null
|
systemctl enable --now proxledger.timer >/dev/null
|
||||||
erfolg "systemd-Timer aktiviert (täglich, zufällig um bis zu 4 Stunden versetzt)."
|
erfolg "systemd-Timer aktiviert (${TIMER_ONCALENDAR}, zufällig um bis zu ${TIMER_STREUUNG} versetzt)."
|
||||||
|
|
||||||
hinweis "Sende ersten Report …"
|
hinweis "Sende ersten Report …"
|
||||||
if "$AGENT_PFAD" --report; then
|
if "$AGENT_PFAD" --report; then
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
set -o nounset
|
set -o nounset
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
AGENT_VERSION="0.2.1"
|
AGENT_VERSION="0.2.2"
|
||||||
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
||||||
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
||||||
SPOOL_MAXIMUM=30
|
SPOOL_MAXIMUM=30
|
||||||
@@ -175,9 +175,24 @@ cluster_name() {
|
|||||||
|
|
||||||
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
||||||
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
||||||
|
# Namen aller Cluster-Knoten aus `pvecm nodes`.
|
||||||
|
#
|
||||||
|
# Die Spaltenzahl ist nicht fest: Mit einem QDevice steht zwischen Votes und Name noch dessen
|
||||||
|
# Zustand (A,V,NMW oder NR). Deshalb wird die Spalte "Name" aus der Kopfzeile bestimmt, statt
|
||||||
|
# blind die dritte zu nehmen. Die Zeile des QDevice selbst traegt die Nodeid 0 und ist kein Knoten.
|
||||||
cluster_mitglieder() {
|
cluster_mitglieder() {
|
||||||
if command -v pvecm >/dev/null 2>&1; then
|
if command -v pvecm >/dev/null 2>&1; then
|
||||||
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//'
|
pvecm nodes 2>/dev/null | awk '
|
||||||
|
/Nodeid/ {
|
||||||
|
for (i = 1; i <= NF; i++) if ($i == "Name") spalte = i
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {
|
||||||
|
if ($1 == 0) next
|
||||||
|
name = (spalte ? $spalte : $3)
|
||||||
|
sub(/\(.*/, "", name)
|
||||||
|
if (name != "") print name
|
||||||
|
}'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sichert Datenbank und hochgeladene PDFs von ProxLedger.
|
# Sichert Datenbank, hochgeladene PDFs und die Konfiguration von ProxLedger.
|
||||||
|
# Erkennt selbst, ob die Installation mit Docker oder als Dienst im Container läuft.
|
||||||
#
|
#
|
||||||
# ./backup.sh /pfad/zum/sicherungsordner
|
# ./backup.sh /pfad/zum/sicherungsordner
|
||||||
#
|
#
|
||||||
@@ -13,17 +14,51 @@ set -o pipefail
|
|||||||
ZIEL="${1:-/var/backups/proxledger}"
|
ZIEL="${1:-/var/backups/proxledger}"
|
||||||
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
|
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
|
||||||
STAND=$(date +%Y%m%d-%H%M%S)
|
STAND=$(date +%Y%m%d-%H%M%S)
|
||||||
COMPOSE_VERZEICHNIS="${COMPOSE_VERZEICHNIS:-/opt/proxledger}"
|
VERZEICHNIS="${COMPOSE_VERZEICHNIS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
|
||||||
mkdir -p "$ZIEL"
|
mkdir -p "$ZIEL"
|
||||||
cd "$COMPOSE_VERZEICHNIS"
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
# Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
|
||||||
|
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||||
|
ART="dienst"
|
||||||
|
else
|
||||||
|
ART="docker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Sichere ProxLedger (${ART}) nach ${ZIEL} …"
|
||||||
|
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
echo "Sichere Datenbank …"
|
echo "Sichere Datenbank …"
|
||||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
docker compose exec -T postgres pg_dump --clean --if-exists \
|
||||||
|
-U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
||||||
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
echo "Sichere hochgeladene Dateien …"
|
echo "Sichere hochgeladene Dateien …"
|
||||||
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
|
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# DATABASE_URL steht in der .env; pg_dump versteht sie direkt.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -o allexport
|
||||||
|
. ./.env
|
||||||
|
set +o allexport
|
||||||
|
|
||||||
|
[ -n "${DATABASE_URL:-}" ] || {
|
||||||
|
echo "DATABASE_URL fehlt in der .env – ohne sie keine Sicherung." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Sichere Datenbank …"
|
||||||
|
pg_dump --clean --if-exists "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
|
echo "Sichere hochgeladene Dateien …"
|
||||||
|
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
|
||||||
|
if [ -d "$UPLOADS" ]; then
|
||||||
|
tar -czf "${ZIEL}/uploads-${STAND}.tar.gz" -C "$(dirname "$UPLOADS")" "$(basename "$UPLOADS")"
|
||||||
|
else
|
||||||
|
echo " (kein Upload-Verzeichnis unter ${UPLOADS})"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Sichere Konfiguration …"
|
echo "Sichere Konfiguration …"
|
||||||
cp .env "${ZIEL}/env-${STAND}.txt"
|
cp .env "${ZIEL}/env-${STAND}.txt"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Installation als LXC-Container nach Art der Proxmox VE Helper-Scripts
|
||||||
|
|
||||||
|
Diese beiden Skripte folgen den Konventionen von [community-scripts.org](https://community-scripts.org)
|
||||||
|
(der Nachfolge von tteck's Helper-Scripts). Sie legen einen unprivilegierten Debian-13-Container an,
|
||||||
|
installieren Node.js 24 und PostgreSQL 17, holen ProxLedger aus dem Git-Repository, richten zwei
|
||||||
|
systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach
|
||||||
|
`/opt/proxledger/.credentials`.
|
||||||
|
|
||||||
|
**Ressourcen:** Die Vorgabe ist 2 CPU-Kerne, **4 GB RAM** und 12 GB Platte. Die 4 GB braucht
|
||||||
|
`next build` während der Installation; im Betrieb genügen 2 GB, die sich im Nachhinein in Proxmox
|
||||||
|
wieder absenken lassen.
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `ct/proxledger.sh` | Container anlegen, Ressourcen abfragen, Aktualisierung (`update_script`) |
|
||||||
|
| `install/proxledger-install.sh` | Installation im Container |
|
||||||
|
|
||||||
|
## Verwendung im eigenen Netz
|
||||||
|
|
||||||
|
Auf dem Proxmox-VE-Host als root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Gerüst (`build.func`) lädt das Skript von den community-scripts nach – das ist öffentlich und ohne
|
||||||
|
Konto erreichbar. Nur ProxLedger selbst kommt aus dem eigenen Git.
|
||||||
|
|
||||||
|
**Wichtig:** Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Ohne
|
||||||
|
Angabe sucht das Gerüst `install/proxledger-install.sh` bei den community-scripts auf GitHub und
|
||||||
|
bricht mit *404 – Could not fetch install/proxledger-install.sh* ab. Das ct-Skript setzt deshalb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMUNITY_SCRIPTS_URL="https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts"
|
||||||
|
```
|
||||||
|
|
||||||
|
selbst. Für einen anderen Zweig oder einen Fork lässt sich die Angabe von außen überschreiben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMUNITY_SCRIPTS_URL=https://git.example.de/<nutzer>/ProxLedger/raw/branch/<zweig>/deploy/community-scripts \
|
||||||
|
bash -c "$(curl -fsSL …/ct/proxledger.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Verzeichnis dahinter muss `ct/` und `install/` enthalten – deshalb zeigt die Angabe auf
|
||||||
|
`deploy/community-scripts`, nicht auf die Wurzel des Repositorys.
|
||||||
|
|
||||||
|
Zwei Umgebungsvariablen steuern die Quelle, solange das Repository nicht öffentlich ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROXLEDGER_REPO=https://git.example.de/fw/ProxLedger.git PROXLEDGER_BRANCH=main bash -c "$(curl -fsSL …)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ist das Git-Repository nicht ohne Anmeldung lesbar, gehört ein Lesezugriffs-Token in die Adresse
|
||||||
|
(`https://<token>@git.example.de/...`). Das Token landet dann in der Prozessliste des Containers –
|
||||||
|
also besser einen Deploy-Token verwenden, der nur lesen darf.
|
||||||
|
|
||||||
|
## Was eine Einreichung bei community-scripts verlangen würde
|
||||||
|
|
||||||
|
Wir liefern die Skripte vorerst **nur intern** aus. Für eine Einreichung wären zusätzlich nötig:
|
||||||
|
|
||||||
|
1. **Öffentlich erreichbarer Quelltext.** Die Skripte dort holen die Anwendung praktisch immer aus
|
||||||
|
einem öffentlichen GitHub-Repository (`fetch_and_deploy_gh_release`). Ein privates Gitea genügt nicht.
|
||||||
|
2. **Pull Request gegen [ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)**, nicht gegen
|
||||||
|
ProxmoxVE. Neue Skripte, die direkt gegen ProxmoxVE eingereicht werden, werden ohne Prüfung
|
||||||
|
geschlossen.
|
||||||
|
3. **MIT-Lizenz** im eigenen Repository und der Lizenzkopf in beiden Skripten (steht bereits darin).
|
||||||
|
4. **Englische Dokumentation** – die Skripte selbst sind auf Englisch gehalten, die deutschen
|
||||||
|
Kommentare in diesem Verzeichnis müssten übersetzt werden.
|
||||||
|
5. **Metadaten für die Webseite.** Die JSON-Dateien liegen inzwischen nicht mehr im Skript-Repository;
|
||||||
|
wo sie hingehören, steht in der aktuellen Beitragsanleitung. Das ist beim Einreichen zu prüfen.
|
||||||
|
|
||||||
|
Die Skripte sind so geschrieben, dass für eine spätere Einreichung nur die Quelle zu tauschen und die
|
||||||
|
Kommentare zu übersetzen sind – am Ablauf ändert sich nichts.
|
||||||
|
|
||||||
|
## Unterschied zum Docker-Weg
|
||||||
|
|
||||||
|
| | Helper-Script (LXC) | Docker Compose |
|
||||||
|
|---|---|---|
|
||||||
|
| Umgebung | eigener Container auf dem Proxmox-Host | beliebiger Docker-Host |
|
||||||
|
| Datenbank | PostgreSQL im selben Container | eigener Dienst |
|
||||||
|
| Aktualisierung | `update`-Weg des Skripts | `bash deploy/update.sh` |
|
||||||
|
| Sicherung | Proxmox-Sicherung des Containers | `bash deploy/backup.sh` |
|
||||||
|
|
||||||
|
Beide Wege sind gleichwertig. Wer ohnehin Proxmox betreibt, fährt mit dem LXC-Weg meist einfacher –
|
||||||
|
die Sicherung des Containers erledigt dann die gewohnte Proxmox-Sicherung mit.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Woher die App-Skripte kommen. Ein per curl geladenes ct-Skript kann seine eigene Herkunft nicht
|
||||||
|
# ermitteln – ohne diese Angabe sucht das Geruest install/proxledger-install.sh bei den
|
||||||
|
# community-scripts und bricht mit 404 ab. Ueberschreibbar fuer eigene Zweige oder Forks.
|
||||||
|
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts}"
|
||||||
|
export COMMUNITY_SCRIPTS_URL
|
||||||
|
_CS_DEFAULT_URL="$COMMUNITY_SCRIPTS_URL"
|
||||||
|
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
|
||||||
|
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
|
||||||
|
# Copyright (c) 2021-2026 tteck
|
||||||
|
# Author: Florian Wilde
|
||||||
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
|
# Source: https://git.wilde-server.de/florian.wilde/ProxLedger
|
||||||
|
|
||||||
|
APP="ProxLedger"
|
||||||
|
var_tags="${var_tags:-monitoring;licensing}"
|
||||||
|
var_cpu="${var_cpu:-2}"
|
||||||
|
# 4 GB fuer die Installation: `next build` braucht deutlich mehr als 2 GB und bricht sonst mit
|
||||||
|
# einem Speicherfehler ab. Nach der Installation genuegen 2 GB im Betrieb.
|
||||||
|
var_ram="${var_ram:-4096}"
|
||||||
|
var_disk="${var_disk:-12}"
|
||||||
|
var_os="${var_os:-debian}"
|
||||||
|
var_version="${var_version:-13}"
|
||||||
|
var_arm64="${var_arm64:-yes}"
|
||||||
|
var_unprivileged="${var_unprivileged:-1}"
|
||||||
|
|
||||||
|
header_info "$APP"
|
||||||
|
variables
|
||||||
|
color
|
||||||
|
catch_errors
|
||||||
|
|
||||||
|
function update_script() {
|
||||||
|
header_info
|
||||||
|
check_container_storage
|
||||||
|
check_container_resources
|
||||||
|
|
||||||
|
if [[ ! -d /opt/proxledger ]]; then
|
||||||
|
msg_error "No ${APP} Installation Found!"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_VERSION="24" setup_nodejs
|
||||||
|
|
||||||
|
msg_info "Stopping Services"
|
||||||
|
systemctl stop proxledger proxledger-worker
|
||||||
|
msg_ok "Stopped Services"
|
||||||
|
|
||||||
|
# Die .env traegt APP_SECRET; geht sie verloren, sind Sitzungen und gespeicherte
|
||||||
|
# Zugangsdaten unbrauchbar. Deshalb vor jedem Update sichern und danach zurueckspielen.
|
||||||
|
create_backup /opt/proxledger/.env
|
||||||
|
|
||||||
|
msg_info "Updating ${APP}"
|
||||||
|
cd /opt/proxledger
|
||||||
|
$STD git pull --ff-only
|
||||||
|
$STD npm ci
|
||||||
|
$STD npx prisma generate
|
||||||
|
$STD npx prisma migrate deploy
|
||||||
|
$STD npm run build
|
||||||
|
$STD npm run build:worker
|
||||||
|
msg_ok "Updated ${APP}"
|
||||||
|
|
||||||
|
restore_backup
|
||||||
|
|
||||||
|
msg_info "Starting Services"
|
||||||
|
systemctl start proxledger proxledger-worker
|
||||||
|
msg_ok "Started Services"
|
||||||
|
|
||||||
|
msg_ok "Update Successful"
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
start
|
||||||
|
build_container
|
||||||
|
description
|
||||||
|
|
||||||
|
msg_ok "Completed Successfully!\n"
|
||||||
|
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||||
|
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||||
|
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"
|
||||||
|
echo -e "${INFO}${YW} Login credentials are in:${CL}"
|
||||||
|
echo -e "${TAB}${GATEWAY}${BGN}/opt/proxledger/.credentials${CL}"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Copyright (c) 2021-2026 tteck
|
||||||
|
# Author: Florian Wilde
|
||||||
|
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||||
|
# Source: https://git.wilde-server.de/florian.wilde/ProxLedger
|
||||||
|
|
||||||
|
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||||
|
color
|
||||||
|
verb_ip6
|
||||||
|
catch_errors
|
||||||
|
setting_up_container
|
||||||
|
network_check
|
||||||
|
update_os
|
||||||
|
|
||||||
|
# Die Quelle laesst sich ueberschreiben, solange das Repository nicht oeffentlich ist.
|
||||||
|
QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/florian.wilde/ProxLedger.git}"
|
||||||
|
ZWEIG="${PROXLEDGER_BRANCH:-main}"
|
||||||
|
|
||||||
|
msg_info "Installing Dependencies"
|
||||||
|
$STD apt install -y git ca-certificates
|
||||||
|
msg_ok "Installed Dependencies"
|
||||||
|
|
||||||
|
NODE_VERSION="24" setup_nodejs
|
||||||
|
PG_VERSION="17" setup_postgresql
|
||||||
|
|
||||||
|
msg_info "Setting up Database"
|
||||||
|
DB_NAME="proxledger"
|
||||||
|
DB_USER="proxledger"
|
||||||
|
DB_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
|
||||||
|
ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 20)
|
||||||
|
# APP_SECRET signiert die Sitzungen und verschluesselt gespeicherte Zugangsdaten.
|
||||||
|
APP_SECRET=$(openssl rand -base64 48)
|
||||||
|
|
||||||
|
$STD sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
|
||||||
|
$STD sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} WITH OWNER ${DB_USER} ENCODING 'UTF8';"
|
||||||
|
msg_ok "Set up Database"
|
||||||
|
|
||||||
|
msg_info "Setting up ProxLedger"
|
||||||
|
$STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger
|
||||||
|
cd /opt/proxledger
|
||||||
|
|
||||||
|
# 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 <<EOF >/opt/proxledger/.env
|
||||||
|
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public"
|
||||||
|
APP_SECRET="${APP_SECRET}"
|
||||||
|
APP_URL="http://${IP_ADRESSE}:3000"
|
||||||
|
ADMIN_EMAIL="admin@proxledger.local"
|
||||||
|
ADMIN_NAME="Administrator"
|
||||||
|
ADMIN_PASSWORD="${ADMIN_PASS}"
|
||||||
|
UPLOAD_DIR="/opt/proxledger/data/uploads"
|
||||||
|
NODE_ENV="production"
|
||||||
|
PORT="3000"
|
||||||
|
EOF
|
||||||
|
chmod 600 /opt/proxledger/.env
|
||||||
|
mkdir -p /opt/proxledger/data/uploads
|
||||||
|
|
||||||
|
$STD npm ci
|
||||||
|
$STD npx prisma generate
|
||||||
|
$STD npx prisma migrate deploy
|
||||||
|
$STD npx prisma db seed
|
||||||
|
$STD npm run build
|
||||||
|
$STD npm run build:worker
|
||||||
|
msg_ok "Set up ProxLedger"
|
||||||
|
|
||||||
|
msg_info "Creating Services"
|
||||||
|
cat <<EOF >/etc/systemd/system/proxledger.service
|
||||||
|
[Unit]
|
||||||
|
Description=ProxLedger Dashboard
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/proxledger
|
||||||
|
EnvironmentFile=/opt/proxledger/.env
|
||||||
|
ExecStart=/usr/bin/npm run start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat <<EOF >/etc/systemd/system/proxledger-worker.service
|
||||||
|
[Unit]
|
||||||
|
Description=ProxLedger Worker (Warnregeln und Benachrichtigungen)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/proxledger
|
||||||
|
EnvironmentFile=/opt/proxledger/.env
|
||||||
|
ExecStart=/usr/bin/node dist/worker.mjs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl enable -q --now proxledger proxledger-worker
|
||||||
|
msg_ok "Created Services"
|
||||||
|
|
||||||
|
# Die Zugangsdaten liegen fuer den Betreiber lesbar im Container; APP_SECRET steht in der .env.
|
||||||
|
{
|
||||||
|
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
|
||||||
|
echo "Datenbank: ${DB_NAME} / ${DB_USER} / ${DB_PASS}"
|
||||||
|
echo
|
||||||
|
echo "Wichtig: APP_SECRET aus /opt/proxledger/.env mitsichern."
|
||||||
|
echo "Ohne diesen Wert sind Sitzungen und gespeicherte Zugangsdaten unbrauchbar."
|
||||||
|
} >>~/proxledger.creds
|
||||||
|
cp ~/proxledger.creds /opt/proxledger/.credentials
|
||||||
|
chmod 600 /opt/proxledger/.credentials
|
||||||
|
|
||||||
|
motd_ssh
|
||||||
|
customize
|
||||||
|
|
||||||
|
msg_info "Cleaning up"
|
||||||
|
$STD apt -y autoremove
|
||||||
|
$STD apt -y autoclean
|
||||||
|
msg_ok "Cleaned"
|
||||||
@@ -181,4 +181,5 @@ printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beisp
|
|||||||
echo
|
echo
|
||||||
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
|
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
|
||||||
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
|
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
|
||||||
printf ' Aktualisieren: pct exec %s -- bash -c "cd /opt/proxledger && git pull && docker compose up -d --build"\n' "$CTID"
|
printf ' Aktualisieren: pct exec %s -- bash -c "bash /opt/proxledger/deploy/update.sh"\n' "$CTID"
|
||||||
|
printf ' Sichern: pct exec %s -- bash -c "bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger"\n' "$CTID"
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProxLedger aus einer Sicherung wiederherstellen (#046).
|
||||||
|
#
|
||||||
|
# bash deploy/restore.sh /var/backups/proxledger # jüngste Sicherung
|
||||||
|
# bash deploy/restore.sh /var/backups/proxledger 20261010-192437
|
||||||
|
# bash deploy/restore.sh … --ja # ohne Rückfrage
|
||||||
|
#
|
||||||
|
# Spielt Datenbank und hochgeladene PDFs zurück. Die `.env` wird **nicht** angefasst:
|
||||||
|
# Sie trägt APP_SECRET, und ein falscher Wert dort macht gespeicherte Zugangsdaten unlesbar.
|
||||||
|
# Die passende Fassung liegt als env-<Stand>.txt in der Sicherung – im Zweifel von Hand vergleichen.
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o nounset
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
|
||||||
|
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
fehler() {
|
||||||
|
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
QUELLE="${1:-/var/backups/proxledger}"
|
||||||
|
STAND="${2:-}"
|
||||||
|
[ "${2:-}" = "--ja" ] && STAND=""
|
||||||
|
OHNE_RUECKFRAGE="nein"
|
||||||
|
for argument in "$@"; do [ "$argument" = "--ja" ] && OHNE_RUECKFRAGE="ja"; done
|
||||||
|
|
||||||
|
[ -d "$QUELLE" ] || fehler "Kein Sicherungsordner unter ${QUELLE}."
|
||||||
|
|
||||||
|
# Jüngste Sicherung nehmen, wenn kein Stand angegeben ist.
|
||||||
|
if [ -z "$STAND" ]; then
|
||||||
|
DATENBANK=$(ls -1t "${QUELLE}"/datenbank-*.sql.gz 2>/dev/null | head -n1 || true)
|
||||||
|
else
|
||||||
|
DATENBANK="${QUELLE}/datenbank-${STAND}.sql.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "${DATENBANK:-}" ] && [ -f "$DATENBANK" ] || fehler "Keine Datenbanksicherung in ${QUELLE} gefunden."
|
||||||
|
UPLOADS="${DATENBANK/datenbank-/uploads-}"
|
||||||
|
UPLOADS="${UPLOADS/.sql.gz/.tar.gz}"
|
||||||
|
|
||||||
|
# Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
|
||||||
|
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||||
|
ART="dienst"
|
||||||
|
else
|
||||||
|
ART="docker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\033[1;33m! Achtung: Der aktuelle Datenbestand wird durch die Sicherung ersetzt.\033[0m\n'
|
||||||
|
echo " Installationsart: ${ART}"
|
||||||
|
echo " Datenbank: $(basename "$DATENBANK")"
|
||||||
|
echo " Uploads: $([ -f "$UPLOADS" ] && basename "$UPLOADS" || echo 'keine in dieser Sicherung')"
|
||||||
|
|
||||||
|
if [ "$OHNE_RUECKFRAGE" != "ja" ]; then
|
||||||
|
read -r -p "Wirklich zurückspielen? [tippen Sie ja] " antwort
|
||||||
|
[ "$antwort" = "ja" ] || fehler "Abgebrochen."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Datenbank
|
||||||
|
blau "Datenbank zurückspielen …"
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
# Web und Worker anhalten: Sie würden während des Einspielens weiterschreiben.
|
||||||
|
docker compose stop web worker >/dev/null 2>&1 || true
|
||||||
|
docker compose up -d postgres >/dev/null
|
||||||
|
# Kurz warten, bis Postgres Verbindungen annimmt.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-proxledger}" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
gunzip -c "$DATENBANK" | docker compose exec -T postgres psql -U "${POSTGRES_USER:-proxledger}" \
|
||||||
|
-d "${POSTGRES_DB:-proxledger}" >/dev/null
|
||||||
|
else
|
||||||
|
systemctl stop proxledger proxledger-worker >/dev/null 2>&1 || true
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -o allexport
|
||||||
|
. ./.env
|
||||||
|
set +o allexport
|
||||||
|
[ -n "${DATABASE_URL:-}" ] || fehler "DATABASE_URL fehlt in der .env."
|
||||||
|
gunzip -c "$DATENBANK" | psql "$DATABASE_URL" >/dev/null
|
||||||
|
fi
|
||||||
|
gruen "Datenbank zurückgespielt."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Uploads
|
||||||
|
if [ -f "$UPLOADS" ]; then
|
||||||
|
blau "Hochgeladene PDFs zurückspielen …"
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
# Das Archiv enthält den Pfad /data/uploads – deshalb nach / entpacken.
|
||||||
|
docker compose run --rm -T --entrypoint tar web -xzf - -C / < "$UPLOADS"
|
||||||
|
else
|
||||||
|
ZIEL_UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
|
||||||
|
mkdir -p "$(dirname "$ZIEL_UPLOADS")"
|
||||||
|
tar -xzf "$UPLOADS" -C "$(dirname "$ZIEL_UPLOADS")"
|
||||||
|
fi
|
||||||
|
gruen "PDFs zurückgespielt."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Starten und prüfen
|
||||||
|
blau "Dienste starten …"
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
docker compose up -d >/dev/null
|
||||||
|
else
|
||||||
|
systemctl start proxledger proxledger-worker
|
||||||
|
fi
|
||||||
|
|
||||||
|
PORT="${PORT:-3000}"
|
||||||
|
for versuch in $(seq 1 45); do
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
|
||||||
|
else
|
||||||
|
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$erreichbar" = "ja" ]; then
|
||||||
|
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
|
||||||
|
echo
|
||||||
|
echo "Bitte jetzt anmelden und stichprobenartig prüfen, ob die Daten vollständig sind."
|
||||||
|
echo "Passt das Passwort nicht, stammt die Sicherung aus einer Zeit vor der letzten Änderung –"
|
||||||
|
echo "dann hilft: npm run benutzer -- passwort <email>"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProxLedger aktualisieren – erkennt selbst, ob die Installation mit Docker
|
||||||
|
# oder als Dienst im Container (Node + PostgreSQL direkt) läuft.
|
||||||
|
#
|
||||||
|
# bash deploy/update.sh # sichern, holen, bauen, migrieren, neu starten
|
||||||
|
# bash deploy/update.sh --ohne-sicherung
|
||||||
|
#
|
||||||
|
# Vor dem Aktualisieren wird gesichert – das dauert Sekunden und erspart schlechte Abende.
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o nounset
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
|
||||||
|
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
fehler() {
|
||||||
|
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SICHERN="ja"
|
||||||
|
[ "${1:-}" = "--ohne-sicherung" ] && SICHERN="nein"
|
||||||
|
|
||||||
|
# Wohin gesichert wird. Der Standardort gehört root; wer anders arbeitet, setzt SICHERUNG_ZIEL.
|
||||||
|
SICHERUNG_ZIEL="${SICHERUNG_ZIEL:-/var/backups/proxledger}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Art der Installation
|
||||||
|
#
|
||||||
|
# Der systemd-Dienst ist das eindeutigere Merkmal: Die docker-compose.yml liegt auch im
|
||||||
|
# Quelltext der Dienst-Installation, ein Dienst namens proxledger aber nur dort.
|
||||||
|
installationsart() {
|
||||||
|
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||||
|
printf 'dienst'
|
||||||
|
elif command -v docker >/dev/null 2>&1 && [ -f docker-compose.yml ]; then
|
||||||
|
printf 'docker'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ART=$(installationsart)
|
||||||
|
[ -n "$ART" ] || fehler "Keine Installation gefunden – weder der Dienst proxledger noch Docker mit docker-compose.yml."
|
||||||
|
|
||||||
|
blau "Installationsart: ${ART}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Sicherung
|
||||||
|
if [ "$SICHERN" = "ja" ]; then
|
||||||
|
blau "Sicherung anlegen nach ${SICHERUNG_ZIEL} …"
|
||||||
|
if ! bash deploy/backup.sh "$SICHERUNG_ZIEL"; then
|
||||||
|
fehler "Die Sicherung ist fehlgeschlagen – deshalb wird nicht aktualisiert.
|
||||||
|
Häufigste Ursache: fehlende Rechte am Zielordner. Mögliche Wege:
|
||||||
|
sudo bash deploy/update.sh
|
||||||
|
SICHERUNG_ZIEL=/pfad/mit/rechten bash deploy/update.sh
|
||||||
|
bash deploy/update.sh --ohne-sicherung (nur wenn anderweitig gesichert ist)"
|
||||||
|
fi
|
||||||
|
gruen "Sicherung abgeschlossen."
|
||||||
|
else
|
||||||
|
printf '\033[1;33m! Ohne Sicherung – auf eigenes Risiko.\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Quelltext
|
||||||
|
if [ -d .git ]; then
|
||||||
|
blau "Neuen Stand holen …"
|
||||||
|
git pull --ff-only
|
||||||
|
else
|
||||||
|
printf '\033[1;33m! Kein Git-Verzeichnis – der Quelltext bleibt wie er ist.\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Aktualisieren
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
|
||||||
|
|
||||||
|
blau "Abbilder neu bauen …"
|
||||||
|
docker compose build --pull
|
||||||
|
|
||||||
|
blau "Datenbank migrieren …"
|
||||||
|
docker compose run --rm migrate
|
||||||
|
|
||||||
|
blau "Dienste neu starten …"
|
||||||
|
docker compose up -d
|
||||||
|
else
|
||||||
|
command -v npm >/dev/null 2>&1 || fehler "npm nicht gefunden – gehört zur Installation als Dienst."
|
||||||
|
[ -f .env ] || fehler "Keine .env gefunden (erwartet in ${VERZEICHNIS})."
|
||||||
|
|
||||||
|
# Die .env trägt APP_SECRET; ein git pull fasst sie nicht an, aber sicher ist sicher.
|
||||||
|
cp .env "/tmp/proxledger-env-$(date +%s).sicherung"
|
||||||
|
|
||||||
|
blau "Abhängigkeiten installieren …"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
blau "Datenbank migrieren …"
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
blau "Anwendung bauen …"
|
||||||
|
npm run build
|
||||||
|
npm run build:worker
|
||||||
|
|
||||||
|
blau "Dienste neu starten …"
|
||||||
|
systemctl restart proxledger proxledger-worker
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Prüfen
|
||||||
|
blau "Warte auf das Dashboard …"
|
||||||
|
PORT="${PORT:-3000}"
|
||||||
|
|
||||||
|
for versuch in $(seq 1 45); do
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
|
||||||
|
else
|
||||||
|
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$erreichbar" = "ja" ]; then
|
||||||
|
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
|
||||||
|
[ "$ART" = "docker" ] && docker compose ps
|
||||||
|
[ "$ART" = "dienst" ] && systemctl --no-pager --lines=0 status proxledger proxledger-worker | grep -E "●|Active:"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
|
||||||
|
else
|
||||||
|
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: journalctl -u proxledger -n 50"
|
||||||
|
fi
|
||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- uploads:/data
|
- uploads:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -81,8 +81,16 @@ services:
|
|||||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||||
WORKER_CRON: ${WORKER_CRON:-0 * * * *}
|
WORKER_CRON: ${WORKER_CRON:-0 * * * *}
|
||||||
ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *}
|
ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *}
|
||||||
|
AUFRAEUM_CRON: ${AUFRAEUM_CRON:-30 3 * * *}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/data
|
- uploads:/data
|
||||||
|
healthcheck:
|
||||||
|
# Der Worker spricht kein HTTP; geprüft wird schlicht, ob der Prozess noch da ist.
|
||||||
|
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
datenbank:
|
datenbank:
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ auch mit Hosts hinter NAT.
|
|||||||
7. [Warnungen](#7-warnungen)
|
7. [Warnungen](#7-warnungen)
|
||||||
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
||||||
9. [Exporte](#9-exporte)
|
9. [Exporte](#9-exporte)
|
||||||
|
9a. [Einstellungen](#9a-einstellungen)
|
||||||
|
9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys)
|
||||||
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
||||||
11. [Fehlersuche](#11-fehlersuche)
|
11. [Fehlersuche](#11-fehlersuche)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Einrichtung statt Bedienung?** Reverse Proxy, OIDC und Sicherung stehen in
|
||||||
|
> [docs/betrieb/](../betrieb/README.md). Dieses Handbuch beschreibt die tägliche Arbeit.
|
||||||
|
|
||||||
## 1. Installation
|
## 1. Installation
|
||||||
|
|
||||||
### Mit Docker
|
### Mit Docker
|
||||||
@@ -71,10 +76,16 @@ Traefik liegen in [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Anmelden mit der E-Mail-Adresse und dem Passwort aus der `.env`. Ist ein Anmeldedienst über OIDC
|
Es gibt drei gleichwertige Wege hinein:
|
||||||
eingerichtet (`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`), erscheint zusätzlich ein Knopf
|
|
||||||
„Mit authentik anmelden“. Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst
|
- **Passkey** – der Knopf ganz oben. Der Browser fragt nach Fingerabdruck, Gesicht oder PIN. Anlegen
|
||||||
einmal ausfällt, kommst du trotzdem hinein.
|
lässt sich einer unter *Meine Passkeys* ([Kapitel 9b](#9b-benutzer-rollen-und-passkeys)).
|
||||||
|
- **Passwort** – E-Mail-Adresse und Passwort aus der `.env` bzw. aus der Benutzerverwaltung.
|
||||||
|
- **OIDC** – ist ein Anmeldedienst eingerichtet, erscheint zusätzlich „Mit authentik anmelden“
|
||||||
|
(Einrichtung: [docs/betrieb/authentik.md](../betrieb/authentik.md)).
|
||||||
|
|
||||||
|
Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst einmal ausfällt, kommst du
|
||||||
|
trotzdem hinein.
|
||||||
|
|
||||||
Nach der Anmeldung landest du auf dem Dashboard:
|
Nach der Anmeldung landest du auf dem Dashboard:
|
||||||
|
|
||||||
@@ -164,6 +175,9 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Im Detail eines Hosts lassen sich Kunde **und Standort** gemeinsam ändern. Die Standortliste zeigt nur
|
||||||
|
die Standorte des gewählten Kunden; angelegt werden sie auf der Kundenseite.
|
||||||
|
|
||||||
## 4a. Cluster
|
## 4a. Cluster
|
||||||
|
|
||||||
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
||||||
@@ -178,6 +192,34 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
|
|||||||
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
||||||
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
||||||
|
|
||||||
|
### Gleichnamige Hosts auseinanderhalten
|
||||||
|
|
||||||
|
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
|
||||||
|
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
|
||||||
|
|
||||||
|
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
|
||||||
|
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
|
||||||
|
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
|
||||||
|
Host-Detail steht die Notiz oben bei den Stammdaten.
|
||||||
|
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
|
||||||
|
|
||||||
|
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
|
||||||
|
|
||||||
|
### Wenn ein Host doppelt erscheint
|
||||||
|
|
||||||
|
Steht derselbe Name zweimal beim selben Kunden, meldet ProxLedger das als Hinweis und nennt die
|
||||||
|
IP-Adressen beider Einträge. Zwei Fälle sind möglich:
|
||||||
|
|
||||||
|
- **Zwei verschiedene Maschinen** heißen gleich. Dann helfen Schlagworte beim Auseinanderhalten.
|
||||||
|
- **Dieselbe Maschine hat sich neu registriert** – etwa nach einer Neuinstallation oder weil ein
|
||||||
|
Container geklont wurde. Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am
|
||||||
|
Namen; ändert sie sich, entsteht ein zweiter Eintrag.
|
||||||
|
|
||||||
|
Den alten Eintrag legst du im Host-Detail still. Er verschwindet dann aus Listen, Zahlen und
|
||||||
|
Warnungen, seine Laufzeiten und Rechnungen bleiben aber erhalten – mit dem Schalter *stillgelegte
|
||||||
|
Hosts zeigen* findest du ihn wieder. **Endgültig löschen** geht nur, solange keine Laufzeit daran
|
||||||
|
hängt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Kunden und Standorte pflegen
|
## 5. Kunden und Standorte pflegen
|
||||||
@@ -195,6 +237,16 @@ mit Marge. Die vier Kästen oben fassen Hosts, Einkauf, Verkauf und Marge zusamm
|
|||||||
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
|
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
|
||||||
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
|
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
|
||||||
|
|
||||||
|
### Eigene Hosts
|
||||||
|
|
||||||
|
Auch die eigenen Hosts gehören einem Kunden zugeordnet – sonst fehlen sie in jeder Auswertung und
|
||||||
|
lösen die Warnung „Host ohne Kunde“ aus. Lege dafür dich selbst als Kunden an und setze im
|
||||||
|
Kundenformular den Haken **Eigenbedarf**.
|
||||||
|
|
||||||
|
Für solche Kunden entfällt alles, was mit Weiterberechnung zu tun hat: keine Kundenrechnung, keine
|
||||||
|
Warnungen darüber, keine Aufgabe „Kundenrechnung erstellen“ und keine Marge. Die Proxmox-Rechnung
|
||||||
|
wird weiterhin geführt und erinnert ans Bezahlen – die Kosten fallen ja an.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Laufzeiten und Rechnungen
|
## 6. Laufzeiten und Rechnungen
|
||||||
@@ -237,6 +289,39 @@ Positionen und wird automatisch nachgeführt.
|
|||||||
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
|
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
|
||||||
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
|
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
|
||||||
|
|
||||||
|
### Woher Laufzeiten kommen
|
||||||
|
|
||||||
|
Eine Laufzeit ist ein Lizenzjahr eines Hosts. Es gibt zwei Wege:
|
||||||
|
|
||||||
|
1. **Vom Agent vorgeschlagen.** Meldet ein Host ein Ablaufdatum, legt ProxLedger eine Laufzeit von
|
||||||
|
diesem Datum bis ein Jahr später an – zunächst als *Vorschlag*. Unter *Rechnungen → Vorschläge*
|
||||||
|
bestätigst oder verwirfst du ihn.
|
||||||
|
2. **Von Hand angelegt** über *Rechnungen → Laufzeit von Hand anlegen*. Nötig, wenn ein Host noch kein
|
||||||
|
Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein zurückliegendes Lizenzjahr
|
||||||
|
nachgetragen wird.
|
||||||
|
|
||||||
|
Jede Laufzeit lässt sich später ändern (*Laufzeit bearbeiten* in der Detailansicht): Key, Beginn, Ende
|
||||||
|
und Notiz. Stehen für denselben Host zwei Laufzeiten, die dasselbe Lizenzjahr meinen – etwa weil das
|
||||||
|
gemeldete Datum um einen Tag abweicht –, lassen sie sich **zusammenführen**. Die aufgelöste Laufzeit
|
||||||
|
gibt ihre Rechnungen an die verbleibende ab.
|
||||||
|
|
||||||
|
### Der übliche Ablauf
|
||||||
|
|
||||||
|
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
||||||
|
2. Die Kundenrechnung wird geschrieben und verschickt.
|
||||||
|
3. Der Kunde zahlt. Jetzt meldet ProxLedger: *Kunde hat bezahlt – Proxmox-Rechnung noch offen*.
|
||||||
|
4. Man zahlt bei Proxmox, setzt den Haken „bezahlt“ und lädt die **PAID**-Fassung des PDFs hoch – sie
|
||||||
|
ersetzt die alte.
|
||||||
|
|
||||||
|
### Die Felder der Proxmox-Rechnung
|
||||||
|
|
||||||
|
| Feld | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Rechnungsnummer | Nummer von Proxmox. **Dieselbe Nummer bei einer weiteren Laufzeit** hängt diese als Position an dieselbe Rechnung – so werden Sammelrechnungen über mehrere Hosts abgebildet. |
|
||||||
|
| Listenpreis netto | Der Betrag dieser **einen** Laufzeit, wie er auf der Rechnung steht (z. B. 1.100 €). |
|
||||||
|
| Rabatt | Der „Client Discount“ dieser Laufzeit (z. B. 110 €). Gerechnet wird mit Listenpreis minus Rabatt. |
|
||||||
|
| USt | Bei Proxmox in aller Regel 0 – Reverse Charge nach Art. 196. Die Vorgabe steht unter *Einstellungen → System*. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Warnungen
|
## 7. Warnungen
|
||||||
@@ -310,36 +395,121 @@ Jahresliste für die Buchhaltung.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9a. Einstellungen
|
||||||
|
|
||||||
|
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Adresse und Agent.** Die Dashboard-Adresse landet im Installations-Einzeiler und in den
|
||||||
|
Benachrichtigungen. Bleibt das Feld leer, gilt `APP_URL` aus der Umgebung. Melderhythmus, Streuung und
|
||||||
|
Selbstaktualisierung gelten für **neu installierte** Agents – bestehende übernehmen sie erst, wenn der
|
||||||
|
Einzeiler dort erneut läuft.
|
||||||
|
|
||||||
|
**Kaufmännisches.** Steuersatz, Zahlungsziel und Aufschlag füllen leere Rechnungsfelder vor. Der
|
||||||
|
Aufschlag rechnet aus dem Einkaufspreis einen Vorschlag für den Verkaufspreis; eingetragene Beträge
|
||||||
|
bleiben unberührt.
|
||||||
|
|
||||||
|
**Sicherheit.** Sitzungsdauer und Anmeldeversuche gelten ab der nächsten Anmeldung. Die beiden
|
||||||
|
OIDC-Felder sind wichtig, wenn ein Identitätsanbieter viele Konten kennt: Ohne Einschränkung darf
|
||||||
|
jeder, der sich dort anmelden kann, hier mitarbeiten. Mit einer Domänenliste oder abgeschaltetem
|
||||||
|
„Konten anlegen“ kommt nur herein, wer hier vorgesehen ist. Die Aufbewahrungsfristen räumen nachts um
|
||||||
|
halb vier auf; der jüngste Report jedes Hosts bleibt immer erhalten, sonst stünde ein lange stummer
|
||||||
|
Host plötzlich ohne Angaben da.
|
||||||
|
|
||||||
|
**Firma und Darstellung.** Name, Anschrift und E-Mail stehen im Kopf der erzeugten PDF-Dateien, die
|
||||||
|
Zeitzone gilt für PDF, CSV und Benachrichtigungen.
|
||||||
|
|
||||||
|
> `APP_SECRET` und `DATABASE_URL` stehen bewusst **nicht** hier: Beide werden gebraucht, bevor die
|
||||||
|
> Anwendung die Datenbank überhaupt lesen kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9b. Benutzer, Rollen und Passkeys
|
||||||
|
|
||||||
|
Unter *Einstellungen → Benutzer* legen Administratoren Konten an und vergeben eine von drei Rollen:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Rolle | Darf |
|
||||||
|
|---|---|
|
||||||
|
| **Administrator** | alles, einschließlich Benutzer und Systemeinstellungen |
|
||||||
|
| **Techniker** | alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer |
|
||||||
|
| **Kunde** | nur die Hosts und Rechnungen des eigenen Kunden, **ohne Einkaufspreise und Marge**; darf Notizen schreiben und Warnungen quittieren |
|
||||||
|
|
||||||
|
Ein Kundenkonto ist an genau einen Kunden gebunden – das Feld erscheint, sobald die Rolle „Kunde“
|
||||||
|
gewählt ist. Die Einschränkung sitzt in den Abfragen selbst, nicht in der Anzeige: Was ein Kundenkonto
|
||||||
|
nicht sehen darf, steht auch nicht im ausgelieferten HTML.
|
||||||
|
|
||||||
|
Ein Passwort ist beim Anlegen freiwillig. Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder
|
||||||
|
einen Passkey anmeldet. **Ein neues Passwort beendet alle laufenden Sitzungen dieses Kontos**, ebenso
|
||||||
|
das Sperren.
|
||||||
|
|
||||||
|
### Passkeys
|
||||||
|
|
||||||
|
Ein Passkey ersetzt das Passwort: Der Browser bestätigt die Anmeldung mit Fingerabdruck, Gesicht, PIN
|
||||||
|
oder einem Sicherheitsschlüssel. Jeder Benutzer verwaltet seine eigenen unter *Meine Passkeys*:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Zwei Dinge sind wichtig:
|
||||||
|
|
||||||
|
1. **Passkeys brauchen HTTPS.** Nur `localhost` ist ausgenommen. Wird das Dashboard über `http://`
|
||||||
|
im LAN aufgerufen, weist die Seite darauf hin und der Knopf bleibt gesperrt.
|
||||||
|
2. **Der Passkey hängt an der Adresse.** Wer später von `http://server:3000` auf
|
||||||
|
`https://proxledger.firma.de` wechselt, muss neu registrieren. Deshalb zuerst den Reverse Proxy
|
||||||
|
einrichten, dann Passkeys anlegen.
|
||||||
|
|
||||||
|
Passwort, OIDC und Passkey stehen gleichberechtigt nebeneinander – der lokale Zugang bleibt als
|
||||||
|
Rückweg immer nutzbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 10. Sicherung und Aktualisierung
|
## 10. Sicherung und Aktualisierung
|
||||||
|
|
||||||
**Sichern:**
|
**Sichern:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/backup.sh /var/backups/proxledger
|
bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env`. Für regelmäßige Sicherungen in die
|
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env` – und erkennt selbst, ob die
|
||||||
Crontab eintragen:
|
Installation mit Docker oder als Dienst im Container läuft. Für regelmäßige Sicherungen in die Crontab:
|
||||||
|
|
||||||
```
|
```
|
||||||
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
||||||
```
|
```
|
||||||
|
|
||||||
**Zurückspielen:**
|
**Zurückspielen** (Docker):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bei der Installation als Dienst stattdessen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c datenbank-20260917-023000.sql.gz | psql "$DATABASE_URL"
|
||||||
|
```
|
||||||
|
|
||||||
**Aktualisieren:**
|
**Aktualisieren:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/proxledger
|
bash /opt/proxledger/deploy/update.sh
|
||||||
git pull
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrationen laufen dabei automatisch.
|
Ein Befehl für beide Installationsarten. Der Reihe nach: sichern, neuen Stand holen, bauen,
|
||||||
|
migrieren, neu starten – und zum Schluss prüfen, ob das Dashboard wieder antwortet. Schlägt die
|
||||||
|
Sicherung fehl, bricht das Skript ab, **bevor** etwas verändert wird.
|
||||||
|
|
||||||
|
| Aufruf | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `bash deploy/update.sh` | der übliche Weg |
|
||||||
|
| `SICHERUNG_ZIEL=/pfad bash deploy/update.sh` | anderer Ort für die Sicherung |
|
||||||
|
| `bash deploy/update.sh --ohne-sicherung` | nur, wenn anderweitig gesichert ist |
|
||||||
|
|
||||||
|
Beim LXC-Weg nach Art der Helper-Scripts geht es auch über das Skript auf dem Proxmox-Host – dort
|
||||||
|
erkennt das Gerüst eine vorhandene Installation und bietet die Aktualisierung an.
|
||||||
|
|
||||||
> `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine
|
> `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine
|
||||||
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
|
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
|
||||||
@@ -377,6 +547,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
|
|||||||
|
|
||||||
## Anhang: dunkles Design und Mobilansicht
|
## Anhang: dunkles Design und Mobilansicht
|
||||||
|
|
||||||
|
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
|
||||||
|
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
|
||||||
|
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
|
||||||
|
übrigen Angaben stehen auf der jeweiligen Detailseite.
|
||||||
|
|
||||||
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
||||||
Kopfleiste umschalten.
|
Kopfleiste umschalten.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
# Betrieb
|
||||||
|
|
||||||
|
Anleitungen für alles, was rund um ProxLedger eingerichtet wird.
|
||||||
|
|
||||||
|
| Dokument | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| [inbetriebnahme.md](inbetriebnahme.md) | Von null zum laufenden Dashboard – Docker oder LXC, erster Agent, erste Zuordnung |
|
||||||
|
| [reverse-proxy.md](reverse-proxy.md) | HTTPS mit nginx Proxy Manager, netbird Reverse Proxy (Beta) oder netbird pur |
|
||||||
|
| [authentik.md](authentik.md) | Anmeldung über OIDC |
|
||||||
|
| [sicherung.md](sicherung.md) | Was gesichert werden muss und wie man es zurückspielt |
|
||||||
|
|
||||||
|
Für die tägliche Arbeit **mit** ProxLedger ist das [Handbuch](../anleitung/README.md) gedacht.
|
||||||
|
Was gegen Angriffe getan wird (und was bewusst nicht), steht in [sicherheit.md](../sicherheit.md).
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Anmeldung über authentik (OIDC)
|
||||||
|
|
||||||
|
ProxLedger kann die Anmeldung an einen OIDC-Anbieter abgeben. Getestet ist authentik; mit jedem
|
||||||
|
anderen Anbieter, der OpenID Connect Discovery beherrscht, funktioniert es genauso.
|
||||||
|
|
||||||
|
**Der lokale Zugang bleibt daneben immer bestehen.** Das ist Absicht: Fällt authentik aus, kommt man
|
||||||
|
trotzdem noch ins Dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. In authentik: Provider anlegen
|
||||||
|
|
||||||
|
*Applications → Providers → Create → OAuth2/OpenID Provider*
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `ProxLedger` |
|
||||||
|
| Authorization flow | der übliche (z. B. *default-provider-authorization-implicit-consent*) |
|
||||||
|
| Client type | **Confidential** |
|
||||||
|
| Client ID | übernehmen, wird gleich gebraucht |
|
||||||
|
| Client Secret | übernehmen, wird gleich gebraucht |
|
||||||
|
| Redirect URIs | `https://proxledger.firma.de/api/auth/oidc/callback` |
|
||||||
|
| Scopes | `openid`, `email`, `profile` |
|
||||||
|
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
||||||
|
|
||||||
|
Zur Rückkehradresse zwei Hinweise, an denen die meisten Einrichtungen scheitern:
|
||||||
|
|
||||||
|
- Sie muss **wortgleich** übereinstimmen – einschließlich `https`, Portangabe und ohne Schrägstrich am
|
||||||
|
Ende. ProxLedger bildet sie aus der Dashboard-Adresse unter *Einstellungen → System*, ersatzweise aus
|
||||||
|
`APP_URL`.
|
||||||
|
- authentik erlaubt auch reguläre Ausdrücke. Für eine einzelne Installation ist die genaue Adresse die
|
||||||
|
bessere Wahl; ein zu weiter Ausdruck öffnet eine Umleitung auf fremde Ziele.
|
||||||
|
|
||||||
|
## 2. In authentik: Application anlegen
|
||||||
|
|
||||||
|
*Applications → Applications → Create*
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `ProxLedger` |
|
||||||
|
| Slug | `proxledger` |
|
||||||
|
| Provider | der eben angelegte |
|
||||||
|
|
||||||
|
Der Slug bestimmt die Discovery-Adresse:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://authentik.firma.de/application/o/proxledger/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Adresse **ohne** den Teil `/.well-known/openid-configuration` ist der Aussteller
|
||||||
|
(`OIDC_ISSUER`) – also `https://authentik.firma.de/application/o/proxledger/`.
|
||||||
|
|
||||||
|
Prüfen lässt sie sich mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS https://authentik.firma.de/application/o/proxledger/.well-known/openid-configuration | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Wer darf sich anmelden, entscheidet in authentik die **Policy-Bindung** an der Application
|
||||||
|
(*Applications → ProxLedger → Policy / Group / User Bindings*). Ohne Bindung darf jeder, der bei
|
||||||
|
authentik ein Konto hat.
|
||||||
|
|
||||||
|
## 3. In ProxLedger: Werte eintragen
|
||||||
|
|
||||||
|
In die `.env` (Docker) oder `/opt/proxledger/.env` (LXC):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_ISSUER="https://authentik.firma.de/application/o/proxledger/"
|
||||||
|
OIDC_CLIENT_ID="…"
|
||||||
|
OIDC_CLIENT_SECRET="…"
|
||||||
|
OIDC_NAME="authentik" # Beschriftung des Knopfes auf der Anmeldeseite
|
||||||
|
APP_URL="https://proxledger.firma.de"
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach neu starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Docker
|
||||||
|
systemctl restart proxledger # LXC
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf der Anmeldeseite erscheint jetzt **Mit authentik anmelden**.
|
||||||
|
|
||||||
|
## 4. Rollen festlegen
|
||||||
|
|
||||||
|
ProxLedger liest **keine** Gruppen aus authentik. Wer sich zum ersten Mal anmeldet, bekommt die Rolle
|
||||||
|
aus *Einstellungen → System → Rolle für neue OIDC-Konten* – Vorgabe ist **Techniker**. Die endgültige
|
||||||
|
Rolle vergibt ein Administrator unter *Einstellungen → Benutzer*.
|
||||||
|
|
||||||
|
Das ist bewusst so: Eine Gruppenzuordnung, die stillschweigend Administratorrechte verteilt, ist
|
||||||
|
schwerer zu überblicken als eine Liste, in der jeder Zugang sichtbar ist.
|
||||||
|
|
||||||
|
Zwei Einstellungen helfen dabei, den Kreis klein zu halten:
|
||||||
|
|
||||||
|
- **Erlaubte E-Mail-Domänen** – etwa `firma.de`. Alles andere wird abgewiesen, auch wenn die Anmeldung
|
||||||
|
bei authentik geklappt hat.
|
||||||
|
- **Unbekannte OIDC-Anmeldungen legen ein Konto an** – abgeschaltet kommt nur herein, wer hier bereits
|
||||||
|
ein Konto hat. Dann legt man Konten vorab an (ohne Passwort) und die Anmeldung läuft trotzdem über
|
||||||
|
authentik.
|
||||||
|
|
||||||
|
## 5. Prüfen
|
||||||
|
|
||||||
|
1. Abmelden, dann **Mit authentik anmelden**.
|
||||||
|
2. Unter *Einstellungen → Protokoll* steht `anmeldung.erfolgreich` mit `weg: oidc`.
|
||||||
|
3. Unter *Einstellungen → Benutzer* steht das Konto mit der vergebenen Rolle und der Anmeldeart `OIDC`.
|
||||||
|
|
||||||
|
## Wenn es nicht klappt
|
||||||
|
|
||||||
|
| Meldung | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| „Die Anmeldung über den Anbieter hat nicht geklappt.“ | Sammelmeldung – die Einzelheiten stehen im Protokoll des Containers (`docker compose logs web`). |
|
||||||
|
| authentik zeigt *Redirect URI Error* | Die Adresse stimmt nicht wortgleich. Häufig: `http` statt `https`, Schrägstrich am Ende, oder die interne statt der öffentlichen Adresse. |
|
||||||
|
| `invalid_client` | Client-ID oder -Secret falsch, oder der Provider steht auf *Public* statt *Confidential*. |
|
||||||
|
| Anmeldung klappt, aber ProxLedger meldet „keine E-Mail-Adresse“ | Der Scope `email` fehlt am Provider. |
|
||||||
|
| Nach der Anmeldung landet man auf einer unerreichbaren Adresse | `APP_URL` bzw. die Dashboard-Adresse zeigt nicht auf die öffentliche Adresse. |
|
||||||
|
|
||||||
|
Siehe auch: [Reverse Proxy](reverse-proxy.md) – ohne HTTPS gibt es weder sinnvolle OIDC-Anmeldung noch
|
||||||
|
Passkeys.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Inbetriebnahme
|
||||||
|
|
||||||
|
Der kürzeste Weg von null zu einem Dashboard, das Hosts meldet. Rechnen Sie mit einer halben Stunde.
|
||||||
|
|
||||||
|
## Reihenfolge
|
||||||
|
|
||||||
|
Die Reihenfolge ist nicht beliebig – zwei Schritte hängen an der endgültigen Adresse:
|
||||||
|
|
||||||
|
1. Dashboard installieren
|
||||||
|
2. **Adresse festlegen** (Reverse Proxy, DNS)
|
||||||
|
3. OIDC einrichten (falls gewünscht)
|
||||||
|
4. Passkeys anlegen
|
||||||
|
5. Agents ausrollen
|
||||||
|
|
||||||
|
Wer Schritt 4 oder 5 vorzieht, macht sie nach einem Adresswechsel noch einmal: Passkeys gelten nur für
|
||||||
|
die Domäne, unter der sie entstanden sind, und die Adresse steckt im Installations-Einzeiler.
|
||||||
|
|
||||||
|
## 1. Installieren
|
||||||
|
|
||||||
|
**Docker** (beliebiger Host mit Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
|
||||||
|
cp .env.example .env
|
||||||
|
# Pflicht: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**LXC auf Proxmox**, zwei Möglichkeiten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/lxc-installer.sh # Container mit Docker darin
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nach Art der Proxmox VE Helper-Scripts, ohne Docker:
|
||||||
|
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Der erste Administrator entsteht beim Einrichten aus `ADMIN_EMAIL` und `ADMIN_PASSWORD`; beim
|
||||||
|
LXC-Weg stehen die erzeugten Zugangsdaten in `/opt/proxledger/.credentials`.
|
||||||
|
|
||||||
|
**`APP_SECRET` gehört sofort in die Sicherung.** Er signiert die Sitzungen und verschlüsselt die
|
||||||
|
gespeicherten Zugangsdaten für SMTP und Webhooks. Ohne ihn sind beide unbrauchbar.
|
||||||
|
|
||||||
|
## 2. Adresse festlegen
|
||||||
|
|
||||||
|
Siehe [reverse-proxy.md](reverse-proxy.md). Danach unter *Einstellungen → System* die **Adresse des
|
||||||
|
Dashboards** eintragen – sie landet im Installations-Einzeiler und in den Benachrichtigungen.
|
||||||
|
|
||||||
|
## 3. Anmeldung einrichten
|
||||||
|
|
||||||
|
- Optional OIDC: [authentik.md](authentik.md)
|
||||||
|
- Weitere Konten unter *Einstellungen → Benutzer* (Administrator, Techniker, Kunde)
|
||||||
|
- Jeder legt sich unter *Meine Passkeys* einen Passkey an – danach entfällt das Passwort
|
||||||
|
|
||||||
|
## 4. Agents ausrollen
|
||||||
|
|
||||||
|
1. *Einstellungen → Agent-Tokens* → Enrollment-Token erzeugen (mit Kundenbindung spart es den
|
||||||
|
Zuordnungsschritt)
|
||||||
|
2. Auf jedem Proxmox-Host als root:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://proxledger.firma.de/install.sh | bash -s -- --token <ENROLL_TOKEN>
|
||||||
|
```
|
||||||
|
3. **In einem Cluster auf jedem Knoten** – jeder Knoten hat eine eigene Subscription. Das Dashboard
|
||||||
|
warnt von selbst, wenn ein Knoten fehlt.
|
||||||
|
4. Danach den Enrollment-Token widerrufen.
|
||||||
|
|
||||||
|
## 5. Benachrichtigungen
|
||||||
|
|
||||||
|
*Einstellungen → Benachrichtigungen*: SMTP-Zugang oder Webhook eintragen, Testnachricht schicken,
|
||||||
|
Zusammenfassung auf täglich oder wöchentlich stellen.
|
||||||
|
|
||||||
|
## Prüfliste
|
||||||
|
|
||||||
|
- [ ] `https://…/api/gesundheit` antwortet mit 200
|
||||||
|
- [ ] Anmeldung klappt, die Adresszeile bleibt auf der öffentlichen Adresse
|
||||||
|
- [ ] Ein Host meldet sich (*Hosts* zeigt ihn mit Ablaufdatum)
|
||||||
|
- [ ] Testnachricht ist angekommen
|
||||||
|
- [ ] Sicherung läuft und wurde **einmal zurückgespielt** ([sicherung.md](sicherung.md))
|
||||||
|
- [ ] `APP_SECRET` liegt an einem zweiten Ort
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# HTTPS einrichten: nginx Proxy Manager oder netbird
|
||||||
|
|
||||||
|
ProxLedger spricht selbst kein HTTPS – davor gehört ein Reverse Proxy. Ohne ihn fehlen drei Dinge:
|
||||||
|
|
||||||
|
- **Passkeys** funktionieren nur im sicheren Kontext (HTTPS oder `localhost`).
|
||||||
|
- Das Sitzungs-Cookie bekommt kein `Secure`-Kennzeichen.
|
||||||
|
- Die Agent-Kommunikation läuft unverschlüsselt durchs Netz.
|
||||||
|
|
||||||
|
Fertige Beispiele für nginx, Caddy und Traefik liegen unter [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
|
||||||
|
Dieses Dokument beschreibt die beiden Wege, die in der Praxis am häufigsten vorkommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weg A: nginx Proxy Manager
|
||||||
|
|
||||||
|
### 1. Proxy Host anlegen
|
||||||
|
|
||||||
|
*Hosts → Proxy Hosts → Add Proxy Host*, Reiter **Details**:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Domain Names | `proxledger.firma.de` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | IP des Docker-Hosts oder des LXC |
|
||||||
|
| Forward Port | `3000` |
|
||||||
|
| Cache Assets | aus |
|
||||||
|
| Block Common Exploits | an |
|
||||||
|
| Websockets Support | **an** |
|
||||||
|
|
||||||
|
Reiter **SSL**: *Request a new SSL Certificate*, dazu **Force SSL** und **HTTP/2 Support**.
|
||||||
|
|
||||||
|
### 2. Kopfzeilen ergänzen
|
||||||
|
|
||||||
|
Reiter **Advanced**, in das Feld *Custom Nginx Configuration*:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Ohne diese Angaben baut ProxLedger seine Weiterleitungen aus der internen Adresse
|
||||||
|
# (etwa http://172.18.0.4:3000) – im Browser landet man dann im Nichts.
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# Rechnungs-PDFs sind größer als die Standardgrenze von 1 MB.
|
||||||
|
client_max_body_size 12m;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. In ProxLedger eintragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_URL="https://proxledger.firma.de"
|
||||||
|
```
|
||||||
|
|
||||||
|
und unter *Einstellungen → System* dieselbe Adresse als **Adresse des Dashboards**. Sie steht danach
|
||||||
|
im Installations-Einzeiler für die Agents und ist die Grundlage für Passkeys und OIDC.
|
||||||
|
|
||||||
|
### 4. Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS -o /dev/null -w "%{http_code}\n" https://proxledger.firma.de/api/gesundheit
|
||||||
|
curl -fsSI https://proxledger.firma.de/login | grep -i "content-security-policy\|strict-transport"
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach einmal anmelden und darauf achten, dass die Adresszeile auf `proxledger.firma.de` bleibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weg B: netbird Reverse Proxy (Beta)
|
||||||
|
|
||||||
|
Seit v0.65 bringt netbird einen **eigenen Reverse Proxy** mit; v0.72 hat private Dienste und „Bring
|
||||||
|
Your Own Proxy“ ergänzt. Damit entfällt der nginx Proxy Manager vollständig: netbird übernimmt die
|
||||||
|
TLS-Terminierung und reicht den Verkehr durch das Mesh an den Ziel-Peer weiter – **ohne offenen Port
|
||||||
|
und ohne Firewall-Regel auf dem ProxLedger-Host**.
|
||||||
|
|
||||||
|
Für ProxLedger ist das der bequemste Weg, weil er die beiden Dinge löst, an denen es sonst hakt: ein
|
||||||
|
gültiges Zertifikat (damit Passkeys funktionieren) und eine feste öffentliche Adresse.
|
||||||
|
|
||||||
|
> **Beta.** Die Funktion ist als Beta gekennzeichnet. Sie unterstützt derzeit **kein Rosenpass**, und
|
||||||
|
> wer netbird selbst hostet, braucht **Traefik** als äußeren Proxy – nur der beherrscht das nötige
|
||||||
|
> TLS-Passthrough.
|
||||||
|
|
||||||
|
### 1. Dienst anlegen
|
||||||
|
|
||||||
|
Im netbird-Dashboard unter *Services → Reverse Proxy → Create*:
|
||||||
|
|
||||||
|
| Reiter | Einstellung |
|
||||||
|
|---|---|
|
||||||
|
| **Details** | Mode `HTTP`; Subdomain `proxledger`, dazu eine Basis-Domäne; als Target den Peer mit ProxLedger, Protokoll `HTTP`, Port `3000` |
|
||||||
|
| **Authentication** | für ProxLedger **aus lassen** – Begründung unten |
|
||||||
|
| **Access Control** | nach Bedarf: IP-Bereiche, Länder, CrowdSec-Bewertung |
|
||||||
|
| **Settings** | *Host header forwarding* **an**, ebenso das Umschreiben von Weiterleitungen |
|
||||||
|
|
||||||
|
Das Zertifikat besorgt netbird selbst über ACME (Let's Encrypt); alternativ lassen sich eigene
|
||||||
|
Zertifikate hinterlegen.
|
||||||
|
|
||||||
|
**Host header forwarding ist nicht optional.** ProxLedger bildet Weiterleitungen, die OIDC-Rückkehr
|
||||||
|
und die `rpID` der Passkeys aus der öffentlichen Adresse. Kommt beim Dienst nur die interne Adresse
|
||||||
|
an, landet man nach der Anmeldung auf einer Adresse, die es im Browser nicht gibt.
|
||||||
|
|
||||||
|
### 2. Adresse eintragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_URL="https://proxledger.<ihre-basis-domaene>"
|
||||||
|
```
|
||||||
|
|
||||||
|
und dieselbe Adresse unter *Einstellungen → System* als **Adresse des Dashboards**. Danach neu starten.
|
||||||
|
|
||||||
|
### 3. Warum die netbird-Anmeldung hier ausbleiben sollte
|
||||||
|
|
||||||
|
netbird kann den Dienst zusätzlich absichern – per SSO über den eigenen Identitätsanbieter, per
|
||||||
|
Passwort, PIN oder Kopfzeile. Verlockend, aber für ProxLedger meist falsch:
|
||||||
|
|
||||||
|
**Die Absicherung gilt für den ganzen Dienst, also auch für `/api/agent`.** Die Agents auf den
|
||||||
|
Proxmox-Hosts können sich dort nicht anmelden – sie weisen sich mit ihrem eigenen Token aus. Steht
|
||||||
|
eine SSO-Hürde davor, melden alle Hosts stumm ins Leere, bis der Zwischenspeicher voll ist.
|
||||||
|
|
||||||
|
Zwei Wege, wenn die zusätzliche Hürde gewünscht ist:
|
||||||
|
|
||||||
|
1. **Zwei Dienste anlegen:** einen für die Oberfläche mit SSO, einen zweiten (eigene Subdomain) ohne
|
||||||
|
Anmeldung für die Agents. Im Installations-Einzeiler dann die Agent-Adresse verwenden
|
||||||
|
(`--url https://agents.<domaene>`).
|
||||||
|
2. **Privater Dienst:** netbird kann einen Dienst so schalten, dass er **nur** aus dem eigenen Netz und
|
||||||
|
nur für ausgewählte Gruppen erreichbar ist. Das ist die sauberste Lösung, wenn sowohl Arbeitsplätze
|
||||||
|
als auch alle Proxmox-Hosts ohnehin im netbird-Netz sind: kein offener Zugang, trotzdem gültiges
|
||||||
|
Zertifikat und damit Passkeys.
|
||||||
|
|
||||||
|
### 4. Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS -o /dev/null -w "%{http_code}\n" https://proxledger.<domaene>/api/gesundheit
|
||||||
|
curl -fsS https://proxledger.<domaene>/install.sh | grep -m1 DASHBOARD_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Die zweite Zeile muss die **öffentliche** Adresse zeigen – das ist die, die später in jedem Agent steht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weg C: netbird ohne Reverse Proxy (kein offener Port)
|
||||||
|
|
||||||
|
netbird spannt ein privates Netz über WireGuard. ProxLedger ist dann **nur** für Geräte im Netz
|
||||||
|
erreichbar – kein Port im Internet, kein Zertifikat von außen nötig.
|
||||||
|
|
||||||
|
Das passt gut zu diesem Werkzeug: Die Oberfläche braucht niemand aus dem Internet, und die Proxmox-
|
||||||
|
Hosts stehen ohnehin meist in Kundennetzen mit netbird oder VPN.
|
||||||
|
|
||||||
|
### Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox-Host (netbird-Client) ──┐
|
||||||
|
Proxmox-Host (netbird-Client) ──┼── netbird-Netz ──► ProxLedger (netbird-Client, Port 3000)
|
||||||
|
Arbeitsplatz (netbird-Client) ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. netbird auf dem ProxLedger-Host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://pkgs.netbird.io/install.sh | sh
|
||||||
|
netbird up --setup-key <SETUP-KEY>
|
||||||
|
netbird status # zeigt die vergebene Adresse, z. B. 100.x.y.z
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. netbird auf jedem Proxmox-Host
|
||||||
|
|
||||||
|
Gleiche Schritte. Danach von dort prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS -o /dev/null -w "%{http_code}\n" http://100.x.y.z:3000/api/gesundheit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Zugriff einschränken
|
||||||
|
|
||||||
|
In der netbird-Verwaltung eine Access-Policy anlegen: Gruppe `proxmox-hosts` darf Gruppe
|
||||||
|
`proxledger` auf Port 3000 erreichen, sonst nichts. Damit können die Hosts melden, aber nicht
|
||||||
|
untereinander sprechen.
|
||||||
|
|
||||||
|
### 4. HTTPS trotzdem einrichten
|
||||||
|
|
||||||
|
netbird verschlüsselt den Transport – der Browser weiß davon aber nichts, und **Passkeys bleiben ohne
|
||||||
|
HTTPS gesperrt**. Am einfachsten ist hier der Reverse Proxy aus Weg B (auch als privater Dienst
|
||||||
|
nutzbar). Sonst bleiben zwei Möglichkeiten:
|
||||||
|
|
||||||
|
- **Empfohlen:** Zusätzlich einen kleinen Reverse Proxy im netbird-Netz betreiben (nginx oder Caddy)
|
||||||
|
mit einem Zertifikat für einen internen Namen, ausgestellt über DNS-01 – dafür muss nichts nach
|
||||||
|
außen offen sein.
|
||||||
|
- Oder ein eigenes CA-Zertifikat verteilen. Dann brauchen auch die Agents das CA-Zertifikat:
|
||||||
|
`curl … | bash -s -- --token … --ca-cert /pfad/zur/ca.crt`.
|
||||||
|
|
||||||
|
Wer auf Passkeys verzichtet, kann ProxLedger im netbird-Netz auch schlicht über `http://100.x.y.z:3000`
|
||||||
|
betreiben. In dem Fall `APP_URL` auf genau diese Adresse setzen – sonst setzt ProxLedger ein
|
||||||
|
`Secure`-Cookie, das der Browser über `http` verwirft, und die Anmeldung läuft ins Leere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Welcher Weg wofür
|
||||||
|
|
||||||
|
| Lage | Empfehlung |
|
||||||
|
|---|---|
|
||||||
|
| Eigener Server, Domäne vorhanden, Kunden sollen zugreifen | **Weg A** – nginx Proxy Manager |
|
||||||
|
| Alles läuft ohnehin über netbird, Zertifikat gewünscht | **Weg B** – netbird Reverse Proxy, bei Bedarf als privater Dienst |
|
||||||
|
| Kein offener Zugang, keine Passkeys nötig | **Weg C** – netbird pur, `APP_URL` auf die netbird-Adresse |
|
||||||
|
|
||||||
|
Weg B ist der kürzeste, wenn netbird ohnehin im Einsatz ist: ein Dienst, ein Zertifikat, kein
|
||||||
|
offener Port. Nur die Beta-Kennzeichnung sollte man im Hinterkopf behalten und nicht gleichzeitig auf
|
||||||
|
etwas anderes umstellen, damit bei einem Problem klar ist, woran es liegt.
|
||||||
|
|
||||||
|
Der häufigste gemischte Aufbau:
|
||||||
|
|
||||||
|
| Zugang | Weg |
|
||||||
|
|---|---|
|
||||||
|
| Oberfläche für Mitarbeiter und Kunden | nginx Proxy Manager oder netbird Reverse Proxy |
|
||||||
|
| Agents aus Kundennetzen | netbird-Netz, oder dieselbe öffentliche Adresse |
|
||||||
|
|
||||||
|
Wichtig ist nur, dass die Agents dieselbe Adresse erreichen, die im Installations-Einzeiler steht.
|
||||||
|
Ändert sich die Adresse später, genügt der Einzeiler erneut auf jedem Host – die Zuordnung bleibt
|
||||||
|
erhalten, weil sie an der Maschinenkennung hängt, nicht an der Adresse.
|
||||||
|
|
||||||
|
## Merkposten
|
||||||
|
|
||||||
|
0. **Keine Anmeldehürde vor `/api/agent`.** Egal welcher Proxy: Wer die Oberfläche zusätzlich absichert,
|
||||||
|
schließt damit auch die Agents aus – die weisen sich mit eigenem Token aus und können sich nirgends
|
||||||
|
anmelden.
|
||||||
|
1. **Adresse zuerst festlegen, dann Passkeys anlegen.** Ein Passkey gilt nur für die Domäne, unter der
|
||||||
|
er registriert wurde.
|
||||||
|
2. **`APP_URL` und die Dashboard-Adresse müssen auf die öffentliche Adresse zeigen** – daraus entstehen
|
||||||
|
Weiterleitungen, OIDC-Rückkehradresse und der Agent-Einzeiler.
|
||||||
|
3. **`client_max_body_size` nicht vergessen**, sonst scheitert das Hochladen von Rechnungs-PDFs mit
|
||||||
|
einem nichtssagenden 413.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Sicherung und Wiederherstellung
|
||||||
|
|
||||||
|
## Was gesichert werden muss
|
||||||
|
|
||||||
|
| Sache | Wo | Ohne sie |
|
||||||
|
|---|---|---|
|
||||||
|
| Datenbank | PostgreSQL | alles weg: Hosts, Kunden, Laufzeiten, Rechnungen |
|
||||||
|
| `APP_SECRET` | `.env` | alle Sitzungen ungültig, SMTP- und Webhook-Zugangsdaten unlesbar |
|
||||||
|
| Hochgeladene PDFs | `/data/uploads` (Docker-Volume) bzw. `/opt/proxledger/data/uploads` | Rechnungsanhänge fehlen |
|
||||||
|
|
||||||
|
Die Agents brauchen **keine** Sicherung: Sie registrieren sich neu, und die Zuordnung hängt an der
|
||||||
|
Maschinenkennung, nicht an einem Token.
|
||||||
|
|
||||||
|
## Sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/backup.sh /var/backups/proxledger
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript erkennt selbst, ob die Installation mit Docker oder als Dienst läuft, und sichert in beiden
|
||||||
|
Fällen Datenbank, Uploads und `.env`. Beim LXC-Weg reicht zusätzlich die gewohnte Proxmox-Sicherung
|
||||||
|
des Containers.
|
||||||
|
|
||||||
|
Vor jeder Aktualisierung läuft es ohnehin automatisch – [`deploy/update.sh`](../../deploy/update.sh)
|
||||||
|
ruft es als Erstes auf und bricht ab, wenn es fehlschlägt.
|
||||||
|
|
||||||
|
## Zurückspielen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/restore.sh /var/backups/proxledger # jüngste Sicherung
|
||||||
|
bash deploy/restore.sh /var/backups/proxledger 20261010-192437 # bestimmter Stand
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript hält Dashboard und Worker an, spielt Datenbank **und** hochgeladene PDFs zurück, startet
|
||||||
|
alles wieder und prüft, ob das Dashboard antwortet. Vorher fragt es nach; `--ja` überspringt die
|
||||||
|
Rückfrage (für Skripte).
|
||||||
|
|
||||||
|
Die `.env` fasst es bewusst **nicht** an: Sie trägt `APP_SECRET`, und ein falscher Wert dort macht die
|
||||||
|
gespeicherten Zugangsdaten für SMTP und Webhook unlesbar. Die zum Stand passende Fassung liegt als
|
||||||
|
`env-<Stand>.txt` in der Sicherung – im Zweifel von Hand vergleichen.
|
||||||
|
|
||||||
|
### Von Hand, wenn es sein muss
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop web worker
|
||||||
|
gunzip -c datenbank-20261010-192437.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
||||||
|
docker compose run --rm -T --entrypoint tar web -xzf - -C / < uploads-20261010-192437.tar.gz
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei der Installation als Dienst stattdessen `psql "$DATABASE_URL"` und `tar -xzf … -C /opt/proxledger/data`.
|
||||||
|
|
||||||
|
## Geprobt am 10.10.2026
|
||||||
|
|
||||||
|
Nicht behauptet, sondern durchgespielt – mit einem eigenen Stapel:
|
||||||
|
|
||||||
|
1. Stand festgehalten (9 Hosts, 4 Kunden, 10 Laufzeiten), ein Kunde zur Wiedererkennung umbenannt,
|
||||||
|
eine PDF-Datei abgelegt
|
||||||
|
2. Sicherung angelegt
|
||||||
|
3. **Alle Daten zerstört** (`docker compose down -v` – Datenbank und Upload-Volume weg)
|
||||||
|
4. `deploy/restore.sh` aufgerufen
|
||||||
|
|
||||||
|
Ergebnis: Datenbank vollständig zurück (die umbenannte Kundin war wieder da), die PDF-Datei lag mit
|
||||||
|
den richtigen Rechten im Upload-Ordner, Anmeldung mit dem alten Passwort funktionierte.
|
||||||
|
|
||||||
|
Dabei zwei Lücken gefunden und geschlossen: Die Sicherung ließ sich nur in eine **leere** Datenbank
|
||||||
|
zurückspielen (jetzt `pg_dump --clean --if-exists`), und für die hochgeladenen PDFs gab es überhaupt
|
||||||
|
keinen beschriebenen Weg.
|
||||||
|
|
||||||
|
> Geprobt wurde der Docker-Weg. Für die Installation als Dienst ist derselbe Ablauf im Skript
|
||||||
|
> vorgesehen, aber noch nicht unter echten Bedingungen gelaufen.
|
||||||
|
|
||||||
|
## Das Wichtigste
|
||||||
|
|
||||||
|
**Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.** Spielen Sie sie einmal in eine
|
||||||
|
Testumgebung ein und melden Sie sich dort an. Erst dann wissen Sie, dass Sicherung und `APP_SECRET`
|
||||||
|
zusammenpassen.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# #023 Feldtest auf Proxmox Backup Server und Mail Gateway
|
# #023 Feldtest auf Proxmox Backup Server und Mail Gateway
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v1.0.0
|
**Meilenstein:** v1.0.0
|
||||||
**Labels:** agent, feldtest
|
**Labels:** agent, feldtest
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min (PBS erledigt, PMG offen)
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Der Feldtest in #018 lief auf zwei Proxmox-VE-Hosts. Für Backup Server und Mail Gateway beruht die
|
Der Feldtest in #018 lief auf zwei Proxmox-VE-Hosts. Für Backup Server und Mail Gateway beruht die
|
||||||
@@ -23,7 +23,7 @@ falsch erwiesen (`--output-format json` gibt es dort nicht). Das gehört vor 1.0
|
|||||||
- [x] Erhobene Ausgaben als Testfälle in `tests/agent-skript.test.ts` aufnehmen – der heutige
|
- [x] Erhobene Ausgaben als Testfälle in `tests/agent-skript.test.ts` aufnehmen – der heutige
|
||||||
PBS-Testfall ist erfunden (JSON) und damit womöglich am echten Verhalten vorbei
|
PBS-Testfall ist erfunden (JSON) und damit womöglich am echten Verhalten vorbei
|
||||||
- [x] Agent auf einem PBS-Host installieren, einen Report abwarten, wieder deinstallieren
|
- [x] Agent auf einem PBS-Host installieren, einen Report abwarten, wieder deinstallieren
|
||||||
- [ ] PMG offen lassen und die Lücke im Handbuch benennen
|
- [x] PMG: von Florian am 23.10.2026 zurückgestellt – wird derzeit nicht betrieben, die Lücke bleibt im Handbuch benannt
|
||||||
|
|
||||||
## Notizen
|
## Notizen
|
||||||
Der Cluster-Abgleich aus #022 betrifft nur PVE: PBS und PMG kennen kein `pvecm`, dort bleibt die
|
Der Cluster-Abgleich aus #022 betrifft nur PVE: PBS und PMG kennen kein `pvecm`, dort bleibt die
|
||||||
@@ -67,3 +67,14 @@ ist auch der letzte offene Punkt aus #018 erledigt.
|
|||||||
|
|
||||||
**Weiterhin offen:** PMG (kein Host) und der Fall „aktive Subscription auf PBS“ (nur unlizenzierte
|
**Weiterhin offen:** PMG (kein Host) und der Fall „aktive Subscription auf PBS“ (nur unlizenzierte
|
||||||
Hosts verfügbar). Dieses Issue bleibt deshalb offen.
|
Hosts verfügbar). Dieses Issue bleibt deshalb offen.
|
||||||
|
|
||||||
|
## Abschluss am 23.10.2026
|
||||||
|
|
||||||
|
**PBS mit aktiver Lizenz: bestätigt.** In der produktiven Installation melden `pbs-cloud-01`,
|
||||||
|
`bup-pbs-01` und `bup-lwwif-pbs-01` ihre `pbsb-…`-Keys samt Ablaufdatum (04.10.2027). Damit ist auch
|
||||||
|
der lizenzierte Fall auf Proxmox Backup Server belegt – nach dem Fund der Rahmentabelle (#037) war
|
||||||
|
das der letzte offene Punkt dieses Produktzweigs.
|
||||||
|
|
||||||
|
**PMG: zurückgestellt.** Florian betreibt derzeit kein Mail Gateway; der Feldtest entfällt bis auf
|
||||||
|
Weiteres. Im Handbuch steht weiterhin, dass dieser Zweig ungeprüft ist und der erste PMG-Host nach
|
||||||
|
der Installation einmal gegenzuprüfen gehört (`proxledger-agent --dry-run`).
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# #024 Härtung nach OWASP
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.15.0
|
||||||
|
**Labels:** security, backend
|
||||||
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Vor der Freigabe als fertiges Produkt eine Durchsicht gegen die OWASP Top 10 – und die gefundenen
|
||||||
|
Lücken schließen, statt sie zu vermerken.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Zugriffskontrolle: jede Server Action prüft die Sitzung selbst, nicht nur der Proxy
|
||||||
|
- [x] Sitzungen widerrufbar machen (gesperrtes Konto, neues Passwort)
|
||||||
|
- [x] Login-CSRF: Herkunft der Anmeldung prüfen
|
||||||
|
- [x] Content-Security-Policy mit Nonce je Anfrage
|
||||||
|
- [x] Abhängigkeiten ohne bekannte Schwachstellen (`npm audit`)
|
||||||
|
- [x] Benutzerverwaltung auf der Kommandozeile (sperren, entsperren, Passwort)
|
||||||
|
- [x] `docs/sicherheit.md` mit der Zuordnung zu den OWASP Top 10
|
||||||
|
- [x] Tests für Sitzungsurteil und Herkunftsprüfung
|
||||||
|
|
||||||
|
## Gefunden und behoben
|
||||||
|
1. **Server Actions waren nur durch `proxy.ts` geschützt.** Keine einzige Aktion prüfte selbst. Der
|
||||||
|
Proxy greift zwar auch bei POST, aber eine einzige Schicht ist zu wenig für 27 schreibende Endpunkte.
|
||||||
|
2. **Login-CSRF möglich:** Der Anmelde-Endpunkt ist ein gewöhnliches Formular und prüfte die Herkunft
|
||||||
|
nicht. Eine fremde Seite hätte jemanden in ein untergeschobenes Konto anmelden können.
|
||||||
|
3. **Keine CSP.** Jetzt mit Nonce und `strict-dynamic`.
|
||||||
|
4. **Sitzungen ließen sich nicht beenden.** Ein gesperrtes Konto arbeitete bis zu zwölf Stunden weiter.
|
||||||
|
5. **`npm audit`: 4 hohe Meldungen** in `mysql2` und `deepmerge-ts` – beides Beiwerk von Prisma. Über
|
||||||
|
`overrides` angehoben statt Prisma zurückzustufen; danach keine Meldung mehr.
|
||||||
|
|
||||||
|
## Bewusst nicht umgesetzt
|
||||||
|
- **Rollen:** Wer angemeldet ist, darf alles. Für ein internes Werkzeug vertretbar, in `docs/sicherheit.md` vermerkt.
|
||||||
|
- **Zwei-Faktor-Anmeldung:** Wer sie braucht, nutzt OIDC.
|
||||||
|
- Die Einschränkung der OIDC-Anmeldung (erlaubte Domänen, automatisches Anlegen) kommt mit #025.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# #025 Einstellungen im Dashboard
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.16.0
|
||||||
|
**Labels:** backend, ui
|
||||||
|
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Werte, die heute im Quelltext oder in der Umgebung stehen, sollen sich im Dashboard ändern lassen –
|
||||||
|
allen voran die Dashboard-Adresse, die im Installations-Einzeiler landet.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Typisierter Einstellungsdienst mit Vorgaben und Prüfung (zod), alles in einer JSON-Zeile
|
||||||
|
- [x] Oberfläche unter *Einstellungen → System* mit vier Bereichen
|
||||||
|
- [x] **Adresse und Agent:** Dashboard-Adresse, Melderhythmus, Streuung, Selbstaktualisierung
|
||||||
|
- [x] **Kaufmännisches:** Währung, Steuersatz, Zahlungsziel, Aufschlag
|
||||||
|
- [x] **Sicherheit:** Sitzungsdauer, Anmeldeversuche, OIDC-Domänen, Konten anlegen, Aufbewahrungsfristen
|
||||||
|
- [x] **Firma und Darstellung:** Name, Anschrift, E-Mail, Zeitzone
|
||||||
|
- [x] Werte wirksam machen (nicht nur speichern)
|
||||||
|
- [x] Nächtliches Aufräumen nach den Aufbewahrungsfristen
|
||||||
|
- [x] Tests und Handbuchkapitel
|
||||||
|
|
||||||
|
## Wo die Werte wirken
|
||||||
|
| Einstellung | Wirkt in |
|
||||||
|
|---|---|
|
||||||
|
| Dashboard-Adresse | `/install.sh`, `/agent.sh`, Benachrichtigungen |
|
||||||
|
| Rhythmus, Streuung, Selbstaktualisierung | erzeugte systemd-Timer und `agent.conf` |
|
||||||
|
| Steuersatz | neue Rechnungspositionen (Einkauf und Verkauf) |
|
||||||
|
| Zahlungsziel | vorgeschlagenes Fälligkeitsdatum |
|
||||||
|
| Aufschlag | vorgeschlagener Verkaufspreis aus dem Einkaufspreis |
|
||||||
|
| Sitzungsdauer | Gültigkeit des Tokens und Lebensdauer des Cookies |
|
||||||
|
| Anmeldeversuche | Ratenbremse der Anmeldung |
|
||||||
|
| OIDC-Domänen, Konten anlegen | `mitOidcAnmelden` – schließt die in #024 vermerkte Lücke |
|
||||||
|
| Aufbewahrungsfristen | nächtlicher Lauf im Worker (03:30) |
|
||||||
|
| Firma, Zeitzone | Kopf und Datum der PDF-Ausgaben |
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
- **Eine JSON-Zeile statt vieler Schlüssel:** Speichern ist ein Schreibvorgang, fehlende Felder
|
||||||
|
bekommen ihren Vorgabewert, und eine kaputte Zeile legt den Betrieb nicht lahm – dann gelten die Vorgaben.
|
||||||
|
- **Zehn Sekunden Zwischenspeicher:** Die Werte werden bei fast jeder Anfrage gebraucht, ändern sich
|
||||||
|
aber selten. Eine Änderung ist trotzdem sofort spürbar.
|
||||||
|
- **`APP_SECRET` und `DATABASE_URL` bleiben in der Umgebung**; beide werden vor dem ersten
|
||||||
|
Datenbankzugriff gebraucht.
|
||||||
|
- **„Stumm nach X Tagen“ bewusst weggelassen:** Das steht schon als Schwellwert bei den Warnregeln.
|
||||||
|
Zwei Stellen für denselben Wert wären eine Fehlerquelle.
|
||||||
|
- **Währung und Zeitzone wirken vorerst nur serverseitig** (PDF, CSV, Benachrichtigungen). Die
|
||||||
|
Browseransicht rechnet weiterhin fest in Euro und Europe/Berlin – das nachzuziehen heißt, die
|
||||||
|
Formatierung durch alle Client-Komponenten zu reichen, und gehört in einen eigenen Schritt.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# #026 Auslieferung finalisieren
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.17.0
|
||||||
|
**Labels:** deployment, doku
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Zwei saubere Wege, ProxLedger in Betrieb zu nehmen: als Docker-Stack und als LXC-Container nach Art
|
||||||
|
der Proxmox VE Helper-Scripts.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `deploy/update.sh`: sichern, bauen, migrieren, neu starten, Erreichbarkeit prüfen
|
||||||
|
- [x] Zustandsprüfung für den Worker in `docker-compose.yml`
|
||||||
|
- [x] `AUFRAEUM_CRON` in `.env.example` und im Compose-Dienst
|
||||||
|
- [x] `deploy/community-scripts/ct/proxledger.sh` und `install/proxledger-install.sh`
|
||||||
|
- [x] `deploy/community-scripts/README.md` mit Voraussetzungen für eine Einreichung
|
||||||
|
- [x] README auf den tatsächlichen Stand gebracht (Feldtest erledigt, Einstellungen, Sicherheit)
|
||||||
|
- [x] `docs/roadmap.md`
|
||||||
|
- [x] Docker-Stapel vollständig durchgespielt: bauen, starten, migrieren, anmelden, Agent-Skript abrufen
|
||||||
|
|
||||||
|
## Erkenntnisse zu community-scripts
|
||||||
|
- Die Skripte laden ihr Gerüst inzwischen aus einem eigenen Repository
|
||||||
|
(`community-scripts/core`, Datei `core/build.func`) – nicht mehr aus `misc/build.func`.
|
||||||
|
- **Neue Skripte gehören als Pull Request nach ProxmoxVED**, nicht nach ProxmoxVE. Direkt eingereichte
|
||||||
|
Skripte werden dort ohne Prüfung geschlossen.
|
||||||
|
- Die JSON-Metadaten für die Webseite liegen nicht mehr im Skript-Repository.
|
||||||
|
- Eine Einreichung setzt öffentlich erreichbaren Quelltext voraus (in aller Regel GitHub). Solange
|
||||||
|
ProxLedger im eigenen Gitea liegt, bleiben die Skripte intern; die Quelle lässt sich über
|
||||||
|
`PROXLEDGER_REPO` setzen.
|
||||||
|
|
||||||
|
## Beim Prüfen gefunden
|
||||||
|
Der Docker-Stapel wurde vollständig gestartet (bauen, migrieren, anmelden). Dabei zeigte sich: Nach der
|
||||||
|
Anmeldung wies die Weiterleitung auf `http://<container-id>:3000` – die **interne** Adresse des
|
||||||
|
Containers. Im Browser hinter einem Reverse Proxy wäre das eine tote Adresse gewesen. Weiterleitungen
|
||||||
|
nehmen jetzt die eingetragene Adresse, sonst `APP_URL`, sonst die Kopfzeilen des Reverse Proxy
|
||||||
|
(`src/lib/adresse.ts`, vier Tests). Im Stapel gegengeprüft: `location: http://localhost:3001/`.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Der eigene `deploy/lxc-installer.sh` (Container **mit Docker darin**) bleibt erhalten. Der neue Weg
|
||||||
|
installiert Node und PostgreSQL direkt in den Container – das ist schlanker und entspricht dem, was
|
||||||
|
Proxmox-Betreiber von Helper-Scripts erwarten. Beide Wege stehen gleichberechtigt im README.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# #027 Rollen und Benutzerverwaltung
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.18.0
|
||||||
|
**Labels:** security, backend, ui
|
||||||
|
**Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Bisher durfte jeder Angemeldete alles – in `docs/sicherheit.md` als offener Punkt vermerkt. Es soll
|
||||||
|
drei Rollen geben: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Daten.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `Rolle` im Datenmodell, Kundenbindung am Benutzer
|
||||||
|
- [x] Rechte an **einer** Stelle (`src/server/auth/rollen.ts`), nicht verteilt über Seiten
|
||||||
|
- [x] `sitzungErforderlich(recht)` – jede der 28 Aktionen nennt ihr Recht
|
||||||
|
- [x] Sicht einschränken: Hosts, Kunden, Rechnungen, Kennzahlen, Suche, Jahresbahn, Dashboard
|
||||||
|
- [x] Einkaufspreise und Marge für Kundenkonten entfernen – in der Abfrage, nicht in der Anzeige
|
||||||
|
- [x] Schreibrechte für Kunden: Notizen am eigenen Host, Warnungen quittieren
|
||||||
|
- [x] Benutzerverwaltung unter *Einstellungen → Benutzer*
|
||||||
|
- [x] Menü nach Rolle
|
||||||
|
- [x] Tests
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
- **Rolle und Kundenbindung kommen aus der Datenbank, nicht aus dem Token.** Eine geänderte Rolle
|
||||||
|
wirkt damit sofort statt erst nach Ablauf der Sitzung.
|
||||||
|
- **Die Abfragen holen sich die Einschränkung selbst** (`sichtbarerKunde()`), statt sie von der Seite
|
||||||
|
gereicht zu bekommen. Eine Seite kann so nicht vergessen, sie weiterzugeben – der häufigste Weg,
|
||||||
|
wie solche Sichten lecken.
|
||||||
|
- **Ein Kundenkonto ohne Kundenbindung sieht nichts** statt alles. Der Rückfall zeigt bewusst ins
|
||||||
|
Leere, nicht auf den vollen Bestand.
|
||||||
|
- **Ausgeblendete Menüpunkte sind keine Zugriffskontrolle.** Sie sind Bequemlichkeit; geprüft wird in
|
||||||
|
Seite und Aktion.
|
||||||
|
|
||||||
|
## Beim Bauen aufgefallen
|
||||||
|
Neu angelegte OIDC-Konten hätten die Vorgabe des Datenmodells geerbt – **ADMIN**. Wer sich beim
|
||||||
|
Identitätsanbieter anmelden darf, wäre damit nebenbei Administrator geworden. Jetzt gibt es dafür eine
|
||||||
|
eigene Einstellung mit der Vorgabe *Techniker*, samt Test.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# #028 Anmeldung mit Passkeys
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.18.0
|
||||||
|
**Labels:** security, backend, ui
|
||||||
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Passkeys (WebAuthn) als **gleichwertiger** Anmeldeweg neben Passwort und OIDC – passwortlos, mit
|
||||||
|
Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `@simplewebauthn/server` und `@simplewebauthn/browser`
|
||||||
|
- [x] Datenmodell `Passkey` (mehrere je Konto)
|
||||||
|
- [x] Registrierung und Anmeldung als Endpunkte, Aufgabe („challenge“) in einem kurzlebigen Cookie
|
||||||
|
- [x] Seite *Meine Passkeys* mit Anlegen und Entfernen
|
||||||
|
- [x] Knopf „Mit Passkey anmelden“ auf der Anmeldeseite
|
||||||
|
- [x] Hinweis, wenn die Adresse kein HTTPS hat
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
- **Keine Liste erlaubter Schlüssel bei der Anmeldung.** Der Browser bietet selbst an, was zu dieser
|
||||||
|
Seite passt. Sonst müsste die Seite vorher verraten, welche Passkeys zu einem Konto gehören – das
|
||||||
|
wäre ein Weg, Konten auszuspähen.
|
||||||
|
- **Fehlermeldung bei der Anmeldung bewusst unscharf** („Anmeldung mit diesem Passkey nicht
|
||||||
|
möglich“). Ob es den Schlüssel gibt, geht einen Unbekannten nichts an.
|
||||||
|
- **Die Aufgabe liegt in einem `SameSite=strict`-Cookie mit fünf Minuten Gültigkeit**, nicht in der
|
||||||
|
Datenbank. Sie gilt nur für diesen Browser und diesen Versuch.
|
||||||
|
- **`rpID` und `origin` kommen aus der öffentlichen Adresse** (Einstellung, sonst `APP_URL`, sonst
|
||||||
|
Proxy-Kopfzeilen). Ein Passkey ist an die Domäne gebunden; steht dort die falsche Adresse, lehnt
|
||||||
|
der Browser wortlos ab.
|
||||||
|
- Passwort und OIDC bleiben bestehen. Ein Passkey, der im kaputten Telefon liegt, darf niemanden
|
||||||
|
aussperren.
|
||||||
|
|
||||||
|
## Grenze
|
||||||
|
Ein Passkey gilt nur für die Adresse, unter der er angelegt wurde. Beim Umzug von `http://server:3000`
|
||||||
|
auf `https://proxledger.firma.de` müssen alle Passkeys neu registriert werden – im Handbuch vermerkt.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# #029 Einrichtungsdokumentation
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.19.0
|
||||||
|
**Labels:** doku
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Anleitungen für alles, was rund um ProxLedger eingerichtet wird: HTTPS, OIDC mit authentik, Sicherung –
|
||||||
|
und die richtige Reihenfolge, in der das geschehen muss.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `docs/betrieb/inbetriebnahme.md` – von null zum meldenden Host
|
||||||
|
- [x] `docs/betrieb/reverse-proxy.md` – nginx Proxy Manager und netbird
|
||||||
|
- [x] `docs/betrieb/authentik.md` – OIDC Schritt für Schritt, mit Fehlerbildern
|
||||||
|
- [x] `docs/betrieb/sicherung.md` – sichern und **zurückspielen**
|
||||||
|
- [x] Verweise aus README und Handbuch
|
||||||
|
|
||||||
|
## Beim Schreiben gefunden
|
||||||
|
Zwei Stellen taugten für einen Betrieb hinter einem Reverse Proxy nicht:
|
||||||
|
|
||||||
|
1. Die **OIDC-Rückkehradresse** kam allein aus `APP_URL` und übersah die Dashboard-Adresse aus den
|
||||||
|
Einstellungen. Wer nur letztere gesetzt hatte, bekam vom Anbieter einen *Redirect URI Error*.
|
||||||
|
2. Beim **Einlösen des Codes** wurde die Adresse der eingehenden Anfrage verwendet – hinter einem
|
||||||
|
Proxy also die interne. Der Anbieter hat aber die öffentliche hinterlegt; der Tausch wäre
|
||||||
|
gescheitert. Jetzt wird dieselbe öffentliche Adresse gebildet wie für die Weiterleitungen.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Die Reihenfolge steht bewusst am Anfang der Inbetriebnahme: **Adresse festlegen, dann Passkeys und
|
||||||
|
Agents.** Ein Passkey gilt nur für die Domäne, unter der er entstanden ist, und die Adresse steckt im
|
||||||
|
Installations-Einzeiler. Wer das übergeht, macht beides zweimal.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# #030 netbird Reverse Proxy in die Anleitung
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.19.1
|
||||||
|
**Labels:** doku
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
netbird bringt seit v0.65 einen eigenen Reverse Proxy mit (Beta), v0.72 ergänzte private Dienste.
|
||||||
|
Damit entfällt ein zusätzlicher nginx – das gehört in die Anleitung, weil es der kürzeste Weg zu
|
||||||
|
HTTPS ist, wenn netbird ohnehin im Einsatz ist.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Weg B: netbird Reverse Proxy – Dienst anlegen, Zertifikat, Einstellungen
|
||||||
|
- [x] Alter netbird-Abschnitt wird Weg C (ohne Proxy)
|
||||||
|
- [x] Entscheidungstabelle, welcher Weg wofür taugt
|
||||||
|
- [x] Beta-Einschränkungen benennen (kein Rosenpass; selbst gehostet nur mit Traefik)
|
||||||
|
|
||||||
|
## Der wichtigste Hinweis
|
||||||
|
Die Absicherung eines Dienstes (SSO, Passwort, PIN) gilt für den **ganzen** Dienst – also auch für
|
||||||
|
`/api/agent`. Die Agents weisen sich mit eigenem Token aus und können sich nirgends anmelden; eine
|
||||||
|
SSO-Hürde davor lässt alle Hosts stumm ins Leere melden, bis der Zwischenspeicher voll ist.
|
||||||
|
|
||||||
|
Zwei brauchbare Auswege stehen in der Anleitung: zwei Dienste (Oberfläche mit SSO, Agents ohne) oder
|
||||||
|
ein privater Dienst, der nur aus dem netbird-Netz erreichbar ist.
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
- <https://docs.netbird.io/manage/reverse-proxy>
|
||||||
|
- <https://netbird.io/knowledge-hub/reverse-proxy>
|
||||||
|
- <https://netbird.io/knowledge-hub/netbird-only-private-services>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #031 Update- und Sicherungsskript für beide Installationsarten
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.20.0
|
||||||
|
**Labels:** deployment
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
`deploy/update.sh` und `deploy/backup.sh` konnten nur Docker. Die Installation als Dienst (Node und
|
||||||
|
PostgreSQL direkt im LXC) ging leer aus – dort half nur der Weg über das Gerüst der community-scripts.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Beide Skripte erkennen die Installationsart selbst (systemd-Dienst oder Docker)
|
||||||
|
- [x] Dienst-Weg: `npm ci`, `prisma migrate deploy`, bauen, `systemctl restart`
|
||||||
|
- [x] Sicherung für den Dienst-Weg: `pg_dump` über `DATABASE_URL`, Uploads aus `UPLOAD_DIR`
|
||||||
|
- [x] Fehlgeschlagene Sicherung bricht die Aktualisierung ab – **vor** jeder Änderung
|
||||||
|
- [x] Hinweis am Ende von `deploy/lxc-installer.sh` auf die Skripte umgestellt
|
||||||
|
- [x] Handbuch und Betriebsdoku angepasst
|
||||||
|
- [x] Docker-Weg vollständig durchgespielt
|
||||||
|
|
||||||
|
## Zwei Fehler beim Durchspielen gefunden
|
||||||
|
1. **Die Zustandsprüfung des Web-Containers konnte nie gelingen.** Next bindet im Standalone-Modus an
|
||||||
|
die Adresse aus `HOSTNAME` – und Docker setzt darin den Containernamen. Der Server lauschte also
|
||||||
|
nur auf der Container-IP, nicht auf `127.0.0.1`. Der Dienst `web` blieb dauerhaft auf
|
||||||
|
„health: starting“; von außen war er trotzdem erreichbar, deshalb fiel es bisher nicht auf.
|
||||||
|
Behoben mit `ENV HOSTNAME=0.0.0.0` im Dockerfile; die Prüfungen verwenden jetzt `127.0.0.1`
|
||||||
|
statt `localhost` (das löst im Container auch auf `::1` auf).
|
||||||
|
2. **Die Sicherung scheiterte stumm an fehlenden Rechten** (`/var/backups` gehört root) und die
|
||||||
|
Aktualisierung lief trotzdem weiter. Jetzt bricht sie ab und nennt die drei Auswege.
|
||||||
|
|
||||||
|
## Prüfung
|
||||||
|
Vollständiger Durchlauf gegen einen laufenden Stapel: Sicherung (Datenbank 6,3 KB mit 25 Tabellen,
|
||||||
|
Uploads, `.env`), Abbilder gebaut, migriert, neu gestartet, Dashboard nach zwei Sekunden wieder da.
|
||||||
|
Danach stand `web` zum ersten Mal auf **healthy**.
|
||||||
@@ -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.
|
||||||
@@ -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ß.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# #034 Bedienung auf dem Handy
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.20.2
|
||||||
|
**Labels:** ui, bug
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian im produktiven Einsatz auf dem Telefon: „Das Menü geht nicht weg.“ Dazu eine Tabelle, von der
|
||||||
|
rechts die Hälfte abgeschnitten war.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Das Markup stammt von AdminLTE, dessen JavaScript binden wir bewusst nicht ein. Die Klasse
|
||||||
|
`sidebar-open` ließ sich damit setzen – aber nichts nahm sie wieder weg: keine Fläche zum Antippen,
|
||||||
|
kein Schließen beim Navigieren, kein Escape. Auf dem Telefon blieb das Menü über dem Inhalt stehen.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Eigener, winziger Zustand (`menuZustand.ts`), auf den Kopfzeile und Menü gemeinsam hören
|
||||||
|
- Abdunkelnde Fläche hinter dem Menü – antippen schließt (ein `button`, damit auch per Tastatur erreichbar)
|
||||||
|
- Schließen-Knopf im Menükopf, nur auf schmalen Bildschirmen
|
||||||
|
- Schließt beim Navigieren und mit Escape
|
||||||
|
- Der Inhalt dahinter scrollt nicht mehr mit, solange das Menü offen ist
|
||||||
|
- Dashboard-Tabelle: auf dem Telefon nur Host und Ablauf, der Kunde steht klein unter dem Hostnamen.
|
||||||
|
Key und Rechnungsstände erscheinen ab Tablet-Breite – die Host-Tabelle machte das schon vorher so.
|
||||||
|
- Fünf Tests für die Zustandslogik
|
||||||
|
|
||||||
|
## Nicht geändert
|
||||||
|
Die abdunkelnde Fläche gilt nur unter 992 px. Auf breiten Bildschirmen bleibt es beim bisherigen
|
||||||
|
Verhalten: Das Menü klappt zu Symbolen zusammen, statt sich über den Inhalt zu legen.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# #035 Tabellen auf dem Handy als Karten
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.21.0
|
||||||
|
**Labels:** ui
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Etwas schöner darstellen auf dem Handy muss doch drin sein.“ Auf Rechnungen, Kunden und Jahresbahn
|
||||||
|
war rechts jeweils die Hälfte abgeschnitten, Knöpfe im Kartenkopf liefen über den Rand, und Namen
|
||||||
|
brachen Buchstabe für Buchstabe um.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `.pl-karten-tabelle`: unterhalb von 768 px wird jede Tabellenzeile zur Karte, jede Zelle bekommt
|
||||||
|
ihre Beschriftung aus `data-label` davor
|
||||||
|
- [x] Angewandt auf Kunden, Rechnungen und den Ablauf-Kalender der Jahresbahn
|
||||||
|
- [x] Kartenkopf bricht um, statt über den Rand zu laufen
|
||||||
|
- [x] Jahresbahn: schmalere Namensspalte auf dem Telefon, dazu der Hinweis aufs seitliche Wischen
|
||||||
|
- [x] Mobilansichten der breiten Seiten kommen jetzt mit in die Bilderstrecke
|
||||||
|
|
||||||
|
## Warum Karten statt Scrollen
|
||||||
|
Eine Tabelle mit elf Spalten lässt sich auf 390 px nicht sinnvoll anzeigen. Seitliches Scrollen
|
||||||
|
versteckt die Hälfte der Angaben, und Spalten wegzulassen nimmt sie ganz weg. Als Karte steht jede
|
||||||
|
Angabe mit ihrer Beschriftung da – länger, aber vollständig.
|
||||||
|
|
||||||
|
## Nebenbefund
|
||||||
|
Das Kontrollkästchen für den Rechnungsstatus trug `pl-touch-target`, also `min-height: 44px` – und
|
||||||
|
wurde damit selbst zum 44 Pixel hohen leeren Kasten. Die Fläche zum Treffen bekommt jetzt sein Umfeld,
|
||||||
|
das Kästchen behält seine Größe.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# #036 Standort am Host setzen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.21.0
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Den Standort bei den Hosts kann man nicht einstellen.“
|
||||||
|
|
||||||
|
## Was wirklich los war
|
||||||
|
Schlimmer als gemeldet: Das Formular in der Host-Detailansicht sendete **nur** den Kunden. Die Aktion
|
||||||
|
`hostKundeSetzen` las aber auch `standortId` – und bekam nichts. Jede Kundenzuweisung hat damit den
|
||||||
|
Standort **stillschweigend gelöscht**. Setzen ging nur beim Ausrollen über die Sammelzuweisung unter
|
||||||
|
*Hosts → Nicht zugeordnet*; danach war er beim nächsten Speichern wieder weg.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Kunde und Standort in **einem** Formular, die Standortliste richtet sich nach dem gewählten Kunden
|
||||||
|
- Wechselt der Kunde, wird die Standortauswahl zurückgesetzt statt stehen zu bleiben
|
||||||
|
- Hat ein Kunde keine Standorte, sagt das Formular es, statt eine leere Auswahl anzubieten
|
||||||
|
- Die Aktion prüft jetzt, dass der Standort zum Kunden gehört, und verwirft ihn sonst – eine falsche
|
||||||
|
Zuordnung wäre schlimmer als keine
|
||||||
|
- Vier Tests, darunter der Fall „Standort eines anderen Kunden“
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# #037 Rabatt je Position und der tatsächliche Ablauf
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.0
|
||||||
|
**Labels:** backend, ui
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 55 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian zeigte eine echte Proxmox-Rechnung (#291017) und beschrieb den Ablauf:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-3ef3be4d75 € 1.100,00
|
||||||
|
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-b7a0d516af € 1.100,00
|
||||||
|
Client Discount - … pve2s-3ef3be4d75 € -110,00
|
||||||
|
Client Discount - … pve2s-b7a0d516af € -110,00
|
||||||
|
Sub Total € 1.980,00
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu: Man bekommt die Rechnung zuerst als **UNPAID**. Erst wenn der Kunde seine Rechnung bezahlt hat,
|
||||||
|
zahlt man selbst bei Proxmox – und bekommt dieselbe Rechnung dann als **PAID** mit Transaktion.
|
||||||
|
|
||||||
|
## Was falsch war
|
||||||
|
1. **Der Rabatt hing an der Rechnung, nicht an der Position.** Bei einer Sammelrechnung über zwei
|
||||||
|
Hosts ließe sich ein Rabatt keinem Host zuordnen; die Marge je Host wäre falsch.
|
||||||
|
2. **Er wurde nirgends abgezogen** – weder in der Summe noch in der Marge. Ein Feld, das aussieht wie
|
||||||
|
eine Rechengröße und keine ist.
|
||||||
|
3. **Vorgabe 19 % USt** für den Einkauf. Proxmox sitzt in Österreich und rechnet gegenüber Unternehmen
|
||||||
|
mit USt-IdNr. über Reverse Charge ab – richtig sind 0 %.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- `rabatt` wandert an `ProxmoxRechnungPosition`; die Migration verteilt vorhandene Werte anteilig auf
|
||||||
|
die Positionen und rechnet die Rechnungssummen neu. Nichts geht verloren.
|
||||||
|
- Rechnungsbetrag = Summe der Positionen **nach Abzug ihrer Rabatte** – wie das „Sub Total“ auf der
|
||||||
|
echten Rechnung.
|
||||||
|
- Marge und alle Summen rechnen mit dem Betrag nach Abzug.
|
||||||
|
- Das Formular heißt jetzt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird.
|
||||||
|
- Eigener Steuersatz für den Einkauf, Vorgabe 0 (Reverse Charge).
|
||||||
|
- Die Sammelrechnung listet je Position „1.100,00 € − 110,00 € = 990,00 €“.
|
||||||
|
|
||||||
|
## Neue Warnregel
|
||||||
|
`kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen. Genau der
|
||||||
|
Zustand aus Florians Ablauf – das Geld ist da, die Rechnung auch, jetzt kann gezahlt werden. Bewusst
|
||||||
|
unabhängig vom Fälligkeitsdatum, denn die Reihenfolge ist Absicht und keine Nachlässigkeit.
|
||||||
|
|
||||||
|
## Zum PDF
|
||||||
|
Die PAID-Fassung ersetzt beim erneuten Hochladen einfach die UNPAID-Fassung – gleiche Rechnungsnummer,
|
||||||
|
gleiche Rechnung. Gleiche Dateien werden an ihrer Prüfsumme erkannt und nicht doppelt abgelegt.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# #038 Seite wird auf dem Handy herausgezoomt
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.1
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
|
||||||
|
Host-Detailansicht saß verschoben im Bild.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
|
||||||
|
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
|
||||||
|
|
||||||
|
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
|
||||||
|
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
|
||||||
|
`table-responsive` – anders als die übrigen.
|
||||||
|
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
|
||||||
|
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
|
||||||
|
- Die drei Tabellen bekommen ihre Hülle
|
||||||
|
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
|
||||||
|
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
|
||||||
|
wurde die Host-Detailansicht gegengeprüft
|
||||||
|
|
||||||
|
## Nachtrag zu #037
|
||||||
|
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
|
||||||
|
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
|
||||||
|
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# #039 Auf dem Telefon führte kein Weg zur Laufzeit
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.2
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Kann ich am Handy nicht rein.“ Gemeint war der Reiter *Rechnungen* in der Host-Detailansicht: Die
|
||||||
|
Zeile zeigt Laufzeit, Rechnung und Beträge – aber nichts davon war anklickbar.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Zwei Stellen:
|
||||||
|
|
||||||
|
1. In der Host-Detailansicht war die Laufzeit nie verlinkt, auch am Rechner nicht. Man kam nur über
|
||||||
|
die Rechnungsliste dorthin.
|
||||||
|
2. In der Rechnungsliste ist die Laufzeit-Spalte erst ab 992 px eingeblendet. Auf dem Telefon fehlte
|
||||||
|
damit der einzige Verweis auf die Formulare – die Karte verwies nur auf den Host.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Die Laufzeit in der Host-Detailansicht ist ein Link und zugleich die Überschrift der Karte
|
||||||
|
- In der Rechnungsliste bleibt die Laufzeit auf dem Telefon sichtbar; ausgeblendet wird sie nur
|
||||||
|
zwischen 768 und 992 px, wo die Tabelle am engsten ist
|
||||||
|
- Der Reiter *Rechnungen* nutzt ebenfalls das Kartenlayout
|
||||||
|
|
||||||
|
## Nebenbefund
|
||||||
|
Auch dieser Reiter rechnete den Einkauf noch ohne Rabatt – 1.100 € statt 990 €. Dritte Stelle nach
|
||||||
|
#037 und #038; alle gefunden, weil Florian die Zahl auf dem Bildschirm mit seiner Rechnung verglichen hat.
|
||||||
|
|
||||||
|
## Gelernt
|
||||||
|
Bootstraps `d-*`-Hilfsklassen setzen `display` mit `!important` und überschreiben damit das
|
||||||
|
Kartenlayout. Für Spalten, die in der Kartenansicht sichtbar bleiben sollen, braucht es eine eigene
|
||||||
|
Klasse (`pl-spalte-laufzeit`).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# #040 Schlagworte und Notizen sichtbar machen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.23.0
|
||||||
|
**Labels:** ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
|
||||||
|
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
|
||||||
|
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
|
||||||
|
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
|
||||||
|
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
|
||||||
|
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
|
||||||
|
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
|
||||||
|
Notiztext; die Trefferzeile nennt die Schlagworte.
|
||||||
|
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
|
||||||
|
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# #041 Gleichnamige Hosts erkennen und stilllegen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.24.0
|
||||||
|
**Labels:** backend, ui, alerts
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian markierte in der Host-Liste zwei Paare: `hv-pve-01` und `hv-pve-02` standen jeweils **zweimal
|
||||||
|
beim selben Kunden** – einmal „Aktiv“ mit einer IP, einmal „Keine Subscription“ mit drei anderen.
|
||||||
|
|
||||||
|
## Was dahintersteckt
|
||||||
|
Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am Namen. Ändert sich diese Kennung
|
||||||
|
– Neuinstallation, geklonter Container, zurückgespielte Sicherung –, meldet sich dieselbe Maschine als
|
||||||
|
**neuer** Host, und der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind
|
||||||
|
aber genauso möglich. Welcher Fall vorliegt, weiß nur der Betreiber.
|
||||||
|
|
||||||
|
## Was fehlte
|
||||||
|
1. Kein Hinweis auf den Doppeleintrag.
|
||||||
|
2. **Keine Möglichkeit, einen Host loszuwerden.** Ein Eintrag, der nicht mehr existiert, blieb für
|
||||||
|
immer in Listen, Zahlen und Warnungen stehen.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- **Dreizehnte Warnregel `hostname-doppelt`:** meldet mehrere Hosts desselben Kunden mit gleichem
|
||||||
|
Namen und nennt die IP-Adressen zum Unterscheiden. Gleiche Namen bei **verschiedenen** Kunden sind
|
||||||
|
der Normalfall und bleiben still.
|
||||||
|
- **Stilllegen** (`archiviertAm`): Der Host verschwindet aus Listen, Zahlen, Suche und Warnungen;
|
||||||
|
seine offenen Warnungen werden geschlossen. Laufzeiten und Rechnungen bleiben erhalten.
|
||||||
|
- **Wieder aufnehmen** mit demselben Knopf.
|
||||||
|
- **Endgültig löschen** nur, solange keine Laufzeit am Host hängt – Buchhaltung wirft man nicht weg.
|
||||||
|
- In der Liste ein Schalter „stillgelegte Hosts zeigen“ und ein Abzeichen an der Zeile.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Stilllegen statt automatisch zusammenführen. Ein Zusammenführen müsste raten, welcher Eintrag welche
|
||||||
|
Rechnung erbt – im Zweifel zulasten der Buchhaltung. Die Warnung nennt beide Hosts mit IPs; die
|
||||||
|
Entscheidung trifft der Mensch.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# #042 Cluster-Knoten falsch gelesen, wenn ein QDevice im Spiel ist
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.24.1
|
||||||
|
**Labels:** bug, agent
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florians Cluster hat drei Knoten (`hv-pve-01`, `hv-pve-02`, `hv-pve-05`). ProxLedger zeigte vier
|
||||||
|
„Cluster-Knoten“: `A,V,NMW`, `A,V,NMW`, `NR` und `Qdevice`.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Der Agent nahm aus `pvecm nodes` blind die dritte Spalte als Namen. Ohne QDevice stimmt das:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodeid Votes Name
|
||||||
|
1 1 hv-rz5-pve-01 (local)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit QDevice steht dort aber dessen Zustand, und der Name rutscht eine Spalte weiter:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodeid Votes Qdevice Name
|
||||||
|
1 1 A,V,NMW hv-pve-01
|
||||||
|
0 1 Qdevice (votes 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemeldet wurden damit die Zustandskürzel – und das QDevice selbst als vierter „Knoten“.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
Die Spalte „Name“ wird aus der Kopfzeile bestimmt, statt sie zu erraten. Die Zeile des QDevice trägt
|
||||||
|
die Nodeid 0 und wird übersprungen. Beide Ausgabeformen sind als Testfall hinterlegt.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
Die Warnregel „Cluster-Knoten ohne Agent“ hatte dadurch Warnungen für Knoten erzeugt, die es nicht
|
||||||
|
gibt. Sie schließen sich von selbst, sobald die Agents mit der neuen Fassung gemeldet haben.
|
||||||
|
|
||||||
|
## Lehre
|
||||||
|
Dritter Fall in dieser Reihe: Eine Ausgabe, die beim Feldtest auf **einem** Cluster richtig gelesen
|
||||||
|
wurde, ist nicht auf jedem Cluster gleich. Spalten gehören über ihre Überschrift angesprochen, nicht
|
||||||
|
über ihre Position.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# #043 Laufzeiten anlegen, bearbeiten und zusammenführen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.25.0
|
||||||
|
**Labels:** backend, ui
|
||||||
|
**Geschätzt:** 4 h **Tatsächlich:** 1 h 05 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Drei Rückmeldungen aus dem Einsatz:
|
||||||
|
|
||||||
|
1. „In der Kundenübersicht wäre es auch cool, die Laufzeit / Rechnung zu überarbeiten.“
|
||||||
|
2. „Wie werden so Laufzeiten erfasst?“
|
||||||
|
3. „Auch die Laufzeit im Nachhinein zu bearbeiten geht nicht. Oder mit einer vorhandenen zu
|
||||||
|
verknüpfen oder zu vereinen.“
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
Laufzeiten entstanden ausschließlich als **Vorschlag** aus dem Agenten-Report: Meldet ein Host ein
|
||||||
|
Ablaufdatum, legt ProxLedger eine Laufzeit von diesem Datum bis ein Jahr später an. Bestätigen oder
|
||||||
|
verwerfen war möglich – mehr nicht:
|
||||||
|
|
||||||
|
- **Anlegen von Hand** gab es als Server-Aktion, aber **nirgends in der Oberfläche**.
|
||||||
|
- **Ändern** war unmöglich. Weicht das gemeldete Datum von der Bestellung ab, blieb es falsch.
|
||||||
|
- **Zusammenführen** gab es nicht, obwohl der Fall zwangsläufig auftritt: Neben einer erfassten
|
||||||
|
Laufzeit legt der Agent eine zweite an, wenn das Ablaufdatum um einen Tag abweicht.
|
||||||
|
- Aus der **Kundenansicht** führte kein Weg in die Laufzeit.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Formular *Laufzeit von Hand anlegen* auf der Rechnungsseite; der gemeldete Key wird vorgeschlagen,
|
||||||
|
das Ende aus dem Beginn vorbelegt (ein Jahr minus ein Tag – dieselbe Rechnung wie beim Vorschlag).
|
||||||
|
- *Laufzeit bearbeiten* im Laufzeit-Detail: Key, Beginn, Ende, Notiz. Eine Änderung bestätigt den
|
||||||
|
Vorschlag zugleich.
|
||||||
|
- *Zusammenführen* mit einer anderen Laufzeit desselben Hosts. Die aufgelöste Laufzeit gibt ihre
|
||||||
|
Rechnungen ab, sofern das Ziel dort nichts hat; haben beide eine Rechnung derselben Art, bricht der
|
||||||
|
Vorgang mit einer Erklärung ab.
|
||||||
|
- Kundenansicht: Laufzeit und Host sind verlinkt, Marge und Rabatt stehen dabei.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
- **Kein stilles Überschreiben beim Zusammenführen.** Zwei Einkaufsrechnungen auf einem Lizenzjahr
|
||||||
|
wären nicht mehr zuzuordnen – lieber ein Abbruch mit Hinweis.
|
||||||
|
- **Beim Ändern bleibt die Regel „ein Startdatum je Host“.** Kollidiert das neue Datum, verweist die
|
||||||
|
Meldung aufs Zusammenführen.
|
||||||
|
- Beim Zusammenführen muss die Quelle ihre Positionen zuerst loslassen: Eine Rechnungsposition gehört
|
||||||
|
zu genau einer Laufzeit, und die Datenbank wacht darüber. Erst dann darf das Ziel sie übernehmen.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# #044 Eigene Hosts ohne Weiterberechnung
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.26.0
|
||||||
|
**Labels:** backend, ui, alerts
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Ich habe mich auch selbst als Kunden angelegt, da ich ja auch Hosts habe. Aber mir selbst schreibe
|
||||||
|
ich ja keine E-Mails.“
|
||||||
|
|
||||||
|
## Das Problem
|
||||||
|
Die eigenen Hosts brauchen einen Kunden – sonst stehen sie unter *Nicht zugeordnet* und lösen die
|
||||||
|
Warnung „Host ohne Kunde“ aus. Als gewöhnlicher Kunde erzeugen sie aber lauter Aufgaben, die es nicht
|
||||||
|
gibt: „Kundenrechnung erstellen“, „Kundenrechnung unbezahlt“, dazu eine negative Marge in jeder
|
||||||
|
Summe, weil dem Einkauf kein Verkauf gegenübersteht.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
Kennzeichen **Eigenbedarf** am Kunden. Für dessen Hosts gilt:
|
||||||
|
|
||||||
|
| | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Proxmox-Rechnung | unverändert – die Kosten fallen ja an |
|
||||||
|
| Kundenrechnung | entfällt; das Formular wird durch eine Erklärung ersetzt |
|
||||||
|
| Warnungen „Kundenrechnung fehlt / unbezahlt“ | entfallen |
|
||||||
|
| Warnung „Kunde hat bezahlt – Einkauf offen“ | entfällt |
|
||||||
|
| Aufgabe „Kundenrechnung erstellen“ | entfällt |
|
||||||
|
| Marge | bleibt leer statt negativ |
|
||||||
|
| Summen | Einkauf zählt mit, Verkauf und Marge nicht |
|
||||||
|
|
||||||
|
Dazu ein Abzeichen in der Kundenliste und auf der Laufzeit.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Die Einkaufsseite bleibt vollständig erhalten. Eigene Hosts kosten echtes Geld, und die Rechnung von
|
||||||
|
Proxmox will bezahlt werden – nur die Weiterberechnung entfällt.
|
||||||
|
|
||||||
|
Die Alternative wäre gewesen, eigene Hosts ohne Kunden zu lassen und die Warnung „Host ohne Kunde“
|
||||||
|
abzuschalten. Dann fehlten sie aber in jeder Auswertung nach Kunde, und der Einkauf ließe sich nicht
|
||||||
|
mehr zuordnen.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# #045 Wiederherstellung proben und Skript dafür
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.27.0
|
||||||
|
**Labels:** deployment, doku
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Einer der vier Punkte bis 1.0.0: eine Sicherung **einmal wirklich zurückspielen**. Bis dahin war nur
|
||||||
|
geprüft, dass `deploy/backup.sh` Dateien schreibt.
|
||||||
|
|
||||||
|
## Ablauf der Probe
|
||||||
|
Mit einem eigenen Docker-Stapel, getrennt von allen anderen Daten:
|
||||||
|
|
||||||
|
1. Datenstand festgehalten (9 Hosts, 4 Kunden, 10 Laufzeiten), einen Kunden zur Wiedererkennung in
|
||||||
|
`Marke-192641` umbenannt, eine PDF-Datei in den Upload-Ordner gelegt
|
||||||
|
2. Sicherung angelegt
|
||||||
|
3. **Alle Daten zerstört** – `docker compose down -v`, also Datenbank- und Upload-Volume weg
|
||||||
|
4. Zurückgespielt und geprüft
|
||||||
|
|
||||||
|
**Ergebnis:** Datenbank vollständig zurück (die Marke war wieder da), die PDF-Datei lag mit den
|
||||||
|
richtigen Rechten im Upload-Ordner, Anmeldung mit dem alten Passwort funktionierte.
|
||||||
|
|
||||||
|
## Zwei Lücken, die erst die Probe gezeigt hat
|
||||||
|
1. **Die Sicherung ließ sich nur in eine leere Datenbank zurückspielen.** `pg_dump` ohne `--clean`
|
||||||
|
scheitert an bestehenden Tabellen – genau im Ernstfall, wenn man in eine laufende Installation
|
||||||
|
zurückspielen will. Jetzt `--clean --if-exists`; gegengeprüft gegen eine Datenbank mit Daten:
|
||||||
|
null Fehlerzeilen, der zwischenzeitlich geänderte Wert war wieder auf dem Stand der Sicherung.
|
||||||
|
2. **Für die hochgeladenen PDFs gab es keinen beschriebenen Weg.** Gesichert wurden sie, zurückspielen
|
||||||
|
musste man selbst herausfinden.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
- `deploy/restore.sh`: hält die Dienste an, spielt Datenbank und PDFs zurück, startet wieder und prüft
|
||||||
|
die Erreichbarkeit. Fragt vorher nach, `--ja` überspringt das.
|
||||||
|
- Die `.env` wird bewusst **nicht** überschrieben: Sie trägt `APP_SECRET`, und ein falscher Wert dort
|
||||||
|
macht die gespeicherten Zugangsdaten unlesbar. Die passende Fassung liegt als `env-<Stand>.txt` daneben.
|
||||||
|
- `docs/betrieb/sicherung.md` beschreibt den geprobten Ablauf samt Handarbeit als Rückfallebene.
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
Geprobt wurde der Docker-Weg. Für die Installation als Dienst ist derselbe Ablauf im Skript
|
||||||
|
vorgesehen, aber noch nicht unter echten Bedingungen gelaufen.
|
||||||
@@ -26,5 +26,27 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
|
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
|
||||||
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
|
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
|
||||||
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
|
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
|
||||||
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
|
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | erledigt | v1.0.0 | 2 h | 0 h 35 min |
|
||||||
| | **Summe** | | | **121 h** | **9 h 43 min** |
|
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
|
||||||
|
| [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min |
|
||||||
|
| [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 min |
|
||||||
|
| [#027](027-rollen-benutzerverwaltung.md) | Rollen und Benutzerverwaltung | erledigt | v0.18.0 | 6 h | 1 h 20 min |
|
||||||
|
| [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min |
|
||||||
|
| [#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 |
|
||||||
|
| [#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 |
|
||||||
|
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
|
||||||
|
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
|
||||||
|
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
|
||||||
|
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
|
||||||
|
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
||||||
|
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
||||||
|
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
|
||||||
|
| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min |
|
||||||
|
| [#042](042-qdevice-cluster.md) | Cluster-Knoten falsch gelesen bei QDevice | erledigt | v0.24.1 | 1 h | 0 h 20 min |
|
||||||
|
| [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min |
|
||||||
|
| [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min |
|
||||||
|
| [#045](045-wiederherstellung-geprobt.md) | Wiederherstellung proben und Skript dafür | erledigt | v0.27.0 | 2 h | 0 h 50 min |
|
||||||
|
| | **Summe** | | | **177 h** | **25 h 28 min** |
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Stand: 23.10.2026, Fassung 1.0.0
|
||||||
|
|
||||||
|
Die erste Fassung dieser Roadmap entstand vor dem produktiven Einsatz. Seitdem läuft ProxLedger mit
|
||||||
|
echten Daten, und das hat die Reihenfolge deutlich verändert: Was aus der Benutzung kam, war fast
|
||||||
|
immer wichtiger als das, was vorher auf der Liste stand.
|
||||||
|
|
||||||
|
## Was seit dem Produktivgang dazukam
|
||||||
|
|
||||||
|
Rollen und Benutzerverwaltung, Passkeys, Einstellungen im Dashboard, Betriebsdokumentation, die
|
||||||
|
mobile Bedienung, Schlagworte und Notizen in der Übersicht, Stilllegen doppelter Hosts, Laufzeiten
|
||||||
|
anlegen/bearbeiten/zusammenführen, Rabatt je Rechnungsposition, Eigenbedarf.
|
||||||
|
|
||||||
|
Ebenso die Fehler, die erst der Einsatz gezeigt hat: QDevice-Spalte im Cluster, PBS-Rahmentabelle,
|
||||||
|
Weiterleitungen auf die Lausch-Adresse, Standort beim Zuweisen gelöscht, Einkauf ohne Rabatt an drei
|
||||||
|
Stellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.0.0 ist erreicht (23.10.2026)
|
||||||
|
|
||||||
|
Alle Kriterien erfüllt:
|
||||||
|
|
||||||
|
| Punkt | Stand |
|
||||||
|
|---|---|
|
||||||
|
| Ein Lizenzjahr vollständig durchlaufen | **erledigt** – `hv-pve-01` und `hv-pve-02` am 17.10.2026, beide Rechnungen bezahlt |
|
||||||
|
| PBS mit aktiver Lizenz | **erledigt** – drei PBS-Hosts melden Key und Ablauf |
|
||||||
|
| Sicherung zurückspielen | **erledigt** ([#045](issues/045-wiederherstellung-geprobt.md)) |
|
||||||
|
| PMG im Feldtest | **zurückgestellt** – wird derzeit nicht betrieben ([#023](issues/023-feldtest-pbs-pmg.md)) |
|
||||||
|
|
||||||
|
Zwei kleinere Punkte bleiben offen, keiner davon hält 1.0.0 auf:
|
||||||
|
|
||||||
|
- Die Wiederherstellungsprobe für die **Installation als Dienst** (geprobt ist der Docker-Weg).
|
||||||
|
- Ein **wöchentlicher `npm audit`-Lauf in der CI**. Zwei Wartungsblicke hintereinander haben hohe bis
|
||||||
|
kritische Meldungen gefunden – zuletzt zwei kritische Lücken in Next.js. Bei einem öffentlich
|
||||||
|
erreichbaren Dashboard gehört das automatisiert.
|
||||||
|
|
||||||
|
---|---|---|
|
||||||
|
| **PMG im Feldtest** ([#023](issues/023-feldtest-pbs-pmg.md)) | Der einzige Produktzweig ohne echten Host. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet. | 1 h, sobald ein Host da ist |
|
||||||
|
| **PBS mit aktiver Lizenz** | Bisher nur „keine Subscription“ geprüft. | 30 min |
|
||||||
|
| ~~Sicherung einmal zurückspielen~~ | **Erledigt am 10.10.2026** ([#045](issues/045-wiederherstellung-geprobt.md)) – dabei kamen zwei Lücken heraus. Offen bleibt die Probe für die Installation als Dienst. | – |
|
||||||
|
| **Ein Lizenzjahr durchlaufen lassen** | Warnung → Verlängerung → Einkauf → Kundenrechnung → bezahlt, einmal vollständig in echt. Die nächsten Abläufe stehen am 17.10.2026 an. | nur Zeit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Danach – nach Nutzen sortiert
|
||||||
|
|
||||||
|
### 1. Proxmox-Rechnung einlesen statt abtippen
|
||||||
|
|
||||||
|
Die Rechnungen von Proxmox haben eine feste Form: je Key eine Zeile mit Zeitraum und Betrag, dazu die
|
||||||
|
Rabattzeilen. Ein Import, der das PDF liest und die Positionen den Laufzeiten über den Key zuordnet,
|
||||||
|
spart bei jeder Sammelrechnung das meiste Tippen – und vermeidet Zahlendreher.
|
||||||
|
|
||||||
|
**Aufwand:** mittel (PDF-Text auslesen, Zeilen erkennen, Zuordnung bestätigen lassen).
|
||||||
|
**Voraussetzung:** zwei, drei echte Rechnungen als Vorlage.
|
||||||
|
|
||||||
|
### 2. Verlängerung mit einem Klick
|
||||||
|
|
||||||
|
Heute wird eine Laufzeit bestätigt und die Folgelaufzeit von Hand angelegt. Ein Knopf „verlängern“,
|
||||||
|
der die nächste Laufzeit anlegt, den Einkauf vormerkt und die Kundenrechnung mit dem eingestellten
|
||||||
|
Aufschlag vorbereitet, nimmt bei jedem Lizenzjahr vier Schritte ab.
|
||||||
|
|
||||||
|
**Aufwand:** klein bis mittel.
|
||||||
|
|
||||||
|
### 3. Kundenrechnung als Dokument
|
||||||
|
|
||||||
|
ProxLedger führt die Kundenrechnung bisher nur als Datensatz – geschrieben wird sie woanders. Mit
|
||||||
|
Nummernkreis, Firmenangaben (stehen schon in den Einstellungen) und PDF-Ausgabe wäre der Kreis
|
||||||
|
geschlossen. Zu klären wäre, ob das die vorhandene Buchhaltung ersetzen soll oder nur ergänzt.
|
||||||
|
|
||||||
|
**Aufwand:** mittel. **Rückfrage zuerst:** Soll ProxLedger Rechnungen *schreiben* oder nur *führen*?
|
||||||
|
|
||||||
|
### 4. Oberflächentests mit echtem Browser
|
||||||
|
|
||||||
|
Die letzten sechs Fehler hat keiner der 251 Tests gefunden – alle kamen aus der Benutzung: Menü ließ
|
||||||
|
sich nicht schließen, Seite zoomte heraus, kein Weg zur Laufzeit, falscher Betrag in einer Tabelle.
|
||||||
|
Ein knappes Dutzend Abläufe mit Playwright (anmelden, Host zuweisen, Rechnung erfassen, auf dem
|
||||||
|
Telefon navigieren) würde genau diese Klasse abdecken.
|
||||||
|
|
||||||
|
**Aufwand:** mittel. **Nutzen:** steigt mit jeder weiteren Änderung.
|
||||||
|
|
||||||
|
### 5. Verlauf statt einer Notiz
|
||||||
|
|
||||||
|
Die Notiz am Host ist ein Textfeld – was wann passiert ist, verschwindet darin. Datierte Einträge
|
||||||
|
(„17.10.2026 · Sockets von 1 auf 2 erhöht“) wären bei Wechseln und Hardwaretausch nützlich.
|
||||||
|
|
||||||
|
**Aufwand:** klein.
|
||||||
|
|
||||||
|
### 6. Ablauf-Kalender abonnieren
|
||||||
|
|
||||||
|
Ein ICS-Feed mit allen Ablaufdaten, den man in Outlook oder Nextcloud einbindet. Dann stehen die
|
||||||
|
Verlängerungen im gewohnten Kalender, nicht nur im Dashboard.
|
||||||
|
|
||||||
|
**Aufwand:** klein.
|
||||||
|
|
||||||
|
### 7. Kundenzugang wirklich nutzen
|
||||||
|
|
||||||
|
Die Rolle *Kunde* existiert samt eingeschränkter Sicht, aber es gibt keinen Weg, jemanden einzuladen:
|
||||||
|
kein Einladungslink, keine erste Anmeldung ohne Passwort. Ohne das bleibt die Rolle Theorie.
|
||||||
|
|
||||||
|
**Aufwand:** klein bis mittel.
|
||||||
|
|
||||||
|
### 8. Metriken für Prometheus
|
||||||
|
|
||||||
|
`/metrics` mit Hosts je Zustand, Tagen bis zum nächsten Ablauf und Alter des letzten Reports. Wer
|
||||||
|
Grafana betreibt, sieht ProxLedger damit im gewohnten Bild – und merkt auch, wenn das Dashboard
|
||||||
|
selbst steht.
|
||||||
|
|
||||||
|
**Aufwand:** klein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Später, falls der Bedarf entsteht
|
||||||
|
|
||||||
|
- **Mandantenfähigkeit** – mehrere Systemhäuser in einer Installation. Ein Umbau, kein Anbau.
|
||||||
|
- **Agent für Proxmox Datacenter Manager**, sobald es dort Subscriptions gibt, die das rechtfertigen.
|
||||||
|
- **Mehrere Instanzen** (Hochverfügbarkeit). Die Ratenbremse liegt im Prozessspeicher und müsste in
|
||||||
|
die Datenbank wandern. Für ein Dashboard eines Systemhauses Aufwand ohne Gewinn.
|
||||||
|
- **Angebot vor der Verlängerung** als PDF aus der Jahresbahn heraus.
|
||||||
|
|
||||||
|
## Bewusst nicht
|
||||||
|
|
||||||
|
- **Pull statt Push.** Das Dashboard bräuchte Zugang zu allen Kundenhosts. Der jetzige Weg kommt ohne
|
||||||
|
gespeicherte root-Zugänge aus – ein Sicherheitsgewinn, den man nicht aufgeben sollte.
|
||||||
|
- **Eigene Rechteverwaltung mit Gruppen und Vererbung.** Wer das braucht, hat ein IdP; dafür gibt es OIDC.
|
||||||
|
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt mit Bash und curl aus und läuft damit auf jedem
|
||||||
|
Proxmox-Host.
|
||||||
|
- **Hosts automatisch zusammenführen.** Beim Doppeleintrag müsste geraten werden, welcher Eintrag
|
||||||
|
welche Rechnung erbt. Die Warnung nennt beide; entscheiden soll der Mensch.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Sicherheit
|
||||||
|
|
||||||
|
Dieses Dokument hält fest, was ProxLedger gegen die üblichen Angriffe tut – und was es **nicht** tut.
|
||||||
|
Die Gliederung folgt den OWASP Top 10 (2021).
|
||||||
|
|
||||||
|
## A01 – Fehlerhafte Zugriffskontrolle
|
||||||
|
|
||||||
|
- `src/proxy.ts` schickt jede Anfrage ohne gültige Sitzung zur Anmeldung. Öffentlich sind nur die
|
||||||
|
Anmeldung, die Agent-API (eigene Tokens), die Gesundheitsprüfung, die beiden Agent-Skripte und die Symbole.
|
||||||
|
- **Jede Server Action prüft zusätzlich selbst** (`sitzungErforderlich()`). Server Actions sind eigene
|
||||||
|
Endpunkte; eine einzige Schutzschicht davor wäre zu wenig, falls der Musterausdruck im Proxy je
|
||||||
|
danebengreift.
|
||||||
|
- Die Prüfung geht in die Datenbank: Ein gesperrtes Konto oder ein Widerruf beendet laufende Sitzungen
|
||||||
|
sofort (`npm run benutzer -- sperren <email>`), statt bis zum Ablauf des Tokens zu warten.
|
||||||
|
- Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes
|
||||||
|
Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst.
|
||||||
|
|
||||||
|
**Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt;
|
||||||
|
die Rechte stehen an einer Stelle (`src/server/auth/rollen.ts`). Kundenkonten sind an einen Kunden
|
||||||
|
gebunden, und die Einschränkung sitzt in den Abfragen – nicht in der Anzeige. Einkaufspreise und Marge
|
||||||
|
werden für sie schon beim Abfragen entfernt, stehen also nicht im ausgelieferten HTML.
|
||||||
|
|
||||||
|
**Weiterhin nicht vorhanden:** Mandantenfähigkeit im eigentlichen Sinn. Mehrere Systemhäuser in einer
|
||||||
|
Installation wären ein Umbau, kein Anbau.
|
||||||
|
|
||||||
|
## A02 – Kryptografische Fehler
|
||||||
|
|
||||||
|
- Passwörter: argon2id (`@node-rs/argon2`), keine eigene Krypto.
|
||||||
|
- Sitzungen: signiertes JWT (HS256) aus `APP_SECRET`, 12 Stunden gültig, Cookie `httpOnly`, `sameSite=lax`
|
||||||
|
und `secure`, sobald `APP_URL` auf `https://` steht.
|
||||||
|
- Zugangsdaten für SMTP und Webhooks liegen AES-256-GCM-verschlüsselt in der Datenbank
|
||||||
|
(`src/lib/geheimnisse.ts`); der Schlüssel wird aus `APP_SECRET` abgeleitet.
|
||||||
|
- Agent- und Enrollment-Tokens werden nur als SHA-256-Hash gespeichert. Das ist hier richtig und nicht
|
||||||
|
zu schwach: Die Tokens sind 32 Byte Zufall, es gibt nichts zu raten.
|
||||||
|
|
||||||
|
**Wichtig:** `APP_SECRET` ist der Generalschlüssel. Geht er verloren, sind alle Sitzungen ungültig und die
|
||||||
|
gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
|
||||||
|
|
||||||
|
## A03 – Injektion
|
||||||
|
|
||||||
|
- Datenbankzugriffe ausschließlich über Prisma mit gebundenen Werten; kein `$queryRawUnsafe` im Projekt.
|
||||||
|
- Ausgaben maskiert React selbst.
|
||||||
|
- Zusätzlich greift eine Content-Security-Policy mit frischem Nonce je Anfrage und `strict-dynamic`
|
||||||
|
(`src/proxy.ts`). Eingeschleustes `<script>` – etwa über einen manipulierten Hostnamen aus einer
|
||||||
|
Proxmox-Ausgabe – läuft damit nicht.
|
||||||
|
- Der Agent baut sein JSON mit einer eigenen Maskierung; Sonderzeichen aus Proxmox-Ausgaben sind als
|
||||||
|
Testfall hinterlegt.
|
||||||
|
|
||||||
|
## A04 – Unsicheres Design
|
||||||
|
|
||||||
|
- Push-Modell: Das Dashboard braucht **keinen** Zugang zu den Proxmox-Hosts. Es gibt keine gespeicherten
|
||||||
|
root-Zugänge, die abhandenkommen könnten – die Hosts melden von sich aus.
|
||||||
|
- Der Agent sendet nur Statusangaben (Subscription, Version, Sockel, IPs). Keine Gastliste, keine Daten.
|
||||||
|
- Enrollment-Tokens sind befristet und in der Zahl der Nutzungen begrenzt.
|
||||||
|
|
||||||
|
## A05 – Sicherheitsrelevante Fehlkonfiguration
|
||||||
|
|
||||||
|
- Kopfzeilen: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`,
|
||||||
|
`Strict-Transport-Security` (`next.config.ts`) sowie die CSP aus dem Proxy.
|
||||||
|
- Die Anwendung startet nicht mit zu kurzem `APP_SECRET`.
|
||||||
|
- Im Container läuft die Anwendung als unprivilegierter Benutzer; Anhänge werden mit Rechten 640 abgelegt.
|
||||||
|
- Fehlermeldungen nennen keine Einzelheiten: Bei der Anmeldung steht dieselbe Meldung für „Adresse
|
||||||
|
unbekannt“ und „Passwort falsch“.
|
||||||
|
|
||||||
|
## A06 – Verwundbare Komponenten
|
||||||
|
|
||||||
|
- `npm audit --omit=dev` meldet **keine** bekannten Schwachstellen (Stand 18.09.2026).
|
||||||
|
- Zwei Fassungen werden über `overrides` angehoben: `mysql2` und `deepmerge-ts` kommen als Beiwerk von
|
||||||
|
Prisma mit, obwohl wir Postgres nutzen. Ohne die Anhebung bliebe nur ein Rückschritt auf Prisma 6.
|
||||||
|
- Die CI prüft bei jedem Push (#019).
|
||||||
|
|
||||||
|
## A07 – Fehler bei Identifizierung und Authentifizierung
|
||||||
|
|
||||||
|
- Ratenbremse: fünf Anmeldeversuche je Kennung und Viertelstunde.
|
||||||
|
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
|
||||||
|
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
|
||||||
|
|
||||||
|
- **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden,
|
||||||
|
mehrere je Konto. Setzt HTTPS voraus (außer auf `localhost`).
|
||||||
|
- Automatisch angelegte OIDC-Konten bekommen die eingestellte Rolle – Vorgabe *Techniker*, nicht
|
||||||
|
Administrator.
|
||||||
|
|
||||||
|
**Nicht vorhanden:** klassische Zwei-Faktor-Anmeldung mit Einmalkennwort. Ein Passkey ersetzt beides
|
||||||
|
in einem Schritt; wer einen zweiten Faktor zum Passwort erzwingen will, stellt OIDC davor.
|
||||||
|
|
||||||
|
## A08 – Datenintegrität
|
||||||
|
|
||||||
|
- Der Agent prüft vor dem Selbstaktualisieren, ob die geladene Datei wie ein Bash-Skript beginnt.
|
||||||
|
- Webhooks werden mit HMAC-SHA256 signiert, damit die Gegenstelle die Herkunft prüfen kann.
|
||||||
|
- Hochgeladene Rechnungen werden nicht dem gemeldeten Typ nach beurteilt, sondern am Dateianfang
|
||||||
|
geprüft (`%PDF-`), auf 10 MB begrenzt und unter ihrer Prüfsumme abgelegt.
|
||||||
|
|
||||||
|
## A09 – Protokollierung
|
||||||
|
|
||||||
|
- Audit-Log für Anmeldungen, Änderungen an Kunden, Rechnungen, Tokens, Kanälen und Warnregeln –
|
||||||
|
einsehbar unter *Einstellungen → Protokoll*.
|
||||||
|
- Der Agent protokolliert auf dem Host über `logger` ins Journal.
|
||||||
|
|
||||||
|
## A10 – Server-Side Request Forgery
|
||||||
|
|
||||||
|
- Die einzige ausgehende Verbindung, die sich einstellen lässt, ist der Benachrichtigungs-Webhook. Er
|
||||||
|
wird bewusst von einem Angemeldeten eingetragen und ist damit kein fremdgesteuerter Aufruf.
|
||||||
|
- Sonst ruft das Dashboard keine Adressen auf, die aus Daten stammen.
|
||||||
|
|
||||||
|
## Was ein Betreiber selbst tun muss
|
||||||
|
|
||||||
|
1. HTTPS davorschalten (Beispiele unter `deploy/reverse-proxy/`) und `APP_URL` auf `https://` setzen.
|
||||||
|
2. `APP_SECRET` und die Datenbank sichern (`deploy/backup.sh`).
|
||||||
|
3. Das Dashboard nicht ins offene Internet stellen, wenn es nicht sein muss. Die Agent-API muss
|
||||||
|
erreichbar sein, die Oberfläche nicht.
|
||||||
|
4. Enrollment-Tokens nach dem Ausrollen widerrufen.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.14.1",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -26,19 +26,22 @@
|
|||||||
"lasttest": "tsx scripts/lasttest.ts",
|
"lasttest": "tsx scripts/lasttest.ts",
|
||||||
"screenshots": "node scripts/screenshots.mjs",
|
"screenshots": "node scripts/screenshots.mjs",
|
||||||
"icons": "node scripts/icons.mjs",
|
"icons": "node scripts/icons.mjs",
|
||||||
"pruefe:doku": "node scripts/pruefe-doku.mjs"
|
"pruefe:doku": "node scripts/pruefe-doku.mjs",
|
||||||
|
"benutzer": "tsx scripts/benutzer.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
|
"@simplewebauthn/browser": "^14.0.0",
|
||||||
|
"@simplewebauthn/server": "^14.0.3",
|
||||||
"admin-lte": "^4.9.1",
|
"admin-lte": "^4.9.1",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"next": "16.3.5",
|
"next": "^16.4.2",
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
@@ -53,11 +56,11 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.5",
|
"eslint-config-next": "^16.4.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
@@ -65,5 +68,10 @@
|
|||||||
"description": "Proxmox Subscription Dashboard",
|
"description": "Proxmox Subscription Dashboard",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"mysql2": "^3.24.4",
|
||||||
|
"deepmerge-ts": "^8.0.0",
|
||||||
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenUngueltigAb" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'TECHNIKER', 'KUNDE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "kundeId" TEXT,
|
||||||
|
ADD COLUMN "rolle" "Rolle" NOT NULL DEFAULT 'ADMIN';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Passkey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"credentialId" TEXT NOT NULL,
|
||||||
|
"publicKey" BYTEA NOT NULL,
|
||||||
|
"zaehler" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"bezeichnung" TEXT NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Passkey_benutzerId_idx" ON "Passkey"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Benutzer_kundeId_idx" ON "Benutzer"("kundeId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Benutzer" ADD CONSTRAINT "Benutzer_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Rabatt gehört an die Position, nicht an die Rechnung (#037).
|
||||||
|
--
|
||||||
|
-- Proxmox weist den Rabatt je Subscription-Key als eigene Zeile aus. Bei einer Sammelrechnung
|
||||||
|
-- über mehrere Hosts ließe sich ein Rabatt an der Rechnung keinem Host zuordnen – und die Marge
|
||||||
|
-- je Host wäre falsch.
|
||||||
|
|
||||||
|
ALTER TABLE "ProxmoxRechnungPosition" ADD COLUMN "rabatt" DECIMAL(12,2);
|
||||||
|
|
||||||
|
-- Vorhandene Rabatte von der Rechnung auf ihre Positionen verteilen, anteilig zum Nettobetrag.
|
||||||
|
-- Bei nur einer Position entspricht das genau dem bisherigen Wert.
|
||||||
|
UPDATE "ProxmoxRechnungPosition" AS p
|
||||||
|
SET "rabatt" = ROUND(r."rabatt" * p."nettoBetrag" / NULLIF(s."summe", 0), 2)
|
||||||
|
FROM "ProxmoxRechnung" AS r,
|
||||||
|
(SELECT "rechnungId", SUM("nettoBetrag") AS "summe"
|
||||||
|
FROM "ProxmoxRechnungPosition"
|
||||||
|
GROUP BY "rechnungId") AS s
|
||||||
|
WHERE p."rechnungId" = r."id"
|
||||||
|
AND s."rechnungId" = r."id"
|
||||||
|
AND r."rabatt" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Der Rechnungsbetrag ist ab jetzt die Summe der Positionen nach Abzug ihrer Rabatte.
|
||||||
|
UPDATE "ProxmoxRechnung" AS r
|
||||||
|
SET "nettoBetrag" = COALESCE((
|
||||||
|
SELECT SUM(p."nettoBetrag" - COALESCE(p."rabatt", 0))
|
||||||
|
FROM "ProxmoxRechnungPosition" AS p
|
||||||
|
WHERE p."rechnungId" = r."id"
|
||||||
|
), r."nettoBetrag");
|
||||||
|
|
||||||
|
ALTER TABLE "ProxmoxRechnung" DROP COLUMN "rabatt";
|
||||||
|
|
||||||
|
-- Einkauf bei Proxmox läuft über Reverse Charge; 19 % war als Vorgabe irreführend.
|
||||||
|
ALTER TABLE "ProxmoxRechnung" ALTER COLUMN "ustSatz" SET DEFAULT 0.00;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Hosts stilllegen, statt sie zu löschen (#041).
|
||||||
|
-- Ein stillgelegter Host verschwindet aus Listen, Zahlen und Warnungen; seine Laufzeiten und
|
||||||
|
-- Rechnungen bleiben erhalten, weil sie zur Buchhaltung gehören.
|
||||||
|
ALTER TABLE "Host" ADD COLUMN "archiviertAm" TIMESTAMP(3);
|
||||||
|
CREATE INDEX "Host_archiviertAm_idx" ON "Host"("archiviertAm");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Eigenbedarf: Kunden, die man selbst ist (#044).
|
||||||
|
-- Für sie gibt es keine Kundenrechnung – und damit auch keine Warnungen darüber und keine Marge.
|
||||||
|
ALTER TABLE "Kunde" ADD COLUMN "eigenbedarf" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -61,6 +61,9 @@ model Kunde {
|
|||||||
ansprechpartner String?
|
ansprechpartner String?
|
||||||
email String?
|
email String?
|
||||||
telefon String?
|
telefon String?
|
||||||
|
/// Eigenbedarf: eigene Hosts, die nicht weiterberechnet werden. Für sie entfallen
|
||||||
|
/// Kundenrechnung, die zugehörigen Warnungen und die Marge (#044).
|
||||||
|
eigenbedarf Boolean @default(false)
|
||||||
notizen String?
|
notizen String?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
@@ -72,6 +75,7 @@ model Kunde {
|
|||||||
alerts Alert[]
|
alerts Alert[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
benutzer Benutzer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Standort {
|
model Standort {
|
||||||
@@ -133,6 +137,10 @@ model Host {
|
|||||||
subNextduedate DateTime? @db.Date
|
subNextduedate DateTime? @db.Date
|
||||||
subChecktime DateTime?
|
subChecktime DateTime?
|
||||||
|
|
||||||
|
/// Stillgelegt: Der Host erscheint nicht mehr in Listen, Zahlen und Warnungen – seine
|
||||||
|
/// Geschichte samt Laufzeiten und Rechnungen bleibt aber erhalten (#041).
|
||||||
|
archiviertAm DateTime?
|
||||||
|
|
||||||
erstmalsGesehenAm DateTime @default(now())
|
erstmalsGesehenAm DateTime @default(now())
|
||||||
letzterReportAm DateTime?
|
letzterReportAm DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
@@ -260,9 +268,10 @@ model ProxmoxRechnung {
|
|||||||
nummer String @unique
|
nummer String @unique
|
||||||
datum DateTime @db.Date
|
datum DateTime @db.Date
|
||||||
faelligAm DateTime? @db.Date
|
faelligAm DateTime? @db.Date
|
||||||
|
/// Summe der Positionen nach Abzug ihrer Rabatte – wird beim Speichern nachgeführt.
|
||||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||||
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
|
/// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
|
||||||
rabatt Decimal? @db.Decimal(12, 2)
|
ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
|
||||||
zahlungsart String?
|
zahlungsart String?
|
||||||
bezahlt Boolean @default(false)
|
bezahlt Boolean @default(false)
|
||||||
bezahltAm DateTime? @db.Date
|
bezahltAm DateTime? @db.Date
|
||||||
@@ -283,7 +292,10 @@ model ProxmoxRechnungPosition {
|
|||||||
rechnungId String
|
rechnungId String
|
||||||
bezeichnung String
|
bezeichnung String
|
||||||
menge Int @default(1)
|
menge Int @default(1)
|
||||||
|
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
|
||||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||||
|
/// Rabatt dieser Position. Proxmox weist ihn je Key als eigene Zeile aus (#037).
|
||||||
|
rabatt Decimal? @db.Decimal(12, 2)
|
||||||
|
|
||||||
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
||||||
laufzeit Laufzeit?
|
laufzeit Laufzeit?
|
||||||
@@ -448,18 +460,53 @@ model Benachrichtigungskanal {
|
|||||||
// ---------------------------------------------------------------- Benutzer und Protokoll
|
// ---------------------------------------------------------------- Benutzer und Protokoll
|
||||||
|
|
||||||
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
|
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
|
||||||
|
/// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027).
|
||||||
|
enum Rolle {
|
||||||
|
ADMIN
|
||||||
|
TECHNIKER
|
||||||
|
KUNDE
|
||||||
|
}
|
||||||
|
|
||||||
model Benutzer {
|
model Benutzer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String
|
name String
|
||||||
passwortHash String?
|
passwortHash String?
|
||||||
oidcSubject String? @unique
|
oidcSubject String? @unique
|
||||||
|
/// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN.
|
||||||
|
rolle Rolle @default(ADMIN)
|
||||||
|
/// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist.
|
||||||
|
kundeId String?
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
letzterLoginAm DateTime?
|
letzterLoginAm DateTime?
|
||||||
|
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
|
||||||
|
sitzungenUngueltigAb DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
|
||||||
auditEintraege AuditEintrag[]
|
auditEintraege AuditEintrag[]
|
||||||
|
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
|
||||||
|
passkeys Passkey[]
|
||||||
|
|
||||||
|
@@index([kundeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028).
|
||||||
|
model Passkey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
/// Kennung des Schlüssels, base64url wie vom Browser geliefert.
|
||||||
|
credentialId String @unique
|
||||||
|
publicKey Bytes
|
||||||
|
zaehler BigInt @default(0)
|
||||||
|
transports String[] @default([])
|
||||||
|
bezeichnung String
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztAm DateTime?
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([benutzerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditEintrag {
|
model AuditEintrag {
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ async function seedDemo() {
|
|||||||
faelligAm: datum(vorlage.faelligAm),
|
faelligAm: datum(vorlage.faelligAm),
|
||||||
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
||||||
ustSatz: vorlage.ustSatz,
|
ustSatz: vorlage.ustSatz,
|
||||||
rabatt: vorlage.rabatt ?? null,
|
|
||||||
zahlungsart: vorlage.zahlungsart ?? null,
|
zahlungsart: vorlage.zahlungsart ?? null,
|
||||||
bezahlt: vorlage.bezahlt,
|
bezahlt: vorlage.bezahlt,
|
||||||
bezahltAm: datum(vorlage.bezahltAm),
|
bezahltAm: datum(vorlage.bezahltAm),
|
||||||
@@ -175,6 +174,8 @@ async function seedDemo() {
|
|||||||
id: `prp-${l.id}`,
|
id: `prp-${l.id}`,
|
||||||
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
||||||
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
||||||
|
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
|
||||||
|
rabatt: vorlage.rabatt ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { hash } from "@node-rs/argon2";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung von der Kommandozeile – solange es dafür keine Oberfläche gibt.
|
||||||
|
*
|
||||||
|
* npm run benutzer -- liste
|
||||||
|
* npm run benutzer -- sperren <email> Konto sperren und alle Sitzungen sofort beenden
|
||||||
|
* npm run benutzer -- entsperren <email>
|
||||||
|
* npm run benutzer -- passwort <email> neues Passwort setzen, beendet alle Sitzungen
|
||||||
|
*/
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function passwortAbfragen(): Promise<string> {
|
||||||
|
const eingabe = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const passwort = await eingabe.question("Neues Passwort (mindestens 12 Zeichen): ");
|
||||||
|
eingabe.close();
|
||||||
|
if (passwort.length < 12) throw new Error("Zu kurz – mindestens 12 Zeichen.");
|
||||||
|
return passwort;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [befehl, email] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (befehl === "liste") {
|
||||||
|
const alle = await prisma.benutzer.findMany({ orderBy: { email: "asc" } });
|
||||||
|
for (const b of alle) {
|
||||||
|
console.log(`${b.aktiv ? "aktiv " : "gesperrt"} ${b.email.padEnd(32)} ${b.name}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) throw new Error("Bitte eine E-Mail-Adresse angeben.");
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
|
||||||
|
if (!benutzer) throw new Error(`Kein Benutzer mit der Adresse ${email}.`);
|
||||||
|
|
||||||
|
switch (befehl) {
|
||||||
|
case "sperren":
|
||||||
|
// Sperren allein genügt nicht: Das Sitzungs-Token gilt sonst bis zum Ablauf weiter.
|
||||||
|
await prisma.benutzer.update({
|
||||||
|
where: { id: benutzer.id },
|
||||||
|
data: { aktiv: false, sitzungenUngueltigAb: new Date() },
|
||||||
|
});
|
||||||
|
console.log(`${benutzer.email} ist gesperrt, alle Sitzungen sind beendet.`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "entsperren":
|
||||||
|
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: true } });
|
||||||
|
console.log(`${benutzer.email} ist wieder freigegeben.`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "passwort": {
|
||||||
|
const passwort = await passwortAbfragen();
|
||||||
|
await prisma.benutzer.update({
|
||||||
|
where: { id: benutzer.id },
|
||||||
|
data: {
|
||||||
|
passwortHash: await hash(passwort),
|
||||||
|
sitzungenUngueltigAb: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Passwort gesetzt; bestehende Sitzungen von ${benutzer.email} sind beendet.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error("Bekannte Befehle: liste, sperren, entsperren, passwort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((fehler: unknown) => {
|
||||||
|
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -18,12 +18,21 @@ import { homedir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { request as httpAnfrage } from "node:http";
|
import { request as httpAnfrage } from "node:http";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
|
import pg from "pg";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||||
const ALLE = process.argv.includes("--alle");
|
const ALLE = process.argv.includes("--alle");
|
||||||
|
|
||||||
|
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
|
||||||
|
const MOBIL_ZUSATZ = process.argv
|
||||||
|
.filter((wert, i) => process.argv[i - 1] === "--mobil")
|
||||||
|
.map((eintrag) => {
|
||||||
|
const [name, ...rest] = eintrag.split("=");
|
||||||
|
return { name, pfad: rest.join("="), titel: "Handy" };
|
||||||
|
});
|
||||||
|
|
||||||
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
||||||
const ZUSATZ = process.argv
|
const ZUSATZ = process.argv
|
||||||
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||||
@@ -48,6 +57,9 @@ const SEITEN = [
|
|||||||
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
||||||
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||||
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||||
|
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
|
||||||
|
{ name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
|
||||||
|
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function sitzungsToken() {
|
async function sitzungsToken() {
|
||||||
@@ -56,14 +68,29 @@ async function sitzungsToken() {
|
|||||||
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
|
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes
|
||||||
|
// Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen.
|
||||||
|
// Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript.
|
||||||
|
const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await datenbank.connect();
|
||||||
|
const { rows } = await datenbank.query(
|
||||||
|
`SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`,
|
||||||
|
);
|
||||||
|
await datenbank.end();
|
||||||
|
const benutzer = rows[0];
|
||||||
|
|
||||||
|
if (!benutzer) {
|
||||||
|
throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen.");
|
||||||
|
}
|
||||||
|
|
||||||
return new SignJWT({
|
return new SignJWT({
|
||||||
benutzerId: "screenshots",
|
benutzerId: benutzer.id,
|
||||||
email: process.env.ADMIN_EMAIL ?? "admin@example.de",
|
email: benutzer.email,
|
||||||
name: process.env.ADMIN_NAME ?? "Florian",
|
name: benutzer.name,
|
||||||
herkunft: "passwort",
|
herkunft: "passwort",
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setSubject("screenshots")
|
.setSubject(benutzer.id)
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setIssuer("proxledger")
|
.setIssuer("proxledger")
|
||||||
.setExpirationTime("1h")
|
.setExpirationTime("1h")
|
||||||
@@ -168,6 +195,11 @@ async function main() {
|
|||||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const seite of MOBIL_ZUSATZ) {
|
||||||
|
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
|
||||||
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (ALLE) {
|
if (ALLE) {
|
||||||
const dunkel = profilVorbereiten("dunkel", true);
|
const dunkel = profilVorbereiten("dunkel", true);
|
||||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||||
@@ -176,7 +208,8 @@ async function main() {
|
|||||||
console.log(` ${seite}-dunkel.png`);
|
console.log(` ${seite}-dunkel.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const seite of ["dashboard", "hosts"]) {
|
// Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
|
||||||
|
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
|
||||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
||||||
console.log(` mobil-${seite}.png`);
|
console.log(` mobil-${seite}.png`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
|
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
|
||||||
|
import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
|
|
||||||
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
|||||||
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
|
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
|
||||||
Mit {oidc.bezeichnung} anmelden
|
Mit {oidc.bezeichnung} anmelden
|
||||||
</Link>
|
</Link>
|
||||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
<div className="text-center text-body-secondary small mb-3">oder</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<PasskeyAnmeldung ziel={ziel} />
|
||||||
|
|
||||||
|
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||||
|
|
||||||
<AnmeldeFormular ziel={ziel} />
|
<AnmeldeFormular ziel={ziel} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BenutzerVerwaltung } from "@/components/einstellungen/BenutzerVerwaltung";
|
||||||
|
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||||
|
import { Callout } from "@/components/ui/Callout";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata = { title: "Benutzer" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function BenutzerSeite() {
|
||||||
|
const ich = await sitzungErforderlich("verwaltung");
|
||||||
|
|
||||||
|
const [benutzer, kunden] = await Promise.all([
|
||||||
|
prisma.benutzer.findMany({
|
||||||
|
orderBy: [{ rolle: "asc" }, { name: "asc" }],
|
||||||
|
include: { kunde: { select: { name: true } }, _count: { select: { passkeys: true } } },
|
||||||
|
}),
|
||||||
|
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const zeilen = benutzer.map((eintrag) => ({
|
||||||
|
id: eintrag.id,
|
||||||
|
email: eintrag.email,
|
||||||
|
name: eintrag.name,
|
||||||
|
rolle: eintrag.rolle,
|
||||||
|
kundeId: eintrag.kundeId,
|
||||||
|
kundeName: eintrag.kunde?.name ?? null,
|
||||||
|
aktiv: eintrag.aktiv,
|
||||||
|
hatPasswort: eintrag.passwortHash !== null,
|
||||||
|
hatOidc: eintrag.oidcSubject !== null,
|
||||||
|
passkeys: eintrag._count.passkeys,
|
||||||
|
letzterLoginAm: eintrag.letzterLoginAm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContentHeader
|
||||||
|
titel="Benutzer"
|
||||||
|
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benutzer" }]}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<Callout farbe="info" titel="Rollen">
|
||||||
|
<strong>Administrator</strong> darf alles, auch Benutzer und Systemeinstellungen.{" "}
|
||||||
|
<strong>Techniker</strong> darf alles Fachliche, aber keine Benutzer anlegen.{" "}
|
||||||
|
<strong>Kunde</strong> sieht nur die Hosts und Rechnungen des eigenen Kunden – ohne
|
||||||
|
Einkaufspreise – und darf Notizen schreiben sowie Warnungen quittieren.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<BenutzerVerwaltung benutzer={zeilen} kunden={kunden} ichId={ich.benutzerId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { PasskeyVerwaltung } from "@/components/auth/PasskeyVerwaltung";
|
||||||
|
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||||
|
import { oeffentlicheBasis } from "@/lib/adresse";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { passkeysMoeglich } from "@/server/auth/passkeys";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
|
export const metadata = { title: "Meine Passkeys" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function PasskeySeite() {
|
||||||
|
const ich = await sitzungErforderlich();
|
||||||
|
const { dashboardUrl } = await einstellungen();
|
||||||
|
|
||||||
|
const [passkeys, kopfzeilen] = await Promise.all([
|
||||||
|
prisma.passkey.findMany({
|
||||||
|
where: { benutzerId: ich.benutzerId },
|
||||||
|
orderBy: { erstelltAm: "desc" },
|
||||||
|
select: { id: true, bezeichnung: true, erstelltAm: true, zuletztAm: true },
|
||||||
|
}),
|
||||||
|
headers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Die Seite kennt die Anfrage nur über die Kopfzeilen; daraus entsteht dieselbe Adresse,
|
||||||
|
// die auch die Passkey-Endpunkte verwenden.
|
||||||
|
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), dashboardUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContentHeader
|
||||||
|
titel="Meine Passkeys"
|
||||||
|
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Passkeys" }]}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<PasskeyVerwaltung
|
||||||
|
passkeys={passkeys}
|
||||||
|
moeglich={passkeysMoeglich(basis.origin)}
|
||||||
|
adresse={basis.origin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
|
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
|
||||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const metadata = { title: "System" };
|
export const metadata = { title: "System" };
|
||||||
|
|
||||||
export default function SystemSeite() {
|
export default async function SystemSeite() {
|
||||||
|
const werte = await einstellungen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
|
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
|
||||||
<div className="app-content">
|
<div className="app-content">
|
||||||
<div className="container-fluid">
|
<div className="container-fluid">
|
||||||
<Card titel="Systeminformationen" rahmen="primary">
|
<SystemFormular werte={werte} appUrl={process.env.APP_URL ?? ""} />
|
||||||
|
|
||||||
|
<Card titel="Systeminformationen" rahmen="keiner">
|
||||||
<dl className="row mb-0">
|
<dl className="row mb-0">
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
|
||||||
<dd className="col-8">{APP_VERSION}</dd>
|
<dd className="col-8">{APP_VERSION}</dd>
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
|
||||||
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
|
<dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
|
||||||
<dd className="col-8">Konfigurierbar ab #011</dd>
|
<dd className="col-8">
|
||||||
|
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
|
||||||
|
die Datenbank gelesen werden kann.
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { Tabs } from "@/components/ui/Tabs";
|
|||||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
||||||
import { hostKundeSetzen } from "@/server/actions/host-detail";
|
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
|
||||||
|
import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts";
|
||||||
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
||||||
import { hostMitDetails } from "@/server/queries/hostliste";
|
import { hostMitDetails } from "@/server/queries/hostliste";
|
||||||
import { meldungsGrenze } from "@/server/queries/hosts";
|
import { meldungsGrenze } from "@/server/queries/hosts";
|
||||||
@@ -43,7 +44,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
const kunden = await prisma.kunde.findMany({
|
const kunden = await prisma.kunde.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
standorte: { orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
||||||
@@ -104,6 +109,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
||||||
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
||||||
{meldetNicht ? <KeineMeldungBadge /> : null}
|
{meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{host.archiviertAm ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="badge pl-bg-neutral">Stillgelegt</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
|
||||||
|
Hosts ist das die Stelle, an der man sie braucht (#040). */}
|
||||||
|
{host.tags.length > 0 ? (
|
||||||
|
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
|
||||||
|
{host.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="badge pl-bg-neutral text-decoration-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{host.notizen ? (
|
||||||
|
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
|
||||||
|
{host.notizen}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Zeile bezeichnung="Kunde">
|
<Zeile bezeichnung="Kunde">
|
||||||
@@ -167,30 +198,38 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
||||||
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
||||||
|
|
||||||
<form action={hostKundeSetzen} className="mt-3">
|
<KundeStandortFormular
|
||||||
|
hostId={host.id}
|
||||||
|
kunden={kunden}
|
||||||
|
kundeId={host.kundeId}
|
||||||
|
standortId={host.standortId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stilllegen statt löschen: Laufzeiten und Rechnungen gehören zur Buchhaltung (#041). */}
|
||||||
|
<div className="mt-3 pt-3 border-top d-flex flex-wrap gap-2">
|
||||||
|
<form action={hostArchivieren}>
|
||||||
<input type="hidden" name="hostId" value={host.id} />
|
<input type="hidden" name="hostId" value={host.id} />
|
||||||
<label className="form-label small" htmlFor="kundeId">
|
<button type="submit" className="btn btn-sm btn-outline-secondary">
|
||||||
Kunde ändern
|
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
|
||||||
</label>
|
|
||||||
<div className="input-group input-group-sm">
|
|
||||||
<select
|
|
||||||
id="kundeId"
|
|
||||||
name="kundeId"
|
|
||||||
className="form-select"
|
|
||||||
defaultValue={host.kundeId ?? ""}
|
|
||||||
>
|
|
||||||
<option value="">– nicht zugeordnet –</option>
|
|
||||||
{kunden.map((kunde) => (
|
|
||||||
<option key={kunde.id} value={kunde.id}>
|
|
||||||
{kunde.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="btn btn-outline-primary">
|
|
||||||
Setzen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{host.laufzeiten.length === 0 ? (
|
||||||
|
<form action={hostLoeschen}>
|
||||||
|
<input type="hidden" name="hostId" value={host.id} />
|
||||||
|
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||||
|
Endgültig löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="form-text">
|
||||||
|
{host.archiviertAm
|
||||||
|
? `Stillgelegt am ${formatDatum(host.archiviertAm)} – erscheint nicht in Listen, Zahlen und Warnungen.`
|
||||||
|
: host.laufzeiten.length === 0
|
||||||
|
? "Stilllegen blendet den Host aus; löschen geht nur, solange keine Laufzeit daran hängt."
|
||||||
|
: "An diesem Host hängen Laufzeiten – er lässt sich nur stilllegen, nicht löschen."}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,7 +278,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
inhalt: (
|
inhalt: (
|
||||||
<>
|
<>
|
||||||
<h3 className="h6">Keys</h3>
|
<h3 className="h6">Keys</h3>
|
||||||
<table className="table table-striped align-middle mb-4">
|
<div className="table-responsive mb-4">
|
||||||
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Key</th>
|
<th scope="col">Key</th>
|
||||||
@@ -266,8 +306,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 className="h6">Server-IDs</h3>
|
<h3 className="h6">Server-IDs</h3>
|
||||||
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -295,6 +337,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{host.serverIdHistorie.length > 1 ? (
|
{host.serverIdHistorie.length > 1 ? (
|
||||||
<p className="small pl-text-warning mt-2 mb-0">
|
<p className="small pl-text-warning mt-2 mb-0">
|
||||||
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
||||||
@@ -356,7 +399,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
icon: "bi-receipt",
|
icon: "bi-receipt",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0 pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Laufzeit</th>
|
<th scope="col">Laufzeit</th>
|
||||||
@@ -369,21 +412,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{host.laufzeiten.map((laufzeit) => {
|
{host.laufzeiten.map((laufzeit) => {
|
||||||
|
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||||
const einkauf = laufzeit.proxmoxPosition
|
const einkauf = laufzeit.proxmoxPosition
|
||||||
? Number(laufzeit.proxmoxPosition.nettoBetrag)
|
? Number(laufzeit.proxmoxPosition.nettoBetrag) -
|
||||||
|
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const verkauf = laufzeit.kundenPosition
|
const verkauf = laufzeit.kundenPosition
|
||||||
? Number(laufzeit.kundenPosition.nettoBetrag)
|
? Number(laufzeit.kundenPosition.nettoBetrag)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<tr key={laufzeit.id}>
|
<tr key={laufzeit.id}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
|
<Link
|
||||||
|
href={`/rechnungen/${laufzeit.id}`}
|
||||||
|
className="fw-semibold text-decoration-none"
|
||||||
|
>
|
||||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||||
|
</Link>
|
||||||
{laufzeit.status === "VORSCHLAG" ? (
|
{laufzeit.status === "VORSCHLAG" ? (
|
||||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<EinkaufBadge
|
<EinkaufBadge
|
||||||
@@ -395,8 +445,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(einkauf)}</td>
|
<td data-label="Einkauf">{formatEuro(einkauf)}</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<VerkaufBadge
|
<VerkaufBadge
|
||||||
@@ -405,8 +455,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(verkauf)}</td>
|
<td data-label="Verkauf">{formatEuro(verkauf)}</td>
|
||||||
<td>
|
<td data-label="Marge">
|
||||||
{einkauf !== null && verkauf !== null
|
{einkauf !== null && verkauf !== null
|
||||||
? formatEuro(verkauf - einkauf)
|
? formatEuro(verkauf - einkauf)
|
||||||
: "–"}
|
: "–"}
|
||||||
@@ -443,6 +493,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
titel: "Tokens",
|
titel: "Tokens",
|
||||||
icon: "bi-key-fill",
|
icon: "bi-key-fill",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -490,6 +541,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
|
|||||||
import { formatDatum } from "@/lib/format";
|
import { formatDatum } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
|
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
|
||||||
|
|
||||||
@@ -34,8 +35,12 @@ export async function GET(request: Request) {
|
|||||||
const datum = new Date().toISOString().slice(0, 10);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (new URL(request.url).searchParams.get("format") === "pdf") {
|
if (new URL(request.url).searchParams.get("format") === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Host-Liste",
|
titel: "Host-Liste",
|
||||||
untertitel: `${alle.length} Hosts`,
|
untertitel: `${alle.length} Hosts`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
const filter: HostFilter = {
|
const filter: HostFilter = {
|
||||||
q: einzeln(roh.q),
|
q: einzeln(roh.q),
|
||||||
kunde: einzeln(roh.kunde),
|
kunde: einzeln(roh.kunde),
|
||||||
|
tag: einzeln(roh.tag),
|
||||||
|
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
|
||||||
status: einzeln(roh.status),
|
status: einzeln(roh.status),
|
||||||
produkt: einzeln(roh.produkt),
|
produkt: einzeln(roh.produkt),
|
||||||
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
||||||
@@ -85,6 +87,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
const basis: Record<string, string | undefined> = {
|
const basis: Record<string, string | undefined> = {
|
||||||
q: filter.q,
|
q: filter.q,
|
||||||
kunde: filter.kunde,
|
kunde: filter.kunde,
|
||||||
|
tag: filter.tag,
|
||||||
|
archiviert: filter.archiviert,
|
||||||
status: filter.status,
|
status: filter.status,
|
||||||
produkt: filter.produkt,
|
produkt: filter.produkt,
|
||||||
ablauf: filter.ablauf,
|
ablauf: filter.ablauf,
|
||||||
@@ -117,7 +121,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
|
<HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
|
||||||
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
@@ -152,7 +156,31 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{host.archiviertAm ? (
|
||||||
|
<span className="badge pl-bg-neutral ms-2">Stillgelegt</span>
|
||||||
|
) : null}
|
||||||
|
{host.notizen ? (
|
||||||
|
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
|
||||||
|
<i
|
||||||
|
className="bi bi-sticky ms-2 text-body-secondary"
|
||||||
|
title={host.notizen}
|
||||||
|
aria-label={`Notiz: ${host.notizen}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
||||||
|
{host.tags.length > 0 ? (
|
||||||
|
<div className="mt-1 d-flex flex-wrap gap-1">
|
||||||
|
{host.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="badge pl-bg-neutral text-decoration-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
|
|||||||
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<div style={{ minWidth: "820px" }}>
|
<div className="pl-timeline">
|
||||||
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
|
<div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
|
||||||
{monate.map((monat) => (
|
{monate.map((monat) => (
|
||||||
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
||||||
{monat}
|
{monat}
|
||||||
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
|
|||||||
: "var(--pl-info)";
|
: "var(--pl-info)";
|
||||||
return (
|
return (
|
||||||
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
||||||
<div className="small text-truncate" style={{ width: "13rem" }}>
|
<div className="small text-truncate pl-timeline-name">
|
||||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
||||||
{eintrag.hostname}
|
{eintrag.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="small text-body-secondary mb-0">
|
<p className="small text-body-secondary mb-0">
|
||||||
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
<span className="d-md-none">Seitwärts wischen zeigt die späteren Monate. </span>Die rote
|
||||||
|
Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
||||||
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
|
|||||||
tabelle
|
tabelle
|
||||||
>
|
>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Ablauf</th>
|
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
<th scope="col">Key</th>
|
<th scope="col">Key</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col">Proxmox</th>
|
||||||
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{naechste90Tage.map((eintrag) => (
|
{naechste90Tage.map((eintrag) => (
|
||||||
<tr key={eintrag.hostId}>
|
<tr key={eintrag.hostId}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
|
||||||
{eintrag.hostname}
|
{eintrag.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Ablauf">
|
||||||
|
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
||||||
|
</td>
|
||||||
|
<td data-label="Kunde">
|
||||||
{eintrag.kunde ? (
|
{eintrag.kunde ? (
|
||||||
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
||||||
{eintrag.kunde.name}
|
{eintrag.kunde.name}
|
||||||
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
|
|||||||
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Key">
|
||||||
<CopyableCode wert={eintrag.key} />
|
<CopyableCode wert={eintrag.key} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox">
|
||||||
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
|
|||||||
ansprechpartner: kunde.ansprechpartner,
|
ansprechpartner: kunde.ansprechpartner,
|
||||||
email: kunde.email,
|
email: kunde.email,
|
||||||
telefon: kunde.telefon,
|
telefon: kunde.telefon,
|
||||||
|
eigenbedarf: kunde.eigenbedarf,
|
||||||
notizen: kunde.notizen,
|
notizen: kunde.notizen,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
|
|||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
const gesamt = summen(laufzeiten);
|
const gesamt = summen(laufzeiten);
|
||||||
const proJahr = summenProJahr(laufzeiten);
|
const proJahr = summenProJahr(laufzeiten);
|
||||||
const datum = new Date().toISOString().slice(0, 10);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: kunde.name,
|
titel: kunde.name,
|
||||||
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
|
|
||||||
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Laufzeit</th>
|
<th scope="col">Laufzeit</th>
|
||||||
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
<th scope="col">Einkauf</th>
|
<th scope="col">Einkauf</th>
|
||||||
<th scope="col">Kundenrechnung</th>
|
<th scope="col">Kundenrechnung</th>
|
||||||
<th scope="col">Verkauf</th>
|
<th scope="col">Verkauf</th>
|
||||||
|
<th scope="col">Marge</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{laufzeiten.map((laufzeit) => (
|
{laufzeiten.map((laufzeit) => (
|
||||||
<tr key={laufzeit.id}>
|
<tr key={laufzeit.id}>
|
||||||
<td>
|
{/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
|
||||||
|
Kundenansicht in eine Sackgasse (#043). */}
|
||||||
|
<td className="pl-karten-kopf">
|
||||||
|
<Link
|
||||||
|
href={`/rechnungen/${laufzeit.id}`}
|
||||||
|
className="fw-semibold text-decoration-none"
|
||||||
|
>
|
||||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||||
|
</Link>
|
||||||
{laufzeit.vorschlag ? (
|
{laufzeit.vorschlag ? (
|
||||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{laufzeit.hostname}</td>
|
<td data-label="Host">
|
||||||
<td>
|
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
|
||||||
|
{laufzeit.hostname}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
|
<td data-label="Einkauf">
|
||||||
<td>
|
{formatEuro(laufzeit.einkauf?.nettoBetrag)}
|
||||||
|
{laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? (
|
||||||
|
<div className="small text-body-secondary">
|
||||||
|
{formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "}
|
||||||
|
Rabatt
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
|
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<VerkaufBadge
|
<VerkaufBadge
|
||||||
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
<td data-label="Verkauf">{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
||||||
|
<td
|
||||||
|
data-label="Marge"
|
||||||
|
className={
|
||||||
|
laufzeit.einkauf && laufzeit.verkauf
|
||||||
|
? (laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag >= 0
|
||||||
|
? "pl-text-success"
|
||||||
|
: "pl-text-danger"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laufzeit.einkauf && laufzeit.verkauf
|
||||||
|
? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag)
|
||||||
|
: "–"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{laufzeiten.length === 0 ? (
|
{laufzeiten.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-center text-body-secondary py-4">
|
<td colSpan={7} className="text-center text-body-secondary py-4">
|
||||||
Noch keine Laufzeiten erfasst.
|
Noch keine Laufzeiten erfasst.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { csvAntwort, csvDatei } from "@/lib/csv";
|
import { csvAntwort, csvDatei } from "@/lib/csv";
|
||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
import {
|
import {
|
||||||
kundenListe,
|
kundenListe,
|
||||||
offeneRechnungspunkteProKunde,
|
offeneRechnungspunkteProKunde,
|
||||||
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
|
|||||||
const datum = new Date().toISOString().slice(0, 10);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Kundenübersicht",
|
titel: "Kundenübersicht",
|
||||||
untertitel: `${zeilen.length} Kunden`,
|
untertitel: `${zeilen.length} Kunden`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
@@ -56,26 +56,31 @@ export default async function KundenSeite() {
|
|||||||
const punkte = rechnungspunkte.get(kunde.id);
|
const punkte = rechnungspunkte.get(kunde.id);
|
||||||
return (
|
return (
|
||||||
<tr key={kunde.id}>
|
<tr key={kunde.id}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||||
{kunde.name}
|
{kunde.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{kunde.eigenbedarf ? (
|
||||||
|
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
|
||||||
|
) : null}
|
||||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="pl-mono">{kunde.kundennummer}</td>
|
<td className="pl-mono" data-label="Kundennummer">
|
||||||
<td>{kunde.hosts}</td>
|
{kunde.kundennummer}
|
||||||
<td>{kunde.aktiv}</td>
|
</td>
|
||||||
<td>
|
<td data-label="Hosts">{kunde.hosts}</td>
|
||||||
|
<td data-label="Aktiv lizenziert">{kunde.aktiv}</td>
|
||||||
|
<td data-label="Nächster Ablauf">
|
||||||
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Warnungen">
|
||||||
{kunde.offeneAlerts > 0 ? (
|
{kunde.offeneAlerts > 0 ? (
|
||||||
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge farbe="success">keine</Badge>
|
<Badge farbe="success">keine</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Rechnungen">
|
||||||
{punkte && punkte.offen > 0 ? (
|
{punkte && punkte.offen > 0 ? (
|
||||||
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { Sidebar } from "@/components/layout/Sidebar";
|
import { Sidebar } from "@/components/layout/Sidebar";
|
||||||
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,9 +11,9 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
|
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
|
||||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
// entsteht – und weil hier Rolle und Kundenbindung herkommen.
|
||||||
if (!sitzung) redirect("/login");
|
const sitzung = await sitzungErforderlich();
|
||||||
|
|
||||||
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
||||||
|
|
||||||
@@ -31,12 +29,14 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
}))}
|
}))}
|
||||||
suchEintraege={suche}
|
suchEintraege={suche}
|
||||||
benutzer={sitzung.name}
|
benutzer={sitzung.name}
|
||||||
|
rolle={sitzung.rolle}
|
||||||
/>
|
/>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
hostsAnzahl={zahlen.hostsGesamt}
|
hostsAnzahl={zahlen.hostsGesamt}
|
||||||
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
||||||
offeneWarnungen={zahlen.offeneAlerts}
|
offeneWarnungen={zahlen.offeneAlerts}
|
||||||
benutzer={sitzung.name}
|
benutzer={sitzung.name}
|
||||||
|
rolle={sitzung.rolle}
|
||||||
/>
|
/>
|
||||||
<main className="app-main">{children}</main>
|
<main className="app-main">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
|
|||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
|
||||||
|
per Seitwärtsscrollen ins Bild (#034). */}
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col" className="d-none d-md-table-cell">
|
||||||
<th scope="col">Key</th>
|
Kunde
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-lg-table-cell">
|
||||||
|
Key
|
||||||
|
</th>
|
||||||
<th scope="col">Ablauf</th>
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
<th scope="col">Kundenrechnung</th>
|
Proxmox
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
|
Kundenrechnung
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
|
||||||
|
<div className="small text-body-secondary d-md-none">
|
||||||
|
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-md-table-cell">
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
||||||
{host.kunde.name}
|
{host.kunde.name}
|
||||||
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-lg-table-cell">
|
||||||
<CopyableCode wert={host.subKey} />
|
<CopyableCode wert={host.subKey} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
|
|||||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
||||||
|
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
||||||
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
||||||
|
import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -24,10 +27,35 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
||||||
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
||||||
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||||
|
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||||
|
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
|
||||||
|
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
|
||||||
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||||
|
|
||||||
|
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
|
||||||
|
const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([
|
||||||
|
einstellungen(),
|
||||||
|
faelligkeitsVorschlag(),
|
||||||
|
prisma.laufzeit.findMany({
|
||||||
|
where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } },
|
||||||
|
orderBy: { start: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
key: true,
|
||||||
|
start: true,
|
||||||
|
ende: true,
|
||||||
|
proxmoxPositionId: true,
|
||||||
|
kundenPositionId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const verkaufVorschlag =
|
||||||
|
verkaufBetrag ??
|
||||||
|
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
|
||||||
|
|
||||||
|
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
|
||||||
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +130,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
<hr />
|
<hr />
|
||||||
<dl className="row mb-0">
|
<dl className="row mb-0">
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
||||||
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
|
<dd className="col-7">
|
||||||
|
{formatEuro(einkaufBetrag)}
|
||||||
|
{einkaufRabatt > 0 ? (
|
||||||
|
<span className="small text-body-secondary d-block">
|
||||||
|
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
||||||
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
||||||
@@ -110,6 +145,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
{marge === null ? "–" : formatEuro(marge)}
|
{marge === null ? "–" : formatEuro(marge)}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<LaufzeitBearbeiten
|
||||||
|
laufzeitId={laufzeit.id}
|
||||||
|
hostId={laufzeit.host.id}
|
||||||
|
schluessel={laufzeit.key}
|
||||||
|
start={laufzeit.start.toISOString().slice(0, 10)}
|
||||||
|
ende={laufzeit.ende.toISOString().slice(0, 10)}
|
||||||
|
notizen={laufzeit.notizen}
|
||||||
|
nachbarn={nachbarn.map((nachbar) => ({
|
||||||
|
id: nachbar.id,
|
||||||
|
zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`,
|
||||||
|
key: nachbar.key,
|
||||||
|
hatEinkauf: nachbar.proxmoxPositionId !== null,
|
||||||
|
hatVerkauf: nachbar.kundenPositionId !== null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{einkauf && einkauf.positionen.length > 1 ? (
|
{einkauf && einkauf.positionen.length > 1 ? (
|
||||||
@@ -128,7 +180,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
) : (
|
) : (
|
||||||
position.bezeichnung
|
position.bezeichnung
|
||||||
)}{" "}
|
)}{" "}
|
||||||
· {formatEuro(Number(position.nettoBetrag))}
|
·{" "}
|
||||||
|
{position.rabatt ? (
|
||||||
|
<>
|
||||||
|
{formatEuro(Number(position.nettoBetrag))} − {formatEuro(Number(position.rabatt))}{" "}
|
||||||
|
= <strong>{formatEuro(Number(position.nettoBetrag) - Number(position.rabatt))}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
formatEuro(Number(position.nettoBetrag))
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -148,13 +208,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
werte={{
|
werte={{
|
||||||
nummer: einkauf?.nummer ?? null,
|
nummer: einkauf?.nummer ?? null,
|
||||||
datum: einkauf?.datum.toISOString() ?? null,
|
datum: einkauf?.datum.toISOString() ?? null,
|
||||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
|
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||||
nettoBetrag: einkaufBetrag,
|
nettoBetrag: einkaufListenpreis,
|
||||||
ustSatz: Number(einkauf?.ustSatz ?? 19),
|
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
rabatt:
|
rabatt:
|
||||||
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
laufzeit.proxmoxPosition?.rabatt === null ||
|
||||||
|
laufzeit.proxmoxPosition?.rabatt === undefined
|
||||||
? null
|
? null
|
||||||
: Number(einkauf.rabatt),
|
: Number(laufzeit.proxmoxPosition.rabatt),
|
||||||
zahlungsart: einkauf?.zahlungsart ?? null,
|
zahlungsart: einkauf?.zahlungsart ?? null,
|
||||||
bezahlt: einkauf?.bezahlt ?? false,
|
bezahlt: einkauf?.bezahlt ?? false,
|
||||||
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
||||||
@@ -166,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
titel="Kundenrechnung (Verkauf)"
|
titel="Kundenrechnung (Verkauf)"
|
||||||
rahmen={verkauf?.erstellt ? "info" : "warning"}
|
rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
|
||||||
werkzeuge={
|
werkzeuge={
|
||||||
|
eigenbedarf ? (
|
||||||
|
<span className="badge pl-bg-neutral">Eigenbedarf</span>
|
||||||
|
) : (
|
||||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{eigenbedarf ? (
|
||||||
|
<p className="mb-0 text-body-secondary">
|
||||||
|
<strong>{laufzeit.host.kunde?.name}</strong> ist als Eigenbedarf gekennzeichnet – diese
|
||||||
|
Hosts werden nicht weiterberechnet. Deshalb gibt es hier keine Kundenrechnung, keine
|
||||||
|
Warnung darüber und keine Marge. Die Proxmox-Rechnung wird weiterhin geführt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<KundenrechnungFormular
|
<KundenrechnungFormular
|
||||||
laufzeitId={laufzeit.id}
|
laufzeitId={laufzeit.id}
|
||||||
hostId={laufzeit.host.id}
|
hostId={laufzeit.host.id}
|
||||||
@@ -179,14 +251,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
erstellt: verkauf?.erstellt ?? false,
|
erstellt: verkauf?.erstellt ?? false,
|
||||||
nummer: verkauf?.nummer ?? null,
|
nummer: verkauf?.nummer ?? null,
|
||||||
datum: verkauf?.datum?.toISOString() ?? null,
|
datum: verkauf?.datum?.toISOString() ?? null,
|
||||||
nettoBetrag: verkaufBetrag,
|
nettoBetrag: verkaufVorschlag,
|
||||||
ustSatz: Number(verkauf?.ustSatz ?? 19),
|
ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
bezahlt: verkauf?.bezahlt ?? false,
|
bezahlt: verkauf?.bezahlt ?? false,
|
||||||
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
|
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
|
||||||
anhangId: verkauf?.anhangId ?? null,
|
anhangId: verkauf?.anhangId ?? null,
|
||||||
anhangName: verkauf?.anhang?.dateiname ?? null,
|
anhangName: verkauf?.anhang?.dateiname ?? null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
|
|||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
|
|||||||
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Rechnungsliste",
|
titel: "Rechnungsliste",
|
||||||
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||