Compare commits

..
4 Commits
Author SHA1 Message Date
florian.wilde 7d8e5c9172 Merge branch 'phase/026-auslieferung'
Prüfung / pruefung (push) Canceled after 0s
2026-09-19 00:11:33 +02:00
florian.wildeandClaude Opus 5 6c3729bae8 feat(deploy): Auslieferung als Docker-Stack und als Helper-Script (#026)
Docker: deploy/update.sh sichert, baut, migriert und prueft die
Erreichbarkeit; der Worker hat jetzt eine Zustandspruefung. Der ganze Stapel
wurde durchgespielt - bauen, starten, migrieren, anmelden, Agent-Skript
abrufen.

Dabei ein echter Fehler aufgefallen: Nach der Anmeldung zeigte die
Weiterleitung auf den internen Containernamen. Hinter einem Reverse Proxy
waere das eine tote Adresse gewesen. Weiterleitungen nehmen jetzt die
eingetragene Adresse, sonst APP_URL, sonst die Proxy-Kopfzeilen.

Dazu Skripte nach Art der Proxmox VE Helper-Scripts (Node und PostgreSQL
direkt im LXC, ohne Docker). Vorerst intern: Eine Einreichung setzt
oeffentlichen Quelltext voraus und laeuft ueber ProxmoxVED.

Ausserdem docs/roadmap.md: Bis 1.0.0 fehlt kein Code mehr, sondern
Erprobung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:11:33 +02:00
florian.wilde ffc80a8fcf Merge branch 'phase/025-einstellungen'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:58:30 +02:00
florian.wildeandClaude Opus 5 d416f3869a feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)
Vier Bereiche unter Einstellungen -> System: Adresse und Agent,
Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als
eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren
Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm.

Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus
landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft),
der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token,
die Firmenangaben im Kopf der PDF-Dateien.

Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein
Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts
nach den eingestellten Fristen auf - der juengste Report je Host bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:58:30 +02:00
49 changed files with 1611 additions and 75 deletions
+5 -1
View File
@@ -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"
+26
View File
@@ -17,6 +17,32 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [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 ## [0.15.0] – 2026-09-18
### Hinzugefügt ### Hinzugefügt
+12 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 10 h 28 min **Gesamt:** 12 h 18 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -30,6 +30,8 @@
| #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 | | #024 OWASP-Härtung | 0 h 45 min |
| #025 Einstellungen | 1 h 10 min |
| #026 Auslieferung | 0 h 40 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -52,7 +54,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.15.0 **Version:** 0.17.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -142,6 +144,14 @@
- 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 - 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 - 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)
### 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)
+38 -8
View File
@@ -3,8 +3,10 @@
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:** einsatzbereit. Der Agent ist auf echten Hosts erprobt – Proxmox VE 9.2 mit und ohne
> Proxmox-Hosts erprobt (#018). > Subscription, im Cluster und einzeln, sowie Proxmox Backup Server 4.2 (#018, #023).
> Ungeprüft: Mail Gateway und eine aktive Subscription auf PBS. Was bis 1.0 fehlt, steht in
> [docs/roadmap.md](docs/roadmap.md).
## Schnellstart ## Schnellstart
@@ -20,14 +22,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>/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 +120,28 @@ 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*).
Benutzer sperren oder Passwort setzen: `npm run benutzer -- sperren <email>`.
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse - **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. 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.
## 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 |
+15 -7
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
# 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`.
| 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/fw/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Das Gerüst (`build.func`, `install.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.
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.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
_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/fw/ProxLedger
APP="ProxLedger"
var_tags="${var_tags:-monitoring;licensing}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-10}"
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}"
+127
View File
@@ -0,0 +1,127 @@
#!/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/fw/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/fw/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
IP_ADRESSE=$(hostname -I | awk '{print $1}')
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 "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"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# ProxLedger aktualisieren (Docker-Installation).
#
# bash deploy/update.sh # Quelltext holen, neu 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"
command -v docker >/dev/null 2>&1 || fehler "Docker nicht gefunden."
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
if [ "$SICHERN" = "ja" ]; then
blau "Sicherung anlegen …"
bash deploy/backup.sh
gruen "Sicherung abgeschlossen."
fi
if [ -d .git ]; then
blau "Neuen Stand holen …"
git pull --ff-only
fi
blau "Abbilder neu bauen …"
docker compose build --pull
blau "Datenbank migrieren …"
docker compose run --rm migrate
blau "Dienste neu starten …"
docker compose up -d
# Kurz warten und nachsehen, ob das Dashboard wieder antwortet.
blau "Warte auf das Dashboard …"
for versuch in $(seq 1 30); do
if docker compose exec -T web wget -qO- http://localhost:3000/api/gesundheit >/dev/null 2>&1; then
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
docker compose ps
exit 0
fi
sleep 1
done
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
+8
View File
@@ -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:
+31
View File
@@ -17,6 +17,7 @@ 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)
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung) 10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
11. [Fehlersuche](#11-fehlersuche) 11. [Fehlersuche](#11-fehlersuche)
@@ -310,6 +311,36 @@ Jahresliste für die Buchhaltung.
--- ---
## 9a. Einstellungen
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
![Systemeinstellungen](bilder/system.png)
**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.
---
## 10. Sicherung und Aktualisierung ## 10. Sicherung und Aktualisierung
**Sichern:** **Sichern:**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

+47
View File
@@ -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.
+41
View File
@@ -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.
+3 -1
View File
@@ -28,4 +28,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#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 | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min | | [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
| | **Summe** | | | **125 h** | **10 h 28 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 |
| | **Summe** | | | **133 h** | **12 h 18 min** |
+72
View File
@@ -0,0 +1,72 @@
# Roadmap
Stand: 19.09.2026, Fassung 0.17.0
## Was steht
Alles, was im ursprünglichen Auftrag stand, ist gebaut und in Betrieb erprobt:
- Agent für PVE, PBS und PMG; Push-Modell über systemd-Timer, Zwischenspeicher bei Ausfall
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
- Anmeldung lokal und per OIDC, Audit-Log, verschlüsselte Zugangsdaten, Härtung nach OWASP
- Auslieferung als Docker-Stack und als LXC-Container (eigener Installer und Helper-Script-Weg)
- Handbuch mit Bildern, 190 Tests, CI bei jedem Push
## Was bis 1.0.0 fehlt
Nichts davon ist eine Baustelle im Code – es fehlt **Erprobung** und eine Handvoll Kanten:
| Punkt | Warum es zählt | Aufwand |
|---|---|---|
| **Mail Gateway im Feldtest** (#023) | Der einzige Produktzweig, den nie ein echter Host bestätigt hat. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet – bei PMG kann es genauso sein. | 1 h, sobald ein Host da ist |
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min |
| **Ein Monat Dauerbetrieb** | Timer, Nachsendung, nächtliches Aufräumen und die Zusammenfassung laufen erst seit einem Tag. Ein Lizenzablauf in echt wäre der beste Beweis. | Zeit, keine Arbeit |
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
Danach ist 1.0.0 gerechtfertigt. Meine Empfehlung: erst laufen lassen, dann das Etikett vergeben.
## Nach 1.0 – Vorschläge, nach Nutzen sortiert
### Lohnt sich am ehesten
1. **Verlängerung mit einem Klick.** Heute wird eine Laufzeit bestätigt und die Rechnung von Hand
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
2. **Rechnungsnummern automatisch.** Ein Schema wie `PL-{jahr}-{nummer}` mit fortlaufendem Zähler;
heute tippt man jede Nummer. (In #025 bewusst weggelassen, weil es eine Vergabelogik braucht.)
3. **Mehrere Benutzer mit Rollen.** Derzeit darf jeder Angemeldete alles – in `docs/sicherheit.md`
offen vermerkt. Für Buchhaltung (nur Rechnungen) und Technik (nur Hosts) wären zwei Rollen genug.
Dazu gehört eine Benutzerverwaltung in der Oberfläche statt auf der Kommandozeille.
4. **Währung und Zeitzone auch in der Browseransicht.** Beides steht schon in den Einstellungen, wirkt
aber nur in PDF, CSV und Benachrichtigungen; die Oberfläche rechnet fest in Euro.
### Sinnvoll, wenn es wächst
5. **Kundenportal oder Bericht per E-Mail.** Ein monatlicher Stand je Kunde, automatisch verschickt –
spart Rückfragen.
6. **Angebot vor der Verlängerung.** Aus der Jahresbahn heraus ein PDF-Angebot für das kommende
Lizenzjahr erzeugen.
7. **Import der Proxmox-Shop-Rechnungen.** Wer dort per Sammelrechnung kauft, tippt heute jede Position
ab. Ein CSV- oder PDF-Import mit Zuordnung über den Key wäre eine echte Ersparnis.
8. **Metriken für Prometheus** (`/metrics`): Hosts je Zustand, Tage bis zum nächsten Ablauf, Alter des
letzten Reports. Wer schon Grafana betreibt, sieht ProxLedger damit im gewohnten Bild.
9. **Zwei-Faktor-Anmeldung** für den lokalen Zugang. Heute lautet die Antwort „dann nimm OIDC“ – das
ist vertretbar, aber nicht für jeden.
### Nur falls der Bedarf entsteht
10. **Mandantenfähigkeit.** Mehrere Systemhäuser in einer Installation. Das ist ein Umbau, kein
Anbau – die Zugriffskontrolle müsste durchgängig auf Mandanten umgestellt werden.
11. **Agent für weitere Produkte** (Proxmox Datacenter Manager), sobald der Bedarf da ist.
12. **Hochverfügbarkeit.** Die Ratenbremse liegt im Prozessspeicher; für mehrere Instanzen müsste sie
in die Datenbank wandern. Bei einem Dashboard für ein Systemhaus ist das Aufwand ohne Gewinn.
## Was ich bewusst nicht vorschlage
- **Pull statt Push.** Das Dashboard würde dann Zugänge zu allen Kundenhosts brauchen. Der jetzige Weg
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
ein IdP – dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt heute mit Bash und curl aus und läuft damit auf
jedem Proxmox-Host, auch auf einem alten.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.15.0", "version": "0.17.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1
View File
@@ -48,6 +48,7 @@ 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" },
]; ];
async function sitzungsToken() { async function sitzungsToken() {
@@ -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>
@@ -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,
@@ -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: [
@@ -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,
+11 -4
View File
@@ -9,6 +9,7 @@ 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";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -28,6 +29,12 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
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] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`; const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return ( return (
@@ -148,9 +155,9 @@ 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: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19), ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt: rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null ? null
@@ -179,8 +186,8 @@ 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,
@@ -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,
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */ /** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) { export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request)); const inhalt = await skriptLaden("proxledger-agent.sh", await basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh"); return skriptAntwort(inhalt, "proxledger-agent.sh");
} }
+16 -9
View File
@@ -1,7 +1,9 @@
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { eigeneAdresse } from "@/lib/adresse";
import { clientIp } from "@/lib/ratelimit"; import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer"; import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -37,6 +39,7 @@ export function herkunftStimmt(request: Request): boolean {
export async function POST(request: Request) { export async function POST(request: Request) {
const formular = await request.formData(); const formular = await request.formData();
const { dashboardUrl } = await einstellungen();
const email = formular.get("email")?.toString().trim() ?? ""; const email = formular.get("email")?.toString().trim() ?? "";
const passwort = formular.get("passwort")?.toString() ?? ""; const passwort = formular.get("passwort")?.toString() ?? "";
const zielEingabe = formular.get("ziel")?.toString() || "/"; const zielEingabe = formular.get("ziel")?.toString() || "/";
@@ -44,7 +47,7 @@ export async function POST(request: Request) {
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/"; const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
function zurueckZurAnmeldung(meldung: string) { function zurueckZurAnmeldung(meldung: string) {
const adresse = new URL("/login", request.url); const adresse = eigeneAdresse(request, "/login", dashboardUrl);
adresse.searchParams.set("fehler", meldung); adresse.searchParams.set("fehler", meldung);
if (ziel !== "/") adresse.searchParams.set("ziel", ziel); if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
return Response.redirect(adresse, 303); return Response.redirect(adresse, 303);
@@ -62,15 +65,19 @@ export async function POST(request: Request) {
const kopfzeilen = await headers(); const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen)); const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({ const { sitzungsdauerStunden } = await einstellungen();
benutzerId: benutzer.id, const token = await sitzungErzeugen(
email: benutzer.email, {
name: benutzer.name, benutzerId: benutzer.id,
herkunft: "passwort", email: benutzer.email,
}); name: benutzer.name,
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen()); herkunft: "passwort",
},
sitzungsdauerStunden,
);
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel, request.url), 303); return Response.redirect(eigeneAdresse(request, ziel, dashboardUrl), 303);
} catch (fehler) { } catch (fehler) {
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message); if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
console.error("Anmeldung fehlgeschlagen:", fehler); console.error("Anmeldung fehlgeschlagen:", fehler);
+18 -10
View File
@@ -1,7 +1,9 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { eigeneAdresse } from "@/lib/adresse";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer"; import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc"; import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,25 +19,31 @@ export async function GET(request: Request) {
speicher.delete(name); speicher.delete(name);
} }
const { dashboardUrl } = await systemEinstellungen();
if (!einstellungen || !pruefwert || !zustand) { if (!einstellungen || !pruefwert || !zustand) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302); return Response.redirect(eigeneAdresse(request, "/login?fehler=oidc", dashboardUrl), 302);
} }
try { try {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand); const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name); const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({ const { sitzungsdauerStunden } = await systemEinstellungen();
benutzerId: benutzer.id, const token = await sitzungErzeugen(
email: benutzer.email, {
name: benutzer.name, benutzerId: benutzer.id,
herkunft: "oidc", email: benutzer.email,
}); name: benutzer.name,
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen()); herkunft: "oidc",
},
sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302); return Response.redirect(eigeneAdresse(request, ziel.startsWith("/") ? ziel : "/", dashboardUrl), 302);
} catch (fehler) { } catch (fehler) {
console.error("OIDC-Rückkehr fehlgeschlagen:", fehler); console.error("OIDC-Rückkehr fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302); return Response.redirect(eigeneAdresse(request, "/login?fehler=oidc", dashboardUrl), 302);
} }
} }
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */ /** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) { export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request)); const inhalt = await skriptLaden("install.sh", await basisUrl(request));
return skriptAntwort(inhalt, "install.sh"); return skriptAntwort(inhalt, "install.sh");
} }
@@ -0,0 +1,341 @@
"use client";
import { useActionState } from "react";
import { Card } from "@/components/ui/Card";
import { type AktionsErgebnis, einstellungenSpeichernAktion } from "@/server/actions/einstellungen";
import type { Einstellungen } from "@/server/einstellungen";
const LEER: AktionsErgebnis = {};
function Feld({
name,
beschriftung,
hinweis,
fehler,
children,
}: {
name: string;
beschriftung: string;
hinweis?: string;
fehler?: string;
children: React.ReactNode;
}) {
return (
<div className="mb-3">
<label className="form-label" htmlFor={name}>
{beschriftung}
</label>
{children}
{hinweis ? <div className="form-text">{hinweis}</div> : null}
{fehler ? <div className="invalid-feedback d-block">{fehler}</div> : null}
</div>
);
}
export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl: string }) {
const [ergebnis, aktion, laeuft] = useActionState(einstellungenSpeichernAktion, LEER);
const fehler = ergebnis.felder ?? {};
return (
<form action={aktion}>
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="row g-4">
<div className="col-12 col-xl-6">
<Card titel="Adresse und Agent" rahmen="primary">
<Feld
name="dashboardUrl"
beschriftung="Adresse des Dashboards"
hinweis={`Steht im Installations-Einzeiler und in Benachrichtigungen. Leer lassen heißt: aus der Umgebung (${appUrl || "nicht gesetzt"}).`}
fehler={fehler.dashboardUrl}
>
<input
type="url"
className="form-control"
id="dashboardUrl"
name="dashboardUrl"
defaultValue={werte.dashboardUrl}
placeholder="https://proxledger.example.de"
/>
</Feld>
<Feld
name="agentRhythmus"
beschriftung="Wie oft meldet ein Host?"
hinweis="Gilt für neu installierte Agents; bestehende übernehmen es bei der nächsten Installation."
>
<select className="form-select" id="agentRhythmus" name="agentRhythmus" defaultValue={werte.agentRhythmus}>
<option value="taeglich">täglich</option>
<option value="zwoelfstuendlich">alle 12 Stunden</option>
<option value="stuendlich">stündlich</option>
</select>
</Feld>
<Feld
name="agentStreuungStunden"
beschriftung="Zufällige Verzögerung (Stunden)"
hinweis="Verteilt die Meldungen, damit nicht alle Hosts gleichzeitig anklopfen."
fehler={fehler.agentStreuungStunden}
>
<input
type="number"
min={0}
max={12}
className="form-control"
id="agentStreuungStunden"
name="agentStreuungStunden"
defaultValue={werte.agentStreuungStunden}
/>
</Feld>
<div className="form-check">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="agentAutoUpdate"
name="agentAutoUpdate"
value="an"
defaultChecked={werte.agentAutoUpdate}
/>
<label className="form-check-label" htmlFor="agentAutoUpdate">
Agents dürfen sich selbst aktualisieren
</label>
<div className="form-text">
Der Agent lädt bei einer neueren Fassung das Skript vom Dashboard nach.
</div>
</div>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Kaufmännisches" rahmen="info">
<Feld name="waehrung" beschriftung="Währung">
<select className="form-select" id="waehrung" name="waehrung" defaultValue={werte.waehrung}>
<option value="EUR">Euro (€)</option>
<option value="CHF">Schweizer Franken (CHF)</option>
</select>
</Feld>
<Feld
name="steuersatz"
beschriftung="Steuersatz in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen."
fehler={fehler.steuersatz}
>
<input
type="number"
step="0.1"
min={0}
max={100}
className="form-control"
id="steuersatz"
name="steuersatz"
defaultValue={werte.steuersatz}
/>
</Feld>
<Feld
name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen"
hinweis="Setzt das Fälligkeitsdatum neuer Rechnungen vor."
fehler={fehler.zahlungszielTage}
>
<input
type="number"
min={0}
max={120}
className="form-control"
id="zahlungszielTage"
name="zahlungszielTage"
defaultValue={werte.zahlungszielTage}
/>
</Feld>
<Feld
name="margeProzent"
beschriftung="Aufschlag in Prozent"
hinweis="Vorschlag für den Verkaufspreis, ausgehend vom Einkaufspreis bei Proxmox."
fehler={fehler.margeProzent}
>
<input
type="number"
step="0.5"
min={0}
max={500}
className="form-control"
id="margeProzent"
name="margeProzent"
defaultValue={werte.margeProzent}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Sicherheit" rahmen="danger">
<Feld
name="sitzungsdauerStunden"
beschriftung="Sitzungsdauer in Stunden"
hinweis="Nach dieser Zeit ist eine neue Anmeldung nötig."
fehler={fehler.sitzungsdauerStunden}
>
<input
type="number"
min={1}
max={168}
className="form-control"
id="sitzungsdauerStunden"
name="sitzungsdauerStunden"
defaultValue={werte.sitzungsdauerStunden}
/>
</Feld>
<Feld
name="anmeldeversuche"
beschriftung="Anmeldeversuche je Viertelstunde"
fehler={fehler.anmeldeversuche}
>
<input
type="number"
min={3}
max={50}
className="form-control"
id="anmeldeversuche"
name="anmeldeversuche"
defaultValue={werte.anmeldeversuche}
/>
</Feld>
<Feld
name="oidcDomaenen"
beschriftung="Erlaubte E-Mail-Domänen für OIDC"
hinweis="Mehrere durch Komma trennen, etwa „wilde-server.de, kunde.de“. Leer heißt: alle."
>
<input
type="text"
className="form-control"
id="oidcDomaenen"
name="oidcDomaenen"
defaultValue={werte.oidcDomaenen}
placeholder="firma.de"
/>
</Feld>
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="oidcKontenAnlegen"
name="oidcKontenAnlegen"
value="an"
defaultChecked={werte.oidcKontenAnlegen}
/>
<label className="form-check-label" htmlFor="oidcKontenAnlegen">
Unbekannte OIDC-Anmeldungen legen ein Konto an
</label>
<div className="form-text">
Aus heißt: Nur wer hier schon ein Konto hat, kommt herein – auch wenn die Anmeldung
beim Identitätsanbieter klappt.
</div>
</div>
<Feld
name="aufbewahrungReportsTage"
beschriftung="Reports aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt. Der aktuelle Stand jedes Hosts bleibt immer erhalten."
fehler={fehler.aufbewahrungReportsTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungReportsTage"
name="aufbewahrungReportsTage"
defaultValue={werte.aufbewahrungReportsTage}
/>
</Feld>
<Feld
name="aufbewahrungAuditTage"
beschriftung="Protokoll aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt."
fehler={fehler.aufbewahrungAuditTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungAuditTage"
name="aufbewahrungAuditTage"
defaultValue={werte.aufbewahrungAuditTage}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Firma und Darstellung" rahmen="keiner">
<Feld
name="firmaName"
beschriftung="Firmenname"
hinweis="Erscheint im Kopf der erzeugten PDF-Rechnungen."
>
<input
type="text"
className="form-control"
id="firmaName"
name="firmaName"
defaultValue={werte.firmaName}
/>
</Feld>
<Feld name="firmaAnschrift" beschriftung="Anschrift">
<textarea
className="form-control"
id="firmaAnschrift"
name="firmaAnschrift"
rows={3}
defaultValue={werte.firmaAnschrift}
/>
</Feld>
<Feld name="firmaEmail" beschriftung="E-Mail-Adresse" fehler={fehler.firmaEmail}>
<input
type="email"
className="form-control"
id="firmaEmail"
name="firmaEmail"
defaultValue={werte.firmaEmail}
/>
</Feld>
<Feld
name="zeitzone"
beschriftung="Zeitzone"
hinweis="Gilt für PDF, CSV-Ausgaben und Benachrichtigungen."
fehler={fehler.zeitzone}
>
<input
type="text"
className="form-control"
id="zeitzone"
name="zeitzone"
defaultValue={werte.zeitzone}
placeholder="Europe/Berlin"
/>
</Feld>
</Card>
</div>
</div>
<div className="mb-4">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Ermittelt die Adresse, unter der das Dashboard **von außen** erreichbar ist.
*
* Im Container ist `request.url` die interne Adresse (`http://<container-id>:3000`). Eine Weiterleitung
* dorthin führt im Browser ins Leere – genau das passierte nach der Anmeldung im Docker-Stapel.
*
* Reihenfolge: eingetragene Adresse → `APP_URL` → Kopfzeilen des Reverse Proxy → Anfrage selbst.
* Die Kopfzeilen stehen bewusst hinter den konfigurierten Werten: Sie kommen vom Client und lassen
* sich fälschen; für eine Weiterleitung auf die eigene Seite ist das unkritisch, als erste Wahl aber
* unnötig.
*/
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
if (eingetragen) {
try {
return new URL(eingetragen);
} catch {
// Unbrauchbarer Eintrag: weiter mit den übrigen Quellen.
}
}
if (process.env.APP_URL) {
try {
return new URL(process.env.APP_URL);
} catch {
// ebenso
}
}
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) {
const schema = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", "");
try {
return new URL(`${schema}://${gastgeber}`);
} catch {
// ebenso
}
}
return new URL(request.url);
}
/** Baut eine Adresse auf derselben Installation – für Weiterleitungen nach der Anmeldung. */
export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL {
return new URL(pfad, oeffentlicheBasis(request, eingetragen));
}
+8 -4
View File
@@ -6,6 +6,7 @@ import { SignJWT, jwtVerify } from "jose";
*/ */
export const SITZUNGS_COOKIE = "pl_sitzung"; export const SITZUNGS_COOKIE = "pl_sitzung";
/** Rückfall, solange nichts in den Einstellungen steht (#025). */
export const SITZUNGSDAUER_STUNDEN = 12; export const SITZUNGSDAUER_STUNDEN = 12;
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */ /** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
@@ -31,13 +32,16 @@ function schluessel(): Uint8Array {
return new TextEncoder().encode(geheimnis); return new TextEncoder().encode(geheimnis);
} }
export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> { export async function sitzungErzeugen(
sitzung: Sitzung,
dauerStunden: number = SITZUNGSDAUER_STUNDEN,
): Promise<string> {
return new SignJWT({ ...sitzung }) return new SignJWT({ ...sitzung })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setSubject(sitzung.benutzerId) .setSubject(sitzung.benutzerId)
.setIssuedAt() .setIssuedAt()
.setIssuer("proxledger") .setIssuer("proxledger")
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`) .setExpirationTime(`${dauerStunden}h`)
.sign(schluessel()); .sign(schluessel());
} }
@@ -70,12 +74,12 @@ export function ueberHttps(): boolean {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} }
export function cookieOptionen() { export function cookieOptionen(dauerStunden: number = SITZUNGSDAUER_STUNDEN) {
return { return {
httpOnly: true, httpOnly: true,
sameSite: "lax" as const, sameSite: "lax" as const,
secure: ueberHttps(), secure: ueberHttps(),
path: "/", path: "/",
maxAge: SITZUNGSDAUER_STUNDEN * 3600, maxAge: dauerStunden * 3600,
}; };
} }
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungen, einstellungenSchema, einstellungenSpeichern } from "@/server/einstellungen";
export interface AktionsErgebnis {
fehler?: string;
felder?: Record<string, string>;
erfolg?: string;
}
const zahl = (formular: FormData, feld: string) => {
const roh = formular.get(feld)?.toString().replace(",", ".").trim();
return roh === undefined || roh === "" ? undefined : Number(roh);
};
const text = (formular: FormData, feld: string) => formular.get(feld)?.toString().trim() ?? "";
const schalter = (formular: FormData, feld: string) => formular.get(feld) === "an";
export async function einstellungenSpeichernAktion(
_zustand: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const vorher = await einstellungen();
const ergebnis = einstellungenSchema.safeParse({
dashboardUrl: text(formular, "dashboardUrl"),
agentRhythmus: text(formular, "agentRhythmus") || vorher.agentRhythmus,
agentStreuungStunden: zahl(formular, "agentStreuungStunden"),
agentAutoUpdate: schalter(formular, "agentAutoUpdate"),
waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"),
zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"),
sitzungsdauerStunden: zahl(formular, "sitzungsdauerStunden"),
anmeldeversuche: zahl(formular, "anmeldeversuche"),
oidcDomaenen: text(formular, "oidcDomaenen"),
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
firmaName: text(formular, "firmaName"),
firmaAnschrift: text(formular, "firmaAnschrift"),
firmaEmail: text(formular, "firmaEmail"),
zeitzone: text(formular, "zeitzone") || vorher.zeitzone,
});
if (!ergebnis.success) {
const felder: Record<string, string> = {};
for (const fehler of ergebnis.error.issues) {
const feld = fehler.path[0];
if (typeof feld === "string" && !felder[feld]) felder[feld] = fehler.message;
}
return { fehler: "Bitte die markierten Felder prüfen.", felder };
}
await einstellungenSpeichern(ergebnis.data);
// Nur die geänderten Felder ins Protokoll – sonst steht bei jedem Speichern alles darin.
const geaendert = Object.fromEntries(
Object.entries(ergebnis.data).filter(
([feld, wert]) => wert !== vorher[feld as keyof typeof vorher],
),
);
await protokolliere("einstellungen.gespeichert", "Einstellung", "system", geaendert);
revalidatePath("/einstellungen/system");
return { erfolg: "Einstellungen gespeichert." };
}
+2 -2
View File
@@ -106,7 +106,7 @@ export async function proxmoxRechnungSpeichern(
datum: rechnungsdatum, datum: rechnungsdatum,
faelligAm: datum(formular.get("faelligAm")), faelligAm: datum(formular.get("faelligAm")),
nettoBetrag: netto, nettoBetrag: netto,
ustSatz: betrag(formular.get("ustSatz")) ?? 19, ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
rabatt: betrag(formular.get("rabatt")), rabatt: betrag(formular.get("rabatt")),
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null, zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
bezahlt: formular.get("bezahlt") === "on", bezahlt: formular.get("bezahlt") === "on",
@@ -139,7 +139,7 @@ export async function kundenrechnungSpeichern(
nummer: formular.get("nummer")?.toString().trim() || null, nummer: formular.get("nummer")?.toString().trim() || null,
datum: datum(formular.get("datum")), datum: datum(formular.get("datum")),
nettoBetrag: betrag(formular.get("nettoBetrag")), nettoBetrag: betrag(formular.get("nettoBetrag")),
ustSatz: betrag(formular.get("ustSatz")) ?? 19, ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
bezahlt: formular.get("bezahlt") === "on", bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")), bezahltAm: datum(formular.get("bezahltAm")),
anhangId, anhangId,
+17 -6
View File
@@ -1,21 +1,32 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { einstellungen, onCalendar } from "@/server/einstellungen";
import { AGENT_VERSION } from "./http"; import { AGENT_VERSION } from "./http";
/** /**
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei * Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
* Dashboard-Adresse und Agent-Version ein. * Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
* Selbstaktualisierung).
*/ */
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) { export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
const pfad = path.join(process.cwd(), "agent", datei); const pfad = path.join(process.cwd(), "agent", datei);
const inhalt = await readFile(pfad, "utf8"); const [inhalt, werte] = await Promise.all([readFile(pfad, "utf8"), einstellungen()]);
return inhalt return inhalt
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, "")) .replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION); .replaceAll("@@AGENT_VERSION@@", AGENT_VERSION)
.replaceAll("@@TIMER_ONCALENDAR@@", onCalendar(werte.agentRhythmus))
.replaceAll("@@TIMER_STREUUNG@@", `${werte.agentStreuungStunden}h`)
.replaceAll("@@AUTO_UPDATE@@", werte.agentAutoUpdate ? "yes" : "no");
} }
/** Bevorzugt die konfigurierte Adresse; sonst die Herkunft der Anfrage. */ /**
export function basisUrl(request: Request): string { * Bevorzugt die im Dashboard eingetragene Adresse, dann `APP_URL`, sonst die Herkunft der Anfrage.
* Die Reihenfolge ist wichtig: Hinter einem Reverse Proxy stimmt die Herkunft oft nicht.
*/
export async function basisUrl(request: Request): Promise<string> {
const werte = await einstellungen();
if (werte.dashboardUrl) return werte.dashboardUrl;
if (process.env.APP_URL) return process.env.APP_URL; if (process.env.APP_URL) return process.env.APP_URL;
return new URL(request.url).origin; return new URL(request.url).origin;
} }
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { einstellungen } from "@/server/einstellungen";
/**
* Löscht alte Reports und Protokolleinträge nach den eingestellten Fristen (#025).
*
* Der jüngste Report jedes Hosts bleibt **immer** erhalten – sonst stünde ein Host, der lange
* nicht gemeldet hat, plötzlich ohne jede Angabe da. Eine Frist von 0 heißt: nichts löschen.
*/
export async function altdatenLoeschen(jetzt: Date = new Date()): Promise<{
reports: number;
protokoll: number;
}> {
const { aufbewahrungReportsTage, aufbewahrungAuditTage } = await einstellungen();
let reports = 0;
if (aufbewahrungReportsTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungReportsTage * 86_400_000);
const neueste = await prisma.report.groupBy({
by: ["hostId"],
_max: { erhobenAm: true },
});
const behalten = await prisma.report.findMany({
where: {
OR: neueste
.filter((eintrag) => eintrag._max.erhobenAm !== null)
.map((eintrag) => ({ hostId: eintrag.hostId, erhobenAm: eintrag._max.erhobenAm as Date })),
},
select: { id: true },
});
const ergebnis = await prisma.report.deleteMany({
where: {
erhobenAm: { lt: grenze },
id: { notIn: behalten.map((eintrag) => eintrag.id) },
},
});
reports = ergebnis.count;
}
let protokoll = 0;
if (aufbewahrungAuditTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungAuditTage * 86_400_000);
const ergebnis = await prisma.auditEintrag.deleteMany({ where: { erstelltAm: { lt: grenze } } });
protokoll = ergebnis.count;
}
return { reports, protokoll };
}
+34 -4
View File
@@ -3,9 +3,26 @@ import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit"; import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { domaeneErlaubt, einstellungen } from "@/server/einstellungen";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */ /**
export const anmeldeRateLimit = new RateLimit(5, 5 / 900); * Anmeldeversuche je Kennung und Viertelstunde. Die Zahl steht in den Einstellungen;
* für jede Einstellung wird eine eigene Bremse gehalten, damit der Zustand beim Ändern
* nicht verlorengeht.
*/
const bremsen = new Map<number, RateLimit>();
export function anmeldeBremse(versuche: number): RateLimit {
const vorhanden = bremsen.get(versuche);
if (vorhanden) return vorhanden;
const neu = new RateLimit(versuche, versuche / 900);
bremsen.set(versuche, neu);
return neu;
}
/** Vorgabe für Aufrufer ohne Einstellungen (Tests). */
export const anmeldeRateLimit = anmeldeBremse(5);
export class AnmeldeFehler extends Error {} export class AnmeldeFehler extends Error {}
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
passwort: string, passwort: string,
quelle: string, quelle: string,
): Promise<BenutzerModel> { ): Promise<BenutzerModel> {
const { anmeldeversuche } = await einstellungen();
const kennung = `${quelle}:${email.toLowerCase()}`; const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) { if (!anmeldeBremse(anmeldeversuche).pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten."); throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
} }
@@ -41,10 +59,17 @@ export async function mitPasswortAnmelden(
/** /**
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail. * Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten. * Ob ein unbekanntes Konto angelegt werden darf und welche E-Mail-Domänen zugelassen sind,
* steht in den Einstellungen (#025). Vorgabe bleibt: anlegen, alle Domänen.
*/ */
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> { export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim(); const normalisiert = email.toLowerCase().trim();
const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen();
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
throw new AnmeldeFehler("Diese E-Mail-Domäne ist für die Anmeldung nicht zugelassen.");
}
const vorhanden = const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ?? (await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
@@ -54,6 +79,11 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
} }
if (!vorhanden && !oidcKontenAnlegen) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "kein-konto" });
throw new AnmeldeFehler("Für diese Adresse gibt es hier kein Konto.");
}
const benutzer = vorhanden const benutzer = vorhanden
? await prisma.benutzer.update({ ? await prisma.benutzer.update({
where: { id: vorhanden.id }, where: { id: vorhanden.id },
+120
View File
@@ -0,0 +1,120 @@
import { z } from "zod";
import { prisma } from "@/lib/db";
/**
* Betriebseinstellungen, die sich im Dashboard ändern lassen (#025).
*
* Alles liegt in **einer** Zeile der Tabelle `Einstellung` als JSON – so ist ein Speichern
* ein einziger Schreibvorgang, und fehlende Felder bekommen ihren Vorgabewert.
*
* Bewusst **nicht** hier: `APP_SECRET` und `DATABASE_URL`. Die braucht die Anwendung, bevor sie
* die Datenbank überhaupt lesen kann; sie bleiben in der Umgebung.
*/
const SCHLUESSEL = "system";
export const einstellungenSchema = z.object({
// --- Adresse und Agent ---
/** Adresse, die im Installations-Einzeiler und in Benachrichtigungen steht. Leer = aus `APP_URL`. */
dashboardUrl: z.union([z.string().url("Bitte eine vollständige Adresse angeben."), z.literal("")]).default(""),
agentRhythmus: z.enum(["taeglich", "zwoelfstuendlich", "stuendlich"]).default("taeglich"),
/** Zufällige Verzögerung, damit nicht alle Hosts gleichzeitig melden. */
agentStreuungStunden: z.number().int().min(0).max(12).default(4),
agentAutoUpdate: z.boolean().default(false),
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
steuersatz: z.number().min(0).max(100).default(19),
zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
// --- Sicherheit ---
sitzungsdauerStunden: z.number().int().min(1).max(168).default(12),
anmeldeversuche: z.number().int().min(3).max(50).default(5),
/** Komma-getrennte Liste erlaubter E-Mail-Domänen für OIDC. Leer = alle. */
oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true),
/** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
// --- Firma und Darstellung ---
firmaName: z.string().max(120).default(""),
firmaAnschrift: z.string().max(400).default(""),
firmaEmail: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.literal("")]).default(""),
zeitzone: z.string().min(1).default("Europe/Berlin"),
});
export type Einstellungen = z.infer<typeof einstellungenSchema>;
export const VORGABEN: Einstellungen = einstellungenSchema.parse({});
/**
* Kurzer Zwischenspeicher: Die Einstellungen werden bei fast jeder Anfrage gebraucht,
* ändern sich aber selten. Zehn Sekunden reichen, damit eine Änderung sofort spürbar ist.
*/
let zwischenspeicher: { werte: Einstellungen; bis: number } | null = null;
export async function einstellungen(): Promise<Einstellungen> {
if (zwischenspeicher && zwischenspeicher.bis > Date.now()) return zwischenspeicher.werte;
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
// Unbekannte oder kaputte Werte fallen auf die Vorgabe zurück, statt den Betrieb anzuhalten.
const ergebnis = einstellungenSchema.safeParse(eintrag?.wert ?? {});
const werte = ergebnis.success ? ergebnis.data : VORGABEN;
zwischenspeicher = { werte, bis: Date.now() + 10_000 };
return werte;
}
export async function einstellungenSpeichern(werte: Einstellungen): Promise<void> {
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: werte },
update: { wert: werte },
});
zwischenspeicher = null;
}
/** Nur für Tests: erzwingt das nächste Lesen aus der Datenbank. */
export function zwischenspeicherLeeren(): void {
zwischenspeicher = null;
}
/**
* Fälligkeitsdatum, das ein leeres Rechnungsformular vorschlägt: heute plus Zahlungsziel.
* Bewusst hier und nicht in der Seite – eine Komponente darf beim Rendern nicht die Uhr lesen.
*/
export async function faelligkeitsVorschlag(jetzt: Date = new Date()): Promise<string> {
const { zahlungszielTage } = await einstellungen();
return new Date(jetzt.getTime() + zahlungszielTage * 86_400_000).toISOString();
}
/** systemd-Angabe für den Melderhythmus des Agents. */
export function onCalendar(rhythmus: Einstellungen["agentRhythmus"]): string {
switch (rhythmus) {
case "stuendlich":
return "hourly";
case "zwoelfstuendlich":
return "0/12:00:00";
default:
return "daily";
}
}
/**
* Prüft, ob eine E-Mail-Adresse zu den erlaubten OIDC-Domänen gehört.
* Ohne Angabe ist jede Domäne erlaubt – so verhält es sich wie bisher.
*/
export function domaeneErlaubt(email: string, domaenen: string): boolean {
const liste = domaenen
.split(",")
.map((eintrag) => eintrag.trim().toLowerCase().replace(/^@/, ""))
.filter(Boolean);
if (liste.length === 0) return true;
const domaene = email.toLowerCase().split("@")[1] ?? "";
return liste.includes(domaene);
}
+9 -3
View File
@@ -121,14 +121,17 @@ export function Bericht({
untertitel, untertitel,
children, children,
quer = false, quer = false,
firma,
}: { }: {
titel: string; titel: string;
untertitel?: string; untertitel?: string;
children: React.ReactNode; children: React.ReactNode;
quer?: boolean; quer?: boolean;
/** Firmenangaben und Zeitzone aus den Einstellungen (#025). */
firma?: { name: string; anschrift: string; email: string; zeitzone: string };
}) { }) {
const erstellt = new Intl.DateTimeFormat("de-DE", { const erstellt = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin", timeZone: firma?.zeitzone || "Europe/Berlin",
dateStyle: "long", dateStyle: "long",
}).format(new Date()); }).format(new Date());
@@ -138,8 +141,11 @@ export function Bericht({
<View style={stile.kopf} fixed> <View style={stile.kopf} fixed>
<Logo /> <Logo />
<View style={stile.kopfText}> <View style={stile.kopfText}>
<Text style={stile.marke}>ProxLedger</Text> <Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text> <Text style={stile.untertitel}>
{firma?.anschrift ? firma.anschrift.replace(/\n/g, " · ") : "Proxmox Subscription Dashboard"}
</Text>
{firma?.email ? <Text style={stile.untertitel}>{firma.email}</Text> : null}
</View> </View>
</View> </View>
+6 -3
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { einstellungen } from "@/server/einstellungen";
export class RechnungsFehler extends Error {} export class RechnungsFehler extends Error {}
@@ -67,6 +68,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden."); if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben."); if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer.trim(); const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`; const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
@@ -79,7 +81,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
data: { data: {
datum: eingabe.datum, datum: eingabe.datum,
faelligAm: eingabe.faelligAm, faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? 19, ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null, rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null, zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt, bezahlt: eingabe.bezahlt,
@@ -94,7 +96,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
datum: eingabe.datum, datum: eingabe.datum,
faelligAm: eingabe.faelligAm, faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag, nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? 19, ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null, rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null, zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt, bezahlt: eingabe.bezahlt,
@@ -146,6 +148,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung."); throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung.");
} }
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer?.trim() || null; const nummer = eingabe.nummer?.trim() || null;
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`; const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
const betrag = eingabe.nettoBetrag ?? 0; const betrag = eingabe.nettoBetrag ?? 0;
@@ -160,7 +163,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
erstellt: eingabe.erstellt, erstellt: eingabe.erstellt,
nummer, nummer,
datum: eingabe.datum ?? null, datum: eingabe.datum ?? null,
ustSatz: eingabe.ustSatz ?? 19, ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
bezahlt: eingabe.bezahlt, bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null, bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null, anhangId: eingabe.anhangId ?? null,
+15 -1
View File
@@ -2,6 +2,7 @@ import { Cron } from "croner";
import "dotenv/config"; import "dotenv/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich"; import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { altdatenLoeschen } from "@/server/aufraeumen";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand"; import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
/** /**
@@ -14,6 +15,7 @@ import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/
const ZEITZONE = "Europe/Berlin"; const ZEITZONE = "Europe/Berlin";
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr
const AUFRAEUM_ZEITPLAN = process.env.AUFRAEUM_CRON ?? "30 3 * * *"; // nachts, wenn nichts los ist
function protokoll(nachricht: string) { function protokoll(nachricht: string) {
console.log(`[${new Date().toISOString()}] ${nachricht}`); console.log(`[${new Date().toISOString()}] ${nachricht}`);
@@ -60,10 +62,21 @@ async function zusammenfassung() {
} }
} }
async function aufraeumen() {
try {
const ergebnis = await altdatenLoeschen();
if (ergebnis.reports > 0 || ergebnis.protokoll > 0) {
protokoll(`Aufgeräumt: ${ergebnis.reports} Reports, ${ergebnis.protokoll} Protokolleinträge gelöscht.`);
}
} catch (fehler) {
console.error("Aufräumen fehlgeschlagen:", fehler);
}
}
async function start() { async function start() {
await warnregelnSicherstellen(); await warnregelnSicherstellen();
protokoll( protokoll(
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}" (${ZEITZONE}).`, `Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}", Aufräumen "${AUFRAEUM_ZEITPLAN}" (${ZEITZONE}).`,
); );
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird. // Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
@@ -72,6 +85,7 @@ async function start() {
const aufgaben = [ const aufgaben = [
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf), new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung), new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
new Cron(AUFRAEUM_ZEITPLAN, { timezone: ZEITZONE }, aufraeumen),
]; ];
for (const signal of ["SIGINT", "SIGTERM"] as const) { for (const signal of ["SIGINT", "SIGTERM"] as const) {
+92
View File
@@ -0,0 +1,92 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { altdatenLoeschen } from "@/server/aufraeumen";
import { einstellungenSpeichern, VORGABEN, zwischenspeicherLeeren } from "@/server/einstellungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const JETZT = new Date("2026-09-18T12:00:00Z");
const tageVorher = (tage: number) => new Date(JETZT.getTime() - tage * 86_400_000);
async function hostMitReports(tage: number[]) {
const host = await testPrisma.host.create({
data: { agentId: "agent-1", hostname: "pve-01", produkt: "PVE", letzterReportAm: JETZT },
});
for (const alter of tage) {
await testPrisma.report.create({
data: {
hostId: host.id,
reportId: `report-${alter}`,
erhobenAm: tageVorher(alter),
empfangenAm: tageVorher(alter),
subStatus: "ACTIVE",
rohdaten: {},
},
});
}
return host;
}
describe.runIf(datenbankVerfuegbar)("Altdaten löschen", () => {
beforeEach(async () => {
await datenbankLeeren();
zwischenspeicherLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("löscht Reports, die älter sind als die Frist", async () => {
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungReportsTage: 30 });
await hostMitReports([1, 10, 45, 400]);
const ergebnis = await altdatenLoeschen(JETZT);
expect(ergebnis.reports).toBe(2);
expect(await testPrisma.report.count()).toBe(2);
});
it("behält den jüngsten Report jedes Hosts, auch wenn er zu alt ist", async () => {
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungReportsTage: 30 });
await hostMitReports([200, 400]);
await altdatenLoeschen(JETZT);
const uebrig = await testPrisma.report.findMany();
expect(uebrig).toHaveLength(1);
expect(uebrig[0].erhobenAm).toEqual(tageVorher(200));
});
it("löscht nichts, wenn die Frist auf 0 steht", async () => {
await einstellungenSpeichern({
...VORGABEN,
aufbewahrungReportsTage: 0,
aufbewahrungAuditTage: 0,
});
await hostMitReports([400, 800]);
await testPrisma.auditEintrag.create({
data: { aktion: "test", entitaet: "Host", erstelltAm: tageVorher(3000) },
});
const ergebnis = await altdatenLoeschen(JETZT);
expect(ergebnis).toEqual({ reports: 0, protokoll: 0 });
expect(await testPrisma.report.count()).toBe(2);
expect(await testPrisma.auditEintrag.count()).toBe(1);
});
it("räumt auch das Protokoll auf", async () => {
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungAuditTage: 365 });
for (const alter of [10, 400]) {
await testPrisma.auditEintrag.create({
data: { aktion: "test", entitaet: "Host", erstelltAm: tageVorher(alter) },
});
}
const ergebnis = await altdatenLoeschen(JETZT);
expect(ergebnis.protokoll).toBe(1);
expect(await testPrisma.auditEintrag.count()).toBe(1);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { domaeneErlaubt, einstellungenSchema, onCalendar, VORGABEN } from "@/server/einstellungen";
describe("Einstellungen", () => {
it("füllt fehlende Werte mit den Vorgaben", () => {
const werte = einstellungenSchema.parse({});
expect(werte.steuersatz).toBe(19);
expect(werte.sitzungsdauerStunden).toBe(12);
expect(werte.agentRhythmus).toBe("taeglich");
expect(werte.oidcKontenAnlegen).toBe(true);
});
it("übernimmt gesetzte Werte und lässt den Rest auf Vorgabe", () => {
const werte = einstellungenSchema.parse({ steuersatz: 7.7, waehrung: "CHF" });
expect(werte.steuersatz).toBe(7.7);
expect(werte.waehrung).toBe("CHF");
expect(werte.zahlungszielTage).toBe(VORGABEN.zahlungszielTage);
});
it("weist unsinnige Werte ab", () => {
expect(einstellungenSchema.safeParse({ steuersatz: 250 }).success).toBe(false);
expect(einstellungenSchema.safeParse({ sitzungsdauerStunden: 0 }).success).toBe(false);
expect(einstellungenSchema.safeParse({ dashboardUrl: "kein-url" }).success).toBe(false);
expect(einstellungenSchema.safeParse({ anmeldeversuche: 1 }).success).toBe(false);
});
it("lässt eine leere Dashboard-Adresse zu (dann gilt die Umgebung)", () => {
expect(einstellungenSchema.parse({ dashboardUrl: "" }).dashboardUrl).toBe("");
});
it("übersetzt den Rhythmus nach systemd", () => {
expect(onCalendar("taeglich")).toBe("daily");
expect(onCalendar("stuendlich")).toBe("hourly");
expect(onCalendar("zwoelfstuendlich")).toBe("0/12:00:00");
});
});
describe("Erlaubte OIDC-Domänen", () => {
it("lässt ohne Angabe jede Domäne zu", () => {
expect(domaeneErlaubt("wer@auch-immer.de", "")).toBe(true);
});
it("lässt nur die genannten Domänen zu", () => {
expect(domaeneErlaubt("fw@wilde-server.de", "wilde-server.de")).toBe(true);
expect(domaeneErlaubt("fremd@example.com", "wilde-server.de")).toBe(false);
});
it("versteht mehrere Domänen, Leerzeichen und führendes @", () => {
const liste = " @wilde-server.de, kunde.de ";
expect(domaeneErlaubt("a@kunde.de", liste)).toBe(true);
expect(domaeneErlaubt("b@WILDE-SERVER.DE", liste)).toBe(true);
expect(domaeneErlaubt("c@fremd.de", liste)).toBe(false);
});
it("weist eine Adresse ohne Domäne ab", () => {
expect(domaeneErlaubt("kaputt", "kunde.de")).toBe(false);
});
});
+34 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { herkunftStimmt } from "@/app/api/auth/anmelden/route"; import { herkunftStimmt } from "@/app/api/auth/anmelden/route";
import { eigeneAdresse } from "@/lib/adresse";
import type { GeleseneSitzung } from "@/lib/sitzung"; import type { GeleseneSitzung } from "@/lib/sitzung";
import { sitzungBewerten } from "@/server/auth/sitzungspflicht"; import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
@@ -72,3 +73,35 @@ describe("Herkunft der Anmeldung", () => {
).toBe(true); ).toBe(true);
}); });
}); });
describe("Öffentliche Adresse", () => {
// APP_URL kommt sonst aus der .env der Entwicklungsumgebung und verfälscht die Rückfallstufen.
beforeEach(() => {
vi.stubEnv("APP_URL", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
const anfrageMit = (kopfzeilen: Record<string, string>) =>
new Request("http://0a5963a591df:3000/api/auth/anmelden", { method: "POST", headers: kopfzeilen });
it("nimmt die eingetragene Adresse, nicht die interne des Containers", () => {
const adresse = eigeneAdresse(anfrageMit({}), "/hosts", "https://proxledger.example.de");
expect(adresse.toString()).toBe("https://proxledger.example.de/hosts");
});
it("fällt auf die Kopfzeilen des Reverse Proxy zurück", () => {
const anfrage = anfrageMit({ "x-forwarded-host": "dashboard.kunde.de", "x-forwarded-proto": "https" });
expect(eigeneAdresse(anfrage, "/").toString()).toBe("https://dashboard.kunde.de/");
});
it("nimmt notfalls die Anfrage selbst", () => {
expect(eigeneAdresse(anfrageMit({}), "/").host).toBe("0a5963a591df:3000");
});
it("übergeht einen unbrauchbaren Eintrag, statt zu scheitern", () => {
const anfrage = anfrageMit({ "x-forwarded-host": "dashboard.kunde.de" });
expect(eigeneAdresse(anfrage, "/", "keine-adresse").host).toBe("dashboard.kunde.de");
});
});