diff --git a/.env.example b/.env.example index ee1a06f..e144583 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,8 @@ OIDC_CLIENT_ID="" OIDC_CLIENT_SECRET="" 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" # 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) 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 UPLOAD_DIR="./data/uploads" diff --git a/CHANGELOG.md b/CHANGELOG.md index a29b340..fefff13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### 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://: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 diff --git a/DEVLOG.md b/DEVLOG.md index ec3626b..c4b6301 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 11 h 38 min +**Gesamt:** 12 h 18 min | Issue | Zeit | |---|---| @@ -31,6 +31,7 @@ | #023 Feldtest PBS | 0 h 35 min | | #024 OWASP-Härtung | 0 h 45 min | | #025 Einstellungen | 1 h 10 min | +| #026 Auslieferung | 0 h 40 min | | Meilenstein | Zeit | |---|---| @@ -53,7 +54,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.16.0 +**Version:** 0.17.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -147,6 +148,10 @@ - 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 - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) diff --git a/README.md b/README.md index d478b1c..ec3715e 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,10 @@ 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. -> **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten -> Proxmox-Hosts erprobt (#018). +> **Status:** einsatzbereit. Der Agent ist auf echten Hosts erprobt – Proxmox VE 9.2 mit und ohne +> 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 @@ -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`. 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 deploy/lxc-installer.sh +bash deploy/update.sh ``` -Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter -und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm. +**Auf einem Proxmox-Host** gibt es zwei Wege zu einem fertigen LXC-Container: + +```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:///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 @@ -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. - **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 `. - **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse ist `/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 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 | Ansicht | CSV | PDF | diff --git a/deploy/community-scripts/README.md b/deploy/community-scripts/README.md new file mode 100644 index 0000000..796a440 --- /dev/null +++ b/deploy/community-scripts/README.md @@ -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://@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. diff --git a/deploy/community-scripts/ct/proxledger.sh b/deploy/community-scripts/ct/proxledger.sh new file mode 100755 index 0000000..2b08504 --- /dev/null +++ b/deploy/community-scripts/ct/proxledger.sh @@ -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}" diff --git a/deploy/community-scripts/install/proxledger-install.sh b/deploy/community-scripts/install/proxledger-install.sh new file mode 100755 index 0000000..ab493fd --- /dev/null +++ b/deploy/community-scripts/install/proxledger-install.sh @@ -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 </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 </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 </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" diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100755 index 0000000..d94d775 --- /dev/null +++ b/deploy/update.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index a95ef7d..05bb636 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,8 +81,16 @@ services: APP_URL: ${APP_URL:-http://localhost:3000} WORKER_CRON: ${WORKER_CRON:-0 * * * *} ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *} + AUFRAEUM_CRON: ${AUFRAEUM_CRON:-30 3 * * *} volumes: - 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: datenbank: diff --git a/docs/issues/026-auslieferung.md b/docs/issues/026-auslieferung.md new file mode 100644 index 0000000..ba2be40 --- /dev/null +++ b/docs/issues/026-auslieferung.md @@ -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://: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. diff --git a/docs/issues/README.md b/docs/issues/README.md index 307878c..8915993 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -29,4 +29,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#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 | | [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min | -| | **Summe** | | | **130 h** | **11 h 38 min** | +| [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 min | +| | **Summe** | | | **133 h** | **12 h 18 min** | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..fdc6fe9 --- /dev/null +++ b/docs/roadmap.md @@ -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. diff --git a/package.json b/package.json index 01e3efe..e8083af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.16.0", + "version": "0.17.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/auth/anmelden/route.ts b/src/app/api/auth/anmelden/route.ts index 28a4044..5759230 100644 --- a/src/app/api/auth/anmelden/route.ts +++ b/src/app/api/auth/anmelden/route.ts @@ -1,4 +1,5 @@ import { cookies, headers } from "next/headers"; +import { eigeneAdresse } from "@/lib/adresse"; import { clientIp } from "@/lib/ratelimit"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer"; @@ -38,6 +39,7 @@ export function herkunftStimmt(request: Request): boolean { export async function POST(request: Request) { const formular = await request.formData(); + const { dashboardUrl } = await einstellungen(); const email = formular.get("email")?.toString().trim() ?? ""; const passwort = formular.get("passwort")?.toString() ?? ""; const zielEingabe = formular.get("ziel")?.toString() || "/"; @@ -45,7 +47,7 @@ export async function POST(request: Request) { const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/"; function zurueckZurAnmeldung(meldung: string) { - const adresse = new URL("/login", request.url); + const adresse = eigeneAdresse(request, "/login", dashboardUrl); adresse.searchParams.set("fehler", meldung); if (ziel !== "/") adresse.searchParams.set("ziel", ziel); return Response.redirect(adresse, 303); @@ -75,7 +77,7 @@ export async function POST(request: Request) { ); (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) { if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message); console.error("Anmeldung fehlgeschlagen:", fehler); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index bd369fc..d5ed64e 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -1,4 +1,5 @@ import { cookies } from "next/headers"; +import { eigeneAdresse } from "@/lib/adresse"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { mitOidcAnmelden } from "@/server/auth/benutzer"; import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc"; @@ -18,8 +19,10 @@ export async function GET(request: Request) { speicher.delete(name); } + const { dashboardUrl } = await systemEinstellungen(); + 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 { @@ -38,9 +41,9 @@ export async function GET(request: Request) { ); 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) { 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); } } diff --git a/src/lib/adresse.ts b/src/lib/adresse.ts new file mode 100644 index 0000000..eeb4641 --- /dev/null +++ b/src/lib/adresse.ts @@ -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://: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)); +} diff --git a/tests/sicherheit.test.ts b/tests/sicherheit.test.ts index e3aafe2..bee03ce 100644 --- a/tests/sicherheit.test.ts +++ b/tests/sicherheit.test.ts @@ -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 { eigeneAdresse } from "@/lib/adresse"; import type { GeleseneSitzung } from "@/lib/sitzung"; import { sitzungBewerten } from "@/server/auth/sitzungspflicht"; @@ -72,3 +73,35 @@ describe("Herkunft der Anmeldung", () => { ).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) => + 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"); + }); +});