From dd9327775096eda456aaa111cf040399757f52c6 Mon Sep 17 00:00:00 2001
From: Bjoern Runden
Date: Sun, 18 Oct 2026 17:08:58 +0200
Subject: [PATCH] Accountant: Inventar, where, Checkmk, Abrechnung
---
.ai/sultan-system-architecture.md | 101 ++++-
.dockerignore | 2 +
.gitignore | 4 +
CLAUDE.md | 17 +-
Dockerfile | 6 +-
README.md | 31 +-
deploy/compose/accountant/.env.example | 23 ++
deploy/compose/accountant/compose.yaml | 21 +
deploy/compose/agent/.env.example | 19 +-
deploy/compose/agent/compose.yaml | 7 +-
deploy/compose/server/.env.example | 39 +-
deploy/compose/server/compose.yaml | 21 +-
docs/hoster.md | 19 +-
pkg/accountant/accountant.go | 333 ++++++++++++++++
pkg/accountant/accountant_test.go | 260 +++++++++++++
pkg/accountant/billing.go | 296 ++++++++++++++
pkg/accountant/billing_test.go | 203 ++++++++++
pkg/accountant/checkmk.go | 329 ++++++++++++++++
pkg/accountant/checkmk_test.go | 271 +++++++++++++
pkg/accountant/customers_test.go | 60 +++
pkg/accountant/money.go | 45 +++
pkg/accountant/report.go | 137 +++++++
pkg/accountant/report_test.go | 144 +++++++
pkg/accountant/where.go | 236 +++++++++++
pkg/accountant/where_test.go | 136 +++++++
pkg/agent/agent.go | 26 +-
pkg/agent/agent_test.go | 54 ++-
pkg/agent/hostinfo.go | 165 ++++++++
pkg/agent/hostinfo_test.go | 96 +++++
pkg/agent/workloads.go | 22 +-
pkg/agent/workloads_test.go | 38 ++
pkg/checkmk/checkmk.go | 4 +
pkg/cli/accountant.go | 516 +++++++++++++++++++++++++
pkg/cli/accountant_test.go | 471 ++++++++++++++++++++++
pkg/cli/agent.go | 1 +
pkg/cli/agent_test.go | 4 +-
pkg/cli/cli.go | 7 +
pkg/cli/cli_test.go | 20 +
pkg/cli/customers.go | 267 +++++++++++++
pkg/cli/products.go | 319 +++++++++++++++
pkg/docker/docker.go | 84 +++-
pkg/docker/docker_test.go | 61 +++
pkg/ledger/customers.go | 299 ++++++++++++++
pkg/ledger/customers_test.go | 118 ++++++
pkg/ledger/ledger.go | 485 +++++++++++++++++++++++
pkg/ledger/ledger_test.go | 280 ++++++++++++++
pkg/ledger/products.go | 326 ++++++++++++++++
pkg/ledger/products_test.go | 124 ++++++
pkg/ratelimit/ratelimit.go | 30 +-
pkg/ratelimit/ratelimit_test.go | 30 +-
pkg/registry/registry.go | 58 +++
pkg/registry/registry_test.go | 62 +++
pkg/server/handlers.go | 2 +-
pkg/server/payee.go | 401 +++++++++++++++++++
pkg/server/payee_routes_test.go | 79 ++++
pkg/server/payee_test.go | 284 ++++++++++++++
pkg/server/server.go | 50 ++-
pkg/store/opendb_test.go | 55 +++
pkg/store/store.go | 37 +-
59 files changed, 7522 insertions(+), 113 deletions(-)
create mode 100644 deploy/compose/accountant/.env.example
create mode 100644 deploy/compose/accountant/compose.yaml
create mode 100644 pkg/accountant/accountant.go
create mode 100644 pkg/accountant/accountant_test.go
create mode 100644 pkg/accountant/billing.go
create mode 100644 pkg/accountant/billing_test.go
create mode 100644 pkg/accountant/checkmk.go
create mode 100644 pkg/accountant/checkmk_test.go
create mode 100644 pkg/accountant/customers_test.go
create mode 100644 pkg/accountant/money.go
create mode 100644 pkg/accountant/report.go
create mode 100644 pkg/accountant/report_test.go
create mode 100644 pkg/accountant/where.go
create mode 100644 pkg/accountant/where_test.go
create mode 100644 pkg/agent/hostinfo.go
create mode 100644 pkg/agent/hostinfo_test.go
create mode 100644 pkg/cli/accountant.go
create mode 100644 pkg/cli/accountant_test.go
create mode 100644 pkg/cli/customers.go
create mode 100644 pkg/cli/products.go
create mode 100644 pkg/ledger/customers.go
create mode 100644 pkg/ledger/customers_test.go
create mode 100644 pkg/ledger/ledger.go
create mode 100644 pkg/ledger/ledger_test.go
create mode 100644 pkg/ledger/products.go
create mode 100644 pkg/ledger/products_test.go
create mode 100644 pkg/server/payee.go
create mode 100644 pkg/server/payee_routes_test.go
create mode 100644 pkg/server/payee_test.go
create mode 100644 pkg/store/opendb_test.go
diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md
index 69c1a49..ab25f83 100644
--- a/.ai/sultan-system-architecture.md
+++ b/.ai/sultan-system-architecture.md
@@ -493,6 +493,7 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende
| `--node-id` | aus dem Hostnamen | z. B. `Proxmox-VM.42` → `proxmox-vm-42` |
| `--docker-socket` | `/var/run/docker.sock` | `none` = ohne Docker |
| `--backend-host` / `--backend-interface` | automatisch | siehe Backend-Adresse |
+| `--fqdn` / `SULTAN_FQDN` | automatisch | Name des Hosts fürs Inventar (5.3), nur Information |
* **Enrollment:**
1. Der Agent erzeugt `node.key` und prüft den Server gegen den Fingerprint aus der Einladung.
@@ -614,6 +615,87 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende
+### 5.3 Accountant: zentrales Inventar & Abrechnung (Entscheidung 2026-10-16; 6a–6c umgesetzt)
+
+**Zweck (Priorität):** (1) Im Störfall sofort wissen, auf welchem Backend-Host und in welchem Compose-Stack eine URL läuft – ohne Umweg über DNS, Reverse-Proxy und VPN-Suche. (2) Inventar je Mandant (Grundlage für spätere Statusberichte an Mandanten). (3) Abrechnung markierter Services.
+
+**Rollen:**
+* **Accountant** – `sultan accountant` (gleiches Binary, eigene Rolle): nimmt Berichte entgegen, führt Inventar und Historie. Eigenes Datenverzeichnis mit **eigener `accountant.db`**, eigenem TLS-Zertifikat (gepinnt wie beim Server) und eigenem Agent-Port (Default `:8444`).
+* **Payee** – ein normaler `sultan server` (Ingress mit Traefik), der zusätzlich an den Accountant meldet: `--accountant=https://…:8444` (bzw. `SULTAN_ACCOUNTANT`) plus Einladung des Accountants. Nur Server melden; Agents (Docker wie systemd) sind nicht meldepflichtig, sie liefern ihrem Server lediglich Zusatzdaten.
+
+**Warum eigene DB:** andere Lebensdauer (Historie über Jahre, eigene Aufbewahrung/Backups), anderes Vertrauen (Berichte sind Eingaben fremder Server und werden nie mit dem eigenen Mandanten- und Schlüsselbestand vermischt), kein Ballast und keine Migrationen auf jedem Ingress. Migrationsmechanik und Zeitformat werden aus `pkg/store` wiederverwendet.
+
+**Transport & Sicherheit:** wie Agent → Server. Payee erzeugt lokal `payee.key` (Ed25519, im Datenverzeichnis des Servers), löst eine Einladung `sltn_enroll_..` des Accountants ein (`sultan accountant invite --payee=`), pinnt dessen Fingerprint, signiert jeden Request (`pkg/auth`). Nur TLS 1.3, kein Klartext.
+
+**Bericht = vollständiger Schnappschuss** (`POST /api/v1/report`), kein Delta: sofort nach Änderungen (Knoten online/offline, `service_state`, Mandantenänderung; 10 s gebündelt) und stündlich als Lebenszeichen. Inhalt:
+* Payee: Name, Version, Zeitpunkt.
+* Mandanten: ID, Name, Tier, Status.
+* Knoten: ID, Mandant, Hostname, **FQDN**, IP, Status, `last_seen`, Betriebssystem, Docker-Version, NetBird-IP (falls vorhanden).
+* Routen: Router, Hosts, Mandant, Knoten, Ziel als **`:`** und `:`, Container, **Compose-Projekt und -Verzeichnis**, Startzeit, Docker-Health, Zustand (läuft / down / Wartung / Mahnstufe), Produkt aus `sultan.accountant.billing`.
+
+**Zusatzdaten vom Agent** (umgesetzt 2026-10-16; nur Information, ändern das Routing nie; **ohne zusätzliche Labels**, alles automatisch erkannt):
+* je Container `meta` im Workload: `compose_project`, `compose_service`, `compose_dir` (aus den Labels, die Docker Compose selbst setzt: `com.docker.compose.project`, `….service`, `….project.working_dir`), `state`, `started_at`, `health` (aus `GET /containers/{id}/json`, `docker.Client.Inspect`). Ein Health-Wechsel löst über das Docker-Event `health_status` eine neue Meldung aus. sultan.d-Dienste haben kein `meta`.
+* je Knoten `host_info` im `service_state`: `fqdn`, `os`, `docker_version`, `netbird_ip`.
+ * **FQDN:** `--fqdn` > Hostname mit Punkt > Hostname über die Suchdomain aufgelöst (CNAME/kanonischer Name) > Reverse-Lookup der Backend-Adresse > kurzer Hostname; einmal beim Start, je Abfrage max. 2 s.
+ * **Betriebssystem/Docker-Version:** aus `GET /info` (das OS des Hosts, auch wenn der Agent im Container läuft); ohne Docker `PRETTY_NAME` aus `/etc/os-release`.
+ * **NetBird-IP:** IPv4 auf `wt0`, sonst die erste Adresse aus 100.64.0.0/10.
+* Der Server liest `service_state` tolerant: ältere Server ignorieren die Felder, ältere Agents schicken sie nicht. Die Registry speichert sie mit dem Zustand (`Registry.State`, `Server.NodeState`) für den Bericht an den Accountant.
+* Das Ziel `:` je URL entsteht beim Bericht aus `host_info.fqdn` und dem (bereits auf den Host-Port umgerechneten) Service-Port.
+
+**Umsetzung Accountant, Teil 1 (6b, 2026-10-16):**
+* `pkg/accountant`: `New` (Datenverzeichnis 0700, Default `./accdata`; `accountant.db`; eigenes `server.crt`/`server.key`), `Start`/`Run`/`Shutdown`, TLS-Port Default `:8444`, `POST /api/v1/enroll` (signiert mit `payee.key`, `auth.Verifier.VerifyUnregistered`; Body `{token, hostname}`, Antwort `{payee_id}`; 403 ohne Details bei unbekannter/verbrauchter/abgelaufener Einladung, 409 bei doppeltem Schlüssel). `--public-url` wird gemerkt (`settings.public_urls`) und steckt in Einladungen.
+* `pkg/ledger` (`accountant.db`, Migrationen über `store.OpenDB`): `payees` (ID aus der Einladung, Hostname, Schlüssel, IP, Zeitpunkt), `invites` (nur Hash, einmalig, Ablauf), `settings`. Ein registrierter Payee bekommt keine neue Einladung, bevor er gelöscht ist.
+* CLI: `sultan accountant [--addr --public-url --data-dir]`, `accountant invite --payee ID [--ttl] [--server-url]`, `accountant payee list`, `accountant payee delete --id ID`. Verwaltungsbefehle legen nie ein neues Datenverzeichnis an (Schutz vor Tippfehlern).
+* Rate-Limit für `/enroll` wie beim Server (`--enroll-limit`, Default 10/min je IP, `pkg/ratelimit`; schützt vor Last und Log-Flut, nicht vor Raten – dafür sorgen 256 Bit Zufall). Compose-Vorlage: `deploy/compose/accountant` (beides umgesetzt 2026-10-18).
+
+**Umsetzung Payee & Berichte (6c, 2026-10-17):**
+* Server: `--accountant-token` (`SULTAN_ACCOUNTANT_TOKEN`, Einladung, nur beim ersten Start) und `--accountant` (`SULTAN_ACCOUNTANT`, Adress-Override; Vorrang: Override > Adresse in der Einladung > `accountant.json`). Im Datenverzeichnis des Servers: `payee.key` (0600) und `accountant.json` (`url`, `fingerprint`, `payee_id`). Ohne Einladung und ohne `accountant.json` meldet der Server nichts.
+* **Fehlerverhalten:** Unbrauchbare Angaben (Einladung unlesbar, `http://`, Einladung ohne jede Adresse, `--accountant` ohne Einladung) verhindern den Start – das sind Tippfehler. Ein nicht erreichbarer Accountant hält den Ingress **nie** auf: Enrollment und Berichte laufen im Hintergrund und werden je Prüfintervall wiederholt; Fehler werden nur bei Änderung geloggt. Einladung eines **anderen** Accountants bei bestehender Registrierung: Fehler im Log, keine Berichte, Server läuft (kein stiller Wechsel).
+* **Wann gemeldet wird:** Der Server baut den Schnappschuss je Prüfintervall (Default 10 s) und sendet nur, wenn sich der Inhalt (Hash ohne Zeitstempel) geändert hat oder das Lebenszeichen fällig ist (Default 1 h). Polling statt Ereignis-Hooks: erfasst auch Mandanten-, Wartungs- und Statusänderungen ohne zusätzliche Kopplung. Fehlgeschlagene Berichte werden beim nächsten Intervall wiederholt.
+* **Inhalt** (`accountant.Report`): Mandanten, alle Knoten aus `sultan.db` (verbunden, Wartung, `last_seen`, Host-Infos aus dem letzten `service_state`), Routen aus den gemeldeten Zuständen (`reportRoutes`: Hosts per `HostsFromRule`/`HostsFromSNIRule`, `backend`, `backend_fqdn` = `:`, Compose-Angaben, Produkt aus `sultan.accountant.billing`, `down` aus `down_services`, `active` = Router steht in der Traefik-Konfiguration, also nicht von der Policy verworfen).
+* Accountant: `POST /api/v1/report` (signiert, Schlüssel muss registriert sein, max. 8 MiB, `generated_at` Pflicht), speichert den **letzten** Bericht je Payee normalisiert (`reports`, Migration v2 von `accountant.db`). CLI: `payee list` mit `LAST_REPORT`, `payee report --id` (JSON).
+
+**Umsetzung Checkmk (6e, 2026-10-18):**
+* `--metrics-addr` (Default `127.0.0.1:8082`, Klartext, nur Loopback; `none` = aus), `GET /metrics/checkmk`. Quelle: letzter Bericht je Payee (keine Historie nötig).
+* Services: `Sultan Payee ` (OK; WARN ab `--stale-warn` 2 h bzw. ohne Bericht; CRIT ab `--stale-crit` 6 h) und `Sultan URL ` je Host jeder Route (OK; **WARN** bei „von Policy verworfen“, „gestoppt/Wartungsseite“, „unhealthy“, „Knoten getrennt“; angekündigte **Wartung** des Knotens bleibt OK mit Hinweis; veralteter Bericht des Payees überträgt WARN/CRIT). Derselbe Host mehrfach → Zusatz `(payee/knoten/router)`.
+* Text per `text/template`: eingebaut (`defaultCheckmkTemplate`), eigene `/checkmk/checkmk.tmpl` hat Vorrang und wirkt sofort; fehlerhafte Vorlage → eingebaute greift + CRIT-Service „Sultan Accountant Vorlage“. `sultan accountant dump-checkmk [--force]`. Felder bereinigt (ASCII, einzeilig, `|`→`/`, `"`→`'`) und HTML-escaped; `.Unescaped.` nur bereinigt. Funktion `status` (0/1/2 → OK/WARN/CRIT).
+* Links: Die Standardvorlage schreibt `https://` und `http://:` als Text; ob Checkmk sie klickbar darstellt, hängt von Version/Einstellung ab – Anpassung über die Vorlage.
+
+**Historie:** Der Accountant führt je (Payee, Mandant, Host, Router) Zeiträume `first_seen` / `ended_at`. Fehlt eine Route in einem *frischen* Schnappschuss, endet ihr Zeitraum (Offboarding). Bleibt das Lebenszeichen eines Payees aus (> 2 h), endet **nichts** – der Server ist weg, nicht seine Services; Checkmk meldet den Payee als veraltet.
+
+**Abrechnung – Modell (Antworten 2026-10-18; Umsetzung 6d).** Der Accountant gewinnt: Das Label `sultan.accountant.billing=` ist der bequeme Weg für den Hoster (viele Kunden pflegen ihre Services nicht selbst; der Hoster trägt das Label am Kunden-Container ein), Vorgaben am Accountant haben aber immer Vorrang. **Alle** Routen werden erfasst; nichts verschwindet aus dem Bericht.
+* **Kunde** (zentral am Accountant): trägt Zahlweise, Pauschale und **Kostenverursacher-Gruppe** (z. B. `extern`, `intern`, `familie` – zum Clustern, keine Buchhaltungs-Kostenstelle; Posten haben keine eigene) sowie eine **Notiz** für persönliche Nebenabreden; Gruppe und Notiz sind im Bericht sichtbar und filterbar. Mandanten werden Kunden zugeordnet – gleiche Mandanten-ID auf allen Payees = derselbe Kunde (automatisch: der Kunde entsteht beim ersten Auftauchen der Mandanten-ID mit dem Mandantennamen und Zahlweise „nicht eingeordnet“; maßgeblich ist der Mandant aus der Einladung auf dem Server, nicht eine Angabe des Agents – von Hand nur Zahlweise, Pauschale, Gruppe, Notiz und Ausnahmen); eine Zuordnung je Payee + Mandant überschreibt das (Tippfehler auf einem Nebencluster, historische Abweichungen). Mandanten ohne Kunde erscheinen im Bericht als „Kunde unbekannt“.
+* **Pauschale** ist ein Posten am Kunden (monatlich); die meisten Kunden zahlen pauschal.
+* **Posten = URL bzw. Router:** je Router ein Posten (seine Hosts werden aufgeführt); alle Posten gehören dem Kunden des Mandanten. Einem Posten kann ein „Dienst“ zugeordnet sein (z. B. OLAT) – das ist dann der abgerechnete Zusatz-Service.
+* **Klassen eines Postens:**
+ * `included` – in der Pauschale enthalten (eingepreist), nur aufgeführt;
+ * `billable` – dazugebuchter Zusatz-Service, eigene Position;
+ * `free` – Give-away, kostet derzeit nichts, wird erfasst und kann später (mit Gültig-ab-Datum) abrechenbar werden;
+ * `internal` – keine Rechnung, aber über die Gruppe des Kunden zuordenbar (z. B. Infrastruktur wie `sultan.chmln.de`, Familie) und mit **Schattenpreis**, damit Fragen wie „Was habe ich dem Neffen geschenkt?“ oder „Was müsste das Brautpaar eigentlich zahlen?“ beantwortbar sind; ein Posten kann später an einen echten Kunden übergehen.
+* **Klasse/Produkt einer Route** (erste zutreffende Regel): Zuordnung am Accountant für Route/Domain > Label `sultan.accountant.billing` (Produkt aus dem Katalog bestimmt die Klasse) > Kunde mit Pauschale: `included` > sonst „nicht zugeordnet“ (fällt im Bericht auf). Klassen- und Preisänderungen gelten ab Datum.
+* **Laufzeit:** Abrechnung immer in vollen Monaten (Stichtag Monatserster/Monatsultimo). **Beginn:** Der Monat, in dem ein Posten erstmals auftaucht, zählt voll ab dem 1. Liegen Go-live und Bestellung weit auseinander, setzt der Hoster den Beginn je Posten manuell auf einen späteren Monatsersten (die Zwischenzeit läuft als Einmalbetrag im Projekt, außerhalb von Sultan). Gestoppte Routen, Wartung, Mahnstufen ändern nichts – erst das Verschwinden aus dem Bericht beendet einen Posten. SLA-Verletzungen werden manuell vergütet (bleibt so). Kündigung: Standard zum Monatsultimo; zusätzlich gibt der Bericht eine **tagesgenaue Gegenrechnung** als Vorschlag aus (beschleunigt seltene Kündigungen).
+* **Zahlweise je Kunde:** monatlich, quartalsweise, jährlich – oder **„noch nicht eingeordnet“** (Feedback der Buchhaltung fehlt). Die Monats-CSV weist bei Quartals-/Jahreszahlern den Monatsanteil aus und vermerkt, dass die Rechnung das 3- bzw. 12-Fache beträgt (bzw. führt den Periodenbetrag mit); bei „nicht eingeordnet“ steht ein Vermerk.
+* **Preise** im Accountant: **optional** (Katalogpreis je Produkt, Kundenpreis überschreibt, Gültig-ab). Nicht buchhaltungsrelevant – führend sind die Abos in Lexware Office; Preise dienen Summen, Plausibilität und Schattenpreisen. Ohne Preis bleibt die Spalte leer.
+* **Ausgabe:** CSV je Monat (Abrechnung erfolgt in Lexware Office). Spalten werden an der Praxis nachgeschärft; kein Templating, bis die Buchhaltungsfragen geklärt sind.
+* **Geparkt:** Abo-Revision (Abgleich mit den Lexware-Abos) bleibt vorerst manuell. **Ausblick (TODO):** Der Accountant als „Realität“ statt der Abopflege in Lexware – also Lexware aus dem Bestand speisen statt umgekehrt.
+* **Offene Fachfragen vor 6d:** siehe Abschnitt 9.
+
+**Abfrage:**
+* CLI auf dem Accountant: `sultan accountant where ` (Mandant, Payee, Knoten, `:`, Container, Compose-Verzeichnis, Zustand), `routes [--tenant=…]`, `billing --month=JJJJ-MM`, `payee list`, `invite`.
+* Checkmk (`GET /metrics/checkmk` auf Loopback, umgesetzt 2026-10-18, siehe unten): ein Service je URL mit Link auf `https://` und auf das interne Ziel `http://:`, dazu Frische je Payee. **Text per Vorlage** (Go `text/template`, wie beim Lander): eingebaute Default-Vorlage, eigene unter `/checkmk/` hat Vorrang; `sultan accountant dump-checkmk` exportiert die Vorlage zum Anpassen. Die Default-Vorlage wird mit den Releases weiterentwickelt. **Werte aus Berichten sind Fremdeingaben** (Containername, Compose-Verzeichnis stammen vom Kunden-Host): Sie werden vor dem Einsetzen bereinigt (keine Zeilenumbrüche, kein `|`, ASCII) und HTML-escaped; rohe Werte nur über eine explizit benannte Funktion. Grund: Wer in Checkmk das HTML-Escaping abschaltet, damit Links klickbar sind, öffnet sonst Script-Injektion in die Checkmk-Oberfläche.
+* Später: HTTP-API als Vorstufe für den Export ins Dokuwiki; Statusberichte an Mandanten. (Uptime Kuma verworfen: keine offizielle API.)
+
+**Umsetzung Historie, `where`, `routes` (6d Schritt 1, 2026-10-18):**
+* `accountant.db` v3: `route_spans` – ein Zeitraum je **Ort** (Payee, Mandant, Knoten, Protokoll, Router) mit Hosts, Backend, `:`, Workload, Compose-Verzeichnis, Produkt, `first_seen`/`last_seen`/`ended_at`. `SaveReport` führt sie im selben Schritt wie den letzten Bericht fort: neue Orte öffnen, bestehende übernehmen geänderte Angaben (`first_seen` bleibt), fehlende enden mit dem Eingang des Berichts. Nur ein Bericht des Payees selbst beendet dessen Orte; ein schweigender Payee beendet nichts. Die Historie bleibt, wenn ein Payee gelöscht wird. Ein Umzug auf einen anderen Knoten ist ein neuer Ort; die Abrechnung fasst Orte später je Mandant + Router zum Posten zusammen.
+* `sultan accountant where `: Teiltreffer im Host (ohne Groß-/Kleinschreibung) oder exakte Mandanten-ID; je Treffer URL, Mandant, Ingress (Zeitpunkt des Berichts), Backend `:` (+ IP, NetBird), Container + Compose-Verzeichnis, Zustand, Knoten (verbunden, OS, Docker), **seit** wann am Ort, **vorher** (beendete Orte derselben Route), dazu passende Routen, die es **nicht mehr** gibt. Kein Treffer → Exit 1.
+* `sultan accountant routes [--tenant] [--payee]`: Tabelle aller aktuellen Routen (Host, Mandant, Payee, Knoten, Ziel, Zustand, seit).
+
+**Umsetzung Abrechnung (6d Schritte 2–4, 2026-10-18):**
+* **Kunden** (`accountant.db` v4: `customers`, `customer_map`): `StoreReport` (Endpunkt) ruft `EnsureCustomers` – unzugeordnete Mandanten-IDs werden Kunden (`auto`, Name folgt dem Mandanten, bis der Kunde von Hand bearbeitet wird). Auflösung `CustomerResolver.Resolve(payee, mandant)`: Zuordnung für den Payee > für alle Payees > Kunde mit gleicher ID. CLI `customer add|set|list|map|unmap|delete`; `set` ändert nur angegebene Felder; `list --group`, `--unclassified`. `where` zeigt den Kunden.
+* **Katalog** (v5: `products`, `product_versions`, `customer_prices`, `assignments`): Klasse und optionaler Preis je Monat (`product set --from JJJJ-MM`, erste Version gilt von Anfang an), Kundenpreis mit Vorrang. **Zuordnung je Posten** (Mandant, Protokoll, Router) mit Produkt, Klasse und/oder Beginn: `assign --host …` (Host → Posten über die Historie; mehrdeutig → `--tenant/--router`), `assign --clear`, `assignments`.
+* **Monatsabrechnung** (`Billing`, `billing --month JJJJ-MM [--out]`): Posten = alle Orte eines (Mandant, Protokoll, Router); abgerechnet, wenn der Monat zwischen Beginn (Zuordnung, sonst Monat des ersten Auftauchens, voll) und dem Monat des Endes (bis Ultimo) liegt. Klasse: Zuordnung > Produkt (Zuordnung, sonst Label) > Pauschale des Kunden → `included` > leer („nicht zugeordnet“; Produkt nicht im Katalog wird benannt). Beträge: billable = Preis (fehlt → Hinweis), included 0, free/internal 0 + Schattenpreis. Pauschale als eigene Zeile je Kunde mit Pauschale (Hinweis, wenn keine URL aktiv). Periodenbetrag = Monatsbetrag × 1/3/12; Hinweise für Quartals-/Jahreszahler und „noch nicht eingeordnet“; Ende im Monat → „beendet am …“ + tagesgenaue Gegenrechnung. Mandanten ohne Kunde: Hinweis „Kunde unbekannt“, am Ende der Liste.
+* **CSV:** UTF-8 mit BOM, Semikolon, Dezimalkomma; Spalten Monat, Kunde, Kundenname, Gruppe, Zahlweise, Posten, Hosts, Mandant, Klasse, Produkt, Von, Bis, Monatsbetrag, Periodenbetrag, Schattenpreis, Hinweis, Notiz; je Kunde die Pauschale zuerst. Spalten werden an der Praxis nachgeschärft.
+
---
## 6. Überwachung, Lizenzfristen & Alerting (Checkmk-Integration)
@@ -672,7 +754,7 @@ Das Ausgabeformat entspricht der nativen Checkmk Local-Check-Syntax:
## 7. Referenzkonfigurationen
-Die lauffähigen Vorlagen liegen unter `deploy/compose/{server,agent}` (mit `.env.example`); die Ausschnitte hier zeigen das Prinzip. Image: ein distroless-Image für Server und Agent (`Dockerfile`, nonroot-User `65532`, kein `ENTRYPOINT`, Arbeitsverzeichnis `/`, Default-Datenverzeichnis also `/suldata`). Ein Bind-Mount für `/suldata` muss vorher `65532:65532` gehören.
+Die lauffähigen Vorlagen liegen unter `deploy/compose/{server,agent,accountant}`; die Ausschnitte hier zeigen das Prinzip. **Einstellungen nur über `.env`** (`env_file: .env`): `compose.yaml` wiederholt keine Variablen, alle Optionen samt Defaults stehen in `.env.example` (`SULTAN_` je Flag, leer = Default). Das Image legt `/suldata` und `/accdata` für den nonroot-User an; `WORKDIR /` macht beide zu den Defaults. Image: ein distroless-Image für Server und Agent (`Dockerfile`, nonroot-User `65532`, kein `ENTRYPOINT`, Arbeitsverzeichnis `/`, Default-Datenverzeichnis also `/suldata`). Ein Bind-Mount für `/suldata` muss vorher `65532:65532` gehören.
### 7.1 Compose-Setup: Ingress-Knoten
@@ -803,6 +885,15 @@ services:
| 2026-10-02 | **Kleinere Punkte:** Rate-Limit `/enroll` 10/min/IP vor der Signaturprüfung (`-1` = aus); `SULTAN_` für alle Flags, Flags gewinnen; „wieder online“ nur nach Offline-Alarm; Checkmk-Zeiten in Server-Zeitzone. Ein Agent, dessen Enrollment mit 429 scheitert, beendet sich; der Neustart erfolgt über systemd bzw. Docker `restart`. | Schutz vor Durchprobieren; Container-/systemd-freundliche Konfiguration; vollständige Alarmkette. |
| 2026-10-03 | **Gestoppter Container = Wartungsseite, entfernter = Route weg.** Der Agent meldet gestoppte Container mit (Ports aus `PortBindings`) und deren Services als `down_services`; laufende Container gewinnen bei Namensgleichheit. Leerer `--public-url`-Wert wird ignoriert; `sultan version` zeigt die Zeitzone; `TZ` kommt über Compose (distroless enthält tzdata). Server-Compose mit `network_mode: host` neben Traefik. | Erster Test gegen echtes Traefik: `docker stop` lieferte 404 statt 503. Abschalten bleibt über Entfernen möglich. |
| 2026-10-11 | **TCP nur als TLS-Passthrough** (Feature `tcp_stream`, auch Community): `HostSNI` mit expliziten Hosts aus `allowed_domains`, keine Wildcard-/Regexp-Router; nur Entrypoint `websecure`; zählt gegen `max_routes`. Ohne Interceptor: Mahnhinweis lässt durch, ab Paywall und bei Wartung Route entfernt. UDP und übrige TCP-Labels weiterhin abgelehnt. | Kunden mit eigener CA/TLS (tinyCA) brauchen Passthrough; DNS-Wildcard ist kein Grund für einen Wildcard-Router – host-genau bleibt prüf- und abrechenbar. Seiten lassen sich bei TCP nicht einblenden. |
+| 2026-10-16 | **Accountant** (5.3): zentrales Inventar über eigene Rolle `sultan accountant` mit eigener `accountant.db`; nur Server (Payees, `--accountant`) melden, per signiertem, gepinntem Push eines vollständigen Schnappschusses bei Änderung und stündlich. Abrechnung: Vorgaben am Accountant (Zuordnung, Abo pauschal/einzeln, Mandantenpreis) gewinnen vor dem Label `sultan.accountant.billing`; alle Routen werden erfasst, ungelabelte gelten bei Abo als „im Abo enthalten“, sonst „nicht zugeordnet“. Abfrage zuerst per CLI (`where`) und Checkmk (Text per `text/template`-Vorlage mit eingebautem Default); HTTP-API später, Uptime Kuma verworfen. | Störfall: Backend-Host sofort finden; Kunden-Labels können Routen nicht vor der Abrechnung verstecken; bewährte Mechanik (Pinning, Signatur, Schnappschuss) statt neuem Protokoll; viele Services sind eingepreist; Vorlagen-Mechanik wie beim Lander, ohne neue Abhängigkeit. |
+| 2026-10-16 | **6a/6b umgesetzt:** Zusatzdaten automatisch aus Docker (Compose-Labels, Inspect, `/info`), DNS und Interfaces – keine neuen Pflicht-Labels; einziges neues Label ist `sultan.accountant.billing` (statt `sultan.billing`). Accountant mit Default `./accdata` und Port `:8444`; `accountant.db` über das exportierte `store.OpenDB`; Payee-ID kommt aus der Einladung (`invite --payee`). | Kein Pflegeaufwand an Kunden-Compose-Dateien; Label-Namensraum zeigt, wer das Label auswertet; getrenntes Datenverzeichnis erlaubt Accountant und Server auf demselben Host. |
+| 2026-10-17 | **6c umgesetzt:** Payee meldet per Polling (Prüfintervall 10 s, Senden nur bei geändertem Inhalt, Lebenszeichen 1 h) statt über Ereignis-Hooks; Konfigurationsfehler verhindern den Start, ein nicht erreichbarer Accountant nie; Einladung eines anderen Accountants wird bei bestehender Registrierung ignoriert (Log-Fehler). Accountant hält bis 6d nur den letzten Bericht je Payee. Routen tragen `active` (in Traefik) und `down`. | Ingress-Verfügbarkeit vor Inventar; Polling erfasst alle Änderungsquellen ohne Kopplung; kein stiller Wechsel der Zentrale. |
+| 2026-10-18 | **Compose: Einstellungen nur über `.env`** (`env_file`), Defaults dokumentiert in `.env.example`; Accountant-Vorlage `deploy/compose/accountant` mit Datenverzeichnis `/accdata`. | Keine doppelte Pflege von Variablen und Defaults; neue Flags brauchen keine Compose-Änderung. |
+| 2026-10-18 | **Abrechnungsmodell** (5.3): Posten = Router/URL; Pauschale am Kunden; Klassen `included`/`billable`/`free`/`internal` (Kostenstelle, Schattenpreis); volle Monate, Kündigung zum Ultimo mit tagesgenauer Gegenrechnung als Vorschlag; Zahlweise m/q/j je Kunde mit Vermerk in der Monats-CSV; Kunden-Zuordnung automatisch über gleiche Mandanten-ID, Ausnahme je Payee. | Spiegelt die Praxis (Lexware-Abos, meist Pauschale, Zusatz-Services je URL); interne und geschenkte Dienste bleiben bewertbar. |
+| 2026-10-18 | **Abrechnung, Details:** Beginn = voller Monat ab dem 1. des ersten Auftauchens, manuell verschiebbar je Posten; Zahlweise je Kunde; Preise optional und nicht buchhaltungsrelevant (Lexware führt); Gruppe (Kostenverursacher) und Notiz (Nebenabreden) je Kunde, filterbar; Abo-Abgleich bleibt manuell. | Projekt- und Einmalbeträge laufen außerhalb; persönliche Kundenverhältnisse bleiben nachvollziehbar, ohne Buchhaltungsstruktur nachzubauen. |
+| 2026-10-18 | **6e umgesetzt:** Checkmk des Accountants auf eigenem Loopback-Port `127.0.0.1:8082`, Quelle = letzter Bericht; Wartung dämpft auf OK; Rate-Limiter nach `pkg/ratelimit` verschoben und für `/enroll` des Accountants genutzt. | Unabhängig von 6d nutzbar; einheitliches Verhalten von Server und Accountant. |
+| 2026-10-18 | **Historie je Ort** (Payee, Mandant, Knoten, Protokoll, Router) statt je Posten; Kunden entstehen automatisch aus der Mandanten-ID der Berichte. | `where` braucht den Ort (wohin springen, wo vorher); Posten für die Abrechnung lassen sich daraus ableiten; der Mandant ist durch die Einladung auf dem Server verlässlich, also keine Nachpflege. |
+| 2026-10-18 | **6d umgesetzt:** Posten = Orte je (Mandant, Protokoll, Router) zusammengefasst; Beginn voller Monat (Zuordnung verschiebt), Ende bis Ultimo mit tagesgenauer Gegenrechnung als Hinweis; Klasse Zuordnung > Produkt > Pauschale > nicht zugeordnet; Preise optional, Schattenpreis für free/internal; Pauschale als eigene Zeile; CSV mit BOM, Semikolon, Dezimalkomma. | Deckt sich mit der Praxis (Lexware, Pauschale + Zusatz-Services); Lücken (kein Kunde, kein Produkt, keine Zahlweise) werden sichtbar statt still verschluckt. |
| geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
---
@@ -889,6 +980,14 @@ services:
* **Registry mit Vanity-URL:** `zot.chmln.de` ist noch nicht weiter getrieben (Image-Name unabhängig vom Registry-Host, z. B. `cr./sultan`).
* **Geparkt:** Veröffentlichung auf GitHub (Quellen, Releases, ggf. ghcr.io-Spiegel).
+**Schritt 6 – Accountant (5.3)** – 6a/6b ✅ 2026-10-16, 6c ✅ 2026-10-17, 6d/6e ✅ 2026-10-18
+* ✅ 6a Agent: Zusatzdaten (Compose-Projekt/-Verzeichnis, Startzeit, Health; FQDN, OS, Docker-Version, NetBird-IP) im `service_state`, automatisch erkannt.
+* ✅ 6b `sultan accountant`: Datenverzeichnis, Zertifikat, `accountant.db`, Enrollment-Endpunkt, `invite`, `payee list`/`delete`.
+* ✅ 6c Payee: `--accountant-token`/`--accountant`, Enrollment, Schnappschuss bei Änderung (Prüfintervall 10 s) und stündlich; Accountant speichert den letzten Bericht.
+* ✅ 6d Accountant (2026-10-18): Schritt 1 Historie (Zeiträume je Ort), `where`, `routes`; Schritt 2 Kunden + Zuordnung (automatisch aus der Mandanten-ID); Schritt 3 Katalog + `assign`; Schritt 4 Monats-CSV. Inhalt: Pauschale, Klassen (`included`/`billable`/`free`/`internal` mit Schattenpreis), Gruppe + Notiz je Kunde, Produktkatalog und optionale Preise mit Gültig-ab, Zahlweise je Kunde, Monats-CSV, tagesgenaue Kündigungs-Gegenrechnung. Fachfragen geklärt 2026-10-18 (Beginn voller Monat ab dem 1., manueller späterer Beginn je Posten; Zahlweise je Kunde; Abo-Abgleich manuell; Preise optional; Kostenverursacher-Gruppe + Notiz je Kunde).
+* ✅ 6e Checkmk-Ausgabe per Vorlage (Default eingebaut, `dump-checkmk`) mit Links; dazu Rate-Limit für `/enroll` des Accountants (2026-10-18).
+* Später: HTTP-API (Dokuwiki-Export), Mandanten-Statusberichte.
+
**Weitere offene Bausteine (ohne feste Reihenfolge):**
* **Lastverteilung einer Domain über mehrere Knoten** (gemeinsamer Load-Balancer statt getrennter Router) – Voraussetzung dafür, dass `drain` seine Stärke ausspielt.
* Admin-API (nur kommerziell): Mandanten, Lizenzen, Zahlungsstatus für Rechnungssystem und Ansible; Authentifizierung per API-Token, TLS über ACME.
diff --git a/.dockerignore b/.dockerignore
index db1a7ca..71ca6b3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,8 @@
# Nie ins Image bzw. in den Build-Kontext: Schlüssel, private Doku, Build-Ausgaben
suldata/
**/suldata/
+accdata/
+**/accdata/
dist/
.git/
.ai/
diff --git a/.gitignore b/.gitignore
index 10e7a93..74cc526 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,7 @@ suldata/
# Build-Ausgaben (deploy/build.sh)
/dist/
+
+# sultan accountant: Datenverzeichnis (enthält server.key!)
+/accdata/
+accdata/
diff --git a/CLAUDE.md b/CLAUDE.md
index 9ff44b9..4cfd8c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,22 +9,22 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar
## Projektstatus
-Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|agent|token|dump-landing|version|help`. Packages:
+Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|agent|token|accountant|dump-landing|version|help`. Packages:
- [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`, HTTP und TCP für TLS-Passthrough) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket.
- [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests.
-- [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`.
+- [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`. `OpenDB(ctx, path, migrations)`, `FormatTime`, `ScanTime` stellen Migrationen und Zeitformat auch anderen Datenbanken bereit (`pkg/ledger`).
- [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben.
- **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver.
- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `PinnedTLSConfig(fingerprint)` – Pinning im Handshake am Fingerprint, **unabhängig von der Adresse**; ohne Fingerprint `ErrUnknownServer` (kein blindes TOFU). Enrollment-Token `sltn_enroll_.[.]` (`NewEnrollmentToken(fp, url)`/`ParseEnrollmentToken`, `ValidateServerURL`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen.
- [pkg/license](pkg/license) – Lizenz nach Spec 4.1: `Issuer.Issue` (normalisiert + signiert mit Master-Key, Präfix `SULTAN-LICENSE-V1`), `Parse` (strikt, unbekannte Felder abgelehnt) / `Verify`, `AllowsDomain` (`*.x.de` = alle Subdomains, nicht Apex; `*.` verboten), `AllowsRoutes` (-1 = unbegrenzt), `HasFeature`, `Expired`/`DaysLeft`.
- [pkg/policy](pkg/policy) – `HostsFromRule` (echter Parser für Traefik-Regeln; jeder `||`-Zweig muss Host-beschränkt sein, `HostRegexp` verboten), `StageFor` (blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) und `Engine.Apply`: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum `____`, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zu `sultan__interceptor` um (Header `X-Sultan-Stage/Tenant/Tier`); bei watermark/firemark zusätzlich Durchlass-Router `sultan______` per Cookie `sultan_ack=`. `DunningSchedule` (8/25/32 Tage) berechnet die Mahnstufe. Wartung: `Input.Maintenance` (Knoten) und `Input.ServiceMaintenance` (Label `sultan.maintenance`), Vorrang Ablauf/Paywall > Wartung > Mahnhinweis, Header `X-Sultan-Until`/`X-Sultan-Public-Reason` (URL-kodiert). Andockstelle `MiddlewareTargetCheck` (+ `EnforceMiddlewareTargets`) für die geplante Middleware-Zielbegrenzung; nil = dulden. TCP (`checkTCPRouter`/`tcpOutput`, `HostsFromSNIRule`): nur Feature `tcp_stream`, `tls.passthrough`, Entrypoint `TCPEntryPoint`, explizite `HostSNI`-Hosts; zählt gegen `max_routes` (HTTP zuerst); kein Interceptor → Route weg ab Paywall und bei Wartung.
-- [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove` (der Server ruft es beim Trennen nur ohne aktive Wartung auf; `pruneDisconnected` räumt nach Wartungsende), `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren.
+- [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove` (der Server ruft es beim Trennen nur ohne aktive Wartung auf; `pruneDisconnected` räumt nach Wartungsende), `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren. `State(nodeID)` liefert eine tiefe Kopie des gemeldeten Zustands inkl. `Meta`/`Host` (die das Routing nie beeinflussen); der Server reicht das als `Server.NodeState` durch.
-- [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur).
+- [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests; Payee vor dem Schließen der DB), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Payee (`payee.go`, Spec 5.3/6c): `Config.AccountantToken`/`AccountantURL`, `payee.key` + `accountant.json`, Hintergrund-Schleife (`AccountantInterval` Default 10s, Senden nur bei geändertem Hash oder nach `AccountantHeartbeat` 1h), `buildReport`/`reportRoutes`; Konfigurationsfehler → `New` scheitert, Accountant nicht erreichbar → nur Log. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur).
- [pkg/cli](pkg/cli) – `Run(ctx, args, stdout, stderr) int`; Flags/Defaults von `sultan server`; `sultan token` in `token.go` (Befehlstabelle `tokenCmds`, `usageError` → Exit 2, `openDataDir` verweigert nicht initialisierte Verzeichnisse). Version per `-ldflags "-X sultan/pkg/cli.Version=…"`.
-- [pkg/docker](pkg/docker) – minimaler Docker-Engine-Client über Unix-Socket (`List` mit `all=1`, `PortBindings` für gestoppte Container, `Events`), bewusst ohne SDK.
-- [pkg/agent](pkg/agent) – `sultan agent`: `Enroll` (node.key, Pinning über Einladungs-Fingerprint, node.json mit `server_fingerprint`; Adresse: `--server`/`SULTAN_SERVER` > URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen), `Run` (Stream mit `OnConnect` → Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler), `Workloads` (Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen; gestoppte Container bleiben mit ihren Services in `down` → Wartungsseite, entfernte verschwinden, laufende gewinnen bei Namensgleichheit), `backendHost` (explizit → Interface → Route zum Server). Dazu `/etc/sultan.d`-YAML (`sultand.go`: `ParseServices`, `LoadServiceDir`, `FileWorkloads` → Traefik-Labels; Polling über `dirFingerprint`) und Healthchecks (`health.go`: `checkHealth`, `healthTracker` mit Fehlerschwelle) → `DownServices`. Tests laufen gegen echten `pkg/server` mit gefälschtem Docker-Socket.
+- [pkg/docker](pkg/docker) – minimaler Docker-Engine-Client über Unix-Socket (`List` mit `all=1`, `Inspect` → Ports auch gestoppter Container, `StartedAt`, `Health`; `PortBindings` als Kurzform; `Info` → OS des Hosts und Docker-Version; `Events`), bewusst ohne SDK.
+- [pkg/agent](pkg/agent) – `sultan agent`: `Enroll` (node.key, Pinning über Einladungs-Fingerprint, node.json mit `server_fingerprint`; Adresse: `--server`/`SULTAN_SERVER` > URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen), `Run` (Stream mit `OnConnect` → Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler), `Workloads` (Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen; gestoppte Container bleiben mit ihren Services in `down` → Wartungsseite, entfernte verschwinden, laufende gewinnen bei Namensgleichheit), `backendHost` (explizit → Interface → Route zum Server). Dazu `/etc/sultan.d`-YAML (`sultand.go`: `ParseServices`, `LoadServiceDir`, `FileWorkloads` → Traefik-Labels; Polling über `dirFingerprint`) und Healthchecks (`health.go`: `checkHealth`, `healthTracker` mit Fehlerschwelle) → `DownServices`. Zusatzdaten fürs Inventar (Spec 5.3, ohne neue Labels): `Workload.Meta` aus Compose-Labels + `Inspect` (`meta` in `workloads.go`), `NodeState.Host` aus `hostinfo.go` (`detectFQDN` mit injizierbarem `resolver`, `netbirdIP`, `osPrettyName`, Docker `Info`); `--fqdn` überschreibt. Tests laufen gegen echten `pkg/server` mit gefälschtem Docker-Socket.
- [pkg/lander](pkg/lander) – Interceptor-Seiten je `X-Sultan-Stage` (eingebettete `templates/*.html`, zweisprachig), Cookie `sultan_ack` bei watermark/firemark, `localTarget` erlaubt nur lokale Pfade (html/template filtert `meta refresh` **nicht**!), eigene Vorlagen aus `/landing` mit eingebettetem CSS/Bild, `Dump`. Läuft im Server auf `--lander-addr`; die Interceptor-URL der Policy folgt der gebundenen Adresse. Test-Server immer mit `LanderAddr: "127.0.0.1:0"` starten (sonst Portkonflikt auf 8081).
@@ -36,9 +36,12 @@ Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgeset
- [deploy/](deploy) – Auslieferung: `Dockerfile` im Root (distroless, nonroot 65532, Multi-Arch, kein `ENTRYPOINT`, `WORKDIR /`), `build.sh` (Binaries amd64/arm64 + `SHA256SUMS` nach `dist//`), `push-image.sh` (liest `deploy/.env` mit `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD`, Vorlage `deploy/.env.example`; buildx → Zot-Registry `zot.chmln.de/sultan`; ohne Passwort Exit 1), `publish-static.sh` (rsync nach `sultan/releases//`), `compose/{server,agent}` (Agent mit `network_mode: host`). Version überall aus `git describe`; `-dirty` wird nicht veröffentlicht. `.dockerignore` hält `suldata/`, `.ai/`, `CLAUDE.md` aus dem Build-Kontext.
+- [pkg/ledger](pkg/ledger) – `accountant.db` (Migrationen über `store.OpenDB`): `payees`, `invites` (nur Hash), `settings`; `AddInvite` (ID per `policy.ValidID`, registrierter Payee → `ErrExists`), `Enroll` (ID aus der Einladung, atomar), `ListPayees` (mit `LastReport`), `GetPayeeByPublicKey`, `DeletePayee`, `SaveReport(ctx, payee, body, []RouteRecord)` (letzter Bericht + Historie `route_spans` je Ort in einer Transaktion, v3)/`LatestReport`, `RouteHistory(HistoryFilter)`, `SetClock` (Tests); Kunden (`customers.go`: `EnsureCustomers`, `MapTenant`, `CustomerResolver`, Zahlweisen `Cycle*`, Pauschale in Cent), Katalog/Zuordnung (`products.go`: Klassen `Class*`, Versionen je Monat `JJJJ-MM`, `Catalog.At`, `Assignment`), Migrationen v4/v5. Fehler aus `pkg/store`.
+- [pkg/accountant](pkg/accountant) – `sultan accountant` (Spec 5.3): `New` (Default `./accdata`, eigenes Zertifikat), `Start`/`Run`/`Shutdown`, TLS-Port `:8444`, `POST /api/v1/enroll` (`VerifyUnregistered`, Body `{token, hostname}`), `POST /api/v1/report` (`report.go`: Typen `Report`/`ReportTenant`/`ReportNode`/`ReportRoute`, `LabelBilling`; max. 8 MiB), `LoadPublicURLs`, Rate-Limit über `pkg/ratelimit` (auch vom Server genutzt). Checkmk (`checkmk.go`): Loopback-Port `MetricsAddr` (Default `127.0.0.1:8082`), `buildCheckmk` (reine Funktion: Zustände, bereinigte/escapte Felder + `.Unescaped`), `renderCheckmk` (eigene Vorlage `/checkmk/checkmk.tmpl`, Fallback + CRIT), `DumpCheckmk`. Abfragen (`where.go`): `RouteRecords`, `Where` (aktuell + vorher + verschwunden, mit Kunde), `Routes`, `ItemsForHost`. `StoreReport` (Bericht + Historie + automatische Kunden – vom Endpunkt und von Tests benutzen). Abrechnung (`billing.go`): `Billing`/`billFrom` (reine Regeln, Ortszeit), `WriteBillCSV`; Geld (`money.go`): `ParseMoney`/`FormatMoney` (Cent, Dezimalkomma). CLI in `pkg/cli/accountant.go` (`invite --payee`, `payee list|report|delete`, `dump-checkmk`, `where ` (Suchbegriff per `splitQuery` vor/nach Flags), `routes`, `customer …` (`customers.go`), `product …`, `assign`, `assignments`, `billing` (`products.go`); ohne Unterbefehl bzw. nur Flags = starten).
+
**CLI-Umgebung:** `pkg/cli/env.go` – `annotateEnv`/`applyEnv` machen jedes Flag als `SULTAN_` setzbar (nach `fs.Parse`, nur für nicht gesetzte Flags; Listen-Flags `stringList` kommagetrennt). Neue Flags bekommen das automatisch. Rate-Limit: `pkg/server/ratelimit.go` (`EnrollLimit`, `-1` = aus).
-Betriebsdoku: `docs/community.md`, `docs/wartung.md`, `docs/hoster.md` – bei Verhaltensänderungen mitpflegen. Projekt gilt seit 2026-10-03 vorerst als fertig; offene Punkte (tenant update, systemd-Agent und kommerzieller Betrieb ungetestet, Lander-Design, Vanity-Registry, GitHub geparkt) und Fachfragen: Spec Abschnitt 9.
+Betriebsdoku: `docs/community.md`, `docs/wartung.md`, `docs/hoster.md` – bei Verhaltensänderungen mitpflegen. Projekt gilt seit 2026-10-03 vorerst als fertig; offene Punkte (tenant update, systemd-Agent und kommerzieller Betrieb ungetestet, Lander-Design, Vanity-Registry, GitHub geparkt) und Fachfragen: Spec Abschnitt 9. Nächster Baustein: **Accountant** (Spec 5.3, Roadmap Schritt 6; 6a–6c und 6e umgesetzt) – Schritt 6 komplett (6a–6e); offen u. a. HTTP-API (Dokuwiki), Mandanten-Statusberichte, Abo-Abgleich mit Lexware (geparkt) – Spec 5.3 und Abschnitt 9.
Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare `Client.sleep`; mit `-race -count=30` auf Stabilität prüfen.
diff --git a/Dockerfile b/Dockerfile
index 4fa25cd..82a0623 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,7 +15,7 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags "-s -w -X sultan/pkg/cli.Version=${VERSION}" -o /out/sultan . \
- && mkdir -p /out/suldata
+ && mkdir -p /out/suldata /out/accdata
FROM gcr.io/distroless/static-debian12:nonroot
ARG VERSION=dev
@@ -26,7 +26,9 @@ COPY --from=build /out/sultan /usr/local/bin/sultan
# Datenverzeichnis gehört dem nonroot-User (65532); ein Named Volume übernimmt
# das, ein Bind-Mount muss vorher per chown 65532:65532 vorbereitet werden.
COPY --from=build --chown=65532:65532 /out/suldata /suldata
-# WORKDIR / → der Default ./suldata ist /suldata; `sultan token …` braucht kein --data-dir.
+COPY --from=build --chown=65532:65532 /out/accdata /accdata
+# WORKDIR / → die Defaults ./suldata bzw. ./accdata (sultan accountant) sind
+# /suldata bzw. /accdata; Verwaltungsbefehle brauchen kein --data-dir.
WORKDIR /
# Kein ENTRYPOINT: Compose ruft "sultan server …" bzw. "sultan agent …" auf
# (Spec 7), `docker compose exec sultan-server sultan token …` funktioniert so ebenfalls.
diff --git a/README.md b/README.md
index 572053f..bf0996d 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ Ablage per `rsync` unter `sultan/releases//` (Binaries + `SHA256SUMS`);
### 6. Mit Docker Compose betreiben
-Vorlagen: [deploy/compose/server](deploy/compose/server) (Ingress-Knoten neben Traefik) und [deploy/compose/agent](deploy/compose/agent) (Docker-Host).
+Vorlagen: [deploy/compose/server](deploy/compose/server) (Ingress-Knoten neben Traefik), [deploy/compose/agent](deploy/compose/agent) (Docker-Host) und [deploy/compose/accountant](deploy/compose/accountant) (zentrales Inventar). Alle Einstellungen stehen in der `.env`; welche es gibt und was ihre Defaults sind, zeigt die jeweilige `.env.example`.
```bash
cp -r deploy/compose/server ~/sultan-server && cd ~/sultan-server
@@ -145,6 +145,8 @@ sultan agent
Der Agent liest die Container mit `traefik.enable=true` aus Docker und meldet sie an den Server. Wichtig: Der Ingress erreicht nur **veröffentlichte** Ports (`ports: "8081:80"`), nicht solche, die nur auf `127.0.0.1` veröffentlicht sind. Ohne Docker: `--docker-socket=none`. Über welche Adresse der Ingress den Host erreicht, ermittelt der Agent automatisch; für NetBird z. B. `--backend-interface=wt0`.
+Für Inventar und Störfall meldet der Agent zusätzlich, was Docker ohnehin weiß – ohne weitere Labels: Compose-Projekt und -Verzeichnis, Startzeit und Health-Status je Container sowie FQDN, Betriebssystem, Docker-Version und NetBird-IP des Hosts. Den FQDN ermittelt er per DNS; abweichend: `--fqdn=webdocker11.lan.example.de`. Am Routing ändern diese Angaben nichts.
+
**Ohne Docker (Bare-Metal, LAMP …)** 🖥️ beschreibst du deine Dienste in `/etc/sultan.d/*.yaml`:
```yaml
@@ -179,6 +181,33 @@ curl -s http://127.0.0.1:8080/metrics/checkmk # Checkmk Local Checks: Vertr
Sultan meldet ablaufende Verträge, Mahnstufen, ausgefallene Knoten und Policy-Verstöße – jedes Ereignis genau einmal.
+### Zentrales Inventar (Accountant) 📒
+
+Mehrere Ingress-Server melden ihr Inventar an eine Zentrale: welche URL bei welchem Mandanten auf welchem Backend-Host (`:`) und in welchem Compose-Verzeichnis läuft.
+
+```bash
+sultan accountant --public-url=https://accountant.example.de:8444 # Zentrale starten (Daten in ./accdata)
+sultan accountant invite --payee=webproxy11 # Einladung für einen Ingress-Server
+SULTAN_ACCOUNTANT_TOKEN= sultan server … # auf dem Ingress-Server (nur beim ersten Start)
+sultan accountant payee list # wer meldet, letzter Bericht
+sultan accountant payee report --id=webproxy11 # letzter Bericht als JSON
+sultan accountant where kunde-a.de # Störfall: Backend, Container, Compose-Verzeichnis, seit wann, wo vorher
+sultan accountant routes --tenant=kunde-a # alle aktuellen Routen als Tabelle
+curl -s http://127.0.0.1:8082/metrics/checkmk # Checkmk: je URL und je Ingress-Server
+sultan accountant dump-checkmk # Checkmk-Text als Vorlage anpassen
+```
+
+Der Server meldet Änderungen innerhalb von 10 s und sonst stündlich. Ist der Accountant nicht erreichbar, läuft der Ingress unbeeinträchtigt weiter und meldet später.
+
+**Abrechnung** 💶 – Kunden entstehen automatisch aus den Mandanten; abrechenbare Services markierst du mit dem Label `sultan.accountant.billing=` oder per `assign`:
+
+```bash
+sultan accountant customer set --id=kunde-a --cycle=q --flat-rate=89,00 --group=extern
+sultan accountant product add --id=olat --name=OLAT --class=billable --price=49,00
+sultan accountant assign --host=olat.kunde-a.de --product=olat
+sultan accountant billing --month=2026-10 --out=2026-10.csv # CSV für die Buchhaltung
+```
+
### Hinweis- und Wartungsseiten 🚧
Fällt ein Dienst aus oder läuft ein Abo ab, zeigt Sultan statt eines Fehlers eine freundliche Seite (Deutsch und Englisch). Eigene Gestaltung:
diff --git a/deploy/compose/accountant/.env.example b/deploy/compose/accountant/.env.example
new file mode 100644
index 0000000..76c2175
--- /dev/null
+++ b/deploy/compose/accountant/.env.example
@@ -0,0 +1,23 @@
+# sultan accountant – Einstellungen für compose.yaml (cp .env.example .env).
+# Jede Variable SULTAN_ entspricht dem Flag -- (sultan accountant --help);
+# leer oder auskommentiert = Default (rechts angegeben).
+
+# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
+SULTAN_IMAGE=zot.chmln.de/sultan:latest
+
+# Adresse(n), unter denen die Sultan-Server (Payees) den Accountant erreichen –
+# die erste steckt in jeder Einladung. Kommagetrennt. Default: keine (Payees
+# brauchen dann SULTAN_ACCOUNTANT).
+SULTAN_PUBLIC_URL=https://accountant.example.de:8444
+
+# Port für Payees, nur TLS – in der Firewall nur für die Ingress-Server öffnen.
+# SULTAN_ADDR=:8444
+# SULTAN_ENROLL_LIMIT=10 # Enrollment-Versuche/Minute je IP (-1 = aus)
+
+# Checkmk: curl -s http://127.0.0.1:8082/metrics/checkmk (Klartext, nur Loopback; none = aus)
+# SULTAN_METRICS_ADDR=127.0.0.1:8082
+# SULTAN_STALE_WARN=2h # Payee ohne Bericht → WARN
+# SULTAN_STALE_CRIT=6h # … → CRIT
+
+# Zeitzone für Zeitstempel im Log und in payee list (ohne TZ: UTC)
+TZ=Europe/Berlin
diff --git a/deploy/compose/accountant/compose.yaml b/deploy/compose/accountant/compose.yaml
new file mode 100644
index 0000000..1af0043
--- /dev/null
+++ b/deploy/compose/accountant/compose.yaml
@@ -0,0 +1,21 @@
+# sultan accountant – Zentrale für Inventar und Abrechnung (Spec 5.3).
+#
+# cp .env.example .env && $EDITOR .env
+# install -d -m 0700 -o 65532 -g 65532 accdata # Datenverzeichnis für den nonroot-User
+# docker compose up -d
+# docker compose exec sultan-accountant sultan accountant invite --payee=webproxy11
+# docker compose exec sultan-accountant sultan accountant payee list
+#
+# network_mode: host wie beim Server: payee list zeigt die echte IP der Payees,
+# der Port ist ohne Mapping über .env wählbar.
+services:
+ sultan-accountant:
+ image: ${SULTAN_IMAGE:-sultan:latest}
+ container_name: sultan-accountant
+ restart: unless-stopped
+ network_mode: host
+ command: ["sultan", "accountant"]
+ # Alle Einstellungen kommen aus .env (Vorlage mit Defaults: .env.example).
+ env_file: .env
+ volumes:
+ - ./accdata:/accdata # accountant.db, server.key/.crt – sichern!
diff --git a/deploy/compose/agent/.env.example b/deploy/compose/agent/.env.example
index ec58898..f8ee909 100644
--- a/deploy/compose/agent/.env.example
+++ b/deploy/compose/agent/.env.example
@@ -1,10 +1,23 @@
+# sultan agent – Einstellungen für compose.yaml (cp .env.example .env).
+# Jede Variable SULTAN_ entspricht dem Flag -- (sultan agent --help);
+# leer oder auskommentiert = Default (rechts angegeben).
+
# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
-# SULTAN_IMAGE=sultan:latest
-# SULTAN_IMAGE=zot.chmln.de/sultan:v0.1.0
SULTAN_IMAGE=zot.chmln.de/sultan:latest
# Einladung vom Server: docker compose exec sultan-server sultan token invite --tenant=…
+# Nach dem ersten Start nicht mehr nötig; stehen lassen ist harmlos.
SULTAN_TOKEN=
-# Gruppe des Docker-Sockets: getent group docker | cut -d: -f3
+# Gruppe des Docker-Sockets (Pflicht): getent group docker | cut -d: -f3
DOCKER_GID=
+
+# Optional
+# SULTAN_SERVER=https://ingress.example.de:8443 # Default: Adresse aus der Einladung
+# SULTAN_NODE_ID=webdocker11 # Default: aus dem Hostnamen
+# SULTAN_BACKEND_HOST=10.0.0.11 # Default: automatisch (Route zum Server)
+# SULTAN_BACKEND_INTERFACE=wt0 # alternativ: IP dieses Interfaces (NetBird)
+# SULTAN_FQDN=webdocker11.lan.example.de # Default: per DNS ermittelt (nur Inventar)
+
+# Zeitzone für Zeitstempel im Log (ohne TZ: UTC)
+TZ=Europe/Berlin
diff --git a/deploy/compose/agent/compose.yaml b/deploy/compose/agent/compose.yaml
index 6f8df8a..3ea4755 100644
--- a/deploy/compose/agent/compose.yaml
+++ b/deploy/compose/agent/compose.yaml
@@ -16,11 +16,8 @@ services:
restart: unless-stopped
network_mode: host
command: ["sultan", "agent", "--data-dir=/suldata", "--config-dir=none"]
- environment:
- # Ausgabe von `sultan token invite`; nach dem ersten Start nicht mehr nötig,
- # stehen lassen ist harmlos.
- SULTAN_TOKEN: ${SULTAN_TOKEN:-}
- TZ: ${TZ:-Europe/Berlin} # Zeitstempel im Log
+ # Alle Einstellungen kommen aus .env (Vorlage mit Defaults: .env.example).
+ env_file: .env
# Lesezugriff auf den Docker-Socket für den nonroot-User (UID 65532).
group_add:
- "${DOCKER_GID:?DOCKER_GID setzen: getent group docker | cut -d: -f3}"
diff --git a/deploy/compose/server/.env.example b/deploy/compose/server/.env.example
index 9be88ff..a19f6ee 100644
--- a/deploy/compose/server/.env.example
+++ b/deploy/compose/server/.env.example
@@ -1,25 +1,42 @@
+# sultan server – Einstellungen für compose.yaml (cp .env.example .env).
+# Jede Variable SULTAN_ entspricht dem Flag -- (sultan server --help);
+# leer oder auskommentiert = Default (rechts angegeben).
+
# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
-SULTAN_IMAGE=sultan:latest
-# SULTAN_IMAGE=git.everdc.eu/chmln-app-dev/sultan:v0.1.0
+SULTAN_IMAGE=zot.chmln.de/sultan:latest
# Adresse(n), unter denen Agents den Server erreichen – die erste steckt in jeder
-# Einladung, alle bekommen Agents als Ausweichweg. Kommagetrennt, leer erlaubt.
+# Einladung, alle bekommen Agents als Ausweichweg. Kommagetrennt. Default: keine
+# (Agents brauchen dann --server).
SULTAN_PUBLIC_URL=https://webproxy11.lan.r25dc.de:8443
-# Ports (host-Netz). Vorher prüfen, ob frei – Traefiks API/Dashboard nutzt oft 8080:
+# Ports (Host-Netz). Vorher prüfen, ob frei – Traefiks API/Dashboard nutzt oft 8080:
# ss -ltnp | grep -E ':(8443|8080|8081)\b'
-# SULTAN_AGENT_ADDR=:8443
-# SULTAN_PROVIDER_ADDR=127.0.0.1:8080
-# SULTAN_LANDER_ADDR=127.0.0.1:8081
+# SULTAN_AGENT_ADDR=:8443 # Agents, nur TLS – in der Firewall für Agent-Hosts öffnen
+# SULTAN_PROVIDER_ADDR=127.0.0.1:8080 # Traefik-Provider & Checkmk – Klartext, nur Loopback!
+# SULTAN_LANDER_ADDR=127.0.0.1:8081 # Hinweis-/Wartungsseiten, nur Loopback
-# Texte/Links der Hinweis- und Einladungsseiten (optional)
+# Texte/Links der Hinweis- und Einladungsseiten (Default: leer)
# SULTAN_HOSTER_NAME=everDC
# SULTAN_HOSTER_URL=https://www.everdc.eu
+# SULTAN_PRO_URL=https://www.everdc.eu/pro
-# Benachrichtigungen (optional, kommagetrennt mehrere möglich)
+# Benachrichtigungen (Default: keine; kommagetrennt mehrere möglich)
# SULTAN_NOTIFY_SLACK=https://hooks.slack.com/services/…
# SULTAN_NOTIFY_NTFY=https://ntfy.example.de/sultan
# SULTAN_NOTIFY_NTFY_TOKEN=tk_…
+# SULTAN_OFFLINE_ALERT_AFTER=15m
-# Zeitzone für Wartungsseite und Checkmk (Default Europe/Berlin)
-# TZ=Europe/Berlin
+# Betrieb
+# SULTAN_REFRESH=1m # Neubewertung, Leases, Befehle
+# SULTAN_ENROLL_LIMIT=10 # Enrollment-Versuche/Minute je IP (-1 = aus)
+# SULTAN_LEASE_DURATION=0 # 0 = je Tier (Community 14 Tage, kommerziell 7)
+
+# Inventar an den Accountant melden (Default: aus). Einladung von
+# sultan accountant invite --payee=
+# nur beim ersten Start nötig (danach accountant.json + payee.key in suldata).
+# SULTAN_ACCOUNTANT_TOKEN=sltn_enroll_…
+# SULTAN_ACCOUNTANT=https://accountant.example.de:8444 # nur, wenn die Einladung keine/eine andere Adresse hat
+
+# Zeitzone für Wartungsseite, Checkmk und Log (ohne TZ: UTC)
+TZ=Europe/Berlin
diff --git a/deploy/compose/server/compose.yaml b/deploy/compose/server/compose.yaml
index d6c5a4d..b08637b 100644
--- a/deploy/compose/server/compose.yaml
+++ b/deploy/compose/server/compose.yaml
@@ -19,23 +19,8 @@ services:
restart: unless-stopped
network_mode: host
command: ["sultan", "server", "--data-dir=/suldata"]
- environment:
- # Agents (TLS 1.3) – in der Firewall für die Agent-Hosts öffnen.
- SULTAN_AGENT_ADDR: ${SULTAN_AGENT_ADDR:-:8443}
- # Traefik-Provider & Checkmk – Klartext, nur Loopback!
- SULTAN_PROVIDER_ADDR: ${SULTAN_PROVIDER_ADDR:-127.0.0.1:8080}
- # Lander (Hinweis-/Wartungsseiten) – nur Loopback, Traefik ruft ihn auf.
- SULTAN_LANDER_ADDR: ${SULTAN_LANDER_ADDR:-127.0.0.1:8081}
- # Leer = keine Adresse in Einladungen (Agent braucht dann --server).
- # Mehrere Adressen kommagetrennt (öffentlich, NetBird …).
- SULTAN_PUBLIC_URL: ${SULTAN_PUBLIC_URL:-}
- SULTAN_HOSTER_NAME: ${SULTAN_HOSTER_NAME:-}
- SULTAN_HOSTER_URL: ${SULTAN_HOSTER_URL:-}
- SULTAN_NOTIFY_SLACK: ${SULTAN_NOTIFY_SLACK:-}
- SULTAN_NOTIFY_NTFY: ${SULTAN_NOTIFY_NTFY:-}
- SULTAN_NOTIFY_NTFY_TOKEN: ${SULTAN_NOTIFY_NTFY_TOKEN:-}
- # Zeitzone für Wartungsseite und Checkmk (sonst UTC). Prüfen:
- # docker compose exec sultan-server sultan version
- TZ: ${TZ:-Europe/Berlin}
+ # Alle Einstellungen kommen aus .env (Vorlage mit Defaults: .env.example).
+ # Jedes Flag ist als SULTAN_ setzbar; leere Werte = Default.
+ env_file: .env
volumes:
- ./suldata:/suldata # sultan.db, server.key/.crt, master.key – sichern!
diff --git a/docs/hoster.md b/docs/hoster.md
index 7f47f6b..082da89 100644
--- a/docs/hoster.md
+++ b/docs/hoster.md
@@ -11,7 +11,9 @@ Für Hoster, die Sultan als bezahlten Dienst anbieten: Du betreibst Ingress (Tra
| Läuft dort | Traefik, `sultan server` | Docker-Host(s) mit `sultan agent` |
| Geheimnisse | `suldata/` des Servers: TLS-Schlüssel, **Master-Key** (signiert Lizenzen, Leases, Befehle) | `node.key` je Host; verlässt den Host nie, auch du siehst ihn nicht |
| Entscheidet | welche Domains, wie viele Routen, bis wann | welche Dienste wie geroutet werden (Labels) |
-| Sieht | alle Routen, Knoten, Verstöße, Fristen | nur sein eigenes Agent-Log |
+| Sieht | alle Routen, Knoten, Verstöße, Fristen; je Container Compose-Projekt und -Verzeichnis, Startzeit, Health; je Host FQDN, Betriebssystem, Docker-Version, NetBird-IP | nur sein eigenes Agent-Log |
+
+Die Host- und Container-Angaben sammelt der Agent automatisch für Inventar und Störfall (wo läuft welche URL, in welchem Compose-Verzeichnis). Sag das dem Kunden; enthält ein Verzeichnisname Vertrauliches, sieht der Hoster es.
Der Kunde kann **nur Domains** aus seinem Mandanten beanspruchen und nur bis `max_routes`. Alles andere verwirft der Server mit Eintrag im Audit-Log. Kunden sehen sich gegenseitig nicht. Gleichnamige Router verschiedener Kunden kollidieren nicht, weil jeder Router in Traefik als `____` erscheint.
@@ -142,6 +144,21 @@ curl -s http://127.0.0.1:8080/metrics/checkmk # als Checkmk-Local-Check einb
Liefert pro kommerziellem Mandanten Vertragsfrist und Mahnstufe, pro Knoten online/offline/Wartung und Lease, dazu Policy-Verstöße der letzten 24 h. Zusätzlich Push per `SULTAN_NOTIFY_SLACK` / `SULTAN_NOTIFY_NTFY`: Knoten > 15 min offline, wieder online, Fristen, Mahnstufen. Policy-Verstöße erscheinen nur im Server-Log und als Zähler in Checkmk.
+### Inventar (Accountant)
+
+Betreibst du mehrere Ingress-Server, melden sie ihr Inventar an deinen `sultan accountant`: Mandanten, Knoten und je URL das Ziel (`:`), Container, Compose-Verzeichnis, Health und das Produkt aus `sultan.accountant.billing`. Im Störfall:
+
+```bash
+sultan accountant where kunde-a.de # Backend :, Container, Compose-Verzeichnis, seit wann, wo vorher
+sultan accountant routes --tenant=k10042
+```
+
+Einrichtung: `sultan accountant invite --payee=` auf dem Accountant, die Einladung als `SULTAN_ACCOUNTANT_TOKEN` in die `.env` des Ingress-Servers, Neustart. Danach liegen `payee.key` und `accountant.json` in `suldata/` (mitsichern).
+
+Abrechnung: Kunden legt der Accountant aus den Mandanten automatisch an; du ergänzt Zahlweise, Pauschale, Gruppe und Notiz (`customer set`), pflegst den Produktkatalog (`product add|set|price`, Preise optional) und ordnest Posten zu (Label `sultan.accountant.billing` am Container oder `assign --host`). `sultan accountant billing --month=JJJJ-MM --out=…csv` liefert die Monatsliste; die Spalte „Hinweis“ zeigt Lücken (kein Kunde, nicht zugeordnet, Zahlweise offen) und Kündigungen mit tagesgenauer Gegenrechnung.
+
+Checkmk auf dem Accountant-Host: `curl -s http://127.0.0.1:8082/metrics/checkmk` als Local Check einbinden. Je URL ein Service `Sultan URL ` (WARN bei gestoppt, Policy-Verstoß, unhealthy, Knoten getrennt; Wartung bleibt OK) mit Ziel `http://:` und Compose-Verzeichnis im Text, je Ingress-Server `Sultan Payee ` (WARN ab 2 h ohne Bericht, CRIT ab 6 h). Den Text passt du mit `sultan accountant dump-checkmk` an.
+
### Sicherheit
- Verdacht auf kompromittierten Kunden-Host: `sultan token node drain --id …` (Routen sofort weg), danach `node delete` und neue Einladung für einen sauberen Host.
diff --git a/pkg/accountant/accountant.go b/pkg/accountant/accountant.go
new file mode 100644
index 0000000..37fe65b
--- /dev/null
+++ b/pkg/accountant/accountant.go
@@ -0,0 +1,333 @@
+// Package accountant ist `sultan accountant` (Spec 5.3): die Zentrale, an
+// die Sultan-Server (Payees) ihr Inventar melden – für Störfall
+// (URL → Backend-Host, Compose-Verzeichnis), Mandanten-Übersicht und
+// Abrechnung.
+//
+// Gleiches Sicherheitsmodell wie Agent → Server: eigenes selbstsigniertes
+// Zertifikat (der Payee pinnt den Fingerprint aus der Einladung), nur TLS
+// 1.3, jeder Request Ed25519-signiert. Im Datenverzeichnis (Default
+// ./accdata) liegen accountant.db und server.crt/server.key.
+//
+// Umgesetzt: Datenverzeichnis, Zertifikat, Datenbank, Enrollment der
+// Payees, Empfang ihrer Berichte (letzter Stand je Payee) und
+// Checkmk-Ausgabe auf einem Loopback-Port. Historie und Abrechnung folgen
+// (Roadmap 6d).
+package accountant
+
+import (
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "math"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "sultan/pkg/auth"
+ "sultan/pkg/crypto"
+ "sultan/pkg/ledger"
+ "sultan/pkg/ratelimit"
+ "sultan/pkg/store"
+)
+
+// PathEnroll ist der Enrollment-Endpunkt für Payees.
+const PathEnroll = "/api/v1/enroll"
+
+// SettingPublicURLs: gemerkte --public-url (JSON-Liste); die erste steckt in
+// Einladungen.
+const SettingPublicURLs = "public_urls"
+
+// EnrollRequest ist der Body von POST /api/v1/enroll (signiert mit dem
+// neuen payee.key).
+type EnrollRequest struct {
+ Token string `json:"token"` // sltn_enroll_, ohne Fingerprint/URL
+ Hostname string `json:"hostname"`
+}
+
+// EnrollResponse ist die Antwort auf ein erfolgreiches Enrollment.
+type EnrollResponse struct {
+ PayeeID string `json:"payee_id"`
+}
+
+// Config ist die Konfiguration; Nullwerte bekommen Defaults.
+type Config struct {
+ DataDir string // Default ./accdata
+ Addr string // Default :8444 (nur TLS)
+ PublicURLs []string // Adressen für Einladungen; werden gemerkt
+ // MetricsAddr: Checkmk-Ausgabe (Klartext, nur Loopback), Default
+ // 127.0.0.1:8082; "none" schaltet ab.
+ MetricsAddr string
+ // EnrollLimit: Enrollment-Versuche pro Minute und Client-IP (Default 10,
+ // -1 = unbegrenzt) – gegen Fluten (Log, Datenbank).
+ EnrollLimit int
+ // StaleWarn/StaleCrit: ab welchem Alter des letzten Berichts ein Payee
+ // (und seine URLs) WARN bzw. CRIT ist (Default 2h/6h).
+ StaleWarn, StaleCrit time.Duration
+ Logger *slog.Logger
+}
+
+func (c Config) withDefaults() Config {
+ c.DataDir = cmp.Or(c.DataDir, "./accdata")
+ c.Addr = cmp.Or(c.Addr, ":8444")
+ c.MetricsAddr = cmp.Or(c.MetricsAddr, "127.0.0.1:8082")
+ if c.EnrollLimit == 0 {
+ c.EnrollLimit = 10
+ }
+ if c.StaleWarn <= 0 {
+ c.StaleWarn = 2 * time.Hour
+ }
+ if c.StaleCrit <= 0 {
+ c.StaleCrit = 6 * time.Hour
+ }
+ if c.Logger == nil {
+ c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
+ }
+ return c
+}
+
+// Accountant ist ein startbereiter Accountant.
+type Accountant struct {
+ cfg Config
+ log *slog.Logger
+ ledger *ledger.Ledger
+ cert crypto.ServerCert
+ verifier *auth.Verifier
+ // reportVerifier: wie verifier, aber für große Berichte.
+ reportVerifier *auth.Verifier
+
+ enrollRL *ratelimit.Limiter
+
+ mu sync.Mutex
+ addr string
+ metricsAddr string
+ srv *http.Server
+ metricsSrv *http.Server
+ serveErr chan error
+ shutdownOnce sync.Once
+ shutdownErr error
+}
+
+// New legt das Datenverzeichnis an (0700) und lädt bzw. erzeugt Datenbank
+// und Zertifikat. Lauschen beginnt erst mit Start.
+func New(ctx context.Context, cfg Config) (*Accountant, error) {
+ cfg = cfg.withDefaults()
+ var urls []string
+ for _, raw := range cfg.PublicURLs {
+ if strings.TrimSpace(raw) == "" {
+ continue
+ }
+ u, err := crypto.ValidateServerURL(raw)
+ if err != nil {
+ return nil, fmt.Errorf("accountant: --public-url: %w", err)
+ }
+ urls = append(urls, u)
+ }
+ if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
+ return nil, fmt.Errorf("accountant: Datenverzeichnis: %w", err)
+ }
+ l, err := ledger.Open(ctx, filepath.Join(cfg.DataDir, "accountant.db"))
+ if err != nil {
+ return nil, err
+ }
+ if len(urls) > 0 {
+ raw, _ := json.Marshal(urls)
+ if err := l.SetSetting(ctx, SettingPublicURLs, string(raw)); err != nil {
+ l.Close()
+ return nil, err
+ }
+ }
+ cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir)
+ if err != nil {
+ l.Close()
+ return nil, err
+ }
+ return &Accountant{
+ cfg: cfg,
+ log: cfg.Logger,
+ ledger: l,
+ cert: cert,
+ verifier: &auth.Verifier{},
+ reportVerifier: &auth.Verifier{MaxBodySize: maxReportSize},
+ enrollRL: &ratelimit.Limiter{Limit: cfg.EnrollLimit, Window: time.Minute},
+ serveErr: make(chan error, 2),
+ }, nil
+}
+
+// LoadPublicURLs liefert die gemerkten Adressen (für `accountant invite`).
+func LoadPublicURLs(ctx context.Context, l *ledger.Ledger) ([]string, error) {
+ raw, err := l.Setting(ctx, SettingPublicURLs)
+ if err != nil || raw == "" {
+ return nil, err
+ }
+ var urls []string
+ if err := json.Unmarshal([]byte(raw), &urls); err != nil {
+ return nil, fmt.Errorf("accountant: Einstellung %s: %w", SettingPublicURLs, err)
+ }
+ return urls, nil
+}
+
+// Fingerprint ist der Fingerprint des TLS-Zertifikats (für Einladungen).
+func (a *Accountant) Fingerprint() crypto.Fingerprint { return a.cert.Fingerprint }
+
+// DataDir ist das Datenverzeichnis.
+func (a *Accountant) DataDir() string { return a.cfg.DataDir }
+
+// Addr ist die tatsächlich gebundene Adresse ("" vor Start).
+func (a *Accountant) Addr() string { a.mu.Lock(); defer a.mu.Unlock(); return a.addr }
+
+// MetricsAddr ist die gebundene Adresse der Checkmk-Ausgabe ("" wenn aus).
+func (a *Accountant) MetricsAddr() string { a.mu.Lock(); defer a.mu.Unlock(); return a.metricsAddr }
+
+// Start bindet die Ports und beginnt zu bedienen; er kehrt sofort zurück.
+func (a *Accountant) Start() error {
+ ln, err := net.Listen("tcp", a.cfg.Addr)
+ if err != nil {
+ return fmt.Errorf("accountant: Port %s: %w", a.cfg.Addr, err)
+ }
+ var metricsLn net.Listener
+ if a.cfg.MetricsAddr != "none" {
+ if metricsLn, err = net.Listen("tcp", a.cfg.MetricsAddr); err != nil {
+ ln.Close()
+ return fmt.Errorf("accountant: Metrik-Port %s: %w", a.cfg.MetricsAddr, err)
+ }
+ }
+ srv := &http.Server{
+ Handler: a.routes(),
+ TLSConfig: crypto.ServerTLSConfig(a.cert),
+ ReadHeaderTimeout: 10 * time.Second,
+ ErrorLog: slog.NewLogLogger(a.log.Handler(), slog.LevelDebug),
+ }
+ a.mu.Lock()
+ a.addr, a.srv = ln.Addr().String(), srv
+ if metricsLn != nil {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /metrics/checkmk", a.handleCheckmk)
+ a.metricsAddr = metricsLn.Addr().String()
+ a.metricsSrv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+ }
+ metricsSrv := a.metricsSrv
+ a.mu.Unlock()
+ go a.serve(func() error { return srv.ServeTLS(ln, "", "") })
+ if metricsSrv != nil {
+ go a.serve(func() error { return metricsSrv.Serve(metricsLn) })
+ }
+ a.log.Info("Sultan-Accountant gestartet", "addr", a.Addr(), "metrics", a.MetricsAddr(),
+ "Fingerprint", string(a.cert.Fingerprint), "data_dir", a.cfg.DataDir)
+ return nil
+}
+
+func (a *Accountant) serve(fn func() error) {
+ if err := fn(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ a.serveErr <- err
+ }
+}
+
+// Run startet den Accountant und fährt ihn herunter, sobald ctx endet oder
+// der Port ausfällt.
+func (a *Accountant) Run(ctx context.Context) error {
+ if err := a.Start(); err != nil {
+ a.Shutdown(context.Background())
+ return err
+ }
+ var runErr error
+ select {
+ case <-ctx.Done():
+ case runErr = <-a.serveErr:
+ }
+ sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return errors.Join(runErr, a.Shutdown(sctx))
+}
+
+// Shutdown beendet den HTTP-Server und schließt die Datenbank; mehrfacher
+// Aufruf ist erlaubt.
+func (a *Accountant) Shutdown(ctx context.Context) error {
+ a.shutdownOnce.Do(func() {
+ a.mu.Lock()
+ srv, metricsSrv := a.srv, a.metricsSrv
+ a.mu.Unlock()
+ var errs []error
+ for _, s := range []*http.Server{srv, metricsSrv} {
+ if s != nil {
+ errs = append(errs, s.Shutdown(ctx))
+ }
+ }
+ errs = append(errs, a.ledger.Close())
+ a.shutdownErr = errors.Join(errs...)
+ })
+ return a.shutdownErr
+}
+
+func (a *Accountant) routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST "+PathEnroll, a.handleEnroll)
+ mux.HandleFunc("POST "+PathReport, a.handleReport)
+ return mux
+}
+
+func (a *Accountant) handleEnroll(w http.ResponseWriter, r *http.Request) {
+ // Vor der Signaturprüfung: Fluten kosten so kaum etwas.
+ if ok, retry := a.enrollRL.Allow(remoteIP(r)); !ok {
+ w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
+ a.log.Warn("Payee-Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
+ http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
+ return
+ }
+ pub, err := a.verifier.VerifyUnregistered(r)
+ if err != nil {
+ code := auth.StatusCode(err)
+ http.Error(w, http.StatusText(code), code)
+ return
+ }
+ var req EnrollRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "ungültiger Body", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.Hostname) == "" {
+ http.Error(w, "hostname fehlt", http.StatusBadRequest)
+ return
+ }
+ p, err := a.ledger.Enroll(r.Context(), req.Token, ledger.Payee{
+ Hostname: req.Hostname,
+ PublicKey: auth.EncodePublicKey(pub),
+ IPAddress: remoteIP(r),
+ })
+ switch {
+ case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrTokenUsed), errors.Is(err, store.ErrTokenExpired):
+ // Keine Details nach außen, ob unbekannt, verbraucht oder abgelaufen.
+ a.log.Warn("Payee-Enrollment abgelehnt", "ip", remoteIP(r), "err", err)
+ http.Error(w, "Einladung ungültig", http.StatusForbidden)
+ return
+ case errors.Is(err, store.ErrExists):
+ http.Error(w, "Payee oder Schlüssel bereits registriert", http.StatusConflict)
+ return
+ case errors.Is(err, store.ErrInvalid):
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ case err != nil:
+ a.log.Error("Payee-Enrollment", "err", err)
+ http.Error(w, "interner Fehler", http.StatusInternalServerError)
+ return
+ }
+ a.log.Info("Payee registriert", "payee", p.ID, "hostname", p.Hostname, "ip", p.IPAddress)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(EnrollResponse{PayeeID: p.ID})
+}
+
+func remoteIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
diff --git a/pkg/accountant/accountant_test.go b/pkg/accountant/accountant_test.go
new file mode 100644
index 0000000..c6ee51e
--- /dev/null
+++ b/pkg/accountant/accountant_test.go
@@ -0,0 +1,260 @@
+package accountant
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "sultan/pkg/auth"
+ "sultan/pkg/crypto"
+ "sultan/pkg/ledger"
+)
+
+var ctx = context.Background()
+
+func start(t *testing.T, dataDir string) *Accountant {
+ t.Helper()
+ if dataDir == "" {
+ dataDir = filepath.Join(t.TempDir(), "accdata")
+ }
+ a, err := New(ctx, Config{DataDir: dataDir, Addr: "127.0.0.1:0", MetricsAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.Shutdown(ctx) })
+ return a
+}
+
+// invite legt eine Einladung direkt im Ledger an (wie `sultan accountant invite`).
+func invite(t *testing.T, a *Accountant, payeeID string) crypto.EnrollmentToken {
+ t.Helper()
+ l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+ tok, err := crypto.NewEnrollmentToken(a.Fingerprint(), "https://"+a.Addr())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := l.AddInvite(ctx, tok.Secret, payeeID, time.Now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ return tok
+}
+
+// enroll spielt den Payee: gepinnt, signiert.
+func enroll(t *testing.T, a *Accountant, key ed25519.PrivateKey, body any) *http.Response {
+ t.Helper()
+ raw, _ := json.Marshal(body)
+ req, _ := http.NewRequest(http.MethodPost, "https://"+a.Addr()+PathEnroll, bytes.NewReader(raw))
+ if key != nil {
+ if err := (&auth.Signer{Key: key}).Sign(req); err != nil {
+ t.Fatal(err)
+ }
+ }
+ hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.Fingerprint())}}
+ resp, err := hc.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func payees(t *testing.T, a *Accountant) []ledger.Payee {
+ t.Helper()
+ l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+ list, err := l.ListPayees(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return list
+}
+
+func TestNew_CreatesDataDir(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "accdata")
+ a := start(t, dir)
+ info, err := os.Stat(dir)
+ if err != nil || info.Mode().Perm() != 0o700 {
+ t.Fatalf("Datenverzeichnis: %v, %v", info, err)
+ }
+ for _, f := range []string{"accountant.db", "server.crt", "server.key"} {
+ if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
+ t.Errorf("%s fehlt: %v", f, err)
+ }
+ }
+ fp := a.Fingerprint()
+ a.Shutdown(ctx)
+
+ // Neustart: gleiche Identität.
+ b := start(t, dir)
+ if b.Fingerprint() != fp {
+ t.Errorf("Fingerprint nach Neustart %s, vorher %s", b.Fingerprint(), fp)
+ }
+}
+
+func TestNew_PublicURLsRemembered(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "accdata")
+ a, err := New(ctx, Config{DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: []string{"https://acc.example.de:8444", ""},
+ Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.Shutdown(ctx)
+ l, _ := ledger.Open(ctx, filepath.Join(dir, "accountant.db"))
+ defer l.Close()
+ urls, err := LoadPublicURLs(ctx, l)
+ if err != nil || len(urls) != 1 || urls[0] != "https://acc.example.de:8444" {
+ t.Errorf("LoadPublicURLs = %v, %v", urls, err)
+ }
+ if _, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"http://klartext:8444"}}); err == nil {
+ t.Error("http-URL akzeptiert")
+ }
+}
+
+func TestEnroll(t *testing.T) {
+ a := start(t, "")
+ tok := invite(t, a, "webproxy11")
+ key, _ := auth.GenerateKey()
+
+ resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: "webproxy11.lan.r25dc.de"})
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("Status %d", resp.StatusCode)
+ }
+ var er EnrollResponse
+ if err := json.NewDecoder(resp.Body).Decode(&er); err != nil || er.PayeeID != "webproxy11" {
+ t.Errorf("Antwort %+v, %v", er, err)
+ }
+ list := payees(t, a)
+ if len(list) != 1 || list[0].ID != "webproxy11" || list[0].Hostname != "webproxy11.lan.r25dc.de" ||
+ list[0].PublicKey != auth.EncodePublicKey(key.Public().(ed25519.PublicKey)) || list[0].IPAddress != "127.0.0.1" {
+ t.Errorf("Payees = %+v", list)
+ }
+
+ // Einladung ist verbraucht.
+ other, _ := auth.GenerateKey()
+ if resp := enroll(t, a, other, EnrollRequest{Token: tok.Secret, Hostname: "x"}); resp.StatusCode != http.StatusForbidden {
+ t.Errorf("verbrauchte Einladung: Status %d", resp.StatusCode)
+ }
+}
+
+func TestEnroll_Errors(t *testing.T) {
+ a := start(t, "")
+ key, _ := auth.GenerateKey()
+ tok := invite(t, a, "webproxy11")
+ tests := []struct {
+ name string
+ key ed25519.PrivateKey
+ body any
+ want int
+ }{
+ {"unsigniert", nil, EnrollRequest{Token: tok.Secret, Hostname: "h"}, http.StatusUnauthorized},
+ {"unbekannte Einladung", key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}, http.StatusForbidden},
+ {"ohne Hostname", key, EnrollRequest{Token: tok.Secret}, http.StatusBadRequest},
+ {"kein JSON", key, "kaputt", http.StatusBadRequest},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if resp := enroll(t, a, tt.key, tt.body); resp.StatusCode != tt.want {
+ t.Errorf("Status %d, erwartet %d", resp.StatusCode, tt.want)
+ }
+ })
+ }
+ if len(payees(t, a)) != 0 {
+ t.Error("Payee trotz Fehler angelegt")
+ }
+ // Einladung ist nach den Fehlversuchen noch gültig.
+ if resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: "h"}); resp.StatusCode != http.StatusCreated {
+ t.Errorf("gültige Einladung danach: Status %d", resp.StatusCode)
+ }
+ // Derselbe Schlüssel ein zweites Mal: Konflikt.
+ tok2 := invite(t, a, "webproxy12")
+ if resp := enroll(t, a, key, EnrollRequest{Token: tok2.Secret, Hostname: "h"}); resp.StatusCode != http.StatusConflict {
+ t.Errorf("doppelter Schlüssel: Status %d", resp.StatusCode)
+ }
+}
+
+// Kein Klartext: Der Port spricht nur TLS.
+func TestPlaintextRejected(t *testing.T) {
+ a := start(t, "")
+ resp, err := http.Post("http://"+a.Addr()+PathEnroll, "application/json", bytes.NewReader([]byte(`{}`)))
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode < 400 {
+ t.Errorf("Klartext beantwortet mit %d", resp.StatusCode)
+ }
+ }
+}
+
+func TestRun_StopsOnCancel(t *testing.T) {
+ a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rctx, cancel := context.WithCancel(ctx)
+ done := make(chan error, 1)
+ go func() { done <- a.Run(rctx) }()
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Errorf("Run: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("Run endet nicht")
+ }
+}
+
+// Fluten am Enrollment-Endpunkt werden je IP gebremst (Log und Datenbank
+// schonen); Raten ist bei 256 Bit Zufall ohnehin aussichtslos.
+func TestEnroll_RateLimited(t *testing.T) {
+ a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", EnrollLimit: 3,
+ MetricsAddr: "none", Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.Shutdown(ctx) })
+ key, _ := auth.GenerateKey()
+ for i := range 3 {
+ if r := enroll(t, a, key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("Versuch %d: %d", i+1, r.StatusCode)
+ }
+ }
+ r := enroll(t, a, key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"})
+ if r.StatusCode != http.StatusTooManyRequests || r.Header.Get("Retry-After") == "" {
+ t.Errorf("4. Versuch: %d, Retry-After %q", r.StatusCode, r.Header.Get("Retry-After"))
+ }
+ // Auch unsignierte Fluten zählen.
+ if r := enroll(t, a, nil, EnrollRequest{}); r.StatusCode != http.StatusTooManyRequests {
+ t.Errorf("unsigniert: %d", r.StatusCode)
+ }
+}
+
+func TestConfigDefaults(t *testing.T) {
+ c := Config{}.withDefaults()
+ if c.DataDir != "./accdata" || c.Addr != ":8444" || c.MetricsAddr != "127.0.0.1:8082" || c.EnrollLimit != 10 ||
+ c.StaleWarn != 2*time.Hour || c.StaleCrit != 6*time.Hour {
+ t.Errorf("Defaults: %+v", c)
+ }
+ if c := (Config{EnrollLimit: -1}).withDefaults(); c.EnrollLimit != -1 {
+ t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
+ }
+}
diff --git a/pkg/accountant/billing.go b/pkg/accountant/billing.go
new file mode 100644
index 0000000..9a18aee
--- /dev/null
+++ b/pkg/accountant/billing.go
@@ -0,0 +1,296 @@
+package accountant
+
+// Monatsabrechnung (Spec 5.3, Roadmap 6d Schritt 4): je Kunde die
+// Pauschale und je Posten (Mandant, Protokoll, Router über alle Payees und
+// Knoten) Klasse, Produkt, Betrag – als CSV für die Buchhaltung (Lexware
+// Office). Preise sind optional und nicht buchhaltungsrelevant.
+
+import (
+ "cmp"
+ "context"
+ "encoding/csv"
+ "fmt"
+ "io"
+ "slices"
+ "strings"
+ "time"
+
+ "sultan/pkg/ledger"
+)
+
+// ItemFlatRate ist der Posten-Name der Pauschale eines Kunden.
+const ItemFlatRate = "Pauschale"
+
+// BillLine ist eine Zeile der Monatsabrechnung.
+type BillLine struct {
+ Month string // JJJJ-MM
+ Customer string // "" = Kunde unbekannt
+ CustomerName string
+ Group string
+ Cycle string // ledger.Cycle*
+ Item string // ItemFlatRate oder //
+ Hosts string // durch Leerzeichen getrennt
+ Tenant string
+ Class string // ledger.Class*; "" = nicht zugeordnet
+ Product string
+ From, To string // JJJJ-MM-TT; To leer = läuft weiter
+ Amount *int64 // Monatsbetrag in Cent; nil = unbekannt (kein Preis/keine Zuordnung)
+ PeriodAmount *int64 // Rechnungsbetrag je Zahlungsperiode (×3 bzw. ×12); nil = unbekannt
+ Shadow *int64 // Schattenpreis (free/internal): was es kosten würde
+ Note string
+ CustomerNote string
+}
+
+// Billing berechnet die Abrechnung eines Monats (JJJJ-MM, Ortszeit) aus der
+// ganzen Historie.
+func Billing(ctx context.Context, l *ledger.Ledger, month string) ([]BillLine, error) {
+ spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
+ if err != nil {
+ return nil, err
+ }
+ return billFrom(ctx, l, month, spans, time.Local)
+}
+
+type item struct {
+ key ItemKey
+ first time.Time
+ ended time.Time // Nullzeit = läuft
+ open bool // mindestens ein Ort läuft noch
+ latest ledger.RouteSpan
+}
+
+func cycleFactor(c string) (int64, string) {
+ switch c {
+ case ledger.CycleMonthly:
+ return 1, ""
+ case ledger.CycleQuarterly:
+ return 3, "Quartalszahler: Rechnung = 3 × Monatsbetrag"
+ case ledger.CycleYearly:
+ return 12, "Jahreszahler: Monatsbetrag = 1/12 der Jahresrechnung"
+ }
+ return 0, "noch nicht in Zahlweise (m, q, j) eingeordnet!"
+}
+
+func ptr(v int64) *int64 { return &v }
+
+func billFrom(ctx context.Context, l *ledger.Ledger, month string, spans []ledger.RouteSpan, loc *time.Location) ([]BillLine, error) {
+ start, err := time.ParseInLocation("2006-01", month, loc)
+ if err != nil || !ledger.ValidMonth(month) {
+ return nil, fmt.Errorf("Monat %q ungültig (JJJJ-MM)", month)
+ }
+ end := start.AddDate(0, 1, 0)
+ daysInMonth := int64(end.Add(-time.Hour).Day())
+
+ customers, err := l.ListCustomers(ctx)
+ if err != nil {
+ return nil, err
+ }
+ byID := map[string]ledger.Customer{}
+ for _, c := range customers {
+ byID[c.ID] = c
+ }
+ resolver, err := l.CustomerResolver(ctx)
+ if err != nil {
+ return nil, err
+ }
+ cat, err := l.Catalog(ctx)
+ if err != nil {
+ return nil, err
+ }
+ assignList, err := l.ListAssignments(ctx)
+ if err != nil {
+ return nil, err
+ }
+ assigns := map[ItemKey]ledger.Assignment{}
+ for _, a := range assignList {
+ assigns[ItemKey{a.Tenant, a.Protocol, a.Router}] = a
+ }
+
+ // Orte zu Posten zusammenfassen.
+ items := map[ItemKey]*item{}
+ for _, s := range spans {
+ k := ItemKey{s.Tenant, s.Protocol, s.Router}
+ it := items[k]
+ if it == nil {
+ it = &item{key: k, first: s.FirstSeen, latest: s}
+ items[k] = it
+ }
+ if s.FirstSeen.Before(it.first) {
+ it.first = s.FirstSeen
+ }
+ if s.Ended.IsZero() {
+ it.open = true
+ } else if s.Ended.After(it.ended) {
+ it.ended = s.Ended
+ }
+ if !s.FirstSeen.Before(it.latest.FirstSeen) {
+ it.latest = s
+ }
+ }
+ for _, it := range items {
+ if it.open {
+ it.ended = time.Time{}
+ }
+ }
+
+ var lines []BillLine
+ activeCustomers := map[string]bool{}
+ for _, it := range items {
+ a, assigned := assigns[it.key]
+ startMonth := time.Date(it.first.In(loc).Year(), it.first.In(loc).Month(), 1, 0, 0, 0, 0, loc)
+ if assigned && a.Start != "" {
+ if t, err := time.ParseInLocation("2006-01", a.Start, loc); err == nil {
+ startMonth = t
+ }
+ }
+ if start.Before(startMonth) || (!it.ended.IsZero() && it.ended.Before(start)) {
+ continue // nicht in diesem Monat
+ }
+ custID := resolver.Resolve(it.latest.Payee, it.key.Tenant)
+ cust, known := byID[custID]
+ ln := BillLine{
+ Month: month, Customer: custID, CustomerName: cust.Name, Group: cust.Group, Cycle: cust.Cycle,
+ Item: it.key.Tenant + "/" + it.key.Protocol + "/" + it.key.Router,
+ Hosts: strings.Join(it.latest.Hosts, " "), Tenant: it.key.Tenant,
+ From: it.first.In(loc).Format(time.DateOnly), CustomerNote: cust.Note,
+ }
+ var notes []string
+ if !known {
+ ln.Customer = ""
+ notes = append(notes, "Kunde unbekannt – sultan accountant customer map")
+ } else {
+ activeCustomers[custID] = true
+ }
+
+ // Klasse und Produkt: Zuordnung > Produkt (Zuordnung, sonst Label) > Pauschale > nicht zugeordnet.
+ ln.Product = it.latest.Product
+ if assigned && a.Product != "" {
+ ln.Product = a.Product
+ }
+ var price *int64
+ if ln.Product != "" {
+ if v, ok := cat.At(ln.Product, custID, month); ok {
+ ln.Class, price = v.Class, v.Price
+ } else {
+ notes = append(notes, "Produkt "+ln.Product+" nicht im Katalog")
+ }
+ }
+ if assigned && a.Class != "" {
+ ln.Class = a.Class
+ }
+ if ln.Class == "" && ln.Product == "" && known && cust.FlatRate != nil {
+ ln.Class = ledger.ClassIncluded
+ }
+
+ switch ln.Class {
+ case ledger.ClassBillable:
+ if price != nil {
+ ln.Amount = ptr(*price)
+ } else {
+ notes = append(notes, "kein Preis hinterlegt")
+ }
+ case ledger.ClassIncluded:
+ ln.Amount = ptr(0)
+ case ledger.ClassFree, ledger.ClassInternal:
+ ln.Amount, ln.Shadow = ptr(0), price
+ case "":
+ if !slices.ContainsFunc(notes, func(n string) bool { return strings.Contains(n, "nicht im Katalog") }) {
+ notes = append(notes, "nicht zugeordnet – Label sultan.accountant.billing oder sultan accountant assign")
+ }
+ }
+
+ if !it.ended.IsZero() && it.ended.Before(end) {
+ ln.To = it.ended.In(loc).Format(time.DateOnly)
+ used := int64(it.ended.In(loc).Day())
+ n := fmt.Sprintf("beendet am %s (Ultimo abgerechnet)", it.ended.In(loc).Format("02.01."))
+ if ln.Amount != nil && *ln.Amount > 0 {
+ n += fmt.Sprintf("; tagesgenau %d/%d Tage = %s", used, daysInMonth, FormatMoney((*ln.Amount*used+daysInMonth/2)/daysInMonth))
+ }
+ notes = append(notes, n)
+ }
+ factor, cycleNote := cycleFactor(cust.Cycle)
+ if known && cycleNote != "" {
+ notes = append(notes, cycleNote)
+ }
+ if ln.Amount != nil && factor > 0 {
+ ln.PeriodAmount = ptr(*ln.Amount * factor)
+ }
+ ln.Note = strings.Join(notes, "; ")
+ lines = append(lines, ln)
+ }
+
+ // Pauschale je Kunde.
+ for _, c := range customers {
+ if c.FlatRate == nil {
+ continue
+ }
+ ln := BillLine{Month: month, Customer: c.ID, CustomerName: c.Name, Group: c.Group, Cycle: c.Cycle,
+ Item: ItemFlatRate, Amount: ptr(*c.FlatRate), CustomerNote: c.Note}
+ var notes []string
+ factor, cycleNote := cycleFactor(c.Cycle)
+ if factor > 0 {
+ ln.PeriodAmount = ptr(*c.FlatRate * factor)
+ }
+ if cycleNote != "" {
+ notes = append(notes, cycleNote)
+ }
+ if !activeCustomers[c.ID] {
+ notes = append(notes, "keine aktiven URLs in diesem Monat")
+ }
+ ln.Note = strings.Join(notes, "; ")
+ lines = append(lines, ln)
+ }
+
+ slices.SortFunc(lines, func(a, b BillLine) int {
+ if (a.Customer == "") != (b.Customer == "") {
+ if a.Customer == "" {
+ return 1
+ }
+ return -1
+ }
+ if c := strings.Compare(a.Customer, b.Customer); c != 0 {
+ return c
+ }
+ if (a.Item == ItemFlatRate) != (b.Item == ItemFlatRate) {
+ if a.Item == ItemFlatRate {
+ return -1
+ }
+ return 1
+ }
+ return cmp.Or(strings.Compare(a.Hosts, b.Hosts), strings.Compare(a.Item, b.Item))
+ })
+ return lines, nil
+}
+
+var cycleNames = map[string]string{
+ ledger.CycleMonthly: "monatlich", ledger.CycleQuarterly: "quartalsweise", ledger.CycleYearly: "jährlich",
+ ledger.CycleUnclassified: "nicht eingeordnet",
+}
+
+// WriteBillCSV schreibt die Abrechnung als CSV (UTF-8 mit BOM, Semikolon,
+// Dezimalkomma) – so liest sie Excel bzw. LibreOffice ohne Importdialog.
+func WriteBillCSV(w io.Writer, lines []BillLine) error {
+ if _, err := io.WriteString(w, "\uFEFF"); err != nil {
+ return err
+ }
+ cw := csv.NewWriter(w)
+ cw.Comma = ';'
+ money := func(p *int64) string {
+ if p == nil {
+ return ""
+ }
+ return FormatMoney(*p)
+ }
+ cw.Write([]string{"Monat", "Kunde", "Kundenname", "Gruppe", "Zahlweise", "Posten", "Hosts", "Mandant", "Klasse", "Produkt",
+ "Von", "Bis", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"})
+ for _, l := range lines {
+ cycle := cycleNames[l.Cycle]
+ if l.Customer == "" {
+ cycle = ""
+ }
+ cw.Write([]string{l.Month, l.Customer, l.CustomerName, l.Group, cycle, l.Item, l.Hosts, l.Tenant, l.Class, l.Product,
+ l.From, l.To, money(l.Amount), money(l.PeriodAmount), money(l.Shadow), l.Note, l.CustomerNote})
+ }
+ cw.Flush()
+ return cw.Error()
+}
diff --git a/pkg/accountant/billing_test.go b/pkg/accountant/billing_test.go
new file mode 100644
index 0000000..0c32a7d
--- /dev/null
+++ b/pkg/accountant/billing_test.go
@@ -0,0 +1,203 @@
+package accountant
+
+import (
+ "bytes"
+ "encoding/csv"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "sultan/pkg/ledger"
+)
+
+func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 12, 0, 0, 0, time.UTC) }
+
+func cents(v int64) *int64 { return &v }
+
+func span(payee, tenant, node, router, product string, first, ended time.Time, hosts ...string) ledger.RouteSpan {
+ return ledger.RouteSpan{
+ RouteRecord: ledger.RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts, Product: product},
+ Payee: payee, FirstSeen: first, LastSeen: first, Ended: ended,
+ }
+}
+
+func billingFixture(t *testing.T) (*ledger.Ledger, []ledger.RouteSpan) {
+ t.Helper()
+ l, err := ledger.Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { l.Close() })
+ flat := int64(8900)
+ for _, c := range []ledger.Customer{
+ {ID: "kunde", Name: "Kunde A", Group: "extern", Cycle: ledger.CycleQuarterly, FlatRate: &flat, Note: "Kumpel"},
+ {ID: "jahr", Name: "Jahreszahler", Cycle: ledger.CycleYearly},
+ {ID: "familie", Name: "Familie", Group: "familie", Cycle: ledger.CycleMonthly},
+ {ID: "offen", Name: "Noch offen"},
+ } {
+ if err := l.AddCustomer(ctx, c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ l.MapTenant(ctx, "", "kunde-a", "kunde") // zweiter Mandant desselben Kunden
+ l.AddProduct(ctx, ledger.Product{ID: "olat", Name: "OLAT", Class: ledger.ClassBillable, Price: cents(4900)})
+ l.AddProduct(ctx, ledger.Product{ID: "pingvin", Name: "Pingvin", Class: ledger.ClassFree, Price: cents(500)})
+ l.SetProductVersion(ctx, "pingvin", "2027-03", ledger.ClassBillable, cents(500))
+ l.AddProduct(ctx, ledger.Product{ID: "hochzeit", Name: "Landingpage", Class: ledger.ClassInternal, Price: cents(1500)})
+ l.SetCustomerPrice(ctx, "olat", "jahr", "2026-01", 3900)
+ l.SetAssignment(ctx, ledger.Assignment{Tenant: "kunde", Protocol: "http", Router: "spaet", Start: "2026-12"})
+ l.SetAssignment(ctx, ledger.Assignment{Tenant: "kunde", Protocol: "http", Router: "wp", Class: ledger.ClassIncluded})
+
+ open := time.Time{}
+ spans := []ledger.RouteSpan{
+ // Kunde A: Website (Umzug webdocker07 → webdocker11, ein Posten), OLAT ab Mitte Oktober,
+ // gekündigter Backup-Dienst, später beginnender Dienst, Pingvin, unbekanntes Produkt.
+ span("p1", "kunde", "webdocker07", "wp", "", day(2026, 1, 5), day(2026, 9, 30), "kunde.de"),
+ span("p1", "kunde", "webdocker11", "wp", "", day(2026, 9, 30), open, "kunde.de", "www.kunde.de"),
+ span("p1", "kunde", "webdocker11", "olat", "olat", day(2026, 10, 17), open, "olat.kunde.de"),
+ span("p2", "kunde-a", "nb1", "backup", "olat", day(2026, 3, 1), day(2026, 10, 14), "backup.kunde.de"),
+ span("p1", "kunde", "webdocker11", "spaet", "olat", day(2026, 10, 2), open, "spaet.kunde.de"),
+ span("p1", "kunde", "webdocker11", "share", "pingvin", day(2026, 5, 1), open, "share.kunde.de"),
+ span("p1", "kunde", "webdocker11", "alt", "gibts-nicht", day(2026, 5, 1), open, "alt.kunde.de"),
+ span("p1", "kunde", "webdocker11", "weg", "olat", day(2026, 5, 1), day(2026, 9, 3), "weg.kunde.de"),
+ // Jahreszahler mit Kundenpreis.
+ span("p1", "jahr", "n2", "olat", "olat", day(2026, 2, 1), open, "olat.jahr.de"),
+ // Familie: intern mit Schattenpreis.
+ span("p1", "familie", "n3", "hochzeit", "hochzeit", day(2026, 6, 1), open, "hochzeit.example.de"),
+ // Ohne Pauschale, ohne Label, Zahlweise offen.
+ span("p1", "offen", "n4", "web", "", day(2026, 6, 1), open, "offen.de"),
+ // Mandant ohne Kunden.
+ span("p1", "fremd", "n5", "web", "", day(2026, 6, 1), open, "fremd.de"),
+ }
+ return l, spans
+}
+
+func lineFor(t *testing.T, lines []BillLine, item string) BillLine {
+ t.Helper()
+ for _, l := range lines {
+ if l.Item == item {
+ return l
+ }
+ }
+ t.Fatalf("Posten %q fehlt", item)
+ return BillLine{}
+}
+
+func amount(p *int64) string {
+ if p == nil {
+ return ""
+ }
+ return FormatMoney(*p)
+}
+
+func TestBill_October(t *testing.T) {
+ l, spans := billingFixture(t)
+ lines, err := billFrom(ctx, l, "2026-10", spans, time.UTC)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tests := []struct {
+ item, customer, class, product, amount, period, shadow, note string
+ }{
+ {ItemFlatRate, "kunde", "", "", "89,00", "267,00", "", "Quartalszahler"},
+ {"kunde/http/wp", "kunde", ledger.ClassIncluded, "", "0,00", "0,00", "", ""},
+ {"kunde/http/olat", "kunde", ledger.ClassBillable, "olat", "49,00", "147,00", "", ""}, // Mitte Oktober: voller Monat
+ {"kunde-a/http/backup", "kunde", ledger.ClassBillable, "olat", "49,00", "147,00", "", "tagesgenau 14/31"},
+ {"kunde/http/share", "kunde", ledger.ClassFree, "pingvin", "0,00", "0,00", "5,00", ""},
+ {"kunde/http/alt", "kunde", "", "gibts-nicht", "", "", "", "nicht im Katalog"},
+ {"jahr/http/olat", "jahr", ledger.ClassBillable, "olat", "39,00", "468,00", "", "Jahreszahler"},
+ {"familie/http/hochzeit", "familie", ledger.ClassInternal, "hochzeit", "0,00", "0,00", "15,00", ""},
+ {"offen/http/web", "offen", "", "", "", "", "", "nicht zugeordnet"},
+ {"fremd/http/web", "", "", "", "", "", "", "Kunde unbekannt"},
+ }
+ for _, tt := range tests {
+ ln := lineFor(t, lines, tt.item)
+ if ln.Customer != tt.customer || ln.Class != tt.class || ln.Product != tt.product || amount(ln.Amount) != tt.amount ||
+ amount(ln.PeriodAmount) != tt.period || amount(ln.Shadow) != tt.shadow || !strings.Contains(ln.Note, tt.note) {
+ t.Errorf("%s:\n %+v\nerwartet Kunde %q Klasse %q Produkt %q Betrag %q Periode %q Schatten %q Hinweis ~%q",
+ tt.item, ln, tt.customer, tt.class, tt.product, tt.amount, tt.period, tt.shadow, tt.note)
+ }
+ }
+ // Gegenrechnung: 49,00 × 14/31 = 22,13.
+ if ln := lineFor(t, lines, "kunde-a/http/backup"); !strings.Contains(ln.Note, "22,13") || ln.To != "2026-10-14" {
+ t.Errorf("Kündigung: %+v", ln)
+ }
+ if ln := lineFor(t, lines, "offen/http/web"); !strings.Contains(ln.Note, "Zahlweise") {
+ t.Errorf("Vermerk Zahlweise fehlt: %q", ln.Note)
+ }
+ if ln := lineFor(t, lines, "kunde/http/wp"); ln.Hosts != "kunde.de www.kunde.de" || ln.From != "2026-01-05" || ln.Group != "extern" || ln.CustomerNote != "Kumpel" {
+ t.Errorf("Website (Umzug = ein Posten): %+v", ln)
+ }
+ for _, ln := range lines {
+ switch ln.Item {
+ case "kunde/http/spaet", "kunde/http/weg":
+ t.Errorf("%s darf im Oktober nicht erscheinen: %+v", ln.Item, ln)
+ }
+ }
+}
+
+// Beginn per Zuordnung im Dezember; Pingvin ab März 2027 kostenpflichtig;
+// beendete Posten verschwinden im Folgemonat.
+func TestBill_LaterMonths(t *testing.T) {
+ l, spans := billingFixture(t)
+ dec, _ := billFrom(ctx, l, "2026-12", spans, time.UTC)
+ if ln := lineFor(t, dec, "kunde/http/spaet"); amount(ln.Amount) != "49,00" {
+ t.Errorf("Beginn Dezember: %+v", ln)
+ }
+ for _, ln := range dec {
+ if ln.Item == "kunde-a/http/backup" {
+ t.Errorf("gekündigter Posten im Dezember: %+v", ln)
+ }
+ }
+ mar, _ := billFrom(ctx, l, "2027-03", spans, time.UTC)
+ if ln := lineFor(t, mar, "kunde/http/share"); ln.Class != ledger.ClassBillable || amount(ln.Amount) != "5,00" {
+ t.Errorf("Pingvin ab März: %+v", ln)
+ }
+ jan, _ := billFrom(ctx, l, "2026-01", spans, time.UTC)
+ for _, ln := range jan {
+ if ln.Item == "kunde/http/olat" {
+ t.Errorf("Posten vor seinem Beginn: %+v", ln)
+ }
+ }
+ if _, err := billFrom(ctx, l, "Oktober", spans, time.UTC); err == nil {
+ t.Error("ungültiger Monat akzeptiert")
+ }
+}
+
+// CSV: Semikolon, Kopfzeile, Dezimalkomma; Pauschale je Kunde vor seinen Posten.
+func TestWriteBillCSV(t *testing.T) {
+ l, spans := billingFixture(t)
+ lines, _ := billFrom(ctx, l, "2026-10", spans, time.UTC)
+ var buf bytes.Buffer
+ if err := WriteBillCSV(&buf, lines); err != nil {
+ t.Fatal(err)
+ }
+ r := csv.NewReader(strings.NewReader(strings.TrimPrefix(buf.String(), "\uFEFF")))
+ r.Comma = ';'
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("CSV unlesbar: %v\n%s", err, buf.String())
+ }
+ head := strings.Join(rows[0], ";")
+ for _, col := range []string{"Monat", "Kunde", "Gruppe", "Zahlweise", "Posten", "Hosts", "Klasse", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"} {
+ if !strings.Contains(head, col) {
+ t.Errorf("Spalte %s fehlt: %s", col, head)
+ }
+ }
+ if len(rows) != len(lines)+1 {
+ t.Errorf("%d Zeilen, erwartet %d", len(rows), len(lines)+1)
+ }
+ var kundeRows []string
+ for _, row := range rows[1:] {
+ if row[1] == "kunde" {
+ kundeRows = append(kundeRows, row[5])
+ }
+ }
+ if len(kundeRows) == 0 || kundeRows[0] != ItemFlatRate {
+ t.Errorf("Pauschale nicht zuerst: %v", kundeRows)
+ }
+ if !strings.HasPrefix(buf.String(), "\uFEFF") {
+ t.Error("ohne BOM (Excel erkennt UTF-8 sonst nicht)")
+ }
+}
diff --git a/pkg/accountant/checkmk.go b/pkg/accountant/checkmk.go
new file mode 100644
index 0000000..541292b
--- /dev/null
+++ b/pkg/accountant/checkmk.go
@@ -0,0 +1,329 @@
+package accountant
+
+// Checkmk-Ausgabe des Accountants (Spec 5.3, Roadmap 6e): ein Service je URL
+// (Störfall: wohin springen?) und je Payee (Frische der Berichte), im
+// Local-Check-Format. Der Text kommt aus einer text/template-Vorlage:
+// eingebaut, überschreibbar durch /checkmk/checkmk.tmpl (wirkt
+// sofort). Werte aus Berichten sind Fremdeingaben (Containername,
+// Compose-Verzeichnis stammen vom Kunden-Host): Sie sind bereinigt
+// (einzeilig, ASCII, ohne | und ") und HTML-escaped; nur bereinigt stehen
+// sie unter .Unescaped – falls in Checkmk das HTML-Escaping abgeschaltet ist,
+// öffnet deren Ausgabe Script-Injektion in die Oberfläche.
+
+import (
+ "bytes"
+ "cmp"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "text/template"
+ "time"
+
+ "sultan/pkg/checkmk"
+ "sultan/pkg/ledger"
+)
+
+// CheckmkTemplateName ist der Dateiname der eigenen Vorlage in /checkmk.
+const CheckmkTemplateName = "checkmk.tmpl"
+
+// Checkmk-Status.
+const (
+ cmkOK = 0
+ cmkWarn = 1
+ cmkCrit = 2
+)
+
+// defaultCheckmkTemplate ist die eingebaute Vorlage (sultan accountant
+// dump-checkmk exportiert sie zum Anpassen). Jede Zeile:
+// "" - -
+const defaultCheckmkTemplate = `{{- /*
+ Checkmk-Vorlage des Sultan-Accountants (text/template).
+ Eine Zeile je Service: "" - -
+ .Payees: .Service .State .Problem .ID .Hostname .Age .LastReport
+ .URLs: .Service .State .Problem .Host .URL .Tenant .Payee .Node .Router .Workload
+ .Backend .BackendFQDN .BackendURL .ComposeProject .ComposeDir .Health .Product
+ Werte sind bereinigt und HTML-escaped; .Unescaped. nur bereinigt.
+ Funktionen: status (0/1/2 → OK/WARN/CRIT)
+*/ -}}
+{{- range .Payees -}}
+{{.State}} "{{.Service}}" - {{status .State}} - {{with .Problem}}{{.}}; {{end}}letzter Bericht {{.Age}}{{with .Hostname}} von {{.}}{{end}}
+{{end -}}
+{{- range .URLs -}}
+{{.State}} "{{.Service}}" - {{status .State}} - {{with .Problem}}{{.}}; {{end}}{{.URL}} -> {{or .BackendURL .Backend}} ({{.Tenant}} auf {{.Node}} via {{.Payee}}{{with .ComposeDir}}, compose {{.}}{{end}}{{with .Health}}, {{.}}{{end}}{{with .Product}}, Produkt {{.}}{{end}})
+{{end -}}
+`
+
+// CheckmkData ist der Inhalt, den die Vorlage sieht.
+type CheckmkData struct {
+ Now time.Time
+ Payees []CheckmkPayee
+ URLs []CheckmkURL
+}
+
+// PayeeFields sind die Textfelder eines Payees.
+type PayeeFields struct {
+ ID string
+ Hostname string
+ Problem string
+}
+
+// CheckmkPayee ist der Frische-Service eines Payees.
+type CheckmkPayee struct {
+ PayeeFields // bereinigt und HTML-escaped
+ Unescaped PayeeFields // nur bereinigt
+ Service string // Name des Checkmk-Services
+ State int // 0 OK, 1 WARN, 2 CRIT
+ Age string // z. B. "vor 5 min", "nie"
+ LastReport time.Time // Nullzeit = nie
+}
+
+// URLFields sind die Textfelder einer URL.
+type URLFields struct {
+ Host, URL string
+ Tenant, Payee, Node string
+ Router, Workload string
+ Backend, BackendFQDN, BackendURL string
+ ComposeProject, ComposeDir string
+ Health, Product string
+ Problem string
+}
+
+// CheckmkURL ist der Service einer URL.
+type CheckmkURL struct {
+ URLFields // bereinigt und HTML-escaped
+ Unescaped URLFields // nur bereinigt
+ Service string
+ State int
+}
+
+// clean macht Fremdtext einzeilig, ASCII und frei von Trennzeichen des
+// Local-Check-Formats.
+func clean(s string) string {
+ return strings.NewReplacer("|", "/", `"`, "'").Replace(checkmk.ASCII(s))
+}
+
+func escapeFields[T any](v T, f func(*T, func(string) string)) T {
+ f(&v, html.EscapeString)
+ return v
+}
+
+func (u *URLFields) apply(fn func(string) string) {
+ for _, p := range []*string{&u.Host, &u.URL, &u.Tenant, &u.Payee, &u.Node, &u.Router, &u.Workload,
+ &u.Backend, &u.BackendFQDN, &u.BackendURL, &u.ComposeProject, &u.ComposeDir, &u.Health, &u.Product, &u.Problem} {
+ *p = fn(*p)
+ }
+}
+
+func (p *PayeeFields) apply(fn func(string) string) {
+ p.ID, p.Hostname, p.Problem = fn(p.ID), fn(p.Hostname), fn(p.Problem)
+}
+
+func age(d time.Duration) string {
+ switch {
+ case d < time.Minute:
+ return "vor weniger als 1 min"
+ case d < time.Hour:
+ return fmt.Sprintf("vor %d min", int(d/time.Minute))
+ case d < 48*time.Hour:
+ return fmt.Sprintf("vor %d h", int(d/time.Hour))
+ default:
+ return fmt.Sprintf("vor %d Tagen", int(d/(24*time.Hour)))
+ }
+}
+
+// freshness bewertet das Alter eines Berichts.
+func freshness(last, now time.Time, warn, crit time.Duration) (int, string) {
+ switch d := now.Sub(last); {
+ case last.IsZero():
+ return cmkWarn, "noch kein Bericht"
+ case d > crit:
+ return cmkCrit, "Bericht veraltet (" + age(d) + ")"
+ case d > warn:
+ return cmkWarn, "Bericht veraltet (" + age(d) + ")"
+ default:
+ return cmkOK, ""
+ }
+}
+
+// buildCheckmk bewertet Payees und URLs. reports enthält den letzten Bericht
+// je Payee-ID (fehlt bei Payees ohne Bericht).
+func buildCheckmk(payees []ledger.Payee, reports map[string]Report, now time.Time, staleWarn, staleCrit time.Duration) CheckmkData {
+ d := CheckmkData{Now: now}
+ for _, p := range payees {
+ state, problem := freshness(p.LastReport, now, staleWarn, staleCrit)
+ raw := PayeeFields{ID: clean(p.ID), Hostname: clean(p.Hostname), Problem: problem}
+ cp := CheckmkPayee{
+ Unescaped: raw, PayeeFields: escapeFields(raw, (*PayeeFields).apply),
+ Service: "Sultan Payee " + raw.ID, State: state, Age: "nie", LastReport: p.LastReport,
+ }
+ if !p.LastReport.IsZero() {
+ cp.Age = age(now.Sub(p.LastReport))
+ }
+ d.Payees = append(d.Payees, cp)
+
+ rep, ok := reports[p.ID]
+ if !ok {
+ continue
+ }
+ nodes := map[string]ReportNode{}
+ for _, n := range rep.Nodes {
+ nodes[n.ID] = n
+ }
+ for _, r := range rep.Routes {
+ for _, host := range r.Hosts {
+ d.URLs = append(d.URLs, urlService(p.ID, host, r, nodes[r.Node], state, problem))
+ }
+ }
+ }
+ slices.SortFunc(d.Payees, func(a, b CheckmkPayee) int { return strings.Compare(a.Service, b.Service) })
+
+ // Eindeutige Servicenamen: derselbe Host mehrfach → Zusatz (payee/knoten).
+ slices.SortFunc(d.URLs, func(a, b CheckmkURL) int {
+ return cmp.Or(strings.Compare(a.Unescaped.Host, b.Unescaped.Host), strings.Compare(a.Unescaped.Payee, b.Unescaped.Payee),
+ strings.Compare(a.Unescaped.Node, b.Unescaped.Node), strings.Compare(a.Unescaped.Router, b.Unescaped.Router))
+ })
+ seen := map[string]bool{}
+ for i := range d.URLs {
+ u := &d.URLs[i]
+ u.Service = "Sultan URL " + u.Unescaped.Host
+ if seen[u.Service] {
+ u.Service += " (" + u.Unescaped.Payee + "/" + u.Unescaped.Node + "/" + u.Unescaped.Router + ")"
+ }
+ seen[u.Service] = true
+ }
+ slices.SortFunc(d.URLs, func(a, b CheckmkURL) int { return strings.Compare(a.Service, b.Service) })
+ return d
+}
+
+func urlService(payeeID, host string, r ReportRoute, node ReportNode, payeeState int, payeeProblem string) CheckmkURL {
+ raw := URLFields{
+ Host: clean(host), Tenant: clean(r.Tenant), Payee: clean(payeeID), Node: clean(r.Node),
+ Router: clean(r.Router), Workload: clean(r.Workload), Backend: clean(r.Backend), BackendFQDN: clean(r.BackendFQDN),
+ ComposeProject: clean(r.ComposeProject), ComposeDir: clean(r.ComposeDir), Health: clean(r.Health), Product: clean(r.Product),
+ }
+ if r.Protocol == "tcp" {
+ raw.URL, raw.BackendURL = raw.Host, raw.BackendFQDN
+ } else {
+ raw.URL = "https://" + raw.Host
+ if raw.BackendFQDN != "" {
+ raw.BackendURL = "http://" + raw.BackendFQDN
+ }
+ }
+
+ state := cmkOK
+ var problems []string
+ if !r.Active {
+ problems = append(problems, "von Policy verworfen")
+ }
+ if r.Down {
+ problems = append(problems, "gestoppt/Wartungsseite")
+ }
+ if r.Health == "unhealthy" {
+ problems = append(problems, "unhealthy")
+ }
+ if !node.Connected && !node.Maintenance {
+ problems = append(problems, "Knoten getrennt")
+ }
+ switch {
+ case node.Maintenance:
+ // Angekündigte Wartung: kein Alarm, aber sichtbar.
+ problems = append([]string{"Wartung"}, problems...)
+ case len(problems) > 0:
+ state = cmkWarn
+ }
+ if payeeState > state {
+ state = payeeState
+ problems = append([]string{"Bericht von " + payeeID + ": " + payeeProblem}, problems...)
+ }
+ raw.Problem = clean(strings.Join(problems, ", "))
+ return CheckmkURL{Unescaped: raw, URLFields: escapeFields(raw, (*URLFields).apply), State: state}
+}
+
+var cmkFuncs = template.FuncMap{
+ "status": func(s int) string {
+ switch s {
+ case cmkOK:
+ return "OK"
+ case cmkWarn:
+ return "WARN"
+ default:
+ return "CRIT"
+ }
+ },
+}
+
+var builtinCheckmk = template.Must(template.New(CheckmkTemplateName).Funcs(cmkFuncs).Parse(defaultCheckmkTemplate))
+
+// renderCheckmk rendert d mit der eigenen Vorlage aus dataDir (falls
+// vorhanden), sonst mit der eingebauten. Eine fehlerhafte eigene Vorlage
+// fällt auf die eingebaute zurück und erscheint als CRIT-Service.
+func renderCheckmk(dataDir string, d CheckmkData) string {
+ path := filepath.Join(dataDir, "checkmk", CheckmkTemplateName)
+ var problem error
+ if raw, err := os.ReadFile(path); err == nil {
+ t, err := template.New(CheckmkTemplateName).Funcs(cmkFuncs).Parse(string(raw))
+ if err == nil {
+ var buf bytes.Buffer
+ if err = t.Execute(&buf, d); err == nil {
+ return buf.String()
+ }
+ }
+ problem = err
+ } else if !errors.Is(err, os.ErrNotExist) {
+ problem = err
+ }
+ var buf bytes.Buffer
+ if problem != nil {
+ fmt.Fprintf(&buf, "%d \"Sultan Accountant Vorlage\" - CRIT - %s fehlerhaft, eingebaute Vorlage aktiv: %s\n",
+ cmkCrit, CheckmkTemplateName, clean(problem.Error()))
+ }
+ if err := builtinCheckmk.Execute(&buf, d); err != nil {
+ fmt.Fprintf(&buf, "%d \"Sultan Accountant Vorlage\" - CRIT - eingebaute Vorlage: %s\n", cmkCrit, clean(err.Error()))
+ }
+ return buf.String()
+}
+
+// DumpCheckmk schreibt die eingebaute Vorlage nach /checkmk zum
+// Anpassen; eine vorhandene Datei bleibt ohne force unangetastet.
+func DumpCheckmk(dataDir string, force bool) (string, error) {
+ dir := filepath.Join(dataDir, "checkmk")
+ path := filepath.Join(dir, CheckmkTemplateName)
+ if _, err := os.Stat(path); err == nil && !force {
+ return "", fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", path, os.ErrExist)
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ return path, os.WriteFile(path, []byte(defaultCheckmkTemplate), 0o644)
+}
+
+func (a *Accountant) handleCheckmk(w http.ResponseWriter, r *http.Request) {
+ payees, err := a.ledger.ListPayees(r.Context())
+ if err != nil {
+ a.log.Error("Checkmk: Payees lesen", "err", err)
+ http.Error(w, "interner Fehler", http.StatusInternalServerError)
+ return
+ }
+ reports := map[string]Report{}
+ for _, p := range payees {
+ body, _, err := a.ledger.LatestReport(r.Context(), p.ID)
+ if err != nil {
+ continue // noch kein Bericht
+ }
+ var rep Report
+ if err := json.Unmarshal(body, &rep); err != nil {
+ a.log.Error("Checkmk: Bericht unlesbar", "payee", p.ID, "err", err)
+ continue
+ }
+ reports[p.ID] = rep
+ }
+ d := buildCheckmk(payees, reports, time.Now(), a.cfg.StaleWarn, a.cfg.StaleCrit)
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Write([]byte(renderCheckmk(a.cfg.DataDir, d)))
+}
diff --git a/pkg/accountant/checkmk_test.go b/pkg/accountant/checkmk_test.go
new file mode 100644
index 0000000..df32c9c
--- /dev/null
+++ b/pkg/accountant/checkmk_test.go
@@ -0,0 +1,271 @@
+package accountant
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "sultan/pkg/ledger"
+)
+
+var cmkNow = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
+
+func cmkReport(routes ...ReportRoute) Report {
+ return Report{
+ GeneratedAt: cmkNow,
+ Nodes: []ReportNode{
+ {ID: "webdocker11", Tenant: "kunde", Connected: true},
+ {ID: "alt", Tenant: "kunde", Connected: false},
+ {ID: "wartung", Tenant: "kunde", Connected: false, Maintenance: true},
+ },
+ Routes: routes,
+ }
+}
+
+func okRoute(host string) ReportRoute {
+ return ReportRoute{Tenant: "kunde", Node: "webdocker11", Workload: "wp", Router: "wp", Protocol: "http",
+ Hosts: []string{host}, Backend: "http://10.0.0.11:8081", BackendFQDN: "webdocker11.lan:8081",
+ Active: true, ComposeDir: "/srv/kunde-a", Health: "healthy", State: "running"}
+}
+
+func findURL(t *testing.T, d CheckmkData, service string) CheckmkURL {
+ t.Helper()
+ for _, u := range d.URLs {
+ if u.Service == service {
+ return u
+ }
+ }
+ t.Fatalf("Service %q fehlt in %+v", service, d.URLs)
+ return CheckmkURL{}
+}
+
+// Zustände: OK, WARN bei Problemen der Route, CRIT bei veraltetem Bericht;
+// Wartung dämpft auf OK.
+func TestBuildCheckmk_States(t *testing.T) {
+ inactive := okRoute("fremd.de")
+ inactive.Active = false
+ down := okRoute("olat.kunde.de")
+ down.Down = true
+ sick := okRoute("krank.kunde.de")
+ sick.Health = "unhealthy"
+ gone := okRoute("weg.kunde.de")
+ gone.Node = "alt"
+ maint := okRoute("pause.kunde.de")
+ maint.Node, maint.Down = "wartung", true
+ multi := okRoute("kunde.de")
+ multi.Hosts = []string{"kunde.de", "www.kunde.de"}
+
+ payees := []ledger.Payee{
+ {ID: "frisch", Hostname: "webproxy11", LastReport: cmkNow.Add(-time.Minute)},
+ {ID: "lahm", Hostname: "webproxy12", LastReport: cmkNow.Add(-3 * time.Hour)},
+ {ID: "tot", Hostname: "webproxy13", LastReport: cmkNow.Add(-7 * time.Hour)},
+ {ID: "neu", Hostname: "webproxy14"},
+ }
+ reports := map[string]Report{
+ "frisch": cmkReport(okRoute("ok.kunde.de"), inactive, down, sick, gone, maint, multi),
+ "lahm": cmkReport(okRoute("lahm.kunde.de")),
+ "tot": cmkReport(okRoute("tot.kunde.de")),
+ }
+ d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
+
+ wantPayees := map[string]int{"frisch": 0, "lahm": 1, "tot": 2, "neu": 1}
+ for _, p := range d.Payees {
+ if p.State != wantPayees[p.ID] {
+ t.Errorf("Payee %s: Status %d, erwartet %d (%+v)", p.ID, p.State, wantPayees[p.ID], p)
+ }
+ }
+ if len(d.Payees) != 4 {
+ t.Errorf("Payees: %+v", d.Payees)
+ }
+
+ tests := []struct {
+ service, problem string
+ state int
+ }{
+ {"Sultan URL ok.kunde.de", "", 0},
+ {"Sultan URL fremd.de", "von Policy verworfen", 1},
+ {"Sultan URL olat.kunde.de", "gestoppt", 1},
+ {"Sultan URL krank.kunde.de", "unhealthy", 1},
+ {"Sultan URL weg.kunde.de", "Knoten getrennt", 1},
+ {"Sultan URL pause.kunde.de", "Wartung", 0},
+ {"Sultan URL kunde.de", "", 0},
+ {"Sultan URL www.kunde.de", "", 0},
+ {"Sultan URL lahm.kunde.de", "Bericht", 1},
+ {"Sultan URL tot.kunde.de", "Bericht", 2},
+ }
+ for _, tt := range tests {
+ u := findURL(t, d, tt.service)
+ if u.State != tt.state || (tt.problem == "") != (u.Problem == "") || !strings.Contains(u.Problem, tt.problem) {
+ t.Errorf("%s: Status %d Problem %q, erwartet %d %q", tt.service, u.State, u.Problem, tt.state, tt.problem)
+ }
+ }
+ u := findURL(t, d, "Sultan URL ok.kunde.de")
+ if u.URL != "https://ok.kunde.de" || u.BackendURL != "http://webdocker11.lan:8081" || u.Payee != "frisch" ||
+ u.Tenant != "kunde" || u.ComposeDir != "/srv/kunde-a" {
+ t.Errorf("Felder: %+v", u)
+ }
+ for i := 1; i < len(d.URLs); i++ {
+ if d.URLs[i-1].Service > d.URLs[i].Service {
+ t.Errorf("nicht sortiert: %s vor %s", d.URLs[i-1].Service, d.URLs[i].Service)
+ }
+ }
+}
+
+// Derselbe Host zweimal (z. B. zwei Payees): eindeutige Servicenamen.
+func TestBuildCheckmk_DuplicateHosts(t *testing.T) {
+ payees := []ledger.Payee{{ID: "a", LastReport: cmkNow}, {ID: "b", LastReport: cmkNow}}
+ reports := map[string]Report{"a": cmkReport(okRoute("kunde.de")), "b": cmkReport(okRoute("kunde.de"))}
+ d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
+ if len(d.URLs) != 2 || d.URLs[0].Service == d.URLs[1].Service {
+ t.Errorf("Servicenamen nicht eindeutig: %+v", d.URLs)
+ }
+}
+
+// Werte vom Kunden-Host sind Fremdeingaben: einzeilig, ASCII, ohne | und ",
+// HTML-escaped; Unescaped nur bereinigt.
+func TestBuildCheckmk_Sanitizes(t *testing.T) {
+ evil := okRoute("kunde.de")
+ evil.ComposeDir = "/srv/\n2 \"Fake\" - OK |x"
+ evil.Tenant = "küche"
+ d := buildCheckmk([]ledger.Payee{{ID: "a", Hostname: "h", LastReport: cmkNow}},
+ map[string]Report{"a": cmkReport(evil)}, cmkNow, 2*time.Hour, 6*time.Hour)
+ u := d.URLs[0]
+ for _, s := range []string{u.ComposeDir, u.Unescaped.ComposeDir, d.Payees[0].Hostname} {
+ if strings.ContainsAny(s, "\n\r|\"") {
+ t.Errorf("nicht bereinigt: %q", s)
+ }
+ }
+ if strings.Contains(u.ComposeDir, "