Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -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_<secret>.<fingerprint>.<url>` des Accountants ein (`sultan accountant invite --payee=<id>`), 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 **`<fqdn>:<port>`** und `<ip>:<port>`, 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 `<fqdn>:<port>` 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` = `<fqdn>:<port>`, 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 <id>` (OK; WARN ab `--stale-warn` 2 h bzw. ohne Bericht; CRIT ab `--stale-crit` 6 h) und `Sultan URL <host>` 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 `<data-dir>/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.<Feld>` nur bereinigt. Funktion `status` (0/1/2 → OK/WARN/CRIT).
|
||||
* Links: Die Standardvorlage schreibt `https://<host>` und `http://<fqdn>:<port>` 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=<produkt>` 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 <domain>` (Mandant, Payee, Knoten, `<fqdn>:<port>`, 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://<host>` und auf das interne Ziel `http://<fqdn>:<port>`, dazu Frische je Payee. **Text per Vorlage** (Go `text/template`, wie beim Lander): eingebaute Default-Vorlage, eigene unter `<data-dir>/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, `<fqdn>:<port>`, 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 <host|mandant>`: Teiltreffer im Host (ohne Groß-/Kleinschreibung) oder exakte Mandanten-ID; je Treffer URL, Mandant, Ingress (Zeitpunkt des Berichts), Backend `<fqdn>:<port>` (+ 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_<FLAG>` 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_<FLAG>` 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.<domain>/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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -33,3 +33,7 @@ suldata/
|
||||
|
||||
# Build-Ausgaben (deploy/build.sh)
|
||||
/dist/
|
||||
|
||||
# sultan accountant: Datenverzeichnis (enthält server.key!)
|
||||
/accdata/
|
||||
accdata/
|
||||
@@ -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_<secret>.<fingerprint>[.<base64url(server-url)>]` (`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; `*.<tld>` 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 `<tenant>__<node>__<name>`, 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__<tenant>__<node>__<router>` per Cookie `sultan_ack=<stufe>`. `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 `<data-dir>/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/<version>/`), `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/<version>/`), `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 `<data-dir>/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 <query>` (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_<FLAG>` 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.
|
||||
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
@@ -97,7 +97,7 @@ Ablage per `rsync` unter `sultan/releases/<version>/` (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 (`<fqdn>:<port>`) 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=<einladung> 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=<produkt>` 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:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# sultan accountant – Einstellungen für compose.yaml (cp .env.example .env).
|
||||
# Jede Variable SULTAN_<FLAG> entspricht dem Flag --<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
|
||||
@@ -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!
|
||||
@@ -1,10 +1,23 @@
|
||||
# sultan agent – Einstellungen für compose.yaml (cp .env.example .env).
|
||||
# Jede Variable SULTAN_<FLAG> entspricht dem Flag --<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
|
||||
@@ -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}"
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
# sultan server – Einstellungen für compose.yaml (cp .env.example .env).
|
||||
# Jede Variable SULTAN_<FLAG> entspricht dem Flag --<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=<dieser-server>
|
||||
# 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
|
||||
@@ -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_<FLAG> setzbar; leere Werte = Default.
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./suldata:/suldata # sultan.db, server.key/.crt, master.key – sichern!
|
||||
+18
-1
@@ -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 `<mandant>__<knoten>__<router>` 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 (`<fqdn>:<port>`), Container, Compose-Verzeichnis, Health und das Produkt aus `sultan.accountant.billing`. Im Störfall:
|
||||
|
||||
```bash
|
||||
sultan accountant where kunde-a.de # Backend <fqdn>:<port>, Container, Compose-Verzeichnis, seit wann, wo vorher
|
||||
sultan accountant routes --tenant=k10042
|
||||
```
|
||||
|
||||
Einrichtung: `sultan accountant invite --payee=<server>` 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 <host>` (WARN bei gestoppt, Policy-Verstoß, unhealthy, Knoten getrennt; Wartung bleibt OK) mit Ziel `http://<fqdn>:<port>` und Compose-Verzeichnis im Text, je Ingress-Server `Sultan Payee <id>` (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.
|
||||
|
||||
@@ -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_<secret>, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <mandant>/<protokoll>/<router>
|
||||
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()
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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 <data-dir>/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 <data-dir>/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:
|
||||
// <status> "<service>" - <OK|WARN|CRIT> - <text>
|
||||
const defaultCheckmkTemplate = `{{- /*
|
||||
Checkmk-Vorlage des Sultan-Accountants (text/template).
|
||||
Eine Zeile je Service: <status> "<service>" - <OK|WARN|CRIT> - <text>
|
||||
.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.<Feld> 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 <dataDir>/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)))
|
||||
}
|
||||
@@ -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/<script>alert(1)</script>\n2 \"Fake\" - OK |x"
|
||||
evil.Tenant = "küche"
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "a", Hostname: "<b>h</b>", 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, "<script>") || !strings.Contains(u.ComposeDir, "<script>") {
|
||||
t.Errorf("nicht escaped: %q", u.ComposeDir)
|
||||
}
|
||||
if !strings.Contains(u.Unescaped.ComposeDir, "<script>") {
|
||||
t.Errorf("Unescaped verändert: %q", u.Unescaped.ComposeDir)
|
||||
}
|
||||
if u.Tenant != "kueche" {
|
||||
t.Errorf("Umlaut: %q", u.Tenant)
|
||||
}
|
||||
}
|
||||
|
||||
var cmkLine = regexp.MustCompile(`^[0-2] "[^"|]+" - (OK|WARN|CRIT) - [ -~]*$`)
|
||||
|
||||
// Die eingebaute Vorlage liefert gültige Local-Check-Zeilen mit Links.
|
||||
func TestRenderCheckmk_Default(t *testing.T) {
|
||||
down := okRoute("olat.kunde.de")
|
||||
down.Down = true
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "webproxy11", Hostname: "webproxy11.lan", LastReport: cmkNow.Add(-5 * time.Minute)}},
|
||||
map[string]Report{"webproxy11": cmkReport(okRoute("kunde.de"), down)}, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
out := renderCheckmk(t.TempDir(), d)
|
||||
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("%d Zeilen:\n%s", len(lines), out)
|
||||
}
|
||||
for _, l := range lines {
|
||||
if !cmkLine.MatchString(l) {
|
||||
t.Errorf("ungültige Zeile: %q", l)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`0 "Sultan Payee webproxy11" - OK - `,
|
||||
`0 "Sultan URL kunde.de" - OK - `,
|
||||
"https://kunde.de", "http://webdocker11.lan:8081", "/srv/kunde-a", "kunde", "webdocker11",
|
||||
`1 "Sultan URL olat.kunde.de" - WARN - gestoppt`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("fehlt %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eigene Vorlage in <data-dir>/checkmk hat Vorrang und wirkt sofort; eine
|
||||
// defekte fällt auf die eingebaute zurück und meldet sich als CRIT.
|
||||
func TestRenderCheckmk_CustomTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "p", LastReport: cmkNow}}, map[string]Report{"p": cmkReport(okRoute("kunde.de"))},
|
||||
cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
os.MkdirAll(filepath.Join(dir, "checkmk"), 0o755)
|
||||
tmpl := filepath.Join(dir, "checkmk", CheckmkTemplateName)
|
||||
|
||||
os.WriteFile(tmpl, []byte(`{{range .URLs}}{{.State}} "{{.Service}}" - {{status .State}} - eigen {{.Unescaped.Host}}{{"\n"}}{{end}}`), 0o644)
|
||||
if out := renderCheckmk(dir, d); out != "0 \"Sultan URL kunde.de\" - OK - eigen kunde.de\n" {
|
||||
t.Errorf("eigene Vorlage: %q", out)
|
||||
}
|
||||
|
||||
os.WriteFile(tmpl, []byte(`{{range .URLs}`), 0o644)
|
||||
out := renderCheckmk(dir, d)
|
||||
if !strings.Contains(out, `2 "Sultan Accountant Vorlage" - CRIT - `) || !strings.Contains(out, `"Sultan URL kunde.de"`) {
|
||||
t.Errorf("defekte Vorlage: %q", out)
|
||||
}
|
||||
os.WriteFile(tmpl, []byte(`{{.GibtsNicht}}`), 0o644)
|
||||
if out := renderCheckmk(dir, d); !strings.Contains(out, `2 "Sultan Accountant Vorlage"`) {
|
||||
t.Errorf("Ausführungsfehler: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpCheckmk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, err := DumpCheckmk(dir, false)
|
||||
if err != nil || path != filepath.Join(dir, "checkmk", CheckmkTemplateName) {
|
||||
t.Fatalf("DumpCheckmk = %q, %v", path, err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != defaultCheckmkTemplate {
|
||||
t.Error("Inhalt weicht von der eingebauten Vorlage ab")
|
||||
}
|
||||
if _, err := DumpCheckmk(dir, false); err == nil {
|
||||
t.Error("vorhandene Datei überschrieben")
|
||||
}
|
||||
if _, err := DumpCheckmk(dir, true); err != nil {
|
||||
t.Errorf("--force: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ende zu Ende: Bericht eines Payees erscheint auf dem Loopback-Port.
|
||||
func TestCheckmkEndpoint(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)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Shutdown(ctx) })
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
rep := cmkReport(okRoute("kunde.de"))
|
||||
rep.GeneratedAt = time.Now()
|
||||
body, _ := json.Marshal(rep)
|
||||
postReport(t, a, key, body)
|
||||
|
||||
resp, err := http.Get("http://" + a.MetricsAddr() + "/metrics/checkmk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") ||
|
||||
!strings.Contains(string(out), `0 "Sultan URL kunde.de"`) || !strings.Contains(string(out), `0 "Sultan Payee webproxy11"`) {
|
||||
t.Errorf("Status %d, %q:\n%s", resp.StatusCode, resp.Header.Get("Content-Type"), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckmkEndpoint_Disabled(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "none",
|
||||
Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Shutdown(ctx)
|
||||
if a.MetricsAddr() != "" {
|
||||
t.Errorf("MetricsAddr = %q, erwartet leer", a.MetricsAddr())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// Ein Bericht legt für neue Mandanten automatisch Kunden an.
|
||||
func TestReport_CreatesCustomers(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
body, _ := json.Marshal(whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de")))
|
||||
postReport(t, a, key, body)
|
||||
l, _ := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
defer l.Close()
|
||||
for _, id := range []string{"kunde", "andere"} {
|
||||
if c, err := l.GetCustomer(ctx, id); err != nil || !c.Auto || c.Cycle != ledger.CycleUnclassified {
|
||||
t.Errorf("Kunde %s: %+v, %v", id, c, err)
|
||||
}
|
||||
}
|
||||
if c, _ := l.GetCustomer(ctx, "kunde"); c.Name != "Kunde A" {
|
||||
t.Errorf("Name aus dem Mandanten: %q", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// where zeigt den Kunden – auch über eine Zuordnung.
|
||||
func TestWhere_Customer(t *testing.T) {
|
||||
l, _ := whereLedger(t)
|
||||
l.AddCustomer(ctx, ledger.Customer{ID: "k-1001", Name: "Kunde A GmbH", Group: "extern"})
|
||||
l.MapTenant(ctx, "", "kunde", "k-1001")
|
||||
save(t, l, "webproxy11", whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de")))
|
||||
res, err := Where(ctx, l, "kunde.de")
|
||||
if err != nil || len(res.Current) != 1 {
|
||||
t.Fatalf("%+v, %v", res, err)
|
||||
}
|
||||
if c := res.Current[0].Customer; c == nil || c.ID != "k-1001" || c.Group != "extern" {
|
||||
t.Errorf("Kunde: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoney(t *testing.T) {
|
||||
for in, want := range map[string]int64{"89": 8900, "89,00": 8900, "89.5": 8950, "1.234,56": 123456, "0,99": 99, " 12,30 ": 1230} {
|
||||
if got, err := ParseMoney(in); err != nil || got != want {
|
||||
t.Errorf("ParseMoney(%q) = %d, %v; erwartet %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "abc", "1,234", "-5", "1,2,3"} {
|
||||
if _, err := ParseMoney(bad); err == nil {
|
||||
t.Errorf("ParseMoney(%q) ohne Fehler", bad)
|
||||
}
|
||||
}
|
||||
for in, want := range map[int64]string{8900: "89,00", 99: "0,99", 123456: "1234,56", 0: "0,00"} {
|
||||
if got := FormatMoney(in); got != want {
|
||||
t.Errorf("FormatMoney(%d) = %q, erwartet %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseMoney liest einen Betrag in Euro (z. B. "89", "89,00", "89.5",
|
||||
// "1.234,56") und liefert Cent. Mehr als zwei Nachkommastellen und negative
|
||||
// Beträge sind Fehler.
|
||||
func ParseMoney(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
orig := s
|
||||
if strings.Contains(s, ",") {
|
||||
s = strings.ReplaceAll(s, ".", "") // Tausenderpunkte
|
||||
s = strings.Replace(s, ",", ".", 1)
|
||||
}
|
||||
whole, frac, _ := strings.Cut(s, ".")
|
||||
if whole == "" || len(frac) > 2 || strings.ContainsAny(frac, ".,") {
|
||||
return 0, fmt.Errorf("Betrag %q nicht lesbar (z. B. 89,00)", orig)
|
||||
}
|
||||
for len(frac) < 2 {
|
||||
frac += "0"
|
||||
}
|
||||
euros, err := strconv.ParseUint(whole, 10, 40)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Betrag %q nicht lesbar (z. B. 89,00)", orig)
|
||||
}
|
||||
cents, err := strconv.ParseUint(frac, 10, 8)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Betrag %q nicht lesbar (z. B. 89,00)", orig)
|
||||
}
|
||||
return int64(euros)*100 + int64(cents), nil
|
||||
}
|
||||
|
||||
// FormatMoney formatiert Cent als Euro mit Dezimalkomma ("89,00"), wie
|
||||
// Lexware Office es beim Import erwartet.
|
||||
func FormatMoney(cents int64) string {
|
||||
sign := ""
|
||||
if cents < 0 {
|
||||
sign, cents = "-", -cents
|
||||
}
|
||||
return fmt.Sprintf("%s%d,%02d", sign, cents/100, cents%100)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/ledger"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// PathReport ist der Endpunkt für Berichte der Payees.
|
||||
const PathReport = "/api/v1/report"
|
||||
|
||||
// maxReportSize begrenzt einen Bericht (signierter Body). Ein Eintrag je
|
||||
// Route ist einige hundert Bytes groß; 8 MiB reichen für zehntausende Routen.
|
||||
const maxReportSize = 8 << 20
|
||||
|
||||
// Report ist der vollständige Schnappschuss eines Payees (Spec 5.3) – kein
|
||||
// Delta: Was fehlt, gibt es beim Payee nicht mehr.
|
||||
type Report struct {
|
||||
Version string `json:"version"` // sultan-Version des Payees
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Tenants []ReportTenant `json:"tenants"`
|
||||
Nodes []ReportNode `json:"nodes"`
|
||||
Routes []ReportRoute `json:"routes"`
|
||||
}
|
||||
|
||||
// ReportTenant ist ein Mandant des Payees.
|
||||
type ReportTenant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tier string `json:"tier"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ReportNode ist ein Knoten (Backend-Host) des Payees.
|
||||
type ReportNode struct {
|
||||
ID string `json:"id"`
|
||||
Tenant string `json:"tenant"`
|
||||
Hostname string `json:"hostname"`
|
||||
FQDN string `json:"fqdn,omitempty"`
|
||||
IP string `json:"ip"` // Absender-IP beim Server
|
||||
Status string `json:"status"`
|
||||
Connected bool `json:"connected"`
|
||||
Maintenance bool `json:"maintenance,omitempty"`
|
||||
LastSeen time.Time `json:"last_seen,omitzero"`
|
||||
OS string `json:"os,omitempty"`
|
||||
DockerVersion string `json:"docker_version,omitempty"`
|
||||
NetBirdIP string `json:"netbird_ip,omitempty"`
|
||||
}
|
||||
|
||||
// ReportRoute ist ein gemeldeter Router mit seinem Ziel.
|
||||
type ReportRoute struct {
|
||||
Tenant string `json:"tenant"`
|
||||
Node string `json:"node"`
|
||||
Workload string `json:"workload"` // Container bzw. sultan.d/<name>
|
||||
Router string `json:"router"` // Name aus den Labels
|
||||
Protocol string `json:"protocol"` // http oder tcp
|
||||
Hosts []string `json:"hosts"`
|
||||
// Backend wie Traefik es erreicht: http://<ip>:<port> bzw. <ip>:<port> (tcp).
|
||||
Backend string `json:"backend"`
|
||||
// BackendFQDN: <fqdn>:<port> – zum Springen auf den Host im Störfall.
|
||||
BackendFQDN string `json:"backend_fqdn,omitempty"`
|
||||
// Active: Router steht in der Traefik-Konfiguration (nicht von der Policy
|
||||
// verworfen, z. B. fremde Domain oder Routenlimit).
|
||||
Active bool `json:"active"`
|
||||
// Down: Service gestoppt bzw. Healthcheck fehlgeschlagen (Wartungsseite).
|
||||
Down bool `json:"down,omitempty"`
|
||||
Product string `json:"product,omitempty"` // Label sultan.accountant.billing
|
||||
|
||||
ComposeProject string `json:"compose_project,omitempty"`
|
||||
ComposeService string `json:"compose_service,omitempty"`
|
||||
ComposeDir string `json:"compose_dir,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitzero"`
|
||||
Health string `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// LabelBilling ordnet einer Route ein Produkt zu (Spec 5.3).
|
||||
const LabelBilling = "sultan.accountant.billing"
|
||||
|
||||
func (a *Accountant) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
pub, err := a.reportVerifier.VerifyUnregistered(r)
|
||||
if err != nil {
|
||||
code := auth.StatusCode(err)
|
||||
http.Error(w, http.StatusText(code), code)
|
||||
return
|
||||
}
|
||||
p, err := a.ledger.GetPayeeByPublicKey(r.Context(), auth.EncodePublicKey(pub))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.log.Error("Payee nachschlagen", "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var rep Report
|
||||
if err := json.NewDecoder(r.Body).Decode(&rep); err != nil {
|
||||
http.Error(w, "ungültiger Bericht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rep.GeneratedAt.IsZero() {
|
||||
http.Error(w, "generated_at fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := StoreReport(r.Context(), a.ledger, p.ID, rep); err != nil {
|
||||
a.log.Error("Bericht speichern", "payee", p.ID, "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.log.Debug("Bericht empfangen", "payee", p.ID, "routes", len(rep.Routes), "nodes", len(rep.Nodes))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// StoreReport legt einen Bericht ab: letzter Stand (normalisiert: nur
|
||||
// bekannte Felder), Historie und automatisch angelegte Kunden für neue
|
||||
// Mandanten.
|
||||
func StoreReport(ctx context.Context, l *ledger.Ledger, payeeID string, rep Report) error {
|
||||
body, err := json.Marshal(rep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.SaveReport(ctx, payeeID, body, RouteRecords(rep)); err != nil {
|
||||
return err
|
||||
}
|
||||
tenants := make([]ledger.TenantRef, 0, len(rep.Tenants))
|
||||
for _, t := range rep.Tenants {
|
||||
tenants = append(tenants, ledger.TenantRef{ID: t.ID, Name: t.Name})
|
||||
}
|
||||
return l.EnsureCustomers(ctx, payeeID, tenants)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// enrolledPayee registriert einen Payee und liefert seinen Schlüssel.
|
||||
func enrolledPayee(t *testing.T, a *Accountant, id string) ed25519.PrivateKey {
|
||||
t.Helper()
|
||||
key, _ := auth.GenerateKey()
|
||||
tok := invite(t, a, id)
|
||||
if resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: id + ".lan"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Enrollment: %d", resp.StatusCode)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func postReport(t *testing.T, a *Accountant, key ed25519.PrivateKey, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://"+a.Addr()+PathReport, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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 latest(t *testing.T, a *Accountant, payeeID string) (Report, time.Time, bool) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
body, at, err := l.LatestReport(ctx, payeeID)
|
||||
if err != nil {
|
||||
return Report{}, time.Time{}, false
|
||||
}
|
||||
var r Report
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatalf("gespeicherter Bericht unlesbar: %v", err)
|
||||
}
|
||||
return r, at, true
|
||||
}
|
||||
|
||||
func sampleReport() Report {
|
||||
return Report{
|
||||
Version: "v0.1.0",
|
||||
GeneratedAt: time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC),
|
||||
Tenants: []ReportTenant{{ID: "kunde", Name: "Kunde A", Tier: "pro", Status: "active"}},
|
||||
Nodes: []ReportNode{{ID: "webdocker11", Tenant: "kunde", Hostname: "webdocker11", FQDN: "webdocker11.lan.r25dc.de",
|
||||
IP: "10.0.0.11", Status: "online", Connected: true}},
|
||||
Routes: []ReportRoute{{Tenant: "kunde", Node: "webdocker11", Workload: "kunde-a-wordpress-1", Router: "wp",
|
||||
Protocol: "http", Hosts: []string{"kunde.de"}, Backend: "http://10.0.0.11:8081",
|
||||
BackendFQDN: "webdocker11.lan.r25dc.de:8081", Active: true, ComposeDir: "/srv/kunde-a"}},
|
||||
}
|
||||
}
|
||||
|
||||
// Ein registrierter Payee meldet seinen Schnappschuss; gespeichert wird der
|
||||
// letzte.
|
||||
func TestReport_StoresLatest(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
first := sampleReport()
|
||||
body, _ := json.Marshal(first)
|
||||
if resp := postReport(t, a, key, body); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("Status %d", resp.StatusCode)
|
||||
}
|
||||
got, _, ok := latest(t, a, "webproxy11")
|
||||
if !ok || got.Routes[0].BackendFQDN != "webdocker11.lan.r25dc.de:8081" || got.Nodes[0].FQDN != "webdocker11.lan.r25dc.de" {
|
||||
t.Errorf("gespeichert: %+v", got)
|
||||
}
|
||||
|
||||
second := sampleReport()
|
||||
second.Routes = nil
|
||||
body, _ = json.Marshal(second)
|
||||
postReport(t, a, key, body)
|
||||
if got, _, _ := latest(t, a, "webproxy11"); len(got.Routes) != 0 {
|
||||
t.Errorf("Bericht nicht ersetzt: %+v", got.Routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReport_Errors(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
stranger, _ := auth.GenerateKey()
|
||||
valid, _ := json.Marshal(sampleReport())
|
||||
tests := []struct {
|
||||
name string
|
||||
key ed25519.PrivateKey
|
||||
body []byte
|
||||
want int
|
||||
}{
|
||||
{"unsigniert", nil, valid, http.StatusUnauthorized},
|
||||
{"nicht registrierter Schlüssel", stranger, valid, http.StatusUnauthorized},
|
||||
{"kein JSON", key, []byte("kaputt"), http.StatusBadRequest},
|
||||
{"kein Zeitpunkt", key, []byte(`{"tenants":[]}`), http.StatusBadRequest},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if resp := postReport(t, a, tt.key, tt.body); resp.StatusCode != tt.want {
|
||||
t.Errorf("Status %d, erwartet %d", resp.StatusCode, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, ok := latest(t, a, "webproxy11"); ok {
|
||||
t.Error("fehlerhafter Bericht gespeichert")
|
||||
}
|
||||
}
|
||||
|
||||
// Große Hoster: Berichte bis 8 MiB werden angenommen.
|
||||
func TestReport_LargeBody(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
r := sampleReport()
|
||||
for i := 0; i < 6000; i++ {
|
||||
r.Routes = append(r.Routes, r.Routes[0])
|
||||
}
|
||||
body, _ := json.Marshal(r)
|
||||
if len(body) < 1<<20 {
|
||||
t.Fatalf("Testbericht nur %d Bytes", len(body))
|
||||
}
|
||||
if resp := postReport(t, a, key, body); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("großer Bericht: Status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package accountant
|
||||
|
||||
// Abfragen auf dem Accountant (Spec 5.3, Roadmap 6d Schritt 1): where – wo
|
||||
// läuft eine URL, seit wann, wo vorher? – und routes – alle aktuellen
|
||||
// Routen. Aktuell = letzter Bericht je Payee; seit wann/vorher = Historie.
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// RouteRecords übersetzt die Routen eines Berichts für die Historie.
|
||||
func RouteRecords(rep Report) []ledger.RouteRecord {
|
||||
out := make([]ledger.RouteRecord, 0, len(rep.Routes))
|
||||
for _, r := range rep.Routes {
|
||||
out = append(out, ledger.RouteRecord{
|
||||
Tenant: r.Tenant, Node: r.Node, Protocol: r.Protocol, Router: r.Router, Hosts: r.Hosts,
|
||||
Backend: r.Backend, BackendFQDN: r.BackendFQDN, Workload: r.Workload, ComposeDir: r.ComposeDir, Product: r.Product,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentRoute ist eine Route aus dem letzten Bericht eines Payees.
|
||||
type CurrentRoute struct {
|
||||
Payee string
|
||||
ReportAt time.Time // Eingang des Berichts
|
||||
Route ReportRoute
|
||||
Node ReportNode // Knoten der Route (leer, falls nicht im Bericht)
|
||||
TenantName string
|
||||
// Customer: zugeordneter Kunde (nil = keinem Kunden zugeordnet).
|
||||
Customer *ledger.Customer
|
||||
Since time.Time // Beginn des Zeitraums an diesem Ort (Historie)
|
||||
// Previous: frühere, beendete Orte derselben Route (Mandant, Protokoll,
|
||||
// Router), neueste zuerst.
|
||||
Previous []ledger.RouteSpan
|
||||
}
|
||||
|
||||
// WhereResult ist das Ergebnis von Where.
|
||||
type WhereResult struct {
|
||||
Current []CurrentRoute
|
||||
// Gone: passende Routen, die es nirgends mehr gibt (neueste zuerst).
|
||||
Gone []ledger.RouteSpan
|
||||
}
|
||||
|
||||
// RoutesFilter schränkt Routes ein; leere Felder filtern nicht.
|
||||
type RoutesFilter struct {
|
||||
Tenant string
|
||||
Payee string
|
||||
}
|
||||
|
||||
func spanKey(tenant, node, protocol, router string) string {
|
||||
return tenant + "\x00" + node + "\x00" + protocol + "\x00" + router
|
||||
}
|
||||
|
||||
func routeKey(tenant, protocol, router string) string {
|
||||
return tenant + "\x00" + protocol + "\x00" + router
|
||||
}
|
||||
|
||||
// currentRoutes liest die letzten Berichte aller Payees und ergänzt den
|
||||
// Beginn je Ort aus der Historie.
|
||||
func currentRoutes(ctx context.Context, l *ledger.Ledger, keep func(ReportRoute) bool) ([]CurrentRoute, error) {
|
||||
payees, err := l.ListPayees(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
open, err := l.RouteHistory(ctx, ledger.HistoryFilter{OpenOnly: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver, err := l.CustomerResolver(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customers := map[string]*ledger.Customer{}
|
||||
cs, err := l.ListCustomers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cs {
|
||||
customers[cs[i].ID] = &cs[i]
|
||||
}
|
||||
since := map[string]time.Time{}
|
||||
for _, s := range open {
|
||||
since[s.Payee+"\x00"+spanKey(s.Tenant, s.Node, s.Protocol, s.Router)] = s.FirstSeen
|
||||
}
|
||||
var out []CurrentRoute
|
||||
for _, p := range payees {
|
||||
body, at, err := l.LatestReport(ctx, p.ID)
|
||||
if err != nil {
|
||||
continue // noch kein Bericht
|
||||
}
|
||||
var rep Report
|
||||
if err := json.Unmarshal(body, &rep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes := map[string]ReportNode{}
|
||||
for _, n := range rep.Nodes {
|
||||
nodes[n.ID] = n
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, t := range rep.Tenants {
|
||||
names[t.ID] = t.Name
|
||||
}
|
||||
for _, r := range rep.Routes {
|
||||
if !keep(r) {
|
||||
continue
|
||||
}
|
||||
out = append(out, CurrentRoute{
|
||||
Payee: p.ID, ReportAt: at, Route: r, Node: nodes[r.Node], TenantName: names[r.Tenant],
|
||||
Customer: customers[resolver.Resolve(p.ID, r.Tenant)],
|
||||
Since: since[p.ID+"\x00"+spanKey(r.Tenant, r.Node, r.Protocol, r.Router)],
|
||||
})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b CurrentRoute) int {
|
||||
return cmp.Or(strings.Compare(firstHost(a.Route.Hosts), firstHost(b.Route.Hosts)),
|
||||
strings.Compare(a.Payee, b.Payee), strings.Compare(a.Route.Node, b.Route.Node), strings.Compare(a.Route.Router, b.Route.Router))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstHost(hosts []string) string {
|
||||
if len(hosts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hosts[0]
|
||||
}
|
||||
|
||||
// matches: ein Host enthält query, oder query ist die Mandanten-ID.
|
||||
func matches(query, tenant string, hosts []string) bool {
|
||||
if strings.EqualFold(tenant, query) {
|
||||
return true
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if strings.Contains(strings.ToLower(h), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Where sucht Routen, deren Host query enthält (ohne Groß-/Kleinschreibung)
|
||||
// oder deren Mandant query heißt – aktuell mit früheren Orten, dazu
|
||||
// passende Routen, die es nicht mehr gibt.
|
||||
func Where(ctx context.Context, l *ledger.Ledger, query string) (WhereResult, error) {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
var res WhereResult
|
||||
if query == "" {
|
||||
return res, nil
|
||||
}
|
||||
cur, err := currentRoutes(ctx, l, func(r ReportRoute) bool { return matches(query, r.Tenant, r.Hosts) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
all, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
ended := map[string][]ledger.RouteSpan{}
|
||||
for _, s := range all {
|
||||
if !s.Ended.IsZero() {
|
||||
k := routeKey(s.Tenant, s.Protocol, s.Router)
|
||||
ended[k] = append(ended[k], s)
|
||||
}
|
||||
}
|
||||
newestFirst := func(a, b ledger.RouteSpan) int { return b.Ended.Compare(a.Ended) }
|
||||
alive := map[string]bool{}
|
||||
for i := range cur {
|
||||
r := cur[i].Route
|
||||
k := routeKey(r.Tenant, r.Protocol, r.Router)
|
||||
alive[k] = true
|
||||
for _, s := range ended[k] {
|
||||
if s.Payee == cur[i].Payee && s.Node == r.Node {
|
||||
continue // derselbe Ort früher einmal – kein Umzug
|
||||
}
|
||||
cur[i].Previous = append(cur[i].Previous, s)
|
||||
}
|
||||
slices.SortFunc(cur[i].Previous, newestFirst)
|
||||
}
|
||||
res.Current = cur
|
||||
for k, spans := range ended {
|
||||
if alive[k] {
|
||||
continue
|
||||
}
|
||||
for _, s := range spans {
|
||||
if matches(query, s.Tenant, s.Hosts) {
|
||||
res.Gone = append(res.Gone, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(res.Gone, newestFirst)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Routes liefert alle aktuellen Routen, sortiert nach Host.
|
||||
func Routes(ctx context.Context, l *ledger.Ledger, f RoutesFilter) ([]CurrentRoute, error) {
|
||||
cur, err := currentRoutes(ctx, l, func(r ReportRoute) bool { return f.Tenant == "" || r.Tenant == f.Tenant })
|
||||
if err != nil || f.Payee == "" {
|
||||
return cur, err
|
||||
}
|
||||
return slices.DeleteFunc(cur, func(c CurrentRoute) bool { return c.Payee != f.Payee }), nil
|
||||
}
|
||||
|
||||
// ItemKey bezeichnet einen Posten: Mandant, Protokoll, Router (über alle
|
||||
// Payees und Knoten – ein Umzug bleibt derselbe Posten).
|
||||
type ItemKey struct {
|
||||
Tenant, Protocol, Router string
|
||||
}
|
||||
|
||||
// ItemsForHost liefert die Posten, zu denen host gehört (exakter Host, aus
|
||||
// der Historie; ohne Groß-/Kleinschreibung), sortiert.
|
||||
func ItemsForHost(ctx context.Context, l *ledger.Ledger, host string) ([]ItemKey, error) {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{Host: host})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ItemKey
|
||||
for _, s := range spans {
|
||||
k := ItemKey{s.Tenant, s.Protocol, s.Router}
|
||||
if slices.Contains(s.Hosts, host) && !slices.Contains(out, k) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b ItemKey) int {
|
||||
return cmp.Or(strings.Compare(a.Tenant, b.Tenant), strings.Compare(a.Router, b.Router), strings.Compare(a.Protocol, b.Protocol))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// whereLedger: zwei Payees; kunde.de zieht von webdocker07 nach webdocker11 um,
|
||||
// olat.kunde.de verschwindet, andere.de liegt auf dem zweiten Payee.
|
||||
func whereLedger(t *testing.T) (*ledger.Ledger, func(time.Time)) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
now := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
setNow := func(at time.Time) { now = at }
|
||||
l.SetClock(func() time.Time { return now })
|
||||
for _, id := range []string{"webproxy11", "webproxy12"} {
|
||||
l.AddInvite(ctx, "tok-"+id, id, now.Add(time.Hour))
|
||||
if _, err := l.Enroll(ctx, "tok-"+id, ledger.Payee{Hostname: id + ".lan", PublicKey: "key-" + id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return l, setNow
|
||||
}
|
||||
|
||||
func save(t *testing.T, l *ledger.Ledger, payeeID string, rep Report) {
|
||||
t.Helper()
|
||||
if err := StoreReport(ctx, l, payeeID, rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func whereReport(node string, routes ...ReportRoute) Report {
|
||||
return Report{
|
||||
GeneratedAt: time.Now(),
|
||||
Tenants: []ReportTenant{{ID: "kunde", Name: "Kunde A"}, {ID: "andere", Name: "Andere GmbH"}},
|
||||
Nodes: []ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true, OS: "Debian 13",
|
||||
DockerVersion: "29.8.2", NetBirdIP: "100.92.10.4"}},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func route(tenant, node, router string, hosts ...string) ReportRoute {
|
||||
return ReportRoute{Tenant: tenant, Node: node, Router: router, Protocol: "http", Hosts: hosts, Workload: "kunde-a-" + router + "-1",
|
||||
Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081", Active: true,
|
||||
ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"}
|
||||
}
|
||||
|
||||
func TestWhere(t *testing.T) {
|
||||
l, setNow := whereLedger(t)
|
||||
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
save(t, l, "webproxy11", whereReport("webdocker07", route("kunde", "webdocker07", "wp", "kunde.de"),
|
||||
route("kunde", "webdocker07", "olat", "olat.kunde.de")))
|
||||
andere := whereReport("n9", route("andere", "n9", "web", "andere.de"))
|
||||
save(t, l, "webproxy12", andere)
|
||||
|
||||
t2 := t1.Add(30 * 24 * time.Hour)
|
||||
setNow(t2)
|
||||
save(t, l, "webproxy11", whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de", "www.kunde.de")))
|
||||
|
||||
res, err := Where(ctx, l, "kunde.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Current) != 1 {
|
||||
t.Fatalf("aktuell: %+v", res.Current)
|
||||
}
|
||||
h := res.Current[0]
|
||||
if h.Payee != "webproxy11" || h.Route.Router != "wp" || h.TenantName != "Kunde A" || h.Node.FQDN != "webdocker11.lan.r25dc.de" ||
|
||||
h.Route.ComposeDir != "/srv/kunde-a" || !h.Since.Equal(t2) || !h.ReportAt.Equal(t2) {
|
||||
t.Errorf("Treffer: %+v", h)
|
||||
}
|
||||
if len(h.Previous) != 1 || h.Previous[0].Node != "webdocker07" || !h.Previous[0].Ended.Equal(t2) || !h.Previous[0].FirstSeen.Equal(t1) {
|
||||
t.Errorf("vorher: %+v", h.Previous)
|
||||
}
|
||||
// olat.kunde.de passt (Teiltreffer "kunde.de"), ist aber verschwunden.
|
||||
if len(res.Gone) != 1 || res.Gone[0].Router != "olat" || !res.Gone[0].Ended.Equal(t2) {
|
||||
t.Errorf("verschwunden: %+v", res.Gone)
|
||||
}
|
||||
|
||||
// Teiltreffer und Mandant.
|
||||
if r, _ := Where(ctx, l, "WWW.Kunde"); len(r.Current) != 1 {
|
||||
t.Errorf("Teiltreffer: %+v", r)
|
||||
}
|
||||
if r, _ := Where(ctx, l, "andere"); len(r.Current) != 1 || r.Current[0].Payee != "webproxy12" {
|
||||
t.Errorf("Mandant: %+v", r)
|
||||
}
|
||||
if r, _ := Where(ctx, l, "gibts-nicht.de"); len(r.Current)+len(r.Gone) != 0 {
|
||||
t.Errorf("kein Treffer erwartet: %+v", r)
|
||||
}
|
||||
if r, _ := Where(ctx, l, "_"); len(r.Current)+len(r.Gone) != 0 {
|
||||
t.Errorf("Platzhalter wirkt als Joker: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Der Bericht geht über den Endpunkt in die Historie ein.
|
||||
func TestReport_RecordsHistory(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
body, _ := json.Marshal(whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de")))
|
||||
postReport(t, a, key, body)
|
||||
l, _ := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
defer l.Close()
|
||||
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
|
||||
if err != nil || len(spans) != 1 || spans[0].Payee != "webproxy11" || spans[0].BackendFQDN != "webdocker11.lan.r25dc.de:8081" {
|
||||
t.Errorf("Historie: %+v, %v", spans, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes(t *testing.T) {
|
||||
l, _ := whereLedger(t)
|
||||
save(t, l, "webproxy11", whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de"),
|
||||
route("kunde", "webdocker11", "olat", "olat.kunde.de")))
|
||||
save(t, l, "webproxy12", whereReport("n9", route("andere", "n9", "web", "andere.de")))
|
||||
|
||||
all, err := Routes(ctx, l, RoutesFilter{})
|
||||
if err != nil || len(all) != 3 {
|
||||
t.Fatalf("Routes = %+v, %v", all, err)
|
||||
}
|
||||
if all[0].Route.Hosts[0] != "andere.de" || all[2].Route.Hosts[0] != "olat.kunde.de" {
|
||||
t.Errorf("nicht nach Host sortiert: %v %v %v", all[0].Route.Hosts, all[1].Route.Hosts, all[2].Route.Hosts)
|
||||
}
|
||||
if r, _ := Routes(ctx, l, RoutesFilter{Tenant: "kunde"}); len(r) != 2 {
|
||||
t.Errorf("--tenant: %+v", r)
|
||||
}
|
||||
if r, _ := Routes(ctx, l, RoutesFilter{Payee: "webproxy12"}); len(r) != 1 || r[0].Since.IsZero() {
|
||||
t.Errorf("--payee: %+v", r)
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,6 +43,7 @@ type Config struct {
|
||||
Token string // Einladung, nur beim ersten Start
|
||||
NodeID string // Default aus dem Hostnamen
|
||||
Hostname string // Default os.Hostname()
|
||||
FQDN string // Default automatisch (detectFQDN); nur Information für das Inventar
|
||||
|
||||
DockerSocket string // Default /var/run/docker.sock; "none" schaltet Docker ab
|
||||
BackendHost string // Adresse, unter der der Ingress die Dienste erreicht
|
||||
@@ -65,6 +67,7 @@ type Agent struct {
|
||||
signer *auth.Signer
|
||||
id Identity
|
||||
serverURL string // tatsächlich genutzte Adresse (Override oder node.json)
|
||||
fqdn string // in Run ermittelt
|
||||
|
||||
mu sync.Mutex
|
||||
fileSpecs []ServiceSpec
|
||||
@@ -144,6 +147,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.fqdn = detectFQDN(ctx, net.DefaultResolver, a.cfg.FQDN, a.cfg.Hostname, host)
|
||||
hc := a.httpClient()
|
||||
a.runCtx = ctx
|
||||
// Abgebrochener Schlüsselwechsel? Vor dem Verbinden abschließen.
|
||||
@@ -153,7 +157,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
a.log.Info("Agent gestartet", "node", a.id.NodeID, "server", a.serverURL, "backend", host,
|
||||
"docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
|
||||
"fqdn", a.fqdn, "docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
|
||||
|
||||
trigger := make(chan struct{}, 1)
|
||||
kick := func() {
|
||||
@@ -235,23 +239,28 @@ func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trig
|
||||
// gesendet (sonst würden alle Container-Routen entfernt).
|
||||
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
|
||||
state := registry.NodeState{Workloads: []registry.Workload{}}
|
||||
var client *docker.Client
|
||||
if a.dockerAvailable() {
|
||||
client := &docker.Client{Socket: a.cfg.DockerSocket}
|
||||
client = &docker.Client{Socket: a.cfg.DockerSocket}
|
||||
containers, err := client.List(ctx)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
// Gestoppte Container nennen in der Liste keine Ports.
|
||||
// Startzeit und Health stehen nur in den Details; gestoppte Container
|
||||
// nennen ihre Ports ebenfalls nur dort.
|
||||
for i, c := range containers {
|
||||
if c.State == "running" || !enabled(c.Labels) {
|
||||
if !enabled(c.Labels) {
|
||||
continue
|
||||
}
|
||||
ports, err := client.PortBindings(ctx, c.ID)
|
||||
d, err := client.Inspect(ctx, c.ID)
|
||||
if err != nil {
|
||||
a.log.Debug("Portzuordnung nicht lesbar", "container", c.Name(), "err", err)
|
||||
continue // inzwischen entfernt o. Ä. → Workloads warnt
|
||||
a.log.Debug("Container-Details nicht lesbar", "container", c.Name(), "err", err)
|
||||
continue // inzwischen entfernt o. Ä. → Workloads warnt ggf.
|
||||
}
|
||||
containers[i].StartedAt, containers[i].Health = d.StartedAt, d.Health
|
||||
if c.State != "running" {
|
||||
containers[i].Ports = d.Ports
|
||||
}
|
||||
containers[i].Ports = ports
|
||||
}
|
||||
ws, down, warnings := Workloads(containers, host)
|
||||
for _, w := range warnings {
|
||||
@@ -265,6 +274,7 @@ func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, e
|
||||
a.mu.Unlock()
|
||||
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
|
||||
state.DownServices = append(state.DownServices, a.health.down()...)
|
||||
state.Host = a.hostInfo(ctx, client)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
||||
+53
-1
@@ -24,6 +24,7 @@ import (
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/docker"
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/server"
|
||||
"sultan/pkg/store"
|
||||
"sultan/pkg/stream"
|
||||
@@ -179,11 +180,18 @@ func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
|
||||
key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
|
||||
bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "HostConfig": map[string]any{"PortBindings": bindings}})
|
||||
state := map[string]any{"Status": c.State, "StartedAt": c.StartedAt}
|
||||
if c.Health != "" {
|
||||
state["Health"] = map[string]string{"Status": c.Health}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "State": state, "HostConfig": map[string]any{"PortBindings": bindings}})
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
|
||||
})
|
||||
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2"}`))
|
||||
})
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
ch := make(chan docker.Event, 8)
|
||||
f.mu.Lock()
|
||||
@@ -872,3 +880,47 @@ func TestServerCandidates(t *testing.T) {
|
||||
t.Errorf("ohne Override: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzdaten für Inventar und Störfall kommen automatisch beim Server an:
|
||||
// Compose-Projekt/-Verzeichnis, Startzeit, Health je Container; FQDN,
|
||||
// Betriebssystem (des Hosts) und Docker-Version je Knoten. Ein Wechsel des
|
||||
// Health-Status wird nachgemeldet.
|
||||
func TestRun_ReportsMetadata(t *testing.T) {
|
||||
s := startServer(t, "", "")
|
||||
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
|
||||
wp := wordpress()
|
||||
wp.Labels["com.docker.compose.project"] = "kunde-a"
|
||||
wp.Labels["com.docker.compose.service"] = "wordpress"
|
||||
wp.Labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
|
||||
wp.StartedAt = started
|
||||
wp.Health = "healthy"
|
||||
dock := newFakeDocker(t, wp)
|
||||
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
|
||||
cfg.FQDN = "webdocker11.lan.r25dc.de"
|
||||
runAgent(t, cfg)
|
||||
|
||||
var st registry.NodeState
|
||||
eventually(t, "Zustand mit Zusatzdaten", func() bool {
|
||||
var ok bool
|
||||
st, ok = s.NodeState("node-1")
|
||||
return ok && len(st.Workloads) == 1 && st.Host != nil
|
||||
})
|
||||
wantMeta := ®istry.WorkloadMeta{
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy",
|
||||
}
|
||||
if !reflect.DeepEqual(st.Workloads[0].Meta, wantMeta) {
|
||||
t.Errorf("Meta = %+v, erwartet %+v", st.Workloads[0].Meta, wantMeta)
|
||||
}
|
||||
if h := st.Host; h.FQDN != "webdocker11.lan.r25dc.de" || h.OS != "Debian GNU/Linux 13 (trixie)" || h.DockerVersion != "29.8.2" {
|
||||
t.Errorf("Host = %+v", h)
|
||||
}
|
||||
|
||||
sick := wp
|
||||
sick.Health = "unhealthy"
|
||||
dock.set("health_status: unhealthy", sick)
|
||||
eventually(t, "Health-Wechsel nachgemeldet", func() bool {
|
||||
st, ok := s.NodeState("node-1")
|
||||
return ok && len(st.Workloads) == 1 && st.Workloads[0].Meta != nil && st.Workloads[0].Meta.Health == "unhealthy"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/docker"
|
||||
"sultan/pkg/registry"
|
||||
)
|
||||
|
||||
// osReleasePath: Quelle des Betriebssystems ohne Docker (systemd-Agent).
|
||||
var osReleasePath = "/etc/os-release"
|
||||
|
||||
// resolver ist der Teil von *net.Resolver, den detectFQDN braucht.
|
||||
type resolver interface {
|
||||
LookupCNAME(ctx context.Context, host string) (string, error)
|
||||
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||
}
|
||||
|
||||
// detectFQDN ermittelt den vollständigen Namen des Knotens für Inventar und
|
||||
// Störfall (Spec 5.3): explizit (--fqdn) > Hostname mit Punkt > Hostname
|
||||
// über die Suchdomain aufgelöst > Reverse-Lookup der Backend-Adresse >
|
||||
// kurzer Hostname. Jede Abfrage ist kurz begrenzt; scheitert alles, bleibt
|
||||
// der Hostname.
|
||||
func detectFQDN(ctx context.Context, res resolver, explicit, hostname, backend string) string {
|
||||
if n := normalizeName(explicit); n != "" {
|
||||
return n
|
||||
}
|
||||
short := normalizeName(hostname)
|
||||
if strings.Contains(short, ".") {
|
||||
return short
|
||||
}
|
||||
lookup := func(f func(context.Context) string) string {
|
||||
lctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
return f(lctx)
|
||||
}
|
||||
if short != "" {
|
||||
if n := lookup(func(c context.Context) string {
|
||||
cname, err := res.LookupCNAME(c, short)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeName(cname)
|
||||
}); strings.Contains(n, ".") {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if net.ParseIP(backend) != nil {
|
||||
if n := lookup(func(c context.Context) string {
|
||||
names, err := res.LookupAddr(c, backend)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
if n := normalizeName(name); strings.Contains(n, ".") {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func normalizeName(s string) string {
|
||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s), "."))
|
||||
}
|
||||
|
||||
// cgnat ist 100.64.0.0/10 – der Bereich, aus dem NetBird Adressen vergibt.
|
||||
var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)}
|
||||
|
||||
// netbirdIP wählt die NetBird-Adresse: IPv4 auf wt0, sonst die erste
|
||||
// IPv4-Adresse aus 100.64.0.0/10 (Interfaces alphabetisch).
|
||||
func netbirdIP(addrs map[string][]net.IP) string {
|
||||
for _, ip := range addrs["wt0"] {
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return ip4.String()
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(addrs))
|
||||
for name := range addrs {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
for _, name := range names {
|
||||
for _, ip := range addrs[name] {
|
||||
if ip4 := ip.To4(); ip4 != nil && cgnat.Contains(ip4) {
|
||||
return ip4.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// interfaceAddrs liest die Adressen aller Interfaces.
|
||||
func interfaceAddrs() map[string][]net.IP {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]net.IP{}
|
||||
for _, ifc := range ifaces {
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
out[ifc.Name] = append(out[ifc.Name], ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// osPrettyName liest PRETTY_NAME (ersatzweise NAME) aus einer os-release-Datei.
|
||||
func osPrettyName(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
var name string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.Trim(value, `"'`)
|
||||
switch key {
|
||||
case "PRETTY_NAME":
|
||||
return value
|
||||
case "NAME":
|
||||
name = value
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// hostInfo sammelt die Angaben zum Knoten. Mit Docker kommt das
|
||||
// Betriebssystem aus der Docker-API (das des Hosts, auch wenn der Agent in
|
||||
// einem Container läuft); Fehler lassen Felder nur leer.
|
||||
func (a *Agent) hostInfo(ctx context.Context, client *docker.Client) *registry.HostInfo {
|
||||
h := ®istry.HostInfo{FQDN: a.fqdn, NetBirdIP: netbirdIP(interfaceAddrs())}
|
||||
if client != nil {
|
||||
info, err := client.Info(ctx)
|
||||
if err != nil {
|
||||
a.log.Debug("Docker-Info nicht lesbar", "err", err)
|
||||
}
|
||||
h.OS, h.DockerVersion = info.OperatingSystem, info.ServerVersion
|
||||
}
|
||||
if h.OS == "" {
|
||||
h.OS = osPrettyName(osReleasePath)
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeResolver beantwortet CNAME- und PTR-Anfragen aus Tabellen.
|
||||
type fakeResolver struct {
|
||||
cname map[string]string
|
||||
ptr map[string][]string
|
||||
}
|
||||
|
||||
func (f fakeResolver) LookupCNAME(_ context.Context, host string) (string, error) {
|
||||
if c, ok := f.cname[host]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return "", errors.New("no such host")
|
||||
}
|
||||
|
||||
func (f fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
|
||||
if p, ok := f.ptr[addr]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return nil, errors.New("no such host")
|
||||
}
|
||||
|
||||
// FQDN: explizit > Hostname mit Punkt > aufgelöster Hostname (Suchdomain) >
|
||||
// Reverse-Lookup der Backend-Adresse > kurzer Hostname.
|
||||
func TestDetectFQDN(t *testing.T) {
|
||||
res := fakeResolver{
|
||||
cname: map[string]string{"webdocker11": "webdocker11.lan.r25dc.de.", "kurz": "kurz"},
|
||||
ptr: map[string][]string{"10.0.0.11": {"backend11.lan.r25dc.de."}},
|
||||
}
|
||||
tests := []struct {
|
||||
name, explicit, hostname, backend, want string
|
||||
}{
|
||||
{"explizit gewinnt", "web.example.de", "webdocker11", "10.0.0.11", "web.example.de"},
|
||||
{"Hostname ist schon FQDN", "", "pi.home.arpa", "10.0.0.11", "pi.home.arpa"},
|
||||
{"über Suchdomain aufgelöst", "", "webdocker11", "10.0.0.11", "webdocker11.lan.r25dc.de"},
|
||||
{"Reverse-Lookup der Backend-Adresse", "", "kurz", "10.0.0.11", "backend11.lan.r25dc.de"},
|
||||
{"nichts auflösbar: kurzer Hostname", "", "solo", "10.9.9.9", "solo"},
|
||||
{"Groß/Klein und Leerraum", " Web.Example.DE ", "x", "", "web.example.de"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := detectFQDN(context.Background(), res, tt.explicit, tt.hostname, tt.backend); got != tt.want {
|
||||
t.Errorf("detectFQDN = %q, erwartet %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NetBird: Interface wt0, sonst eine Adresse aus 100.64.0.0/10 (CGNAT, wie
|
||||
// NetBird sie vergibt).
|
||||
func TestNetBirdIP(t *testing.T) {
|
||||
ip := net.ParseIP
|
||||
tests := []struct {
|
||||
name string
|
||||
addrs map[string][]net.IP
|
||||
want string
|
||||
}{
|
||||
{"wt0", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "wt0": {ip("fe80::1"), ip("100.92.10.4")}}, "100.92.10.4"},
|
||||
{"CGNAT auf anderem Interface", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "nb0": {ip("100.64.3.2")}}, "100.64.3.2"},
|
||||
{"kein Overlay", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "lo": {ip("127.0.0.1")}}, ""},
|
||||
{"100.128.x ist kein CGNAT", map[string][]net.IP{"eth0": {ip("100.128.0.1")}}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := netbirdIP(tt.addrs); got != tt.want {
|
||||
t.Errorf("netbirdIP = %q, erwartet %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne Docker (systemd-Agent) kommt das Betriebssystem aus /etc/os-release.
|
||||
func TestOSPrettyName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "os-release")
|
||||
os.WriteFile(p, []byte("NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nID=debian\n"), 0o644)
|
||||
if got := osPrettyName(p); got != "Debian GNU/Linux 13 (trixie)" {
|
||||
t.Errorf("osPrettyName = %q", got)
|
||||
}
|
||||
os.WriteFile(p, []byte("NAME=Alpine\nID=alpine\n"), 0o644)
|
||||
if got := osPrettyName(p); got != "Alpine" {
|
||||
t.Errorf("ohne PRETTY_NAME: %q, erwartet NAME", got)
|
||||
}
|
||||
if got := osPrettyName(filepath.Join(dir, "fehlt")); got != "" {
|
||||
t.Errorf("fehlende Datei: %q", got)
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -57,7 +57,7 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
|
||||
continue
|
||||
}
|
||||
cand := candidate{
|
||||
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
|
||||
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels, Meta: meta(c)},
|
||||
running: c.State == "running",
|
||||
}
|
||||
for _, proto := range protocols {
|
||||
@@ -108,6 +108,26 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
|
||||
return workloads, slices.Compact(down), warnings
|
||||
}
|
||||
|
||||
// Labels, die Docker Compose an jeden Container setzt.
|
||||
const (
|
||||
labelComposeProject = "com.docker.compose.project"
|
||||
labelComposeService = "com.docker.compose.service"
|
||||
labelComposeDir = "com.docker.compose.project.working_dir"
|
||||
)
|
||||
|
||||
// meta liefert die Zusatzdaten eines Containers (Spec 5.3) – nur aus dem,
|
||||
// was Docker ohnehin kennt, ohne zusätzliche Labels.
|
||||
func meta(c docker.Container) *registry.WorkloadMeta {
|
||||
return ®istry.WorkloadMeta{
|
||||
ComposeProject: c.Labels[labelComposeProject],
|
||||
ComposeService: c.Labels[labelComposeService],
|
||||
ComposeDir: c.Labels[labelComposeDir],
|
||||
State: c.State,
|
||||
StartedAt: c.StartedAt,
|
||||
Health: c.Health,
|
||||
}
|
||||
}
|
||||
|
||||
// labelNames liefert die Namen (Schreibweise aus dem Label) unter prefix,
|
||||
// z. B. die Router aus traefik.http.routers.<name>.…
|
||||
func labelNames(labels map[string]string, prefix string) []string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/docker"
|
||||
"sultan/pkg/registry"
|
||||
@@ -38,6 +39,7 @@ func TestWorkloads_TranslatesPublishedPort(t *testing.T) {
|
||||
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
|
||||
"traefik.http.services.wp.loadbalancer.server.port": "8081",
|
||||
},
|
||||
Meta: ®istry.WorkloadMeta{State: "running"},
|
||||
}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want)
|
||||
@@ -220,3 +222,39 @@ func TestWorkloads_RunningWinsOverStopped(t *testing.T) {
|
||||
t.Errorf("ohne Überschneidung: got=%+v down=%v", got, down)
|
||||
}
|
||||
}
|
||||
|
||||
// Compose-Projekt, -Verzeichnis, Zustand, Startzeit und Health kommen
|
||||
// automatisch aus Docker – ohne zusätzliche Labels.
|
||||
func TestWorkloads_Meta(t *testing.T) {
|
||||
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
|
||||
labels := wpLabels()
|
||||
labels["com.docker.compose.project"] = "kunde-a"
|
||||
labels["com.docker.compose.service"] = "wordpress"
|
||||
labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
|
||||
compose := ctr("kunde-a-wordpress-1", labels, pub(80, 8081))
|
||||
compose.StartedAt = started
|
||||
compose.Health = "healthy"
|
||||
|
||||
plain := ctr("solo", map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.so.rule": "Host(`solo.kunde.de`)",
|
||||
}, pub(80, 8082))
|
||||
plain.State = "exited"
|
||||
|
||||
got, _, warnings := Workloads([]docker.Container{compose, plain}, "100.64.0.42")
|
||||
if len(warnings) != 0 || len(got) != 2 {
|
||||
t.Fatalf("Workloads = %+v, Warnungen %v", got, warnings)
|
||||
}
|
||||
want := map[string]*registry.WorkloadMeta{
|
||||
"kunde-a-wordpress-1": {
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy",
|
||||
},
|
||||
"solo": {State: "exited"},
|
||||
}
|
||||
for _, w := range got {
|
||||
if !reflect.DeepEqual(w.Meta, want[w.Name]) {
|
||||
t.Errorf("%s: Meta = %+v, erwartet %+v", w.Name, w.Meta, want[w.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,10 @@ func shortDuration(d time.Duration) string {
|
||||
|
||||
var umlauts = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", "ß", "ss")
|
||||
|
||||
// ASCII macht Text Checkmk-tauglich (siehe ascii) – auch für andere
|
||||
// Checkmk-Ausgaben, z. B. die des Accountants.
|
||||
func ASCII(s string) string { return ascii(s) }
|
||||
|
||||
// ascii macht Text Checkmk-tauglich: einzeilig, Umlaute umschrieben,
|
||||
// übrige Nicht-ASCII-Zeichen ersetzt.
|
||||
func ascii(s string) string {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
const accountantUsage = `sultan accountant – Zentrale für Inventar und Abrechnung (Spec 5.3).
|
||||
Sultan-Server (Payees) melden ihr Inventar per signiertem Bericht.
|
||||
|
||||
sultan accountant [--addr :8444] [--public-url https://…] [--metrics-addr 127.0.0.1:8082] [--data-dir ./accdata]
|
||||
Accountant starten (nur TLS; Adresse wird gemerkt und steckt in Einladungen;
|
||||
Checkmk: curl http://127.0.0.1:8082/metrics/checkmk)
|
||||
sultan accountant invite --payee ID [--ttl 24h|7d] [--server-url https://…]
|
||||
Einladung für einen Sultan-Server (Payee) erzeugen
|
||||
sultan accountant where <host|mandant> Störfall: wo läuft eine URL (Backend, Container,
|
||||
Compose-Verzeichnis), seit wann, wo vorher? Teiltreffer genügen.
|
||||
sultan accountant routes [--tenant ID] [--payee ID] alle aktuellen Routen
|
||||
sultan accountant customer add --id ID --name NAME [--group G] [--note TEXT] [--cycle m|q|j|offen] [--flat-rate 89,00]
|
||||
sultan accountant customer set --id ID [dieselben Felder] [--no-flat-rate] (nur angegebene Felder ändern)
|
||||
sultan accountant customer list [--group G] [--unclassified]
|
||||
sultan accountant customer map --tenant T --customer ID [--payee P] Mandant (z. B. Tippfehler) zuordnen
|
||||
sultan accountant customer unmap --tenant T [--payee P]
|
||||
sultan accountant customer delete --id ID
|
||||
Kunden entstehen automatisch aus den Mandanten-IDs der Berichte.
|
||||
sultan accountant product add --id ID --name NAME --class included|billable|free|internal [--price 49,00]
|
||||
sultan accountant product set --id ID --from JJJJ-MM [--class …] [--price … | --no-price] (gilt ab Monat)
|
||||
sultan accountant product price --id ID --customer ID --price 39,00 [--from JJJJ-MM] Kundenpreis
|
||||
sultan accountant product list | product delete --id ID
|
||||
Preise sind optional und nicht buchhaltungsrelevant (Lexware führt).
|
||||
sultan accountant assign (--host H | --tenant T --router R [--protocol tcp]) [--product P] [--class C] [--start JJJJ-MM] [--clear]
|
||||
Posten zuordnen – Vorrang vor dem Label sultan.accountant.billing
|
||||
sultan accountant assignments
|
||||
sultan accountant billing --month JJJJ-MM [--out datei.csv] Monatsabrechnung als CSV (Semikolon, Dezimalkomma)
|
||||
sultan accountant payee list
|
||||
sultan accountant payee report --id ID (letzten Bericht als JSON ausgeben)
|
||||
sultan accountant payee delete --id ID (z. B. vor neuer Einladung nach Schlüsselverlust)
|
||||
sultan accountant dump-checkmk [--force] Checkmk-Vorlage nach <data-dir>/checkmk exportieren
|
||||
(anpassen – wirkt sofort, ohne Neustart)
|
||||
|
||||
Verwaltungsbefehle arbeiten direkt auf dem Datenverzeichnis (auch während er läuft).
|
||||
Alle Befehle: --data-dir DIR (Default ./accdata)
|
||||
`
|
||||
|
||||
var accountantCmds = map[string]tokenCmd{
|
||||
"invite": accountantInvite,
|
||||
"payee list": payeeList,
|
||||
"payee report": payeeReport,
|
||||
"payee delete": payeeDelete,
|
||||
"dump-checkmk": dumpCheckmk,
|
||||
"where": accountantWhere,
|
||||
"routes": accountantRoutes,
|
||||
"customer add": customerAdd,
|
||||
"customer set": customerSet,
|
||||
"customer list": customerList,
|
||||
"customer delete": customerDelete,
|
||||
"customer map": customerMap,
|
||||
"customer unmap": customerUnmap,
|
||||
"product add": productAdd,
|
||||
"product set": productSet,
|
||||
"product price": productPrice,
|
||||
"product list": productList,
|
||||
"product delete": productDelete,
|
||||
"assign": assign,
|
||||
"assignments": assignments,
|
||||
"billing": billing,
|
||||
}
|
||||
|
||||
func runAccountant(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
// Ohne Unterbefehl (bzw. nur Flags): Accountant starten.
|
||||
if len(args) == 0 || len(args[0]) > 0 && args[0][0] == '-' {
|
||||
return serveAccountant(ctx, args, stderr)
|
||||
}
|
||||
if args[0] == "help" {
|
||||
fmt.Fprint(stdout, accountantUsage)
|
||||
return 0
|
||||
}
|
||||
name, rest := args[0], args[1:]
|
||||
if (name == "payee" || name == "customer" || name == "product") && len(rest) > 0 {
|
||||
name, rest = name+" "+rest[0], rest[1:]
|
||||
}
|
||||
cmd, ok := accountantCmds[name]
|
||||
if !ok {
|
||||
fmt.Fprintf(stderr, "unbekannter Befehl \"accountant %s\"\n\n%s", name, accountantUsage)
|
||||
return 2
|
||||
}
|
||||
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
|
||||
var ue usageError
|
||||
switch {
|
||||
case err == nil, errors.Is(err, flag.ErrHelp):
|
||||
return 0
|
||||
case errors.As(err, &ue):
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
default:
|
||||
fmt.Fprintln(stderr, "Fehler:", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func serveAccountant(ctx context.Context, args []string, stderr io.Writer) int {
|
||||
cfg, err := parseAccountantFlags(args, stderr, os.Getenv)
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
|
||||
a, err := accountant.New(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
|
||||
return 1
|
||||
}
|
||||
if err := a.Run(ctx); err != nil {
|
||||
fmt.Fprintln(stderr, "Accountant beendet mit Fehler:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseAccountantFlags liest die Flags von `sultan accountant`; jedes Flag ist
|
||||
// auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
|
||||
func parseAccountantFlags(args []string, output io.Writer, getenv func(string) string) (accountant.Config, error) {
|
||||
fs := flag.NewFlagSet("sultan accountant", flag.ContinueOnError)
|
||||
fs.SetOutput(output)
|
||||
var cfg accountant.Config
|
||||
fs.StringVar(&cfg.DataDir, "data-dir", "./accdata", "Datenverzeichnis (accountant.db, server.key/.crt)")
|
||||
fs.StringVar(&cfg.Addr, "addr", ":8444", "Adresse für Payees (nur TLS)")
|
||||
fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Payees den Accountant erreichen (z. B. https://accountant.example.de:8444); mehrfach angebbar; wird gemerkt, die erste steckt in Einladungen")
|
||||
fs.StringVar(&cfg.MetricsAddr, "metrics-addr", "127.0.0.1:8082", `Checkmk-Ausgabe /metrics/checkmk (Klartext, nur Loopback; "none" = aus)`)
|
||||
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
|
||||
fs.DurationVar(&cfg.StaleWarn, "stale-warn", 2*time.Hour, "Checkmk WARN, wenn ein Payee so lange nicht berichtet hat")
|
||||
fs.DurationVar(&cfg.StaleCrit, "stale-crit", 6*time.Hour, "Checkmk CRIT, wenn ein Payee so lange nicht berichtet hat")
|
||||
annotateEnv(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return accountant.Config{}, err
|
||||
}
|
||||
if err := applyEnv(fs, getenv); err != nil {
|
||||
return accountant.Config{}, err
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return accountant.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func accountantFlags(name string, env tokenEnv) (*flag.FlagSet, *string) {
|
||||
fs := flag.NewFlagSet("sultan accountant "+name, flag.ContinueOnError)
|
||||
fs.SetOutput(env.stderr)
|
||||
dir := fs.String("data-dir", "./accdata", "Datenverzeichnis des Accountants")
|
||||
return fs, dir
|
||||
}
|
||||
|
||||
// openLedger öffnet die DB eines bereits initialisierten Datenverzeichnisses
|
||||
// (nie neu anlegen: ein Tippfehler würde sonst eine neue Identität erzeugen).
|
||||
func openLedger(env tokenEnv, dir string) (*ledger.Ledger, error) {
|
||||
for _, f := range []string{"accountant.db", "server.crt"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan accountant` starten oder --data-dir prüfen", dir, f)
|
||||
}
|
||||
}
|
||||
return ledger.Open(env.ctx, filepath.Join(dir, "accountant.db"))
|
||||
}
|
||||
|
||||
func accountantInvite(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("invite", env)
|
||||
payee := fs.String("payee", "", "ID des Sultan-Servers (a–z, 0–9, - und _), z. B. webproxy11")
|
||||
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
|
||||
serverURL := fs.String("server-url", "", "Accountant-Adresse für diese Einladung (Default: --public-url)")
|
||||
if err := parse(fs, args, "payee"); err != nil {
|
||||
return err
|
||||
}
|
||||
ttl, err := parseTTL(*ttlRaw)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
if *serverURL != "" {
|
||||
u, err := crypto.ValidateServerURL(*serverURL)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
*serverURL = u
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if *serverURL == "" {
|
||||
urls, err := accountant.LoadPublicURLs(env.ctx, l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(urls) > 0 {
|
||||
*serverURL = urls[0]
|
||||
}
|
||||
}
|
||||
cert, err := crypto.LoadOrCreateServerCert(*dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := time.Now().Add(ttl)
|
||||
if err := l.AddInvite(env.ctx, tok.Secret, *payee, expires); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(env.stdout, tok.String())
|
||||
fmt.Fprintf(env.stderr, "Einladung für Payee %s, einmalig gültig bis %s.\n", *payee, expires.Format(time.DateTime))
|
||||
if tok.ServerURL != "" {
|
||||
fmt.Fprintf(env.stderr, "Accountant-Adresse %s ist enthalten. Auf dem Sultan-Server:\n SULTAN_ACCOUNTANT_TOKEN=<token> sultan server …\n", tok.ServerURL)
|
||||
} else {
|
||||
fmt.Fprint(env.stderr, "Auf dem Sultan-Server: SULTAN_ACCOUNTANT_TOKEN=<token> sultan server --accountant=https://<accountant>:8444 …\n"+
|
||||
"Tipp: Mit `sultan accountant --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func payeeList(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("payee list", env)
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
payees, err := l.ListPayees(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tHOSTNAME\tIP\tENROLLED\tLAST_REPORT")
|
||||
for _, p := range payees {
|
||||
last := "nie"
|
||||
if !p.LastReport.IsZero() {
|
||||
last = p.LastReport.Local().Format(time.DateTime)
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Hostname, p.IPAddress, p.EnrolledAt.Local().Format(time.DateTime), last)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func payeeReport(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("payee report", env)
|
||||
id := fs.String("id", "", "Payee-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
body, at, err := l.LatestReport(env.ctx, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, body, "", " "); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(env.stdout, buf.String())
|
||||
fmt.Fprintf(env.stderr, "Bericht von %s, empfangen %s\n", *id, at.Local().Format(time.DateTime))
|
||||
return nil
|
||||
}
|
||||
|
||||
func payeeDelete(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("payee delete", env)
|
||||
id := fs.String("id", "", "Payee-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.DeletePayee(env.ctx, *id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Payee %s gelöscht\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func dumpCheckmk(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("dump-checkmk", env)
|
||||
force := fs.Bool("force", false, "vorhandene Vorlage überschreiben")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir) // nur prüfen, ob das Verzeichnis initialisiert ist
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.Close()
|
||||
path, err := accountant.DumpCheckmk(*dir, *force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(env.stdout, path)
|
||||
fmt.Fprintln(env.stderr, "Vorlage geschrieben – Änderungen wirken sofort, ohne Neustart. Fehlerhafte Vorlage: eingebaute greift, Checkmk meldet CRIT.")
|
||||
return nil
|
||||
}
|
||||
|
||||
const shortTime = "2006-01-02 15:04"
|
||||
|
||||
func localTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "?"
|
||||
}
|
||||
return t.Local().Format(shortTime)
|
||||
}
|
||||
|
||||
// splitQuery trennt den Suchbegriff (erstes Argument ohne -) von den Flags;
|
||||
// ein Flag ohne "=" nimmt das folgende Argument als Wert.
|
||||
func splitQuery(args []string) (query string, flags []string) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case strings.HasPrefix(a, "-"):
|
||||
flags = append(flags, a)
|
||||
if !strings.Contains(a, "=") && a != "-h" && a != "--help" && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
case query == "":
|
||||
query = a
|
||||
default:
|
||||
flags = append(flags, a) // parse meldet das überzählige Argument
|
||||
}
|
||||
}
|
||||
return query, flags
|
||||
}
|
||||
|
||||
// routeProblems fasst den Zustand einer Route knapp zusammen ("ok" ohne Befund).
|
||||
func routeProblems(r accountant.ReportRoute, n accountant.ReportNode) string {
|
||||
var p []string
|
||||
if !r.Active {
|
||||
p = append(p, "verworfen")
|
||||
}
|
||||
if r.Down {
|
||||
p = append(p, "gestoppt")
|
||||
}
|
||||
if r.Health == "unhealthy" {
|
||||
p = append(p, "unhealthy")
|
||||
}
|
||||
switch {
|
||||
case n.Maintenance:
|
||||
p = append(p, "Wartung")
|
||||
case !n.Connected:
|
||||
p = append(p, "getrennt")
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return "ok"
|
||||
}
|
||||
return strings.Join(p, ",")
|
||||
}
|
||||
|
||||
func accountantWhere(env tokenEnv, args []string) error {
|
||||
query, rest := splitQuery(args)
|
||||
fs, dir := accountantFlags("where", env)
|
||||
if err := parse(fs, rest); err != nil {
|
||||
return err
|
||||
}
|
||||
if query == "" {
|
||||
return usageError{errors.New("Suchbegriff fehlt: sultan accountant where <host|mandant>")}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
res, err := accountant.Where(env.ctx, l, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(res.Current) == 0 && len(res.Gone) == 0 {
|
||||
return fmt.Errorf("nichts gefunden für %q", query)
|
||||
}
|
||||
out := env.stdout
|
||||
for i, c := range res.Current {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
r, n := c.Route, c.Node
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "URL\t%s (Router %s, %s)\n", strings.Join(r.Hosts, ", "), r.Router, r.Protocol)
|
||||
tenant := r.Tenant
|
||||
if c.TenantName != "" {
|
||||
tenant += " (" + c.TenantName + ")"
|
||||
}
|
||||
fmt.Fprintf(tw, "Mandant\t%s\n", tenant)
|
||||
if c.Customer != nil {
|
||||
cust := c.Customer.ID + " " + c.Customer.Name + " (" + cycleLabel(c.Customer.Cycle)
|
||||
if c.Customer.Group != "" {
|
||||
cust += ", Gruppe " + c.Customer.Group
|
||||
}
|
||||
fmt.Fprintf(tw, "Kunde\t%s)\n", cust)
|
||||
} else {
|
||||
fmt.Fprintf(tw, "Kunde\tnicht zugeordnet\n")
|
||||
}
|
||||
fmt.Fprintf(tw, "Ingress\t%s (Bericht %s)\n", c.Payee, localTime(c.ReportAt))
|
||||
backend := r.Backend
|
||||
if n.NetBirdIP != "" {
|
||||
backend += ", NetBird " + n.NetBirdIP
|
||||
}
|
||||
fmt.Fprintf(tw, "Backend\t%s (%s)\n", cmp.Or(r.BackendFQDN, r.Backend), backend)
|
||||
container := r.Workload
|
||||
if r.ComposeDir != "" {
|
||||
container += ", compose " + r.ComposeDir
|
||||
if r.ComposeProject != "" {
|
||||
container += " (Projekt " + r.ComposeProject + ")"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "Container\t%s\n", container)
|
||||
state := []string{routeProblems(r, n)}
|
||||
if r.State != "" {
|
||||
s := r.State
|
||||
if !r.StartedAt.IsZero() {
|
||||
s += " seit " + localTime(r.StartedAt)
|
||||
}
|
||||
state = append(state, s)
|
||||
}
|
||||
if r.Health != "" {
|
||||
state = append(state, r.Health)
|
||||
}
|
||||
if r.Product != "" {
|
||||
state = append(state, "Produkt "+r.Product)
|
||||
}
|
||||
fmt.Fprintf(tw, "Zustand\t%s\n", strings.Join(state, ", "))
|
||||
node := r.Node
|
||||
if n.Connected {
|
||||
node += " verbunden"
|
||||
} else {
|
||||
node += " getrennt"
|
||||
}
|
||||
for _, v := range []string{n.OS, prefixed("Docker ", n.DockerVersion)} {
|
||||
if v != "" {
|
||||
node += ", " + v
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "Knoten\t%s\n", node)
|
||||
fmt.Fprintf(tw, "Seit\t%s\n", localTime(c.Since))
|
||||
for _, p := range c.Previous {
|
||||
fmt.Fprintf(tw, "Vorher\t%s (%s) %s – %s\n", p.Node, p.Payee, localTime(p.FirstSeen), localTime(p.Ended))
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
if len(res.Gone) > 0 {
|
||||
if len(res.Current) > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintln(out, "Nicht mehr vorhanden:")
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
for _, g := range res.Gone {
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s (%s)\t%s – %s\n", strings.Join(g.Hosts, ", "), g.Tenant, g.Node, g.Payee,
|
||||
localTime(g.FirstSeen), localTime(g.Ended))
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prefixed(prefix, v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + v
|
||||
}
|
||||
|
||||
func accountantRoutes(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("routes", env)
|
||||
tenant := fs.String("tenant", "", "nur Routen dieses Mandanten")
|
||||
payee := fs.String("payee", "", "nur Routen dieses Payees")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
routes, err := accountant.Routes(env.ctx, l, accountant.RoutesFilter{Tenant: *tenant, Payee: *payee})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "HOST\tTENANT\tPAYEE\tNODE\tBACKEND\tSTATE\tSINCE")
|
||||
for _, c := range routes {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", strings.Join(c.Route.Hosts, ","), c.Route.Tenant, c.Payee,
|
||||
c.Route.Node, cmp.Or(c.Route.BackendFQDN, c.Route.Backend), routeProblems(c.Route, c.Node), localTime(c.Since))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// initAccDir initialisiert ein Datenverzeichnis wie der erste Start von
|
||||
// `sultan accountant`.
|
||||
func initAccDir(t *testing.T, publicURLs ...string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "accdata")
|
||||
a, err := accountant.New(context.Background(), accountant.Config{
|
||||
DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: publicURLs, Logger: slog.New(slog.DiscardHandler),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Shutdown(context.Background())
|
||||
return dir
|
||||
}
|
||||
|
||||
func acc(t *testing.T, dir string, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
full := append([]string{"accountant"}, args...)
|
||||
full = append(full, "--data-dir="+dir)
|
||||
return run(context.Background(), full...)
|
||||
}
|
||||
|
||||
func TestAccountantFlags(t *testing.T) {
|
||||
cfg, err := parseAccountantFlags(nil, &bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DataDir != "./accdata" || cfg.Addr != ":8444" || len(cfg.PublicURLs) != 0 || cfg.MetricsAddr != "127.0.0.1:8082" ||
|
||||
cfg.EnrollLimit != 10 || cfg.StaleWarn != 2*time.Hour || cfg.StaleCrit != 6*time.Hour {
|
||||
t.Errorf("Defaults: %+v", cfg)
|
||||
}
|
||||
cfg, err = parseAccountantFlags([]string{"--metrics-addr=none", "--enroll-limit=-1", "--stale-warn=30m", "--stale-crit=1h"},
|
||||
&bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MetricsAddr != "none" || cfg.EnrollLimit != -1 || cfg.StaleWarn != 30*time.Minute || cfg.StaleCrit != time.Hour {
|
||||
t.Errorf("Overrides: %+v", cfg)
|
||||
}
|
||||
env := map[string]string{"SULTAN_PUBLIC_URL": "https://a:8444,https://b:8444"}
|
||||
cfg, err = parseAccountantFlags([]string{"--data-dir=/srv/acc", "--addr=:9444"}, &bytes.Buffer{},
|
||||
func(k string) string { return env[k] })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DataDir != "/srv/acc" || cfg.Addr != ":9444" || strings.Join(cfg.PublicURLs, " ") != "https://a:8444 https://b:8444" {
|
||||
t.Errorf("Overrides: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// Einladung mit gemerkter Adresse und Fingerprint des Accountants; in der
|
||||
// DB liegt nur der Hash.
|
||||
func TestAccountantInvite(t *testing.T) {
|
||||
dir := initAccDir(t, "https://acc.example.de:8444")
|
||||
code, stdout, stderr := acc(t, dir, "invite", "--payee=webproxy11")
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stderr=%s", code, stderr)
|
||||
}
|
||||
tk, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
|
||||
if err != nil {
|
||||
t.Fatalf("Ausgabe ist keine Einladung: %q (%v)", stdout, err)
|
||||
}
|
||||
cert, _ := crypto.LoadOrCreateServerCert(dir)
|
||||
if tk.Fingerprint != cert.Fingerprint || tk.ServerURL != "https://acc.example.de:8444" {
|
||||
t.Errorf("Einladung %+v", tk)
|
||||
}
|
||||
if !strings.Contains(stderr, "webproxy11") || !strings.Contains(stderr, "SULTAN_ACCOUNTANT_TOKEN") {
|
||||
t.Errorf("Hinweis fehlt: %q", stderr)
|
||||
}
|
||||
|
||||
// Gleiche Einladung lässt sich im Ledger einlösen (Hash passt).
|
||||
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
if _, err := l.Enroll(context.Background(), tk.Secret, ledger.Payee{Hostname: "h", PublicKey: "k"}); err != nil {
|
||||
t.Errorf("Einladung nicht einlösbar: %v", err)
|
||||
}
|
||||
|
||||
// --server-url überschreibt, --ttl wird geprüft.
|
||||
code, stdout, _ = acc(t, dir, "invite", "--payee=webproxy12", "--server-url=https://other:8444", "--ttl=7d")
|
||||
if tk, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); code != 0 || tk.ServerURL != "https://other:8444" {
|
||||
t.Errorf("--server-url: code=%d %+v", code, tk)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"invite"}, // --payee fehlt
|
||||
{"invite", "--payee=webproxy13", "--ttl=x"}, // ttl unlesbar
|
||||
{"invite", "--payee=webproxy13", "--server-url=http://klartext"},
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code != 2 {
|
||||
t.Errorf("%v: code=%d, erwartet 2", args, code)
|
||||
}
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "invite", "--payee=webproxy11"); code != 1 || !strings.Contains(stderr, "existiert") {
|
||||
t.Errorf("schon registriert: code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountantPayees(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "ID") || strings.Contains(stdout, "webproxy11") {
|
||||
t.Errorf("leere Liste: code=%d %q", code, stdout)
|
||||
}
|
||||
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "webproxy11.lan.r25dc.de", PublicKey: "k", IPAddress: "10.0.0.5"})
|
||||
l.Close()
|
||||
|
||||
code, stdout, _ := acc(t, dir, "payee", "list")
|
||||
if code != 0 || !strings.Contains(stdout, "webproxy11") || !strings.Contains(stdout, "webproxy11.lan.r25dc.de") ||
|
||||
!strings.Contains(stdout, "10.0.0.5") {
|
||||
t.Errorf("Liste: code=%d %q", code, stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 0 {
|
||||
t.Errorf("delete: code=%d", code)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 1 {
|
||||
t.Errorf("zweites delete: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Tippfehler bei --data-dir legt keine neue Accountant-Identität an.
|
||||
func TestAccountant_UninitializedDataDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "tippfehler")
|
||||
if code, _, stderr := acc(t, dir, "payee", "list"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
|
||||
t.Errorf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "invite", "--payee=x1"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
|
||||
t.Errorf("invite: code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountant_Usage(t *testing.T) {
|
||||
if code, stdout, _ := run(context.Background(), "accountant", "help"); code != 0 || !strings.Contains(stdout, "invite") {
|
||||
t.Errorf("help: code=%d %q", code, stdout)
|
||||
}
|
||||
if code, _, stderr := run(context.Background(), "accountant", "gibts-nicht"); code != 2 || !strings.Contains(stderr, "unbekannt") {
|
||||
t.Errorf("unbekannt: code=%d %q", code, stderr)
|
||||
}
|
||||
if code, stdout, _ := run(context.Background(), "help"); code != 0 || !strings.Contains(stdout, "sultan accountant") {
|
||||
t.Errorf("Hauptusage ohne accountant: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// payee list zeigt den letzten Bericht, payee report seinen Inhalt.
|
||||
func TestAccountantPayeeReport(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "h", PublicKey: "k", IPAddress: "10.0.0.5"})
|
||||
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "LAST_REPORT") || !strings.Contains(stdout, "nie") {
|
||||
t.Errorf("ohne Bericht: code=%d %q", code, stdout)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "payee", "report", "--id=webproxy11"); code != 1 || !strings.Contains(stderr, "nicht gefunden") {
|
||||
t.Errorf("report ohne Bericht: code=%d %q", code, stderr)
|
||||
}
|
||||
l.SaveReport(context.Background(), "webproxy11", []byte(`{"version":"v1","routes":[{"hosts":["kunde.de"]}]}`), nil)
|
||||
l.Close()
|
||||
|
||||
code, stdout, _ := acc(t, dir, "payee", "list")
|
||||
if code != 0 || strings.Contains(stdout, "nie") || !strings.Contains(stdout, time.Now().Format("2006-01-02")) {
|
||||
t.Errorf("mit Bericht: code=%d %q", code, stdout)
|
||||
}
|
||||
code, stdout, _ = acc(t, dir, "payee", "report", "--id=webproxy11")
|
||||
if code != 0 || !strings.Contains(stdout, `"kunde.de"`) || !strings.Contains(stdout, "\n ") {
|
||||
t.Errorf("report: code=%d %q (erwartet eingerücktes JSON)", code, stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "payee", "report"); code != 2 {
|
||||
t.Errorf("report ohne --id: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// dump-checkmk legt die eingebaute Vorlage zum Anpassen ins Datenverzeichnis.
|
||||
func TestAccountantDumpCheckmk(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
code, stdout, _ := acc(t, dir, "dump-checkmk")
|
||||
want := filepath.Join(dir, "checkmk", accountant.CheckmkTemplateName)
|
||||
if code != 0 || strings.TrimSpace(stdout) != want {
|
||||
t.Fatalf("code=%d stdout=%q", code, stdout)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "dump-checkmk"); code != 1 || !strings.Contains(stderr, "--force") {
|
||||
t.Errorf("zweiter Lauf: code=%d %q", code, stderr)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "dump-checkmk", "--force"); code != 0 {
|
||||
t.Errorf("--force: code=%d", code)
|
||||
}
|
||||
// Nicht initialisiertes Verzeichnis: nichts anlegen.
|
||||
if code, _, _ := acc(t, filepath.Join(t.TempDir(), "tippfehler"), "dump-checkmk"); code != 1 {
|
||||
t.Errorf("Tippfehler: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// accReport legt einen Bericht samt Historie ab, wie es der Endpunkt tut.
|
||||
func accReport(t *testing.T, dir, payeeID string, at time.Time, rep accountant.Report) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
l.SetClock(func() time.Time { return at })
|
||||
if list, _ := l.ListPayees(context.Background()); !slices.ContainsFunc(list, func(p ledger.Payee) bool { return p.ID == payeeID }) {
|
||||
l.AddInvite(context.Background(), "tok-"+payeeID, payeeID, at.Add(time.Hour))
|
||||
if _, err := l.Enroll(context.Background(), "tok-"+payeeID, ledger.Payee{Hostname: payeeID, PublicKey: "k-" + payeeID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := accountant.StoreReport(context.Background(), l, payeeID, rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func cliReport(node string, routes ...accountant.ReportRoute) accountant.Report {
|
||||
return accountant.Report{
|
||||
GeneratedAt: time.Now(),
|
||||
Tenants: []accountant.ReportTenant{{ID: "kunde", Name: "Kunde A"}},
|
||||
Nodes: []accountant.ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true,
|
||||
OS: "Debian 13", DockerVersion: "29.8.2"}},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func cliRoute(node, router string, hosts ...string) accountant.ReportRoute {
|
||||
return accountant.ReportRoute{Tenant: "kunde", Node: node, Router: router, Protocol: "http", Hosts: hosts,
|
||||
Workload: "kunde-a-" + router + "-1", Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081",
|
||||
Active: true, ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"}
|
||||
}
|
||||
|
||||
// where: Störfall – wohin springen, seit wann, wo vorher?
|
||||
func TestAccountantWhere(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
|
||||
accReport(t, dir, "webproxy11", t1, cliReport("webdocker07", cliRoute("webdocker07", "wp", "kunde.de"),
|
||||
cliRoute("webdocker07", "olat", "olat.kunde.de")))
|
||||
t2 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local)
|
||||
accReport(t, dir, "webproxy11", t2, cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de")))
|
||||
|
||||
code, stdout, stderr := acc(t, dir, "where", "kunde.de")
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stderr=%s", code, stderr)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"kunde.de, www.kunde.de", "Router wp", "kunde (Kunde A)", "webproxy11", "webdocker11.lan.r25dc.de:8081",
|
||||
"kunde-a-wp-1", "/srv/kunde-a", "healthy", "Debian 13", "2026-10-01 10:00",
|
||||
"webdocker07", "2026-09-01 10:00", // vorher
|
||||
"olat.kunde.de", // verschwunden
|
||||
} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("fehlt %q in:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "where", "gibts-nicht.de"); code != 1 || !strings.Contains(stderr, "nichts gefunden") {
|
||||
t.Errorf("kein Treffer: code=%d %q", code, stderr)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "where"); code != 2 {
|
||||
t.Errorf("ohne Suchbegriff: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountantRoutes(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
down := cliRoute("webdocker11", "olat", "olat.kunde.de")
|
||||
down.Down = true
|
||||
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de"), down))
|
||||
code, stdout, _ := acc(t, dir, "routes")
|
||||
if code != 0 || !strings.Contains(stdout, "HOST") || !strings.Contains(stdout, "webdocker11.lan.r25dc.de:8081") {
|
||||
t.Fatalf("code=%d\n%s", code, stdout)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
||||
if len(lines) != 3 || !strings.HasPrefix(lines[1], "kunde.de") || !strings.Contains(lines[2], "gestoppt") {
|
||||
t.Errorf("Tabelle:\n%s", stdout)
|
||||
}
|
||||
if code, stdout, _ := acc(t, dir, "routes", "--tenant=andere"); code != 0 || strings.Contains(stdout, "kunde.de") {
|
||||
t.Errorf("--tenant: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// Kunden: anlegen, ändern (nur angegebene Felder), filtern, zuordnen.
|
||||
func TestAccountantCustomers(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
if code, _, stderr := acc(t, dir, "customer", "add", "--id=familie", "--name=Familie", "--group=familie", "--cycle=j"); code != 0 {
|
||||
t.Fatalf("add: code=%d %s", code, stderr)
|
||||
}
|
||||
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de")))
|
||||
|
||||
// Automatisch angelegt: kunde.
|
||||
code, stdout, _ := acc(t, dir, "customer", "list", "--unclassified")
|
||||
if code != 0 || !strings.Contains(stdout, "kunde") || strings.Contains(stdout, "familie") || !strings.Contains(stdout, "nicht eingeordnet") {
|
||||
t.Errorf("--unclassified: code=%d\n%s", code, stdout)
|
||||
}
|
||||
|
||||
if code, _, stderr := acc(t, dir, "customer", "set", "--id=kunde", "--cycle=quarterly", "--flat-rate=89,00",
|
||||
"--note=Kumpel – Nebenabrede Bier", "--group=extern"); code != 0 {
|
||||
t.Fatalf("set: code=%d %s", code, stderr)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--name=Kunde A GmbH"); code != 0 { // nur der Name
|
||||
t.Fatal("set --name")
|
||||
}
|
||||
code, stdout, _ = acc(t, dir, "customer", "list")
|
||||
for _, want := range []string{"Kunde A GmbH", "quartalsweise", "89,00", "extern", "Nebenabrede", "jährlich"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("fehlt %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if code, stdout, _ := acc(t, dir, "customer", "list", "--group=familie"); code != 0 || strings.Contains(stdout, "kunde ") || !strings.Contains(stdout, "Familie") {
|
||||
t.Errorf("--group:\n%s", stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--no-flat-rate"); code != 0 {
|
||||
t.Error("--no-flat-rate")
|
||||
}
|
||||
|
||||
// Tippfehler-Mandant auf Nebencluster zuordnen; where zeigt den Kunden.
|
||||
if code, _, stderr := acc(t, dir, "customer", "map", "--tenant=kunde-a", "--payee=webproxy12", "--customer=kunde"); code != 0 {
|
||||
t.Fatalf("map: %d %s", code, stderr)
|
||||
}
|
||||
code, stdout, _ = acc(t, dir, "customer", "list")
|
||||
if !strings.Contains(stdout, "webproxy12/kunde-a") {
|
||||
t.Errorf("Zuordnung nicht gelistet:\n%s", stdout)
|
||||
}
|
||||
if _, stdout, _ := acc(t, dir, "where", "kunde.de"); !strings.Contains(stdout, "Kunde A GmbH") || !strings.Contains(stdout, "quartalsweise") {
|
||||
t.Errorf("where ohne Kunde:\n%s", stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "unmap", "--tenant=kunde-a", "--payee=webproxy12"); code != 0 {
|
||||
t.Error("unmap")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"customer", "add", "--id=x1"}, // --name fehlt
|
||||
{"customer", "add", "--id=x1", "--name=X", "--cycle=w"}, // Zahlweise unbekannt
|
||||
{"customer", "set", "--id=kunde", "--flat-rate=abc"},
|
||||
{"customer", "set", "--id=kunde", "--flat-rate=1", "--no-flat-rate"},
|
||||
{"customer", "map", "--tenant=x"},
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code != 2 {
|
||||
t.Errorf("%v: code=%d, erwartet 2", args, code)
|
||||
}
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "delete", "--id=familie"); code != 0 {
|
||||
t.Error("delete")
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "set", "--id=familie", "--name=x"); code != 1 {
|
||||
t.Error("set nach delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Katalog: Klasse und Preis mit Gültig-ab, Kundenpreis.
|
||||
func TestAccountantProducts(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
mustAcc := func(args ...string) string {
|
||||
t.Helper()
|
||||
code, stdout, stderr := acc(t, dir, args...)
|
||||
if code != 0 {
|
||||
t.Fatalf("%v: code=%d %s", args, code, stderr)
|
||||
}
|
||||
return stdout
|
||||
}
|
||||
mustAcc("product", "add", "--id=pingvin", "--name=Pingvin Share", "--class=free")
|
||||
mustAcc("product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
|
||||
mustAcc("product", "set", "--id=pingvin", "--from=2027-03", "--class=billable", "--price=5,00")
|
||||
mustAcc("product", "set", "--id=olat", "--from=2027-01", "--price=52,00") // Klasse bleibt
|
||||
mustAcc("customer", "add", "--id=k-1001", "--name=Kunde A")
|
||||
mustAcc("product", "price", "--id=olat", "--customer=k-1001", "--price=39,00", "--from=2026-11")
|
||||
|
||||
out := mustAcc("product", "list")
|
||||
for _, want := range []string{"pingvin", "Pingvin Share", "ab 2027-03: billable 5,00", "olat", "49,00", "ab 2027-01: billable 52,00"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("fehlt %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"product", "add", "--id=x1", "--name=X", "--class=teuer"},
|
||||
{"product", "add", "--id=x1", "--name=X"}, // --class fehlt
|
||||
{"product", "set", "--id=olat", "--from=März"},
|
||||
{"product", "set", "--id=olat", "--from=2027-02", "--price=1", "--no-price"},
|
||||
{"product", "price", "--id=olat", "--customer=k-1001"}, // --price fehlt
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code != 2 && code != 1 {
|
||||
t.Errorf("%v: code=%d, erwartet Fehler", args, code)
|
||||
}
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "product", "set", "--id=gibts-nicht", "--from=2027-01", "--class=free"); code != 1 {
|
||||
t.Errorf("unbekanntes Produkt: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// assign: Produkt, Klasse, Beginn je Posten – bequem über den Host.
|
||||
func TestAccountantAssign(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable")
|
||||
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de"),
|
||||
cliRoute("webdocker11", "olat", "olat.kunde.de")))
|
||||
if code, _, stderr := acc(t, dir, "assign", "--host=olat.kunde.de", "--product=olat", "--start=2026-11"); code != 0 {
|
||||
t.Fatalf("assign: %d %s", code, stderr)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "assign", "--tenant=kunde", "--router=wp", "--class=internal"); code != 0 {
|
||||
t.Fatalf("assign --router: %d %s", code, stderr)
|
||||
}
|
||||
code, stdout, _ := acc(t, dir, "assignments")
|
||||
if code != 0 || !strings.Contains(stdout, "olat.kunde.de") || !strings.Contains(stdout, "2026-11") || !strings.Contains(stdout, "internal") {
|
||||
t.Errorf("assignments:\n%s", stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "assign", "--host=www.kunde.de", "--clear"); code != 0 {
|
||||
t.Error("--clear")
|
||||
}
|
||||
if _, stdout, _ := acc(t, dir, "assignments"); strings.Contains(stdout, "internal") {
|
||||
t.Errorf("nach --clear:\n%s", stdout)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"assign", "--host=gibts-nicht.de", "--class=free"},
|
||||
{"assign", "--host=kunde.de", "--product=gibts-nicht"},
|
||||
{"assign", "--host=kunde.de"}, // nichts zugeordnet
|
||||
{"assign", "--class=free"}, // weder --host noch --tenant/--router
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code == 0 {
|
||||
t.Errorf("%v akzeptiert", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// billing: Monats-CSV auf stdout bzw. in eine Datei.
|
||||
func TestAccountantBilling(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
|
||||
r := cliRoute("webdocker11", "olat", "olat.kunde.de")
|
||||
r.Product = "olat"
|
||||
accReport(t, dir, "webproxy11", time.Date(2026, 10, 17, 10, 0, 0, 0, time.Local), cliReport("webdocker11", r))
|
||||
acc(t, dir, "customer", "set", "--id=kunde", "--cycle=q", "--flat-rate=89,00")
|
||||
|
||||
code, stdout, stderr := acc(t, dir, "billing", "--month=2026-10")
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d %s", code, stderr)
|
||||
}
|
||||
for _, want := range []string{"Monat;Kunde", "2026-10;kunde;Kunde A;;quartalsweise;Pauschale", "89,00;267,00",
|
||||
"olat.kunde.de", "billable;olat", "49,00;147,00"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("fehlt %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "2026-10.csv")
|
||||
if code, _, _ := acc(t, dir, "billing", "--month=2026-10", "--out="+out); code != 0 {
|
||||
t.Fatal("--out")
|
||||
}
|
||||
if data, _ := os.ReadFile(out); !strings.Contains(string(data), "olat.kunde.de") {
|
||||
t.Errorf("Datei: %s", data)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "billing"); code != 2 {
|
||||
t.Errorf("ohne --month: code=%d", code)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "billing", "--month=Oktober"); code != 2 {
|
||||
t.Errorf("ungültiger Monat: code=%d", code)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func parseAgentFlags(args []string, output io.Writer, getenv func(string) string
|
||||
fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)")
|
||||
fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)")
|
||||
fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`)
|
||||
fs.StringVar(&cfg.FQDN, "fqdn", "", "vollständiger Name dieses Hosts fürs Inventar (Default: automatisch über DNS)")
|
||||
annotateEnv(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return agent.Config{}, err
|
||||
|
||||
@@ -20,14 +20,14 @@ func TestAgentFlags(t *testing.T) {
|
||||
cfg, err = parseAgentFlags([]string{
|
||||
"--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul",
|
||||
"--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0",
|
||||
"--config-dir=/srv/sultan.d",
|
||||
"--config-dir=/srv/sultan.d", "--fqdn=webdocker11.lan.r25dc.de",
|
||||
}, &bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" ||
|
||||
cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" ||
|
||||
cfg.ConfigDir != "/srv/sultan.d" {
|
||||
cfg.ConfigDir != "/srv/sultan.d" || cfg.FQDN != "webdocker11.lan.r25dc.de" {
|
||||
t.Errorf("Overrides: %+v", cfg)
|
||||
}
|
||||
if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider
|
||||
// sultan agent Agent: Enrollment, Stream, Docker-Watcher
|
||||
// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten
|
||||
// sultan accountant Zentrale für Inventar und Abrechnung
|
||||
// sultan version
|
||||
//
|
||||
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
|
||||
@@ -31,6 +32,7 @@ Befehle:
|
||||
sultan server Server starten (Registry, Agent-Port, Traefik-Provider)
|
||||
sultan agent Agent starten (Docker-Host oder Bare-Metal)
|
||||
sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten
|
||||
sultan accountant Zentrale für Inventar und Abrechnung starten bzw. verwalten
|
||||
sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben
|
||||
sultan version Version ausgeben
|
||||
sultan help diese Hilfe
|
||||
@@ -58,6 +60,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return runToken(ctx, args[1:], stdout, stderr)
|
||||
case "agent":
|
||||
return runAgent(ctx, args[1:], stderr)
|
||||
case "accountant":
|
||||
return runAccountant(ctx, args[1:], stdout, stderr)
|
||||
case "dump-landing":
|
||||
return runDump(args[1:], stdout, stderr)
|
||||
default:
|
||||
@@ -76,6 +80,7 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int
|
||||
return 2
|
||||
}
|
||||
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
|
||||
cfg.Version = Version
|
||||
s, err := server.New(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
|
||||
@@ -111,6 +116,8 @@ func parseServerFlags(args []string, output io.Writer, getenv func(string) strin
|
||||
fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)")
|
||||
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
|
||||
fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)")
|
||||
fs.StringVar(&cfg.AccountantToken, "accountant-token", "", "Einladung von `sultan accountant invite` (nur beim ersten Start; danach accountant.json)")
|
||||
fs.StringVar(&cfg.AccountantURL, "accountant", "", "Accountant-Adresse, z. B. https://accountant.example.de:8444 (Override; sonst aus der Einladung)")
|
||||
annotateEnv(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return server.Config{}, err
|
||||
|
||||
@@ -236,3 +236,23 @@ func TestServerFlags_EmptyPublicURLIgnored(t *testing.T) {
|
||||
t.Errorf("leere Werte übernommen: %q %q", cfg.PublicURLs, cfg.SlackWebhooks)
|
||||
}
|
||||
}
|
||||
|
||||
// Accountant-Anbindung (Spec 5.3): Einladung und Adresse, auch per Umgebung.
|
||||
func TestServerFlags_Accountant(t *testing.T) {
|
||||
cfg, err := parseServerFlags([]string{"--accountant=https://acc.example:8444", "--accountant-token=sltn_enroll_x.y"},
|
||||
&bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AccountantURL != "https://acc.example:8444" || cfg.AccountantToken != "sltn_enroll_x.y" {
|
||||
t.Errorf("Flags: %+v", cfg)
|
||||
}
|
||||
env := envMap(map[string]string{"SULTAN_ACCOUNTANT_TOKEN": "sltn_enroll_env", "SULTAN_ACCOUNTANT": "https://env:8444"})
|
||||
cfg, err = parseServerFlags(nil, &bytes.Buffer{}, env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AccountantURL != "https://env:8444" || cfg.AccountantToken != "sltn_enroll_env" {
|
||||
t.Errorf("Umgebung: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// parseCycle versteht m/q/j bzw. monthly/quarterly/yearly (und deutsche
|
||||
// Varianten); "offen" bzw. "none" = noch nicht eingeordnet.
|
||||
func parseCycle(s string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "m", "monthly", "monatlich":
|
||||
return ledger.CycleMonthly, nil
|
||||
case "q", "quarterly", "quartal", "quartalsweise":
|
||||
return ledger.CycleQuarterly, nil
|
||||
case "j", "y", "yearly", "jährlich", "jaehrlich":
|
||||
return ledger.CycleYearly, nil
|
||||
case "offen", "none", "-":
|
||||
return ledger.CycleUnclassified, nil
|
||||
}
|
||||
return "", fmt.Errorf("Zahlweise %q unbekannt (m, q, j oder offen)", s)
|
||||
}
|
||||
|
||||
func cycleLabel(c string) string {
|
||||
switch c {
|
||||
case ledger.CycleMonthly:
|
||||
return "monatlich"
|
||||
case ledger.CycleQuarterly:
|
||||
return "quartalsweise"
|
||||
case ledger.CycleYearly:
|
||||
return "jährlich"
|
||||
}
|
||||
return "nicht eingeordnet"
|
||||
}
|
||||
|
||||
// customerFields sind die Felder, die add und set gemeinsam haben.
|
||||
type customerFields struct {
|
||||
name, group, note, cycle, flat *string
|
||||
noFlat *bool
|
||||
}
|
||||
|
||||
func addCustomerFlags(fs *flag.FlagSet) customerFields {
|
||||
return customerFields{
|
||||
name: fs.String("name", "", "Name"),
|
||||
group: fs.String("group", "", "Kostenverursacher-Gruppe zum Clustern (z. B. extern, intern, familie)"),
|
||||
note: fs.String("note", "", "Notiz, z. B. persönliche Nebenabreden"),
|
||||
cycle: fs.String("cycle", "", "Zahlweise: m (monatlich), q (quartalsweise), j (jährlich), offen"),
|
||||
flat: fs.String("flat-rate", "", "Pauschale je Monat in Euro, z. B. 89,00"),
|
||||
noFlat: fs.Bool("no-flat-rate", false, "keine Pauschale"),
|
||||
}
|
||||
}
|
||||
|
||||
// apply übernimmt die auf der Befehlszeile gesetzten Felder in c.
|
||||
func (f customerFields) apply(fs *flag.FlagSet, c *ledger.Customer) error {
|
||||
set := map[string]bool{}
|
||||
fs.Visit(func(fl *flag.Flag) { set[fl.Name] = true })
|
||||
if set["flat-rate"] && *f.noFlat {
|
||||
return usageError{errors.New("--flat-rate und --no-flat-rate schließen sich aus")}
|
||||
}
|
||||
if set["name"] {
|
||||
c.Name = *f.name
|
||||
}
|
||||
if set["group"] {
|
||||
c.Group = *f.group
|
||||
}
|
||||
if set["note"] {
|
||||
c.Note = *f.note
|
||||
}
|
||||
if set["cycle"] {
|
||||
cycle, err := parseCycle(*f.cycle)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
c.Cycle = cycle
|
||||
}
|
||||
if set["flat-rate"] {
|
||||
cents, err := accountant.ParseMoney(*f.flat)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
c.FlatRate = ¢s
|
||||
}
|
||||
if *f.noFlat {
|
||||
c.FlatRate = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerAdd(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer add", env)
|
||||
id := fs.String("id", "", "Kunden-ID (a–z, 0–9, - und _), z. B. Kundennummer")
|
||||
fields := addCustomerFlags(fs)
|
||||
if err := parse(fs, args, "id", "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
c := ledger.Customer{ID: *id}
|
||||
if err := fields.apply(fs, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.AddCustomer(env.ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kunde %s angelegt\n", c.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerSet(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer set", env)
|
||||
id := fs.String("id", "", "Kunden-ID")
|
||||
fields := addCustomerFlags(fs)
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
c, err := l.GetCustomer(env.ctx, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fields.apply(fs, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.UpdateCustomer(env.ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kunde %s geändert\n", c.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerList(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer list", env)
|
||||
group := fs.String("group", "", "nur Kunden dieser Gruppe")
|
||||
unclassified := fs.Bool("unclassified", false, "nur Kunden ohne Zahlweise (Feedback der Buchhaltung fehlt)")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
customers, err := l.ListCustomers(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maps, err := l.ListMappings(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tenants := map[string][]string{}
|
||||
for _, c := range customers {
|
||||
tenants[c.ID] = []string{c.ID} // Mandant mit gleicher ID (sofern nicht anders zugeordnet)
|
||||
}
|
||||
for _, m := range maps {
|
||||
name := m.Tenant
|
||||
if m.Payee != "" {
|
||||
name = m.Payee + "/" + m.Tenant
|
||||
}
|
||||
if !slices.Contains(tenants[m.Customer], name) {
|
||||
tenants[m.Customer] = append(tenants[m.Customer], name)
|
||||
}
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tGROUP\tCYCLE\tFLAT_RATE\tTENANTS\tNOTE")
|
||||
for _, c := range customers {
|
||||
if (*group != "" && c.Group != *group) || (*unclassified && c.Cycle != ledger.CycleUnclassified) {
|
||||
continue
|
||||
}
|
||||
flat := "-"
|
||||
if c.FlatRate != nil {
|
||||
flat = accountant.FormatMoney(*c.FlatRate)
|
||||
}
|
||||
name := c.Name
|
||||
if c.Auto {
|
||||
name += " (automatisch)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", c.ID, name, cmpOrDash(c.Group), cycleLabel(c.Cycle), flat,
|
||||
strings.Join(tenants[c.ID], ","), c.Note)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func cmpOrDash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func customerDelete(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer delete", env)
|
||||
id := fs.String("id", "", "Kunden-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.DeleteCustomer(env.ctx, *id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kunde %s samt Zuordnungen gelöscht\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerMap(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer map", env)
|
||||
tenant := fs.String("tenant", "", "Mandanten-ID, wie sie der Sultan-Server meldet")
|
||||
payee := fs.String("payee", "", "nur auf diesem Payee (Default: auf allen)")
|
||||
customer := fs.String("customer", "", "Kunden-ID")
|
||||
if err := parse(fs, args, "tenant", "customer"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.MapTenant(env.ctx, *payee, *tenant, *customer); err != nil {
|
||||
return err
|
||||
}
|
||||
where := "allen Payees"
|
||||
if *payee != "" {
|
||||
where = "Payee " + *payee
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Mandant %s auf %s → Kunde %s\n", *tenant, where, *customer)
|
||||
if c, err := l.GetCustomer(env.ctx, *tenant); err == nil && c.Auto && c.ID != *customer {
|
||||
fmt.Fprintf(env.stderr, "Hinweis: Kunde %s wurde automatisch angelegt und ist ggf. überflüssig: sultan accountant customer delete --id=%s\n", c.ID, c.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerUnmap(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer unmap", env)
|
||||
tenant := fs.String("tenant", "", "Mandanten-ID")
|
||||
payee := fs.String("payee", "", "Payee der Zuordnung (leer = Zuordnung für alle Payees)")
|
||||
if err := parse(fs, args, "tenant"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.UnmapTenant(env.ctx, *payee, *tenant); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Zuordnung von %s entfernt\n", *tenant)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
func classHelp() string {
|
||||
return "Klasse: included (in der Pauschale), billable (Zusatz-Service), free (Give-away), internal"
|
||||
}
|
||||
|
||||
func parseOptionalPrice(raw string, set bool) (*int64, error) {
|
||||
if !set {
|
||||
return nil, nil
|
||||
}
|
||||
cents, err := accountant.ParseMoney(raw)
|
||||
if err != nil {
|
||||
return nil, usageError{err}
|
||||
}
|
||||
return ¢s, nil
|
||||
}
|
||||
|
||||
func productAdd(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product add", env)
|
||||
id := fs.String("id", "", "Produkt-ID (a–z, 0–9, - und _), z. B. olat")
|
||||
name := fs.String("name", "", "Name")
|
||||
class := fs.String("class", "", classHelp())
|
||||
price := fs.String("price", "", "Preis je Monat in Euro (optional, nicht buchhaltungsrelevant), z. B. 49,00")
|
||||
if err := parse(fs, args, "id", "name", "class"); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := parseOptionalPrice(*price, *price != "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.AddProduct(env.ctx, ledger.Product{ID: *id, Name: *name, Class: *class, Price: p}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Produkt %s angelegt\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// productSet: neue Version ab einem Monat; ohne --class bleibt die Klasse,
|
||||
// ohne --price/--no-price der Preis der bis dahin gültigen Version.
|
||||
func productSet(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product set", env)
|
||||
id := fs.String("id", "", "Produkt-ID")
|
||||
from := fs.String("from", "", "gültig ab Monat JJJJ-MM (z. B. 2027-03)")
|
||||
class := fs.String("class", "", classHelp()+" (Default: unverändert)")
|
||||
price := fs.String("price", "", "Preis je Monat in Euro (Default: unverändert)")
|
||||
noPrice := fs.Bool("no-price", false, "ab dann ohne Preis")
|
||||
if err := parse(fs, args, "id", "from"); err != nil {
|
||||
return err
|
||||
}
|
||||
if *price != "" && *noPrice {
|
||||
return usageError{errors.New("--price und --no-price schließen sich aus")}
|
||||
}
|
||||
if !ledger.ValidMonth(*from) {
|
||||
return usageError{fmt.Errorf("--from %q ungültig (JJJJ-MM)", *from)}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
cat, err := l.Catalog(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, ok := cat.At(*id, "", *from)
|
||||
if !ok {
|
||||
return fmt.Errorf("Produkt %s nicht gefunden", *id)
|
||||
}
|
||||
if *class != "" {
|
||||
cur.Class = *class
|
||||
}
|
||||
switch {
|
||||
case *noPrice:
|
||||
cur.Price = nil
|
||||
case *price != "":
|
||||
if cur.Price, err = parseOptionalPrice(*price, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := l.SetProductVersion(env.ctx, *id, *from, cur.Class, cur.Price); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Produkt %s ab %s: %s %s\n", *id, *from, cur.Class, priceText(cur.Price))
|
||||
return nil
|
||||
}
|
||||
|
||||
func priceText(p *int64) string {
|
||||
if p == nil {
|
||||
return "ohne Preis"
|
||||
}
|
||||
return accountant.FormatMoney(*p)
|
||||
}
|
||||
|
||||
func productPrice(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product price", env)
|
||||
id := fs.String("id", "", "Produkt-ID")
|
||||
customer := fs.String("customer", "", "Kunden-ID")
|
||||
price := fs.String("price", "", "Kundenpreis je Monat in Euro, z. B. 39,00")
|
||||
from := fs.String("from", time.Now().Format("2006-01"), "gültig ab Monat JJJJ-MM")
|
||||
if err := parse(fs, args, "id", "customer", "price"); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := parseOptionalPrice(*price, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.SetCustomerPrice(env.ctx, *id, *customer, *from, *p); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kundenpreis %s für %s ab %s: %s\n", *id, *customer, *from, accountant.FormatMoney(*p))
|
||||
return nil
|
||||
}
|
||||
|
||||
func productList(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product list", env)
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
products, err := l.ListProducts(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tCLASS\tPRICE\tVERSIONS")
|
||||
for _, p := range products {
|
||||
var versions []string
|
||||
for _, v := range p.Versions[1:] {
|
||||
versions = append(versions, fmt.Sprintf("ab %s: %s %s", v.From, v.Class, priceText(v.Price)))
|
||||
}
|
||||
price := "-"
|
||||
if p.Price != nil {
|
||||
price = accountant.FormatMoney(*p.Price)
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Name, p.Class, price, strings.Join(versions, "; "))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func productDelete(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product delete", env)
|
||||
id := fs.String("id", "", "Produkt-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.DeleteProduct(env.ctx, *id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Produkt %s gelöscht\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// assign: Zuordnung eines Postens, gefunden über --host oder direkt über
|
||||
// --tenant/--router.
|
||||
func assign(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("assign", env)
|
||||
host := fs.String("host", "", "Host einer Route, z. B. olat.kunde.de")
|
||||
tenant := fs.String("tenant", "", "alternativ: Mandant …")
|
||||
router := fs.String("router", "", "… und Router")
|
||||
protocol := fs.String("protocol", "http", "Protokoll bei --tenant/--router (http oder tcp)")
|
||||
product := fs.String("product", "", "Produkt aus dem Katalog (Vorrang vor dem Label sultan.accountant.billing)")
|
||||
class := fs.String("class", "", classHelp()+" (Vorrang vor dem Produkt)")
|
||||
start := fs.String("start", "", "Abrechnungsbeginn JJJJ-MM (Default: Monat des ersten Auftauchens)")
|
||||
clear := fs.Bool("clear", false, "Zuordnung entfernen")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if (*host == "") == (*tenant == "" || *router == "") {
|
||||
return usageError{errors.New("entweder --host oder --tenant mit --router angeben")}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
key := accountant.ItemKey{Tenant: *tenant, Protocol: *protocol, Router: *router}
|
||||
if *host != "" {
|
||||
keys, err := accountant.ItemsForHost(env.ctx, l, *host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch len(keys) {
|
||||
case 0:
|
||||
return fmt.Errorf("keine Route mit Host %s bekannt", *host)
|
||||
case 1:
|
||||
key = keys[0]
|
||||
default:
|
||||
var names []string
|
||||
for _, k := range keys {
|
||||
names = append(names, k.Tenant+"/"+k.Protocol+"/"+k.Router)
|
||||
}
|
||||
return fmt.Errorf("Host %s gehört zu mehreren Posten (%s) – --tenant und --router angeben", *host, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if *clear {
|
||||
if err := l.DeleteAssignment(env.ctx, key.Tenant, key.Protocol, key.Router); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Zuordnung von %s/%s/%s entfernt\n", key.Tenant, key.Protocol, key.Router)
|
||||
return nil
|
||||
}
|
||||
a := ledger.Assignment{Tenant: key.Tenant, Protocol: key.Protocol, Router: key.Router, Product: *product, Class: *class, Start: *start}
|
||||
if err := l.SetAssignment(env.ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Posten %s/%s/%s zugeordnet\n", key.Tenant, key.Protocol, key.Router)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assignments(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("assignments", env)
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
list, err := l.ListAssignments(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "TENANT\tROUTER\tHOSTS\tPRODUCT\tCLASS\tSTART")
|
||||
for _, a := range list {
|
||||
var hosts []string
|
||||
spans, _ := l.RouteHistory(env.ctx, ledger.HistoryFilter{Tenant: a.Tenant})
|
||||
for _, s := range spans {
|
||||
if s.Protocol == a.Protocol && s.Router == a.Router {
|
||||
for _, h := range s.Hosts {
|
||||
if !slices.Contains(hosts, h) {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s/%s\t%s\t%s\t%s\t%s\n", a.Tenant, a.Protocol, a.Router, cmpOrDash(strings.Join(hosts, ",")),
|
||||
cmpOrDash(a.Product), cmpOrDash(a.Class), cmpOrDash(a.Start))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func billing(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("billing", env)
|
||||
month := fs.String("month", "", "Abrechnungsmonat JJJJ-MM")
|
||||
out := fs.String("out", "", "CSV in diese Datei schreiben (Default: stdout)")
|
||||
if err := parse(fs, args, "month"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ledger.ValidMonth(*month) {
|
||||
return usageError{fmt.Errorf("--month %q ungültig (JJJJ-MM)", *month)}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
lines, err := accountant.Billing(env.ctx, l, *month)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := env.stdout
|
||||
if *out != "" {
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
if err := accountant.WriteBillCSV(w, lines); err != nil {
|
||||
return err
|
||||
}
|
||||
var open int
|
||||
for _, ln := range lines {
|
||||
if (ln.Class == "" && ln.Item != accountant.ItemFlatRate) || ln.Customer == "" {
|
||||
open++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(env.stderr, "%d Zeilen für %s", len(lines), *month)
|
||||
if open > 0 {
|
||||
fmt.Fprintf(env.stderr, ", davon %d ohne Zuordnung bzw. Kunde (Spalte Hinweis)", open)
|
||||
}
|
||||
fmt.Fprintln(env.stderr)
|
||||
return nil
|
||||
}
|
||||
+71
-13
@@ -1,7 +1,8 @@
|
||||
// Package docker ist ein minimaler Client für die Docker Engine API über
|
||||
// den Unix-Socket – genau das, was der Agent braucht: Container mit Labels
|
||||
// und Ports auflisten, Portzuordnungen gestoppter Container lesen und
|
||||
// Container-Events verfolgen. Bewusst
|
||||
// und Ports auflisten, Details (Portzuordnungen auch gestoppter Container,
|
||||
// Startzeit, Health) lesen, Host-Infos abfragen und Container-Events
|
||||
// verfolgen. Bewusst
|
||||
// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte).
|
||||
package docker
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Container ist ein Eintrag aus GET /containers/json.
|
||||
@@ -27,6 +29,10 @@ type Container struct {
|
||||
State string `json:"State"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Ports []Port `json:"Ports"`
|
||||
|
||||
// Aus Inspect ergänzt (nicht Teil von /containers/json).
|
||||
StartedAt time.Time `json:"-"`
|
||||
Health string `json:"-"`
|
||||
}
|
||||
|
||||
// Name liefert den Containernamen ohne führenden Schrägstrich.
|
||||
@@ -106,19 +112,36 @@ func (c *Client) List(ctx context.Context) ([]Container, error) {
|
||||
|
||||
var validID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
|
||||
// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
|
||||
// (HostConfig.PortBindings) – auch wenn er gestoppt ist. Ohne festen
|
||||
// Host-Port ist PublicPort 0. Sortiert nach "<port>/<proto>".
|
||||
func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
// Details sind die Angaben aus GET /containers/{id}/json, die der Agent
|
||||
// braucht.
|
||||
type Details struct {
|
||||
// Ports: konfigurierte Zuordnungen (HostConfig.PortBindings) – auch bei
|
||||
// gestoppten Containern. Ohne festen Host-Port ist PublicPort 0.
|
||||
// Sortiert nach "<port>/<proto>".
|
||||
Ports []Port
|
||||
// StartedAt: letzter Start; Nullzeit, wenn nie gestartet.
|
||||
StartedAt time.Time
|
||||
// Health: healthy, unhealthy, starting; leer ohne Healthcheck.
|
||||
Health string
|
||||
}
|
||||
|
||||
// Inspect liefert die Details eines Containers.
|
||||
func (c *Client) Inspect(ctx context.Context, id string) (Details, error) {
|
||||
if !validID.MatchString(id) {
|
||||
return nil, fmt.Errorf("docker: ungültige Container-ID %q", id)
|
||||
return Details{}, fmt.Errorf("docker: ungültige Container-ID %q", id)
|
||||
}
|
||||
resp, err := c.get(ctx, "/containers/"+id+"/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Details{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var info struct {
|
||||
State struct {
|
||||
StartedAt time.Time `json:"StartedAt"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
} `json:"State"`
|
||||
HostConfig struct {
|
||||
PortBindings map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
@@ -127,14 +150,20 @@ func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("docker: Container %s: %w", id, err)
|
||||
return Details{}, fmt.Errorf("docker: Container %s: %w", id, err)
|
||||
}
|
||||
var d Details
|
||||
if !info.State.StartedAt.IsZero() {
|
||||
d.StartedAt = info.State.StartedAt.UTC()
|
||||
}
|
||||
if info.State.Health != nil {
|
||||
d.Health = info.State.Health.Status
|
||||
}
|
||||
keys := make([]string, 0, len(info.HostConfig.PortBindings))
|
||||
for k := range info.HostConfig.PortBindings {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var out []Port
|
||||
for _, k := range keys {
|
||||
port, proto, _ := strings.Cut(k, "/")
|
||||
private, err := strconv.ParseUint(port, 10, 16)
|
||||
@@ -146,15 +175,44 @@ func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
}
|
||||
bindings := info.HostConfig.PortBindings[k]
|
||||
if len(bindings) == 0 {
|
||||
out = append(out, Port{PrivatePort: uint16(private), Type: proto})
|
||||
d.Ports = append(d.Ports, Port{PrivatePort: uint16(private), Type: proto})
|
||||
continue
|
||||
}
|
||||
for _, b := range bindings {
|
||||
public, _ := strconv.ParseUint(b.HostPort, 10, 16) // "" → 0
|
||||
out = append(out, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
|
||||
d.Ports = append(d.Ports, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
|
||||
// (siehe Details.Ports) – auch wenn er gestoppt ist.
|
||||
func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
d, err := c.Inspect(ctx, id)
|
||||
return d.Ports, err
|
||||
}
|
||||
|
||||
// Info sind die Host-Angaben aus GET /info. OperatingSystem ist das des
|
||||
// Hosts – auch wenn der Agent selbst in einem Container läuft.
|
||||
type Info struct {
|
||||
Name string `json:"Name"`
|
||||
OperatingSystem string `json:"OperatingSystem"`
|
||||
ServerVersion string `json:"ServerVersion"`
|
||||
}
|
||||
|
||||
// Info fragt die Host-Angaben ab.
|
||||
func (c *Client) Info(ctx context.Context) (Info, error) {
|
||||
resp, err := c.get(ctx, "/info")
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var info Info
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return Info{}, fmt.Errorf("docker: Info: %w", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht.
|
||||
|
||||
@@ -38,6 +38,7 @@ func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Req
|
||||
handler(w, r)
|
||||
})
|
||||
mux.HandleFunc("/containers/{id}/json", handler)
|
||||
mux.HandleFunc("/info", handler)
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastQ <- r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -199,3 +200,63 @@ func TestEvents_StreamEnds(t *testing.T) {
|
||||
t.Error("erwartet Fehler bei beendetem Stream")
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect liefert Ports (auch gestoppter Container), Startzeit und
|
||||
// Health-Status in einem Aufruf.
|
||||
func TestInspect(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.PathValue("id") {
|
||||
case "abc":
|
||||
w.Write([]byte(`{"Id":"abc","State":{"Status":"running","StartedAt":"2026-10-16T08:15:30.123456789Z",
|
||||
"Health":{"Status":"healthy"}},
|
||||
"HostConfig":{"PortBindings":{"80/tcp":[{"HostIp":"","HostPort":"9080"}]}}}`))
|
||||
case "nie":
|
||||
// Nie gestartet, kein Healthcheck: Docker liefert die Nullzeit.
|
||||
w.Write([]byte(`{"Id":"nie","State":{"Status":"created","StartedAt":"0001-01-01T00:00:00Z"},"HostConfig":{}}`))
|
||||
default:
|
||||
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
c := &Client{Socket: f.path}
|
||||
got, err := c.Inspect(context.Background(), "abc")
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect: %v", err)
|
||||
}
|
||||
want := Details{
|
||||
Ports: []Port{{PrivatePort: 80, PublicPort: 9080, Type: "tcp"}},
|
||||
StartedAt: time.Date(2026, 10, 16, 8, 15, 30, 123456789, time.UTC),
|
||||
Health: "healthy",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Inspect =\n%+v\nerwartet\n%+v", got, want)
|
||||
}
|
||||
got, err = c.Inspect(context.Background(), "nie")
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect: %v", err)
|
||||
}
|
||||
if !got.StartedAt.IsZero() || got.Health != "" || len(got.Ports) != 0 {
|
||||
t.Errorf("nie gestartet: %+v", got)
|
||||
}
|
||||
if _, err := c.Inspect(context.Background(), "weg"); err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("unbekannter Container: %v", err)
|
||||
}
|
||||
if _, err := c.Inspect(context.Background(), "../x"); err == nil {
|
||||
t.Error("ungültige ID akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
// Info liefert das Betriebssystem des Hosts (nicht des Agent-Containers) und
|
||||
// die Docker-Version.
|
||||
func TestInfo(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2","NCPU":4}`))
|
||||
})
|
||||
got, err := (&Client{Socket: f.path}).Info(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Info: %v", err)
|
||||
}
|
||||
want := Info{Name: "webdocker11", OperatingSystem: "Debian GNU/Linux 13 (trixie)", ServerVersion: "29.8.2"}
|
||||
if got != want {
|
||||
t.Errorf("Info = %+v, erwartet %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Zahlweisen eines Kunden (Spec 5.3).
|
||||
const (
|
||||
CycleUnclassified = "" // Feedback der Buchhaltung fehlt noch
|
||||
CycleMonthly = "monthly" // monatlich
|
||||
CycleQuarterly = "quarterly" // quartalsweise
|
||||
CycleYearly = "yearly" // jährlich
|
||||
)
|
||||
|
||||
// Customer ist ein Kunde des Hosters; Mandanten der Payees werden ihm
|
||||
// zugeordnet.
|
||||
type Customer struct {
|
||||
ID string
|
||||
Name string
|
||||
Group string // Kostenverursacher-Gruppe zum Clustern (extern, intern, familie …)
|
||||
Note string // persönliche Nebenabreden
|
||||
Cycle string // Cycle*
|
||||
// FlatRate: Pauschale je Monat in Cent; nil = keine Pauschale.
|
||||
FlatRate *int64
|
||||
// Auto: automatisch aus einer Mandanten-ID angelegt und noch nie von Hand
|
||||
// bearbeitet (Name folgt dann dem Mandanten).
|
||||
Auto bool
|
||||
}
|
||||
|
||||
// TenantRef ist ein Mandant aus einem Bericht.
|
||||
type TenantRef struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Mapping ordnet einen Mandanten (auf einem Payee oder, Payee leer, auf
|
||||
// allen) einem Kunden zu.
|
||||
type Mapping struct {
|
||||
Payee string
|
||||
Tenant string
|
||||
Customer string
|
||||
}
|
||||
|
||||
func validCycle(c string) bool {
|
||||
switch c {
|
||||
case CycleUnclassified, CycleMonthly, CycleQuarterly, CycleYearly:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Customer) validate() error {
|
||||
c.Name, c.Group, c.Note = strings.TrimSpace(c.Name), strings.TrimSpace(c.Group), strings.TrimSpace(c.Note)
|
||||
switch {
|
||||
case !policy.ValidID(c.ID):
|
||||
return invalid("Kunden-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", c.ID)
|
||||
case c.Name == "":
|
||||
return invalid("Name fehlt")
|
||||
case !validCycle(c.Cycle):
|
||||
return invalid("Zahlweise %q unbekannt", c.Cycle)
|
||||
case c.FlatRate != nil && *c.FlatRate < 0:
|
||||
return invalid("Pauschale negativ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const customerColumns = `id, name, grp, note, cycle, flat_rate, auto`
|
||||
|
||||
func scanCustomer(row interface{ Scan(...any) error }) (Customer, error) {
|
||||
var c Customer
|
||||
var flat sql.NullInt64
|
||||
err := row.Scan(&c.ID, &c.Name, &c.Group, &c.Note, &c.Cycle, &flat, &c.Auto)
|
||||
if flat.Valid {
|
||||
c.FlatRate = &flat.Int64
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func flatArg(f *int64) any {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
// AddCustomer legt einen Kunden von Hand an.
|
||||
func (l *Ledger) AddCustomer(ctx context.Context, c Customer) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM customers WHERE id = ?`, c.ID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (`+customerColumns+`) VALUES (?, ?, ?, ?, ?, ?, 0)`,
|
||||
c.ID, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateCustomer übernimmt alle Felder; danach gilt der Kunde als von Hand
|
||||
// gepflegt (Auto = false).
|
||||
func (l *Ledger) UpdateCustomer(ctx context.Context, c Customer) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := l.db.ExecContext(ctx, `UPDATE customers SET name = ?, grp = ?, note = ?, cycle = ?, flat_rate = ?, auto = 0
|
||||
WHERE id = ?`, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate), c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCustomer liefert einen Kunden.
|
||||
func (l *Ledger) GetCustomer(ctx context.Context, id string) (Customer, error) {
|
||||
c, err := scanCustomer(l.db.QueryRowContext(ctx, `SELECT `+customerColumns+` FROM customers WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Customer{}, fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// ListCustomers liefert alle Kunden, sortiert nach ID.
|
||||
func (l *Ledger) ListCustomers(ctx context.Context) ([]Customer, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT `+customerColumns+` FROM customers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Customer
|
||||
for rows.Next() {
|
||||
c, err := scanCustomer(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteCustomer löscht einen Kunden samt seinen Zuordnungen.
|
||||
func (l *Ledger) DeleteCustomer(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM customers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureCustomers legt für Mandanten eines Berichts, die keinem Kunden
|
||||
// zugeordnet sind, automatisch einen Kunden mit der Mandanten-ID an.
|
||||
// Automatisch angelegte Kunden übernehmen Namensänderungen des Mandanten;
|
||||
// von Hand gepflegte bleiben unberührt. Ungültige IDs werden übersprungen.
|
||||
func (l *Ledger) EnsureCustomers(ctx context.Context, payeeID string, tenants []TenantRef) error {
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, t := range tenants {
|
||||
if !policy.ValidID(t.ID) {
|
||||
continue
|
||||
}
|
||||
mapped, err := rowExists(ctx, tx, `SELECT 1 FROM customer_map WHERE tenant = ? AND (payee_id = ? OR payee_id = '')`, t.ID, payeeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mapped {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(t.Name)
|
||||
if name == "" {
|
||||
name = t.ID
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (id, name, grp, note, cycle, flat_rate, auto)
|
||||
VALUES (?, ?, '', '', '', NULL, 1)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name WHERE customers.auto = 1`, t.ID, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MapTenant ordnet den Mandanten tenant (auf payee; "" = auf allen Payees)
|
||||
// dem Kunden customer zu; eine bestehende Zuordnung wird ersetzt.
|
||||
func (l *Ledger) MapTenant(ctx context.Context, payee, tenant, customer string) error {
|
||||
if !policy.ValidID(tenant) {
|
||||
return invalid("Mandanten-ID %q ungültig", tenant)
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, customer); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_map (payee_id, tenant, customer_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(payee_id, tenant) DO UPDATE SET customer_id = excluded.customer_id`, payee, tenant, customer)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnmapTenant entfernt eine Zuordnung.
|
||||
func (l *Ledger) UnmapTenant(ctx context.Context, payee, tenant string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM customer_map WHERE payee_id = ? AND tenant = ?`, payee, tenant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Zuordnung %s/%s: %w", payee, tenant, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMappings liefert alle Zuordnungen, sortiert nach Mandant und Payee.
|
||||
func (l *Ledger) ListMappings(ctx context.Context) ([]Mapping, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT payee_id, tenant, customer_id FROM customer_map ORDER BY tenant, payee_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mapping
|
||||
for rows.Next() {
|
||||
var m Mapping
|
||||
if err := rows.Scan(&m.Payee, &m.Tenant, &m.Customer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CustomerResolver löst (Payee, Mandant) zum Kunden auf: Zuordnung für den
|
||||
// Payee > Zuordnung für alle Payees > Kunde mit der Mandanten-ID > "".
|
||||
type CustomerResolver struct {
|
||||
byPayee map[[2]string]string
|
||||
byTenant map[string]string
|
||||
customers map[string]bool
|
||||
}
|
||||
|
||||
// CustomerResolver liest Kunden und Zuordnungen für viele Auflösungen.
|
||||
func (l *Ledger) CustomerResolver(ctx context.Context) (*CustomerResolver, error) {
|
||||
r := &CustomerResolver{byPayee: map[[2]string]string{}, byTenant: map[string]string{}, customers: map[string]bool{}}
|
||||
maps, err := l.ListMappings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range maps {
|
||||
if m.Payee == "" {
|
||||
r.byTenant[m.Tenant] = m.Customer
|
||||
} else {
|
||||
r.byPayee[[2]string{m.Payee, m.Tenant}] = m.Customer
|
||||
}
|
||||
}
|
||||
cs, err := l.ListCustomers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range cs {
|
||||
r.customers[c.ID] = true
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Resolve liefert die Kunden-ID ("" = keinem Kunden zugeordnet).
|
||||
func (r *CustomerResolver) Resolve(payee, tenant string) string {
|
||||
if c, ok := r.byPayee[[2]string{payee, tenant}]; ok {
|
||||
return c
|
||||
}
|
||||
if c, ok := r.byTenant[tenant]; ok {
|
||||
return c
|
||||
}
|
||||
if r.customers[tenant] {
|
||||
return tenant
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Kunden entstehen automatisch aus Mandanten-IDs; vorhandene bleiben unberührt.
|
||||
func TestEnsureCustomers(t *testing.T) {
|
||||
l := open(t)
|
||||
if err := l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde", Name: "Kunde A"}, {ID: "andere", Name: "Andere"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := l.GetCustomer(ctx, "kunde")
|
||||
if err != nil || c.Name != "Kunde A" || !c.Auto || c.Cycle != CycleUnclassified || c.FlatRate != nil {
|
||||
t.Errorf("automatisch angelegt: %+v, %v", c, err)
|
||||
}
|
||||
// Von Hand geändert: ein späterer Bericht überschreibt nichts.
|
||||
c.Name, c.Group, c.Note, c.Cycle = "Kunde A GmbH", "extern", "Kumpel, zahlt in Bier", CycleQuarterly
|
||||
cents := int64(8900)
|
||||
c.FlatRate = ¢s
|
||||
if err := l.UpdateCustomer(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde", Name: "Neuer Name"}})
|
||||
got, _ := l.GetCustomer(ctx, "kunde")
|
||||
if got.Name != "Kunde A GmbH" || got.Auto || got.Group != "extern" || got.Cycle != CycleQuarterly || *got.FlatRate != 8900 {
|
||||
t.Errorf("nach erneutem Bericht: %+v", got)
|
||||
}
|
||||
// Ungültige Mandanten-IDs (sollte es nicht geben) werden übersprungen.
|
||||
if err := l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "Kein Gültiger"}}); err != nil {
|
||||
t.Errorf("ungültige ID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Zuordnung: Payee + Mandant > Mandant auf allen Payees > gleiche ID. Ein
|
||||
// zugeordneter Mandant erzeugt keinen eigenen Kunden mehr.
|
||||
func TestResolveCustomer(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A"})
|
||||
l.AddCustomer(ctx, Customer{ID: "familie", Name: "Familie", Group: "familie"})
|
||||
if err := l.MapTenant(ctx, "", "kunde", "k-1001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.MapTenant(ctx, "webproxy12", "kunde-a", "k-1001"); err != nil { // Tippfehler auf Nebencluster
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde"}, {ID: "neffe"}})
|
||||
l.EnsureCustomers(ctx, "webproxy12", []TenantRef{{ID: "kunde-a"}})
|
||||
|
||||
tests := []struct{ payee, tenant, want string }{
|
||||
{"webproxy11", "kunde", "k-1001"},
|
||||
{"webproxy12", "kunde", "k-1001"},
|
||||
{"webproxy12", "kunde-a", "k-1001"},
|
||||
{"webproxy11", "neffe", "neffe"}, // automatisch
|
||||
{"webproxy11", "unbekannt", ""},
|
||||
}
|
||||
r, err := l.CustomerResolver(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := r.Resolve(tt.payee, tt.tenant); got != tt.want {
|
||||
t.Errorf("Resolve(%s, %s) = %q, erwartet %q", tt.payee, tt.tenant, got, tt.want)
|
||||
}
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, "kunde"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zugeordneter Mandant hat eigenen Kunden erzeugt: %v", err)
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, "kunde-a"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Tippfehler-Mandant hat eigenen Kunden erzeugt: %v", err)
|
||||
}
|
||||
maps, _ := l.ListMappings(ctx)
|
||||
if len(maps) != 2 {
|
||||
t.Errorf("Zuordnungen: %+v", maps)
|
||||
}
|
||||
if err := l.UnmapTenant(ctx, "webproxy12", "kunde-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.UnmapTenant(ctx, "webproxy12", "kunde-a"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zweites Entfernen: %v", err)
|
||||
}
|
||||
if err := l.MapTenant(ctx, "", "x", "gibts-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Zuordnung zu unbekanntem Kunden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomers_CRUD(t *testing.T) {
|
||||
l := open(t)
|
||||
if err := l.AddCustomer(ctx, Customer{ID: "familie", Name: "Familie", Group: "familie", Cycle: CycleYearly}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.AddCustomer(ctx, Customer{ID: "familie", Name: "x"}); !errors.Is(err, store.ErrExists) {
|
||||
t.Errorf("doppelt: %v", err)
|
||||
}
|
||||
for _, bad := range []Customer{{ID: "Ungültig", Name: "x"}, {ID: "ok", Name: ""}, {ID: "ok2", Name: "x", Cycle: "wöchentlich"}} {
|
||||
if err := l.AddCustomer(ctx, bad); !errors.Is(err, store.ErrInvalid) {
|
||||
t.Errorf("%+v: %v", bad, err)
|
||||
}
|
||||
}
|
||||
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A", Group: "extern"})
|
||||
list, _ := l.ListCustomers(ctx)
|
||||
if len(list) != 2 || list[0].ID != "familie" {
|
||||
t.Errorf("Liste: %+v", list)
|
||||
}
|
||||
if err := l.UpdateCustomer(ctx, Customer{ID: "gibts-nicht", Name: "x"}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Update unbekannt: %v", err)
|
||||
}
|
||||
l.MapTenant(ctx, "", "kunde", "k-1001")
|
||||
if err := l.DeleteCustomer(ctx, "k-1001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maps, _ := l.ListMappings(ctx); len(maps) != 0 {
|
||||
t.Errorf("Zuordnungen nach Löschen: %+v", maps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// Package ledger ist die Datenbank des Accountants (accountant.db, Spec
|
||||
// 5.3): registrierte Payees (meldende Sultan-Server), ihre Einladungen und
|
||||
// Einstellungen. der jeweils letzte Bericht je Payee und die Historie (Zeiträume je Ort).
|
||||
//
|
||||
// Bewusst getrennt von sultan.db: andere Lebensdauer (Historie über Jahre)
|
||||
// und anderes Vertrauen (Berichte sind Eingaben fremder Server). Migrationen
|
||||
// und Zeitformat kommen aus pkg/store; Fehler sind die von pkg/store
|
||||
// (ErrNotFound, ErrExists, ErrInvalid, ErrTokenUsed, ErrTokenExpired).
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// migrations: nur anhängen, nie ändern (siehe store.OpenDB).
|
||||
var migrations = []string{
|
||||
`CREATE TABLE payees (
|
||||
id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT NOT NULL,
|
||||
enrolled_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE invites (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payee_id TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
used_at DATETIME
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`,
|
||||
// v2: letzter Bericht je Payee.
|
||||
`CREATE TABLE reports (
|
||||
payee_id TEXT PRIMARY KEY REFERENCES payees(id) ON DELETE CASCADE,
|
||||
received_at DATETIME NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
);`,
|
||||
// v3: Historie – Zeiträume je Ort (Payee, Mandant, Knoten, Protokoll,
|
||||
// Router). Ohne Fremdschlüssel: bleibt, wenn ein Payee gelöscht wird.
|
||||
`CREATE TABLE route_spans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
payee_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
node TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
router TEXT NOT NULL,
|
||||
hosts TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
backend_fqdn TEXT NOT NULL,
|
||||
workload TEXT NOT NULL,
|
||||
compose_dir TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
ended_at DATETIME
|
||||
);
|
||||
CREATE INDEX route_spans_payee_open ON route_spans(payee_id, ended_at);
|
||||
CREATE INDEX route_spans_tenant ON route_spans(tenant);`,
|
||||
// v4: Kunden und die Zuordnung von Mandanten (payee_id '' = alle Payees).
|
||||
`CREATE TABLE customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
cycle TEXT NOT NULL DEFAULT '',
|
||||
flat_rate INTEGER,
|
||||
auto INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE customer_map (
|
||||
payee_id TEXT NOT NULL DEFAULT '',
|
||||
tenant TEXT NOT NULL,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (payee_id, tenant)
|
||||
);`,
|
||||
// v5: Produktkatalog (Klasse/Preis je Monat), Kundenpreise, Zuordnung je
|
||||
// Posten (Mandant, Protokoll, Router).
|
||||
`CREATE TABLE products (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE product_versions (
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
valid_from TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
price INTEGER,
|
||||
PRIMARY KEY (product_id, valid_from)
|
||||
);
|
||||
CREATE TABLE customer_prices (
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
valid_from TEXT NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
PRIMARY KEY (product_id, customer_id, valid_from)
|
||||
);
|
||||
CREATE TABLE assignments (
|
||||
tenant TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
router TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL DEFAULT '',
|
||||
class TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (tenant, protocol, router)
|
||||
);`,
|
||||
}
|
||||
|
||||
// Ledger ist der Zugriff auf accountant.db; parallel nutzbar.
|
||||
type Ledger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Payee ist ein registrierter, meldender Sultan-Server.
|
||||
type Payee struct {
|
||||
ID string
|
||||
Hostname string
|
||||
PublicKey string // auth.EncodePublicKey
|
||||
IPAddress string // beim Enrollment
|
||||
EnrolledAt time.Time
|
||||
LastReport time.Time // Nullzeit = noch kein Bericht
|
||||
}
|
||||
|
||||
// Open öffnet (bzw. erstellt) die Datenbank unter path.
|
||||
func Open(ctx context.Context, path string) (*Ledger, error) {
|
||||
db, err := store.OpenDB(ctx, path, migrations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Ledger{db: db, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Close schließt die Datenbank.
|
||||
func (l *Ledger) Close() error { return l.db.Close() }
|
||||
|
||||
// SetClock ersetzt die Uhr (Tests mit festen Zeitpunkten); nicht parallel
|
||||
// zu anderen Aufrufen verwenden.
|
||||
func (l *Ledger) SetClock(now func() time.Time) { l.now = now }
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", store.ErrInvalid, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// AddInvite hinterlegt eine einmalige Einladung für den Payee payeeID
|
||||
// (gespeichert wird nur der Hash von token). Ein bereits registrierter Payee
|
||||
// muss erst gelöscht werden (ErrExists).
|
||||
func (l *Ledger) AddInvite(ctx context.Context, token, payeeID string, expiresAt time.Time) error {
|
||||
switch {
|
||||
case token == "":
|
||||
return invalid("Token fehlt")
|
||||
case !policy.ValidID(payeeID):
|
||||
return invalid("Payee-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", payeeID)
|
||||
case !expiresAt.After(l.now()):
|
||||
return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Payee %s: %w (erst löschen)", payeeID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO invites (token_hash, payee_id, expires_at, used) VALUES (?, ?, ?, 0)`,
|
||||
hashToken(token), payeeID, store.FormatTime(expiresAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Enroll löst token ein und legt den Payee an; seine ID bestimmt die
|
||||
// Einladung (p.ID wird ersetzt), EnrolledAt setzt der Ledger. Schlägt das
|
||||
// Anlegen fehl, bleibt die Einladung unverbraucht.
|
||||
func (l *Ledger) Enroll(ctx context.Context, token string, p Payee) (Payee, error) {
|
||||
if strings.TrimSpace(p.Hostname) == "" || p.PublicKey == "" {
|
||||
return Payee{}, invalid("Hostname und Schlüssel sind Pflicht")
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
hash := hashToken(token)
|
||||
var payeeID string
|
||||
var expiresAt time.Time
|
||||
var used bool
|
||||
err = tx.QueryRowContext(ctx, `SELECT payee_id, expires_at, used FROM invites WHERE token_hash = ?`, hash).
|
||||
Scan(&payeeID, store.ScanTime(&expiresAt), &used)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return Payee{}, fmt.Errorf("Einladung: %w", store.ErrNotFound)
|
||||
case err != nil:
|
||||
return Payee{}, err
|
||||
case used:
|
||||
return Payee{}, store.ErrTokenUsed
|
||||
case !l.now().Before(expiresAt):
|
||||
return Payee{}, store.ErrTokenExpired
|
||||
}
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ? OR public_key = ?`, payeeID, p.PublicKey); err != nil {
|
||||
return Payee{}, err
|
||||
} else if exists {
|
||||
return Payee{}, fmt.Errorf("Payee %s oder Schlüssel: %w", payeeID, store.ErrExists)
|
||||
}
|
||||
|
||||
now := l.now().Round(0).UTC()
|
||||
p.ID, p.EnrolledAt = payeeID, now
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE invites SET used = 1, used_at = ? WHERE token_hash = ?`,
|
||||
store.FormatTime(now), hash); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payees (id, hostname, public_key, ip_address, enrolled_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Hostname, p.PublicKey, p.IPAddress, store.FormatTime(now)); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
const payeeSelect = `SELECT p.id, p.hostname, p.public_key, p.ip_address, p.enrolled_at, r.received_at
|
||||
FROM payees p LEFT JOIN reports r ON r.payee_id = p.id`
|
||||
|
||||
func scanPayee(row interface{ Scan(...any) error }) (Payee, error) {
|
||||
var p Payee
|
||||
err := row.Scan(&p.ID, &p.Hostname, &p.PublicKey, &p.IPAddress, store.ScanTime(&p.EnrolledAt), store.ScanTime(&p.LastReport))
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ListPayees liefert alle Payees, sortiert nach ID.
|
||||
func (l *Ledger) ListPayees(ctx context.Context) ([]Payee, error) {
|
||||
rows, err := l.db.QueryContext(ctx, payeeSelect+` ORDER BY p.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Payee
|
||||
for rows.Next() {
|
||||
p, err := scanPayee(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPayeeByPublicKey sucht den Payee zu einem Schlüssel (Prüfung signierter
|
||||
// Berichte).
|
||||
func (l *Ledger) GetPayeeByPublicKey(ctx context.Context, publicKey string) (Payee, error) {
|
||||
p, err := scanPayee(l.db.QueryRowContext(ctx, payeeSelect+` WHERE p.public_key = ?`, publicKey))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Payee{}, fmt.Errorf("Payee: %w", store.ErrNotFound)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// DeletePayee entfernt einen Payee (z. B. vor einer neuen Einladung nach
|
||||
// Schlüsselverlust).
|
||||
func (l *Ledger) DeletePayee(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM payees WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Payee %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RouteRecord ist eine Route aus einem Bericht, wie sie in die Historie eingeht.
|
||||
type RouteRecord struct {
|
||||
Tenant, Node, Protocol, Router string
|
||||
Hosts []string
|
||||
Backend, BackendFQDN string
|
||||
Workload, ComposeDir, Product string
|
||||
}
|
||||
|
||||
func (r RouteRecord) key() string {
|
||||
return r.Tenant + "\x00" + r.Node + "\x00" + r.Protocol + "\x00" + r.Router
|
||||
}
|
||||
|
||||
// RouteSpan ist ein Zeitraum an einem Ort.
|
||||
type RouteSpan struct {
|
||||
RouteRecord
|
||||
ID int64
|
||||
Payee string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Ended time.Time // Nullzeit = noch offen
|
||||
}
|
||||
|
||||
// SaveReport ersetzt den letzten Bericht des Payees und führt die Historie
|
||||
// fort (Zeitpunkt: jetzt): neue Orte öffnen einen Zeitraum, bestehende
|
||||
// übernehmen geänderte Angaben (first_seen bleibt), fehlende enden. Ein
|
||||
// Bericht ist vollständig – was fehlt, gibt es beim Payee nicht mehr.
|
||||
func (l *Ledger) SaveReport(ctx context.Context, payeeID string, body []byte, routes []RouteRecord) error {
|
||||
now := store.FormatTime(l.now())
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
return fmt.Errorf("Payee %s: %w", payeeID, store.ErrNotFound)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO reports (payee_id, received_at, body) VALUES (?, ?, ?)
|
||||
ON CONFLICT(payee_id) DO UPDATE SET received_at = excluded.received_at, body = excluded.body`,
|
||||
payeeID, now, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
open := map[string]int64{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, tenant, node, protocol, router FROM route_spans
|
||||
WHERE payee_id = ? AND ended_at IS NULL`, payeeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var r RouteRecord
|
||||
if err := rows.Scan(&id, &r.Tenant, &r.Node, &r.Protocol, &r.Router); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
open[r.key()] = id
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
hosts := normalizeHosts(r.Hosts)
|
||||
if id, ok := open[r.key()]; ok {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET hosts = ?, backend = ?, backend_fqdn = ?,
|
||||
workload = ?, compose_dir = ?, product = ?, last_seen = ? WHERE id = ?`,
|
||||
hosts, r.Backend, r.BackendFQDN, r.Workload, r.ComposeDir, r.Product, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(open, r.key())
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO route_spans (payee_id, tenant, node, protocol, router, hosts,
|
||||
backend, backend_fqdn, workload, compose_dir, product, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
payeeID, r.Tenant, r.Node, r.Protocol, r.Router, hosts, r.Backend, r.BackendFQDN,
|
||||
r.Workload, r.ComposeDir, r.Product, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range open {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET ended_at = ? WHERE id = ?`, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// normalizeHosts speichert Hosts klein, sortiert, ohne Doppelte (JSON).
|
||||
func normalizeHosts(hosts []string) string {
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
out = append(out, strings.ToLower(strings.TrimSpace(h)))
|
||||
}
|
||||
slices.Sort(out)
|
||||
raw, _ := json.Marshal(slices.Compact(out))
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// HistoryFilter schränkt RouteHistory ein; leere Felder filtern nicht.
|
||||
type HistoryFilter struct {
|
||||
Host string // Teiltreffer, ohne Groß-/Kleinschreibung
|
||||
Tenant string
|
||||
Payee string
|
||||
OpenOnly bool
|
||||
}
|
||||
|
||||
// RouteHistory liefert Zeiträume, sortiert nach Beginn, Payee, Knoten, Router.
|
||||
func (l *Ledger) RouteHistory(ctx context.Context, f HistoryFilter) ([]RouteSpan, error) {
|
||||
q := `SELECT id, payee_id, tenant, node, protocol, router, hosts, backend, backend_fqdn, workload,
|
||||
compose_dir, product, first_seen, last_seen, ended_at FROM route_spans WHERE 1=1`
|
||||
var args []any
|
||||
if f.Host != "" {
|
||||
like := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.ToLower(f.Host))
|
||||
q += ` AND hosts LIKE ? ESCAPE '\'`
|
||||
args = append(args, "%"+like+"%")
|
||||
}
|
||||
if f.Tenant != "" {
|
||||
q += ` AND tenant = ?`
|
||||
args = append(args, f.Tenant)
|
||||
}
|
||||
if f.Payee != "" {
|
||||
q += ` AND payee_id = ?`
|
||||
args = append(args, f.Payee)
|
||||
}
|
||||
if f.OpenOnly {
|
||||
q += ` AND ended_at IS NULL`
|
||||
}
|
||||
q += ` ORDER BY first_seen, payee_id, node, router, id`
|
||||
rows, err := l.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RouteSpan
|
||||
for rows.Next() {
|
||||
var sp RouteSpan
|
||||
var hosts string
|
||||
if err := rows.Scan(&sp.ID, &sp.Payee, &sp.Tenant, &sp.Node, &sp.Protocol, &sp.Router, &hosts, &sp.Backend,
|
||||
&sp.BackendFQDN, &sp.Workload, &sp.ComposeDir, &sp.Product, store.ScanTime(&sp.FirstSeen),
|
||||
store.ScanTime(&sp.LastSeen), store.ScanTime(&sp.Ended)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(hosts), &sp.Hosts); err != nil {
|
||||
return nil, fmt.Errorf("Historie %d: Hosts unlesbar: %w", sp.ID, err)
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LatestReport liefert den letzten Bericht des Payees und seinen Eingang.
|
||||
func (l *Ledger) LatestReport(ctx context.Context, payeeID string) ([]byte, time.Time, error) {
|
||||
var body string
|
||||
var at time.Time
|
||||
err := l.db.QueryRowContext(ctx, `SELECT body, received_at FROM reports WHERE payee_id = ?`, payeeID).
|
||||
Scan(&body, store.ScanTime(&at))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, time.Time{}, fmt.Errorf("Bericht von %s: %w", payeeID, store.ErrNotFound)
|
||||
}
|
||||
return []byte(body), at, err
|
||||
}
|
||||
|
||||
// Setting liefert eine Einstellung ("" wenn nicht gesetzt).
|
||||
func (l *Ledger) Setting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := l.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting setzt eine Einstellung.
|
||||
func (l *Ledger) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := l.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func rowExists(ctx context.Context, tx *sql.Tx, query string, args ...any) (bool, error) {
|
||||
var one int
|
||||
err := tx.QueryRowContext(ctx, query, args...).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func open(t *testing.T) *Ledger {
|
||||
t.Helper()
|
||||
l, err := Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
return l
|
||||
}
|
||||
|
||||
func payee(id, key string) Payee {
|
||||
return Payee{ID: id, Hostname: id + ".lan", PublicKey: key, IPAddress: "10.0.0.1"}
|
||||
}
|
||||
|
||||
// Einladung → Enrollment legt den Payee an; die Einladung bestimmt die ID.
|
||||
func TestEnroll(t *testing.T) {
|
||||
l := open(t)
|
||||
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
if err := l.AddInvite(ctx, "sltn_enroll_a", "webproxy11", now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := payee("ignoriert", "key-a")
|
||||
got, err := l.Enroll(ctx, "sltn_enroll_a", p)
|
||||
if err != nil {
|
||||
t.Fatalf("Enroll: %v", err)
|
||||
}
|
||||
want := Payee{ID: "webproxy11", Hostname: "ignoriert.lan", PublicKey: "key-a", IPAddress: "10.0.0.1", EnrolledAt: now}
|
||||
if got != want {
|
||||
t.Errorf("Enroll = %+v, erwartet %+v", got, want)
|
||||
}
|
||||
list, err := l.ListPayees(ctx)
|
||||
if err != nil || len(list) != 1 || list[0] != want {
|
||||
t.Errorf("ListPayees = %+v, %v", list, err)
|
||||
}
|
||||
if byKey, err := l.GetPayeeByPublicKey(ctx, "key-a"); err != nil || byKey != want {
|
||||
t.Errorf("GetPayeeByPublicKey = %+v, %v", byKey, err)
|
||||
}
|
||||
if _, err := l.GetPayeeByPublicKey(ctx, "fremd"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("unbekannter Schlüssel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnroll_Errors(t *testing.T) {
|
||||
l := open(t)
|
||||
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
|
||||
l.AddInvite(ctx, "tok-2", "webproxy12", now.Add(time.Hour))
|
||||
l.AddInvite(ctx, "tok-alt", "webproxy13", now.Add(time.Minute))
|
||||
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := l.Enroll(ctx, "gibts-nicht", payee("x", "key-9")); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("unbekannte Einladung: %v", err)
|
||||
}
|
||||
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-8")); !errors.Is(err, store.ErrTokenUsed) {
|
||||
t.Errorf("verbrauchte Einladung: %v", err)
|
||||
}
|
||||
// Schlüssel schon vergeben: Einladung bleibt unverbraucht.
|
||||
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-1")); !errors.Is(err, store.ErrExists) {
|
||||
t.Errorf("doppelter Schlüssel: %v", err)
|
||||
}
|
||||
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-2")); err != nil {
|
||||
t.Errorf("Einladung nach Fehlschlag verbraucht: %v", err)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, err := l.Enroll(ctx, "tok-alt", payee("x", "key-3")); !errors.Is(err, store.ErrTokenExpired) {
|
||||
t.Errorf("abgelaufene Einladung: %v", err)
|
||||
}
|
||||
if _, err := l.Enroll(ctx, "tok-x", Payee{ID: "x", PublicKey: "k"}); err == nil {
|
||||
t.Error("Payee ohne Hostname akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddInvite_Validation(t *testing.T) {
|
||||
l := open(t)
|
||||
future := time.Now().Add(time.Hour)
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", future)
|
||||
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
|
||||
tests := []struct {
|
||||
name, token, id string
|
||||
expires time.Time
|
||||
want error
|
||||
}{
|
||||
{"ungültige ID", "t", "Web Proxy", future, store.ErrInvalid},
|
||||
{"doppelter Unterstrich", "t", "a__b", future, store.ErrInvalid},
|
||||
{"ohne Token", "", "webproxy12", future, store.ErrInvalid},
|
||||
{"Ablauf in der Vergangenheit", "t", "webproxy12", time.Now().Add(-time.Minute), store.ErrInvalid},
|
||||
{"Payee schon registriert", "t", "webproxy11", future, store.ErrExists},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := l.AddInvite(ctx, tt.token, tt.id, tt.expires); !errors.Is(err, tt.want) {
|
||||
t.Errorf("AddInvite = %v, erwartet %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePayee(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
|
||||
if err := l.DeletePayee(ctx, "webproxy11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := l.ListPayees(ctx); len(list) != 0 {
|
||||
t.Errorf("nach Löschen: %+v", list)
|
||||
}
|
||||
if err := l.DeletePayee(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zweites Löschen: %v", err)
|
||||
}
|
||||
// Neu einladen (z. B. nach Verlust von payee.key).
|
||||
if err := l.AddInvite(ctx, "tok-2", "webproxy11", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Errorf("neue Einladung nach Löschen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
l := open(t)
|
||||
if v, err := l.Setting(ctx, "public_urls"); err != nil || v != "" {
|
||||
t.Errorf("leer: %q, %v", v, err)
|
||||
}
|
||||
l.SetSetting(ctx, "public_urls", `["https://a:8444"]`)
|
||||
l.SetSetting(ctx, "public_urls", `["https://b:8444"]`)
|
||||
if v, _ := l.Setting(ctx, "public_urls"); v != `["https://b:8444"]` {
|
||||
t.Errorf("Setting = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Der jeweils letzte Bericht je Payee; ListPayees zeigt seinen Zeitpunkt,
|
||||
// Löschen des Payees entfernt ihn mit.
|
||||
func TestReports(t *testing.T) {
|
||||
l := open(t)
|
||||
now := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
|
||||
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
|
||||
|
||||
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("ohne Bericht: %v", err)
|
||||
}
|
||||
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":1}`), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":2}`), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, at, err := l.LatestReport(ctx, "webproxy11")
|
||||
if err != nil || string(body) != `{"v":2}` || !at.Equal(now) {
|
||||
t.Errorf("LatestReport = %s, %v, %v", body, at, err)
|
||||
}
|
||||
list, _ := l.ListPayees(ctx)
|
||||
if len(list) != 1 || !list[0].LastReport.Equal(now) {
|
||||
t.Errorf("ListPayees = %+v", list)
|
||||
}
|
||||
if err := l.SaveReport(ctx, "gibts-nicht", []byte(`{}`), nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Bericht für unbekannten Payee: %v", err)
|
||||
}
|
||||
l.DeletePayee(ctx, "webproxy11")
|
||||
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Bericht nach Löschen des Payees: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loc(tenant, node, router string, hosts ...string) RouteRecord {
|
||||
return RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts,
|
||||
Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan:8081", Workload: router, ComposeDir: "/srv/" + router}
|
||||
}
|
||||
|
||||
// Jeder Bericht führt die Historie fort: neue Orte öffnen, fehlende enden,
|
||||
// bestehende behalten first_seen und übernehmen geänderte Angaben.
|
||||
func TestRouteHistory(t *testing.T) {
|
||||
l := open(t)
|
||||
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
now := t1
|
||||
l.now = func() time.Time { return now }
|
||||
for _, id := range []string{"webproxy11", "webproxy12"} {
|
||||
l.AddInvite(ctx, "tok-"+id, id, now.Add(time.Hour))
|
||||
l.Enroll(ctx, "tok-"+id, payee("x", "key-"+id))
|
||||
}
|
||||
|
||||
if err := l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{
|
||||
loc("kunde", "webdocker07", "wp", "kunde.de"), loc("kunde", "webdocker07", "olat", "olat.kunde.de"),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.SaveReport(ctx, "webproxy12", []byte(`{}`), []RouteRecord{loc("andere", "n9", "web", "andere.de")})
|
||||
|
||||
now = t1.Add(24 * time.Hour)
|
||||
moved := loc("kunde", "webdocker11", "wp", "kunde.de", "www.kunde.de") // Umzug + neuer Host
|
||||
olat := loc("kunde", "webdocker07", "olat", "olat.kunde.de", "lms.kunde.de")
|
||||
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat})
|
||||
|
||||
all, err := l.RouteHistory(ctx, HistoryFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type key struct{ payee, node, router string }
|
||||
got := map[key]RouteSpan{}
|
||||
for _, s := range all {
|
||||
got[key{s.Payee, s.Node, s.Router}] = s
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Fatalf("%d Einträge: %+v", len(all), all)
|
||||
}
|
||||
old := got[key{"webproxy11", "webdocker07", "wp"}]
|
||||
if !old.FirstSeen.Equal(t1) || !old.Ended.Equal(now) || !old.LastSeen.Equal(t1) {
|
||||
t.Errorf("alter Ort: %+v", old)
|
||||
}
|
||||
cur := got[key{"webproxy11", "webdocker11", "wp"}]
|
||||
if !cur.FirstSeen.Equal(now) || !cur.Ended.IsZero() || strings.Join(cur.Hosts, ",") != "kunde.de,www.kunde.de" {
|
||||
t.Errorf("neuer Ort: %+v", cur)
|
||||
}
|
||||
o := got[key{"webproxy11", "webdocker07", "olat"}]
|
||||
if !o.FirstSeen.Equal(t1) || !o.LastSeen.Equal(now) || !o.Ended.IsZero() || len(o.Hosts) != 2 {
|
||||
t.Errorf("geänderte Hosts behalten first_seen: %+v", o)
|
||||
}
|
||||
if other := got[key{"webproxy12", "n9", "web"}]; !other.Ended.IsZero() {
|
||||
t.Errorf("Bericht eines Payees beendet Orte eines anderen: %+v", other)
|
||||
}
|
||||
|
||||
// Wieder da: neuer Zeitraum, der alte bleibt beendet.
|
||||
now = t1.Add(48 * time.Hour)
|
||||
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat, loc("kunde", "webdocker07", "wp", "kunde.de")})
|
||||
spans, _ := l.RouteHistory(ctx, HistoryFilter{Host: "kunde.de"})
|
||||
var open07 int
|
||||
for _, s := range spans {
|
||||
if s.Node == "webdocker07" && s.Router == "wp" && s.Ended.IsZero() {
|
||||
open07++
|
||||
}
|
||||
}
|
||||
if open07 != 1 {
|
||||
t.Errorf("Wiederkehr: %+v", spans)
|
||||
}
|
||||
|
||||
// Filter: Host (Teiltreffer), Mandant, Payee; nur offene.
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{Host: "lms"}); len(s) != 1 || s[0].Router != "olat" {
|
||||
t.Errorf("Host-Teiltreffer: %+v", s)
|
||||
}
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{Tenant: "andere"}); len(s) != 1 {
|
||||
t.Errorf("Mandant: %+v", s)
|
||||
}
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{Payee: "webproxy12"}); len(s) != 1 {
|
||||
t.Errorf("Payee: %+v", s)
|
||||
}
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{OpenOnly: true, Tenant: "kunde"}); len(s) != 3 {
|
||||
t.Errorf("nur offene: %d %+v", len(s), s)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Payee wird gelöscht: Die Historie bleibt (Abrechnung, Verlauf).
|
||||
func TestRouteHistory_SurvivesPayeeDelete(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddInvite(ctx, "tok", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(ctx, "tok", payee("x", "k"))
|
||||
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{loc("kunde", "n1", "wp", "kunde.de")})
|
||||
l.DeletePayee(ctx, "webproxy11")
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{}); len(s) != 1 {
|
||||
t.Errorf("Historie nach Löschen: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Klassen eines Postens (Spec 5.3).
|
||||
const (
|
||||
ClassIncluded = "included" // in der Pauschale enthalten
|
||||
ClassBillable = "billable" // Zusatz-Service, eigene Position
|
||||
ClassFree = "free" // Give-away, erfasst, derzeit kostenlos
|
||||
ClassInternal = "internal" // keine Rechnung, Schattenpreis
|
||||
)
|
||||
|
||||
// sinceAlways ist der Beginn der ersten Version eines Produkts.
|
||||
const sinceAlways = "0000-00"
|
||||
|
||||
// ValidClass meldet, ob c eine bekannte Klasse ist.
|
||||
func ValidClass(c string) bool {
|
||||
switch c {
|
||||
case ClassIncluded, ClassBillable, ClassFree, ClassInternal:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidMonth meldet, ob m die Form JJJJ-MM hat.
|
||||
func ValidMonth(m string) bool {
|
||||
_, err := time.Parse("2006-01", m)
|
||||
return err == nil && len(m) == 7
|
||||
}
|
||||
|
||||
// Product ist ein Eintrag im Katalog; Class und Price beim Anlegen sind die
|
||||
// erste Version (gilt von Anfang an).
|
||||
type Product struct {
|
||||
ID string
|
||||
Name string
|
||||
Class string
|
||||
Price *int64 // Cent je Monat; nil = kein Preis hinterlegt (optional)
|
||||
Versions []ProductVersion
|
||||
}
|
||||
|
||||
// ProductVersion gilt ab From (JJJJ-MM) bis zur nächsten Version.
|
||||
type ProductVersion struct {
|
||||
From string
|
||||
Class string
|
||||
Price *int64
|
||||
}
|
||||
|
||||
// Assignment legt für einen Posten (Mandant, Protokoll, Router) Produkt,
|
||||
// Klasse und/oder Abrechnungsbeginn fest; sie hat Vorrang vor dem Label.
|
||||
type Assignment struct {
|
||||
Tenant, Protocol, Router string
|
||||
Product string // "" = aus dem Label
|
||||
Class string // "" = aus dem Produkt
|
||||
Start string // JJJJ-MM; "" = Monat des ersten Auftauchens
|
||||
}
|
||||
|
||||
func priceArg(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// AddProduct legt ein Produkt an; Class/Price gelten von Anfang an.
|
||||
func (l *Ledger) AddProduct(ctx context.Context, p Product) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
switch {
|
||||
case !policy.ValidID(p.ID):
|
||||
return invalid("Produkt-ID %q ungültig (a–z, 0–9, - und _)", p.ID)
|
||||
case p.Name == "":
|
||||
return invalid("Name fehlt")
|
||||
case !ValidClass(p.Class):
|
||||
return invalid("Klasse %q unbekannt (included, billable, free, internal)", p.Class)
|
||||
case p.Price != nil && *p.Price < 0:
|
||||
return invalid("Preis negativ")
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM products WHERE id = ?`, p.ID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Produkt %s: %w", p.ID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO products (id, name) VALUES (?, ?)`, p.ID, p.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)`,
|
||||
p.ID, sinceAlways, p.Class, priceArg(p.Price)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SetProductVersion setzt Klasse und Preis eines Produkts ab dem Monat from
|
||||
// (eine bestehende Version desselben Monats wird ersetzt).
|
||||
func (l *Ledger) SetProductVersion(ctx context.Context, product, from, class string, price *int64) error {
|
||||
switch {
|
||||
case !ValidMonth(from):
|
||||
return invalid("Monat %q ungültig (JJJJ-MM)", from)
|
||||
case !ValidClass(class):
|
||||
return invalid("Klasse %q unbekannt", class)
|
||||
case price != nil && *price < 0:
|
||||
return invalid("Preis negativ")
|
||||
}
|
||||
if err := l.requireProduct(ctx, product); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(product_id, valid_from) DO UPDATE SET class = excluded.class, price = excluded.price`,
|
||||
product, from, class, priceArg(price))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCustomerPrice setzt einen Kundenpreis ab dem Monat from; er hat Vorrang
|
||||
// vor dem Katalogpreis.
|
||||
func (l *Ledger) SetCustomerPrice(ctx context.Context, product, customer, from string, price int64) error {
|
||||
switch {
|
||||
case !ValidMonth(from):
|
||||
return invalid("Monat %q ungültig (JJJJ-MM)", from)
|
||||
case price < 0:
|
||||
return invalid("Preis negativ")
|
||||
}
|
||||
if err := l.requireProduct(ctx, product); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, customer); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_prices (product_id, customer_id, valid_from, price) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(product_id, customer_id, valid_from) DO UPDATE SET price = excluded.price`, product, customer, from, price)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *Ledger) requireProduct(ctx context.Context, id string) error {
|
||||
var one int
|
||||
err := l.db.QueryRowContext(ctx, `SELECT 1 FROM products WHERE id = ?`, id).Scan(&one)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProduct löscht ein Produkt samt Versionen und Kundenpreisen.
|
||||
func (l *Ledger) DeleteProduct(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM products WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Catalog sind alle Produkte samt Versionen und Kundenpreisen.
|
||||
type Catalog struct {
|
||||
names map[string]string
|
||||
versions map[string][]ProductVersion // nach From aufsteigend
|
||||
customers map[[2]string][]ProductVersion
|
||||
}
|
||||
|
||||
// Catalog liest den Katalog für viele Abfragen.
|
||||
func (l *Ledger) Catalog(ctx context.Context) (*Catalog, error) {
|
||||
c := &Catalog{names: map[string]string{}, versions: map[string][]ProductVersion{}, customers: map[[2]string][]ProductVersion{}}
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT p.id, p.name, v.valid_from, v.class, v.price FROM products p
|
||||
JOIN product_versions v ON v.product_id = p.id ORDER BY p.id, v.valid_from`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
var v ProductVersion
|
||||
var price sql.NullInt64
|
||||
if err := rows.Scan(&id, &name, &v.From, &v.Class, &price); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if price.Valid {
|
||||
v.Price = &price.Int64
|
||||
}
|
||||
c.names[id] = name
|
||||
c.versions[id] = append(c.versions[id], v)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = l.db.QueryContext(ctx, `SELECT product_id, customer_id, valid_from, price FROM customer_prices
|
||||
ORDER BY product_id, customer_id, valid_from`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var product, customer string
|
||||
var v ProductVersion
|
||||
var price int64
|
||||
if err := rows.Scan(&product, &customer, &v.From, &price); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Price = &price
|
||||
k := [2]string{product, customer}
|
||||
c.customers[k] = append(c.customers[k], v)
|
||||
}
|
||||
return c, rows.Err()
|
||||
}
|
||||
|
||||
func versionAt(vs []ProductVersion, month string) (ProductVersion, bool) {
|
||||
var out ProductVersion
|
||||
found := false
|
||||
for _, v := range vs {
|
||||
if v.From <= month {
|
||||
out, found = v, true
|
||||
}
|
||||
}
|
||||
return out, found
|
||||
}
|
||||
|
||||
// At liefert Klasse und Preis eines Produkts im Monat (JJJJ-MM) für einen
|
||||
// Kunden; der Kundenpreis hat Vorrang vor dem Katalogpreis.
|
||||
func (c *Catalog) At(product, customer, month string) (ProductVersion, bool) {
|
||||
v, ok := versionAt(c.versions[product], month)
|
||||
if !ok {
|
||||
return ProductVersion{}, false
|
||||
}
|
||||
if cp, ok := versionAt(c.customers[[2]string{product, customer}], month); ok {
|
||||
v.Price = cp.Price
|
||||
}
|
||||
v.From = ""
|
||||
return v, true
|
||||
}
|
||||
|
||||
// Name liefert den Namen eines Produkts ("" wenn unbekannt).
|
||||
func (c *Catalog) Name(product string) string { return c.names[product] }
|
||||
|
||||
// ListProducts liefert alle Produkte mit Versionen; Class/Price sind die
|
||||
// des aktuellen Monats.
|
||||
func (l *Ledger) ListProducts(ctx context.Context) ([]Product, error) {
|
||||
cat, err := l.Catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
month := l.now().UTC().Format("2006-01")
|
||||
var out []Product
|
||||
for id, name := range cat.names {
|
||||
p := Product{ID: id, Name: name, Versions: cat.versions[id]}
|
||||
if v, ok := versionAt(p.Versions, month); ok {
|
||||
p.Class, p.Price = v.Class, v.Price
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Product) int { return strings.Compare(a.ID, b.ID) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetAssignment setzt (bzw. ersetzt) die Zuordnung eines Postens.
|
||||
func (l *Ledger) SetAssignment(ctx context.Context, a Assignment) error {
|
||||
switch {
|
||||
case a.Tenant == "" || a.Router == "" || (a.Protocol != "http" && a.Protocol != "tcp"):
|
||||
return invalid("Posten unvollständig (Mandant, Protokoll http/tcp, Router)")
|
||||
case a.Product == "" && a.Class == "" && a.Start == "":
|
||||
return invalid("nichts zugeordnet (Produkt, Klasse oder Beginn angeben)")
|
||||
case a.Class != "" && !ValidClass(a.Class):
|
||||
return invalid("Klasse %q unbekannt", a.Class)
|
||||
case a.Start != "" && !ValidMonth(a.Start):
|
||||
return invalid("Beginn %q ungültig (JJJJ-MM)", a.Start)
|
||||
}
|
||||
if a.Product != "" {
|
||||
if err := l.requireProduct(ctx, a.Product); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO assignments (tenant, protocol, router, product_id, class, start)
|
||||
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(tenant, protocol, router) DO UPDATE SET
|
||||
product_id = excluded.product_id, class = excluded.class, start = excluded.start`,
|
||||
a.Tenant, a.Protocol, a.Router, a.Product, a.Class, a.Start)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAssignments liefert alle Zuordnungen, sortiert nach Mandant, Router.
|
||||
func (l *Ledger) ListAssignments(ctx context.Context) ([]Assignment, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT tenant, protocol, router, product_id, class, start FROM assignments
|
||||
ORDER BY tenant, router, protocol`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Assignment
|
||||
for rows.Next() {
|
||||
var a Assignment
|
||||
if err := rows.Scan(&a.Tenant, &a.Protocol, &a.Router, &a.Product, &a.Class, &a.Start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteAssignment entfernt die Zuordnung eines Postens.
|
||||
func (l *Ledger) DeleteAssignment(ctx context.Context, tenant, protocol, router string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM assignments WHERE tenant = ? AND protocol = ? AND router = ?`,
|
||||
tenant, protocol, router)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Zuordnung %s/%s/%s: %w", tenant, protocol, router, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
func cents(v int64) *int64 { return &v }
|
||||
|
||||
// Klasse und Preis gelten ab einem Monat; Kundenpreise haben Vorrang.
|
||||
func TestProducts_Versions(t *testing.T) {
|
||||
l := open(t)
|
||||
if err := l.AddProduct(ctx, Product{ID: "pingvin", Name: "Pingvin Share", Class: ClassFree}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.AddProduct(ctx, Product{ID: "olat", Name: "OLAT", Class: ClassBillable, Price: cents(4900)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.SetProductVersion(ctx, "pingvin", "2027-03", ClassBillable, cents(500)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A"})
|
||||
if err := l.SetCustomerPrice(ctx, "olat", "k-1001", "2026-11", 3900); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cat, err := l.Catalog(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
product, customer, month string
|
||||
class string
|
||||
price *int64
|
||||
}{
|
||||
{"pingvin", "", "2027-02", ClassFree, nil},
|
||||
{"pingvin", "", "2027-03", ClassBillable, cents(500)},
|
||||
{"pingvin", "", "2028-01", ClassBillable, cents(500)},
|
||||
{"olat", "", "2026-10", ClassBillable, cents(4900)},
|
||||
{"olat", "k-1001", "2026-10", ClassBillable, cents(4900)}, // Kundenpreis erst ab November
|
||||
{"olat", "k-1001", "2026-11", ClassBillable, cents(3900)},
|
||||
{"olat", "andere", "2026-11", ClassBillable, cents(4900)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
v, ok := cat.At(tt.product, tt.customer, tt.month)
|
||||
if !ok || v.Class != tt.class || !samePrice(v.Price, tt.price) {
|
||||
t.Errorf("At(%s, %s, %s) = %+v %v, erwartet %s %v", tt.product, tt.customer, tt.month, v, ok, tt.class, deref(tt.price))
|
||||
}
|
||||
}
|
||||
if _, ok := cat.At("gibts-nicht", "", "2026-10"); ok {
|
||||
t.Error("unbekanntes Produkt gefunden")
|
||||
}
|
||||
list, _ := l.ListProducts(ctx)
|
||||
if len(list) != 2 || list[0].ID != "olat" {
|
||||
t.Errorf("ListProducts: %+v", list)
|
||||
}
|
||||
|
||||
for _, err := range []error{
|
||||
l.AddProduct(ctx, Product{ID: "olat", Name: "x", Class: ClassFree}),
|
||||
} {
|
||||
if !errors.Is(err, store.ErrExists) {
|
||||
t.Errorf("doppelt: %v", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{
|
||||
l.AddProduct(ctx, Product{ID: "x1", Name: "x", Class: "teuer"}),
|
||||
l.AddProduct(ctx, Product{ID: "x2", Name: "", Class: ClassFree}),
|
||||
l.SetProductVersion(ctx, "olat", "2026-13", ClassFree, nil),
|
||||
l.SetCustomerPrice(ctx, "olat", "k-1001", "2026/11", 100),
|
||||
} {
|
||||
if !errors.Is(err, store.ErrInvalid) {
|
||||
t.Errorf("ungültig: %v", err)
|
||||
}
|
||||
}
|
||||
if err := l.SetProductVersion(ctx, "gibts-nicht", "2026-11", ClassFree, nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("unbekanntes Produkt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func samePrice(a, b *int64) bool { return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) }
|
||||
func deref(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Zuordnung je Posten (Mandant, Protokoll, Router).
|
||||
func TestAssignments(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddProduct(ctx, Product{ID: "olat", Name: "OLAT", Class: ClassBillable})
|
||||
a := Assignment{Tenant: "kunde", Protocol: "http", Router: "olat", Product: "olat", Start: "2026-11"}
|
||||
if err := l.SetAssignment(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2 := Assignment{Tenant: "kunde", Protocol: "http", Router: "share", Class: ClassInternal}
|
||||
l.SetAssignment(ctx, a2)
|
||||
got, err := l.ListAssignments(ctx)
|
||||
if err != nil || len(got) != 2 || got[0] != a || got[1] != a2 {
|
||||
t.Errorf("ListAssignments = %+v, %v", got, err)
|
||||
}
|
||||
a.Start = ""
|
||||
l.SetAssignment(ctx, a) // ersetzt
|
||||
if got, _ := l.ListAssignments(ctx); got[0].Start != "" {
|
||||
t.Errorf("nicht ersetzt: %+v", got[0])
|
||||
}
|
||||
for _, bad := range []Assignment{
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x", Product: "gibts-nicht"},
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x", Class: "teuer"},
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x", Start: "November"},
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x"}, // nichts zugeordnet
|
||||
} {
|
||||
if err := l.SetAssignment(ctx, bad); err == nil {
|
||||
t.Errorf("%+v akzeptiert", bad)
|
||||
}
|
||||
}
|
||||
if err := l.DeleteAssignment(ctx, "kunde", "http", "share"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.DeleteAssignment(ctx, "kunde", "http", "share"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zweites Löschen: %v", err)
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -1,15 +1,17 @@
|
||||
package server
|
||||
// Package ratelimit begrenzt Versuche je Schlüssel (z. B. Client-IP) –
|
||||
// genutzt für die Enrollment-Endpunkte von Server und Accountant.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter begrenzt Versuche je Schlüssel (Client-IP) auf limit pro
|
||||
// window (gleitendes Fenster). limit < 0 = unbegrenzt.
|
||||
type rateLimiter struct {
|
||||
limit int
|
||||
window time.Duration
|
||||
// Limiter begrenzt Versuche je Schlüssel (Client-IP) auf Limit pro Window
|
||||
// (gleitendes Fenster). Limit < 0 = unbegrenzt. Parallel nutzbar.
|
||||
type Limiter struct {
|
||||
Limit int
|
||||
Window time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -17,22 +19,22 @@ type rateLimiter struct {
|
||||
lastPurge time.Time
|
||||
}
|
||||
|
||||
// allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
|
||||
func (l *rateLimiter) allow(key string) (bool, time.Duration) {
|
||||
if l.limit < 0 {
|
||||
// Allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
|
||||
func (l *Limiter) Allow(key string) (bool, time.Duration) {
|
||||
if l.Limit < 0 {
|
||||
return true, 0
|
||||
}
|
||||
now := time.Now()
|
||||
if l.now != nil {
|
||||
now = l.now()
|
||||
}
|
||||
cutoff := now.Add(-l.window)
|
||||
cutoff := now.Add(-l.Window)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.hits == nil {
|
||||
l.hits = map[string][]time.Time{}
|
||||
}
|
||||
if now.Sub(l.lastPurge) >= l.window { // vergessen, was nicht mehr zählt
|
||||
if now.Sub(l.lastPurge) >= l.Window { // vergessen, was nicht mehr zählt
|
||||
for k, ts := range l.hits {
|
||||
if !ts[len(ts)-1].After(cutoff) {
|
||||
delete(l.hits, k)
|
||||
@@ -46,15 +48,15 @@ func (l *rateLimiter) allow(key string) (bool, time.Duration) {
|
||||
i++
|
||||
}
|
||||
ts = ts[i:]
|
||||
if len(ts) >= l.limit {
|
||||
if len(ts) >= l.Limit {
|
||||
l.hits[key] = ts
|
||||
return false, ts[0].Add(l.window).Sub(now)
|
||||
return false, ts[0].Add(l.Window).Sub(now)
|
||||
}
|
||||
l.hits[key] = append(ts, now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (l *rateLimiter) size() int {
|
||||
func (l *Limiter) size() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.hits)
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package server
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
func TestLimiter(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l := &rateLimiter{limit: 3, window: time.Minute, now: func() time.Time { return now }}
|
||||
l := &Limiter{Limit: 3, Window: time.Minute, now: func() time.Time { return now }}
|
||||
|
||||
for i := range 3 {
|
||||
if ok, _ := l.allow("1.2.3.4"); !ok {
|
||||
if ok, _ := l.Allow("1.2.3.4"); !ok {
|
||||
t.Fatalf("Versuch %d abgelehnt", i+1)
|
||||
}
|
||||
}
|
||||
ok, retry := l.allow("1.2.3.4")
|
||||
ok, retry := l.Allow("1.2.3.4")
|
||||
if ok || retry != time.Minute {
|
||||
t.Errorf("4. Versuch: ok=%v retry=%v, erwartet abgelehnt mit 1 min", ok, retry)
|
||||
}
|
||||
if ok, _ := l.allow("5.6.7.8"); !ok {
|
||||
if ok, _ := l.Allow("5.6.7.8"); !ok {
|
||||
t.Error("andere IP mitbetroffen")
|
||||
}
|
||||
|
||||
now = now.Add(40 * time.Second)
|
||||
if _, retry := l.allow("1.2.3.4"); retry != 20*time.Second {
|
||||
if _, retry := l.Allow("1.2.3.4"); retry != 20*time.Second {
|
||||
t.Errorf("Retry-After nach 40 s: %v, erwartet 20 s", retry)
|
||||
}
|
||||
now = now.Add(20 * time.Second)
|
||||
if ok, _ := l.allow("1.2.3.4"); !ok {
|
||||
if ok, _ := l.Allow("1.2.3.4"); !ok {
|
||||
t.Error("nach Ablauf des Fensters weiter gesperrt")
|
||||
}
|
||||
}
|
||||
|
||||
// Die Zählerliste darf bei vielen IPs nicht unbegrenzt wachsen.
|
||||
func TestRateLimiter_ForgetsOldEntries(t *testing.T) {
|
||||
func TestLimiter_ForgetsOldEntries(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l := &rateLimiter{limit: 1, window: time.Minute, now: func() time.Time { return now }}
|
||||
l := &Limiter{Limit: 1, Window: time.Minute, now: func() time.Time { return now }}
|
||||
for i := range 1000 {
|
||||
l.allow(string(rune('a'+i%26)) + time.Duration(i).String())
|
||||
l.Allow(string(rune('a'+i%26)) + time.Duration(i).String())
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
l.allow("neu")
|
||||
l.Allow("neu")
|
||||
if n := l.size(); n != 1 {
|
||||
t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Disabled(t *testing.T) {
|
||||
l := &rateLimiter{limit: -1, window: time.Minute}
|
||||
func TestLimiter_Disabled(t *testing.T) {
|
||||
l := &Limiter{Limit: -1, Window: time.Minute}
|
||||
for range 100 {
|
||||
if ok, _ := l.allow("x"); !ok {
|
||||
if ok, _ := l.Allow("x"); !ok {
|
||||
t.Fatal("Limit -1 muss unbegrenzt sein")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/policy"
|
||||
@@ -44,6 +46,28 @@ type Workload struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
// Meta: Zusatzdaten für Inventar und Störfall (Spec 5.3); nil bei
|
||||
// sultan.d-Diensten und älteren Agents. Ändert das Routing nie.
|
||||
Meta *WorkloadMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// WorkloadMeta beschreibt, wo und wie ein Container läuft – automatisch
|
||||
// ermittelt, ohne zusätzliche Labels.
|
||||
type WorkloadMeta struct {
|
||||
ComposeProject string `json:"compose_project,omitempty"` // com.docker.compose.project
|
||||
ComposeService string `json:"compose_service,omitempty"` // com.docker.compose.service
|
||||
ComposeDir string `json:"compose_dir,omitempty"` // com.docker.compose.project.working_dir
|
||||
State string `json:"state,omitempty"` // running, exited, …
|
||||
StartedAt time.Time `json:"started_at,omitzero"` // letzter Start
|
||||
Health string `json:"health,omitempty"` // healthy, unhealthy, starting; leer = kein Healthcheck
|
||||
}
|
||||
|
||||
// HostInfo beschreibt den Knoten (Spec 5.3); nil bei älteren Agents.
|
||||
type HostInfo struct {
|
||||
FQDN string `json:"fqdn,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
DockerVersion string `json:"docker_version,omitempty"`
|
||||
NetBirdIP string `json:"netbird_ip,omitempty"`
|
||||
}
|
||||
|
||||
// NodeState ist der vollständige Zustand eines Knotens (Payload von
|
||||
@@ -52,6 +76,29 @@ type NodeState struct {
|
||||
Workloads []Workload `json:"workloads"`
|
||||
// DownServices: Services (Originalnamen), deren Healthcheck fehlschlägt.
|
||||
DownServices []string `json:"down_services,omitempty"`
|
||||
// Host: Angaben zum Knoten; ändern das Routing nie.
|
||||
Host *HostInfo `json:"host_info,omitempty"`
|
||||
}
|
||||
|
||||
// clone liefert eine tiefe Kopie.
|
||||
func (s NodeState) clone() NodeState {
|
||||
out := NodeState{DownServices: slices.Clone(s.DownServices)}
|
||||
if s.Workloads != nil {
|
||||
out.Workloads = make([]Workload, len(s.Workloads))
|
||||
}
|
||||
for i, w := range s.Workloads {
|
||||
w.Labels = maps.Clone(w.Labels)
|
||||
if w.Meta != nil {
|
||||
m := *w.Meta
|
||||
w.Meta = &m
|
||||
}
|
||||
out.Workloads[i] = w
|
||||
}
|
||||
if s.Host != nil {
|
||||
h := *s.Host
|
||||
out.Host = &h
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeContext ist, was die Policy über einen Knoten wissen muss.
|
||||
@@ -179,6 +226,17 @@ func (r *Registry) Nodes() []string {
|
||||
return r.nodeIDs()
|
||||
}
|
||||
|
||||
// State liefert eine Kopie des zuletzt gemeldeten Zustands eines Knotens.
|
||||
func (r *Registry) State(nodeID string) (NodeState, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, ok := r.nodes[nodeID]
|
||||
if !ok {
|
||||
return NodeState{}, false
|
||||
}
|
||||
return entry.state.clone(), true
|
||||
}
|
||||
|
||||
// Handler liefert die Konfiguration für Traefiks HTTP-Provider aus
|
||||
// (?format=json|yaml, Default JSON).
|
||||
func (r *Registry) Handler() http.Handler {
|
||||
|
||||
@@ -441,3 +441,65 @@ func TestRegistry_NodeMaintenanceFromDirectory(t *testing.T) {
|
||||
t.Error("Knotenwartung aus dem Directory nicht angewendet")
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzdaten (Compose, Startzeit, Health, Host-Infos) werden mit dem
|
||||
// Zustand gespeichert, ändern das Routing aber nicht.
|
||||
func TestRegistry_StateKeepsMetadata(t *testing.T) {
|
||||
r, _, _ := newRegistry()
|
||||
plain := wp("wordpress", "100.64.0.1", "kunde.de")
|
||||
if err := r.Update(ctx, "n1", NodeState{Workloads: []Workload{plain}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := json.Marshal(r.Config())
|
||||
|
||||
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
|
||||
withMeta := plain
|
||||
withMeta.Meta = &WorkloadMeta{
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy",
|
||||
}
|
||||
state := NodeState{
|
||||
Workloads: []Workload{withMeta},
|
||||
Host: &HostInfo{
|
||||
FQDN: "webdocker11.lan.r25dc.de", OS: "Debian GNU/Linux 13 (trixie)",
|
||||
DockerVersion: "29.8.2", NetBirdIP: "100.64.0.42",
|
||||
},
|
||||
}
|
||||
if err := r.Update(ctx, "n1", state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after, _ := json.Marshal(r.Config()); string(after) != string(before) {
|
||||
t.Errorf("Zusatzdaten ändern das Routing:\nvorher %s\nnachher %s", before, after)
|
||||
}
|
||||
got, ok := r.State("n1")
|
||||
if !ok || !reflect.DeepEqual(got, state) {
|
||||
t.Errorf("State(n1) = %+v, %v; erwartet %+v", got, ok, state)
|
||||
}
|
||||
if _, ok := r.State("gibts-nicht"); ok {
|
||||
t.Error("State für unbekannten Knoten")
|
||||
}
|
||||
|
||||
// Kopie: Änderungen am Ergebnis wirken nicht auf die Registry zurück.
|
||||
got.Workloads[0].Meta.ComposeDir = "/manipuliert"
|
||||
got.Host.FQDN = "manipuliert"
|
||||
again, _ := r.State("n1")
|
||||
if again.Workloads[0].Meta.ComposeDir != "/srv/kunde-a" || again.Host.FQDN != "webdocker11.lan.r25dc.de" {
|
||||
t.Errorf("State liefert keine Kopie: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Ältere Agents schicken keine Zusatzdaten; JSON ohne die Felder bleibt gültig
|
||||
// und erzeugt keine leeren Objekte.
|
||||
func TestNodeState_JSONWithoutMetadata(t *testing.T) {
|
||||
var st NodeState
|
||||
if err := json.Unmarshal([]byte(`{"workloads":[{"name":"wp","host":"10.0.0.1","labels":{}}]}`), &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Host != nil || st.Workloads[0].Meta != nil {
|
||||
t.Errorf("Zusatzdaten erfunden: %+v", st)
|
||||
}
|
||||
out, _ := json.Marshal(st)
|
||||
if want := `{"workloads":[{"name":"wp","host":"10.0.0.1","labels":{}}]}`; string(out) != want {
|
||||
t.Errorf("JSON = %s, erwartet %s", out, want)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (s *Server) providerRoutes() http.Handler {
|
||||
|
||||
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
|
||||
if ok, retry := s.enrollRL.allow(remoteIP(r)); !ok {
|
||||
if ok, retry := s.enrollRL.Allow(remoteIP(r)); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
|
||||
s.log.Warn("Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
|
||||
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package server
|
||||
|
||||
// Payee: der Server meldet sein Inventar an den Accountant (Spec 5.3,
|
||||
// Roadmap 6c) – mit demselben Sicherheitsmodell wie Agent → Server:
|
||||
// eigener Schlüssel payee.key, Pinning des Accountant-Fingerprints aus der
|
||||
// Einladung, signierte Requests, nur TLS. Ein nicht erreichbarer Accountant
|
||||
// hält den Ingress nie auf: Enrollment und Berichte werden im Hintergrund
|
||||
// wiederholt.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/traefik"
|
||||
)
|
||||
|
||||
// payeeIdentity ist accountant.json im Datenverzeichnis des Servers.
|
||||
type payeeIdentity struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint crypto.Fingerprint `json:"fingerprint"`
|
||||
PayeeID string `json:"payee_id"`
|
||||
}
|
||||
|
||||
type payee struct {
|
||||
s *Server
|
||||
log *slog.Logger
|
||||
dir string
|
||||
key ed25519.PrivateKey
|
||||
token *crypto.EnrollmentToken // nil = schon registriert
|
||||
url string // tatsächlich genutzte Adresse
|
||||
fp crypto.Fingerprint // gepinnter Accountant
|
||||
id *payeeIdentity // nil bis zum Enrollment
|
||||
// conflict: Einladung eines anderen Accountants bei bestehender
|
||||
// Registrierung – es wird nichts gemeldet.
|
||||
conflict error
|
||||
|
||||
interval, heartbeat time.Duration
|
||||
hc *http.Client
|
||||
|
||||
lastHash [32]byte
|
||||
lastSent time.Time
|
||||
lastErr string // nur geänderte Fehler loggen
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// newPayee liest die Accountant-Konfiguration; nil = Server meldet nicht.
|
||||
// Fehler gibt es nur für unbrauchbare Angaben (Tippfehler), nie für einen
|
||||
// nicht erreichbaren Accountant.
|
||||
func newPayee(cfg Config, log *slog.Logger) (*payee, error) {
|
||||
p := &payee{
|
||||
log: log, dir: cfg.DataDir,
|
||||
interval: cfg.AccountantInterval, heartbeat: cfg.AccountantHeartbeat,
|
||||
stop: make(chan struct{}), done: make(chan struct{}),
|
||||
}
|
||||
if raw := strings.TrimSpace(cfg.AccountantToken); raw != "" {
|
||||
tok, err := crypto.ParseEnrollmentToken(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: --accountant-token: %w", err)
|
||||
}
|
||||
p.token = &tok
|
||||
}
|
||||
var override string
|
||||
if cfg.AccountantURL != "" {
|
||||
u, err := crypto.ValidateServerURL(cfg.AccountantURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: --accountant: %w", err)
|
||||
}
|
||||
override = u
|
||||
}
|
||||
if data, err := os.ReadFile(p.path("accountant.json")); err == nil {
|
||||
var id payeeIdentity
|
||||
if err := json.Unmarshal(data, &id); err != nil || id.Fingerprint == "" || id.PayeeID == "" {
|
||||
return nil, fmt.Errorf("server: accountant.json beschädigt – Datei löschen und neu einladen")
|
||||
}
|
||||
p.id = &id
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("server: accountant.json: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case p.token == nil && p.id == nil:
|
||||
if override != "" {
|
||||
return nil, errors.New("server: --accountant ohne Einladung (--accountant-token) und ohne accountant.json")
|
||||
}
|
||||
return nil, nil
|
||||
case p.id != nil:
|
||||
p.fp, p.url = p.id.Fingerprint, cmp.Or(override, p.id.URL)
|
||||
if p.token != nil && p.token.Fingerprint != p.id.Fingerprint {
|
||||
p.conflict = fmt.Errorf("bereits bei einem anderen Accountant registriert (Fingerprint %s) – für den neuen accountant.json und payee.key löschen", p.id.Fingerprint)
|
||||
}
|
||||
p.token = nil // eine stehen gebliebene Einladung desselben Accountants ist harmlos
|
||||
default:
|
||||
p.fp, p.url = p.token.Fingerprint, cmp.Or(override, p.token.ServerURL)
|
||||
if p.url == "" {
|
||||
return nil, errors.New("server: Einladung ohne Accountant-Adresse – --accountant=https://…:8444 angeben")
|
||||
}
|
||||
}
|
||||
|
||||
key, err := loadOrCreateKey(p.path("payee.key"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.key = key
|
||||
p.hc = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(p.fp)},
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *payee) path(name string) string { return filepath.Join(p.dir, name) }
|
||||
|
||||
func loadOrCreateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
key, err := auth.GenerateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := auth.WritePrivateKey(path, key); err != nil {
|
||||
return nil, fmt.Errorf("server: %s anlegen: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
key, err := auth.ReadPrivateKey(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: %s laden: %w", filepath.Base(path), err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// run arbeitet, bis stop geschlossen wird: Enrollment (falls nötig), dann
|
||||
// Bericht bei Änderung bzw. als Lebenszeichen.
|
||||
func (p *payee) run() {
|
||||
defer close(p.done)
|
||||
if p.conflict != nil {
|
||||
p.log.Error("Accountant: Berichte abgeschaltet", "err", p.conflict)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { <-p.stop; cancel() }()
|
||||
|
||||
t := time.NewTicker(p.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if p.id == nil {
|
||||
p.report(p.enroll(ctx), "Accountant: Enrollment fehlgeschlagen – neuer Versuch folgt")
|
||||
}
|
||||
if p.id != nil {
|
||||
p.report(p.maybeSend(ctx), "Accountant: Bericht fehlgeschlagen – neuer Versuch folgt")
|
||||
}
|
||||
select {
|
||||
case <-p.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// report loggt Fehler nur, wenn sie sich ändern (kein Log-Spam bei Ausfall).
|
||||
func (p *payee) report(err error, msg string) {
|
||||
if err == nil {
|
||||
if p.lastErr != "" {
|
||||
p.log.Info("Accountant wieder erreichbar", "url", p.url)
|
||||
}
|
||||
p.lastErr = ""
|
||||
return
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || err.Error() == p.lastErr {
|
||||
return
|
||||
}
|
||||
p.lastErr = err.Error()
|
||||
p.log.Warn(msg, "url", p.url, "err", err)
|
||||
}
|
||||
|
||||
func (p *payee) post(ctx context.Context, path string, body any) (*http.Response, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if err := (&auth.Signer{Key: p.key}).Sign(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.hc.Do(req)
|
||||
}
|
||||
|
||||
func responseError(resp *http.Response) error {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("Accountant antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
|
||||
func (p *payee) enroll(ctx context.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
resp, err := p.post(ctx, accountant.PathEnroll, accountant.EnrollRequest{Token: p.token.Secret, Hostname: cmp.Or(hostname, "sultan-server")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return responseError(resp)
|
||||
}
|
||||
var er accountant.EnrollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil || !policy.ValidID(er.PayeeID) {
|
||||
return fmt.Errorf("Antwort des Accountants unlesbar")
|
||||
}
|
||||
id := payeeIdentity{URL: p.url, Fingerprint: p.fp, PayeeID: er.PayeeID}
|
||||
raw, _ := json.MarshalIndent(id, "", " ")
|
||||
if err := os.WriteFile(p.path("accountant.json"), append(raw, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("accountant.json schreiben: %w", err)
|
||||
}
|
||||
p.id, p.token = &id, nil
|
||||
p.log.Info("Beim Accountant registriert", "payee", er.PayeeID, "url", p.url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeSend sendet den Schnappschuss, wenn er sich geändert hat oder das
|
||||
// Lebenszeichen fällig ist.
|
||||
func (p *payee) maybeSend(ctx context.Context) error {
|
||||
rep, err := p.s.buildReport(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(rep) // ohne Zeitpunkt: nur der Inhalt zählt
|
||||
hash := sha256.Sum256(raw)
|
||||
if hash == p.lastHash && time.Since(p.lastSent) < p.heartbeat {
|
||||
return nil
|
||||
}
|
||||
rep.GeneratedAt = time.Now().UTC()
|
||||
resp, err := p.post(ctx, accountant.PathReport, rep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return responseError(resp)
|
||||
}
|
||||
p.lastHash, p.lastSent = hash, time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *payee) shutdown() {
|
||||
close(p.stop)
|
||||
<-p.done
|
||||
}
|
||||
|
||||
// buildReport erzeugt den Schnappschuss (ohne GeneratedAt).
|
||||
func (s *Server) buildReport(ctx context.Context) (accountant.Report, error) {
|
||||
rep := accountant.Report{Version: s.cfg.Version, Tenants: []accountant.ReportTenant{},
|
||||
Nodes: []accountant.ReportNode{}, Routes: []accountant.ReportRoute{}}
|
||||
tenants, err := s.store.ListTenants(ctx)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
for _, t := range tenants {
|
||||
rep.Tenants = append(rep.Tenants, accountant.ReportTenant{ID: t.ID, Name: t.Name, Tier: string(t.Tier), Status: string(t.Status)})
|
||||
}
|
||||
nodes, err := s.store.ListNodes(ctx, "")
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
active := activeRouters(s.registry.Config())
|
||||
now := time.Now()
|
||||
for _, n := range nodes {
|
||||
rn := accountant.ReportNode{
|
||||
ID: n.ID, Tenant: n.TenantID, Hostname: n.Hostname, IP: n.IPAddress, Status: string(n.Status),
|
||||
Connected: s.hub.Connected(n.ID), Maintenance: n.Maintenance.Active(now), LastSeen: n.LastSeen.UTC(),
|
||||
}
|
||||
if st, ok := s.registry.State(n.ID); ok {
|
||||
if h := st.Host; h != nil {
|
||||
rn.FQDN, rn.OS, rn.DockerVersion, rn.NetBirdIP = h.FQDN, h.OS, h.DockerVersion, h.NetBirdIP
|
||||
}
|
||||
rep.Routes = append(rep.Routes, reportRoutes(n.TenantID, n.ID, st, active)...)
|
||||
}
|
||||
rep.Nodes = append(rep.Nodes, rn)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// activeRouters: ob ein (qualifizierter) Router in der Traefik-Konfiguration steht.
|
||||
func activeRouters(c *traefik.Configuration) func(proto, name string) bool {
|
||||
return func(proto, name string) bool {
|
||||
switch {
|
||||
case c == nil:
|
||||
return false
|
||||
case proto == "tcp":
|
||||
return c.TCP != nil && c.TCP.Routers[name] != nil
|
||||
default:
|
||||
return c.HTTP != nil && c.HTTP.Routers[name] != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportRoutes übersetzt den Zustand eines Knotens in Berichtszeilen, sortiert
|
||||
// nach Workload, Protokoll, Router. Workloads mit unlesbaren Labels fehlen
|
||||
// (die Registry meldet sie als Verstoß).
|
||||
func reportRoutes(tenantID, nodeID string, st registry.NodeState, active func(proto, name string) bool) []accountant.ReportRoute {
|
||||
fqdn := ""
|
||||
if st.Host != nil {
|
||||
fqdn = st.Host.FQDN
|
||||
}
|
||||
down := map[string]bool{}
|
||||
for _, d := range st.DownServices {
|
||||
down[d] = true
|
||||
}
|
||||
prefix := tenantID + "__" + nodeID + "__"
|
||||
var out []accountant.ReportRoute
|
||||
for _, w := range st.Workloads {
|
||||
cfg, err := traefik.FromLabels(w.Host, w.Labels)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
base := accountant.ReportRoute{Tenant: tenantID, Node: nodeID, Workload: w.Name, Product: labelValue(w.Labels, accountant.LabelBilling)}
|
||||
if m := w.Meta; m != nil {
|
||||
base.ComposeProject, base.ComposeService, base.ComposeDir = m.ComposeProject, m.ComposeService, m.ComposeDir
|
||||
base.State, base.StartedAt, base.Health = m.State, m.StartedAt, m.Health
|
||||
}
|
||||
if cfg.HTTP != nil {
|
||||
for name, r := range cfg.HTTP.Routers {
|
||||
route := base
|
||||
route.Router, route.Protocol = name, "http"
|
||||
route.Hosts = sortedHosts(policy.HostsFromRule(r.Rule))
|
||||
if svc := cfg.HTTP.Services[r.Service]; svc != nil && svc.LoadBalancer != nil && len(svc.LoadBalancer.Servers) > 0 {
|
||||
route.Backend = svc.LoadBalancer.Servers[0].URL
|
||||
if u, err := url.Parse(route.Backend); err == nil && fqdn != "" && u.Port() != "" {
|
||||
route.BackendFQDN = net.JoinHostPort(fqdn, u.Port())
|
||||
}
|
||||
}
|
||||
route.Active, route.Down = active("http", prefix+name), down[r.Service]
|
||||
out = append(out, route)
|
||||
}
|
||||
}
|
||||
if cfg.TCP != nil {
|
||||
for name, r := range cfg.TCP.Routers {
|
||||
route := base
|
||||
route.Router, route.Protocol = name, "tcp"
|
||||
route.Hosts = sortedHosts(policy.HostsFromSNIRule(r.Rule))
|
||||
if svc := cfg.TCP.Services[r.Service]; svc != nil && svc.LoadBalancer != nil && len(svc.LoadBalancer.Servers) > 0 {
|
||||
route.Backend = svc.LoadBalancer.Servers[0].Address
|
||||
if _, port, err := net.SplitHostPort(route.Backend); err == nil && fqdn != "" {
|
||||
route.BackendFQDN = net.JoinHostPort(fqdn, port)
|
||||
}
|
||||
}
|
||||
route.Active, route.Down = active("tcp", prefix+name), down[r.Service]
|
||||
out = append(out, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b accountant.ReportRoute) int {
|
||||
return cmp.Or(strings.Compare(a.Workload, b.Workload), strings.Compare(a.Protocol, b.Protocol), strings.Compare(a.Router, b.Router))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedHosts(hosts []string, err error) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
out = append(out, strings.ToLower(h))
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// labelValue sucht einen Label-Wert ohne Beachtung der Groß-/Kleinschreibung.
|
||||
func labelValue(labels map[string]string, key string) string {
|
||||
for k, v := range labels {
|
||||
if strings.EqualFold(k, key) {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/registry"
|
||||
)
|
||||
|
||||
// Aus dem gemeldeten Zustand eines Knotens entstehen die Berichtszeilen:
|
||||
// Hosts, Ziel (IP und FQDN), Compose-Angaben, Produkt, down/aktiv.
|
||||
func TestReportRoutes(t *testing.T) {
|
||||
started := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
|
||||
st := registry.NodeState{
|
||||
Host: ®istry.HostInfo{FQDN: "webdocker11.lan.r25dc.de"},
|
||||
Workloads: []registry.Workload{
|
||||
{
|
||||
Name: "kunde-a-wordpress-1", Host: "10.0.0.11",
|
||||
Labels: map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.wp.rule": "Host(`www.kunde.de`) || Host(`kunde.de`)",
|
||||
"traefik.http.services.wp.loadbalancer.server.port": "8081",
|
||||
"sultan.accountant.billing": "web-hosting",
|
||||
},
|
||||
Meta: ®istry.WorkloadMeta{ComposeProject: "kunde-a", ComposeService: "wordpress",
|
||||
ComposeDir: "/srv/kunde-a", State: "running", StartedAt: started, Health: "healthy"},
|
||||
},
|
||||
{
|
||||
Name: "olat", Host: "10.0.0.11",
|
||||
Labels: map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.olat.rule": "Host(`olat.kunde.de`)",
|
||||
"traefik.http.services.olat.loadbalancer.server.port": "8090",
|
||||
},
|
||||
Meta: ®istry.WorkloadMeta{State: "exited"},
|
||||
},
|
||||
{
|
||||
Name: "ca", Host: "10.0.0.11",
|
||||
Labels: map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.kunde.de`)",
|
||||
"traefik.tcp.routers.ca.entrypoints": "websecure",
|
||||
"traefik.tcp.routers.ca.tls.passthrough": "true",
|
||||
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
|
||||
},
|
||||
},
|
||||
{Name: "kaputt", Host: "10.0.0.11", Labels: map[string]string{"traefik.enable": "vielleicht"}},
|
||||
},
|
||||
DownServices: []string{"olat"},
|
||||
}
|
||||
active := map[string]bool{"http:kunde__webdocker11__wp": true, "tcp:kunde__webdocker11__ca": true}
|
||||
got := reportRoutes("kunde", "webdocker11", st, func(proto, name string) bool { return active[proto+":"+name] })
|
||||
|
||||
want := []accountant.ReportRoute{
|
||||
{Tenant: "kunde", Node: "webdocker11", Workload: "ca", Router: "ca", Protocol: "tcp",
|
||||
Hosts: []string{"ca.kunde.de"}, Backend: "10.0.0.11:9000", BackendFQDN: "webdocker11.lan.r25dc.de:9000", Active: true},
|
||||
{Tenant: "kunde", Node: "webdocker11", Workload: "kunde-a-wordpress-1", Router: "wp", Protocol: "http",
|
||||
Hosts: []string{"kunde.de", "www.kunde.de"}, Backend: "http://10.0.0.11:8081",
|
||||
BackendFQDN: "webdocker11.lan.r25dc.de:8081", Active: true, Product: "web-hosting",
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy"},
|
||||
{Tenant: "kunde", Node: "webdocker11", Workload: "olat", Router: "olat", Protocol: "http",
|
||||
Hosts: []string{"olat.kunde.de"}, Backend: "http://10.0.0.11:8090",
|
||||
BackendFQDN: "webdocker11.lan.r25dc.de:8090", Active: false, Down: true, State: "exited"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("reportRoutes =\n%+v\nerwartet\n%+v", got, want)
|
||||
}
|
||||
|
||||
// Ohne Host-Infos (älterer Agent): kein FQDN-Ziel, Rest bleibt.
|
||||
st.Host = nil
|
||||
for _, r := range reportRoutes("kunde", "webdocker11", st, func(string, string) bool { return true }) {
|
||||
if r.BackendFQDN != "" || r.Backend == "" {
|
||||
t.Errorf("ohne Host-Infos: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
"sultan/pkg/registry"
|
||||
)
|
||||
|
||||
// startAccountant startet einen echten Accountant; addr "" = freier Port.
|
||||
func startAccountant(t *testing.T, dataDir, addr string) *accountant.Accountant {
|
||||
t.Helper()
|
||||
if dataDir == "" {
|
||||
dataDir = filepath.Join(t.TempDir(), "accdata")
|
||||
}
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:0"
|
||||
}
|
||||
a, err := accountant.New(ctx, accountant.Config{DataDir: dataDir, Addr: addr, 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
|
||||
}
|
||||
|
||||
// accInvite erzeugt eine Einladung wie `sultan accountant invite`.
|
||||
func accInvite(t *testing.T, a *accountant.Accountant, payeeID string) string {
|
||||
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.String()
|
||||
}
|
||||
|
||||
func latestReport(t *testing.T, a *accountant.Accountant, payeeID string) (accountant.Report, time.Time, bool) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
body, at, err := l.LatestReport(ctx, payeeID)
|
||||
if err != nil {
|
||||
return accountant.Report{}, time.Time{}, false
|
||||
}
|
||||
var r accountant.Report
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r, at, true
|
||||
}
|
||||
|
||||
// payeeConfig: Server meldet an den Accountant mit kurzen Intervallen.
|
||||
func payeeConfig(token string, heartbeat time.Duration) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.AccountantToken = token
|
||||
c.AccountantInterval = 20 * time.Millisecond
|
||||
c.AccountantHeartbeat = heartbeat
|
||||
c.Version = "v-test"
|
||||
}
|
||||
}
|
||||
|
||||
func metaState(domain string) registry.NodeState {
|
||||
st := wpState(domain)
|
||||
st.Workloads[0].Meta = ®istry.WorkloadMeta{ComposeProject: "kunde-a", ComposeDir: "/srv/kunde-a", State: "running"}
|
||||
st.Host = ®istry.HostInfo{FQDN: "webdocker11.lan.r25dc.de", OS: "Debian GNU/Linux 13 (trixie)", DockerVersion: "29.8.2"}
|
||||
return st
|
||||
}
|
||||
|
||||
// Der ganze Weg: Einladung → Enrollment beim Start → Bericht mit Mandanten,
|
||||
// Knoten und Routen samt FQDN-Ziel und Compose-Verzeichnis.
|
||||
func TestPayee_EnrollsAndReports(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour))
|
||||
agent, _ := connectedAgent(t, s, "node-1")
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/state", metaState("kunde.de")); r.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("State: %d", r.StatusCode)
|
||||
}
|
||||
|
||||
var rep accountant.Report
|
||||
eventually(t, "Bericht mit Zusatzdaten beim Accountant", func() bool {
|
||||
r, _, ok := latestReport(t, acc, "webproxy11")
|
||||
rep = r
|
||||
return ok && len(r.Routes) == 1 && r.Routes[0].ComposeDir != ""
|
||||
})
|
||||
if rep.Version != "v-test" || rep.GeneratedAt.IsZero() {
|
||||
t.Errorf("Kopf: %+v", rep)
|
||||
}
|
||||
if len(rep.Tenants) != 1 || rep.Tenants[0].ID != "kunde" || rep.Tenants[0].Tier != "pro" || rep.Tenants[0].Status != "active" {
|
||||
t.Errorf("Mandanten: %+v", rep.Tenants)
|
||||
}
|
||||
if len(rep.Nodes) != 1 {
|
||||
t.Fatalf("Knoten: %+v", rep.Nodes)
|
||||
}
|
||||
if n := rep.Nodes[0]; n.ID != "node-1" || n.Tenant != "kunde" || !n.Connected || n.FQDN != "webdocker11.lan.r25dc.de" ||
|
||||
n.OS != "Debian GNU/Linux 13 (trixie)" || n.DockerVersion != "29.8.2" || n.Hostname != "host-node-1" {
|
||||
t.Errorf("Knoten: %+v", n)
|
||||
}
|
||||
if r := rep.Routes[0]; r.Router != "wp" || strings.Join(r.Hosts, ",") != "kunde.de" || r.Backend != "http://100.64.0.42:80" ||
|
||||
r.BackendFQDN != "webdocker11.lan.r25dc.de:80" || !r.Active || r.ComposeProject != "kunde-a" {
|
||||
t.Errorf("Route: %+v", r)
|
||||
}
|
||||
|
||||
// Identität des Payees liegt im Datenverzeichnis des Servers.
|
||||
info, err := os.Stat(filepath.Join(s.cfg.DataDir, "payee.key"))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("payee.key: %v, %v", info, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(s.cfg.DataDir, "accountant.json"))
|
||||
var id payeeIdentity
|
||||
if err := json.Unmarshal(raw, &id); err != nil || id.PayeeID != "webproxy11" || id.Fingerprint != acc.Fingerprint() ||
|
||||
id.URL != "https://"+acc.Addr() {
|
||||
t.Errorf("accountant.json = %s (%v)", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Gesendet wird bei Änderungen und als Lebenszeichen – nicht bei jedem Prüfen.
|
||||
func TestPayee_ReportsChangesNotEveryTick(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour))
|
||||
agent, _ := connectedAgent(t, s, "node-1")
|
||||
|
||||
var first time.Time
|
||||
eventually(t, "erster Bericht", func() bool {
|
||||
r, at, ok := latestReport(t, acc, "webproxy11")
|
||||
first = at
|
||||
return ok && len(r.Routes) == 1
|
||||
})
|
||||
time.Sleep(200 * time.Millisecond) // zehn Prüfintervalle ohne Änderung
|
||||
if _, at, _ := latestReport(t, acc, "webproxy11"); !at.Equal(first) {
|
||||
t.Errorf("ohne Änderung erneut gesendet (%v → %v)", first, at)
|
||||
}
|
||||
|
||||
st := wpState("kunde.de")
|
||||
st.Workloads[0].Labels["traefik.http.routers.wp.rule"] = "Host(`kunde.de`) || Host(`www.kunde.de`)"
|
||||
agent.do(t, http.MethodPost, "/api/v1/state", st)
|
||||
eventually(t, "Änderung nachgemeldet", func() bool {
|
||||
r, _, _ := latestReport(t, acc, "webproxy11")
|
||||
return len(r.Routes) == 1 && len(r.Routes[0].Hosts) == 2
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayee_Heartbeat(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), 100*time.Millisecond))
|
||||
connectedAgent(t, s, "node-1")
|
||||
var first time.Time
|
||||
eventually(t, "erster Bericht", func() bool {
|
||||
_, at, ok := latestReport(t, acc, "webproxy11")
|
||||
first = at
|
||||
return ok
|
||||
})
|
||||
eventually(t, "Lebenszeichen ohne Änderung", func() bool {
|
||||
_, at, _ := latestReport(t, acc, "webproxy11")
|
||||
return at.After(first)
|
||||
})
|
||||
}
|
||||
|
||||
// Nach dem Neustart genügt accountant.json; die Einladung ist verbraucht.
|
||||
func TestPayee_RestartWithoutToken(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
dir := filepath.Join(t.TempDir(), "suldata")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir })
|
||||
var first time.Time
|
||||
eventually(t, "erster Bericht", func() bool {
|
||||
_, at, ok := latestReport(t, acc, "webproxy11")
|
||||
first = at
|
||||
return ok
|
||||
})
|
||||
s.Shutdown(ctx)
|
||||
|
||||
startServer(t, payeeConfig("", 50*time.Millisecond), func(c *Config) { c.DataDir = dir })
|
||||
eventually(t, "Bericht nach Neustart ohne Einladung", func() bool {
|
||||
_, at, _ := latestReport(t, acc, "webproxy11")
|
||||
return at.After(first)
|
||||
})
|
||||
}
|
||||
|
||||
// Ein nicht erreichbarer Accountant hält den Ingress nicht auf; der Server
|
||||
// holt das Enrollment nach.
|
||||
func TestPayee_AccountantDownAtStart(t *testing.T) {
|
||||
accDir := filepath.Join(t.TempDir(), "accdata")
|
||||
acc := startAccountant(t, accDir, "")
|
||||
addr := acc.Addr()
|
||||
token := accInvite(t, acc, "webproxy11")
|
||||
acc.Shutdown(ctx)
|
||||
|
||||
s, logs := startServer(t, payeeConfig(token, time.Hour))
|
||||
if r, err := http.Get("http://" + s.ProviderAddr() + "/healthz"); err != nil || r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Server ohne Accountant nicht bereit: %v", err)
|
||||
}
|
||||
eventually(t, "Warnung im Log", func() bool { return strings.Contains(logs.String(), "Enrollment fehlgeschlagen") })
|
||||
|
||||
acc = startAccountant(t, accDir, addr)
|
||||
eventually(t, "Enrollment nachgeholt", func() bool {
|
||||
_, _, ok := latestReport(t, acc, "webproxy11")
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
// Schon bei Accountant A registriert: eine Einladung von B wird nicht
|
||||
// eingelöst (kein stiller Wechsel), der Server läuft trotzdem.
|
||||
func TestPayee_OtherAccountantRejected(t *testing.T) {
|
||||
a := startAccountant(t, "", "")
|
||||
dir := filepath.Join(t.TempDir(), "suldata")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, a, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir })
|
||||
eventually(t, "bei A registriert", func() bool { _, _, ok := latestReport(t, a, "webproxy11"); return ok })
|
||||
s.Shutdown(ctx)
|
||||
|
||||
b := startAccountant(t, "", "")
|
||||
_, logs := startServer(t, payeeConfig(accInvite(t, b, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir })
|
||||
eventually(t, "Fehler im Log", func() bool { return strings.Contains(logs.String(), "anderen Accountant") })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if _, _, ok := latestReport(t, b, "webproxy11"); ok {
|
||||
t.Error("Bericht an B trotz Registrierung bei A")
|
||||
}
|
||||
}
|
||||
|
||||
// --accountant überschreibt die Adresse aus der Einladung.
|
||||
func TestPayee_URLOverride(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
l, _ := ledger.Open(ctx, filepath.Join(acc.DataDir(), "accountant.db"))
|
||||
tok, _ := crypto.NewEnrollmentToken(acc.Fingerprint(), "https://127.0.0.1:1") // falsche Adresse
|
||||
l.AddInvite(ctx, tok.Secret, "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Close()
|
||||
startServer(t, payeeConfig(tok.String(), time.Hour), func(c *Config) { c.AccountantURL = "https://" + acc.Addr() })
|
||||
eventually(t, "Bericht über die überschriebene Adresse", func() bool {
|
||||
_, _, ok := latestReport(t, acc, "webproxy11")
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayee_ConfigErrors(t *testing.T) {
|
||||
noURL, err := crypto.NewEnrollmentToken("PQeFR_XBBVMz70LWWvGsFsVrFT9SZVCvQk-HQvrBaTk", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"Einladung unlesbar": func(c *Config) { c.AccountantToken = "kaputt" },
|
||||
"Adresse ohne TLS": func(c *Config) { c.AccountantURL = "http://acc:8444" },
|
||||
"Einladung ohne jede Adresse": func(c *Config) { c.AccountantToken = noURL.String() },
|
||||
"Adresse ohne Einladung": func(c *Config) { c.AccountantURL = "https://acc:8444" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Config{DataDir: filepath.Join(t.TempDir(), "suldata"), Logger: slog.New(slog.DiscardHandler)}
|
||||
mutate(&cfg)
|
||||
if s, err := New(ctx, cfg); err == nil {
|
||||
s.Shutdown(ctx)
|
||||
t.Error("erwartet Fehler")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne Einladung und ohne accountant.json meldet der Server nichts.
|
||||
func TestPayee_Disabled(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := os.Stat(filepath.Join(s.cfg.DataDir, "payee.key")); !os.IsNotExist(err) {
|
||||
t.Errorf("payee.key ohne Accountant angelegt: %v", err)
|
||||
}
|
||||
}
|
||||
+48
-2
@@ -32,6 +32,7 @@ import (
|
||||
"sultan/pkg/lander"
|
||||
"sultan/pkg/notify"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/ratelimit"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/store"
|
||||
"sultan/pkg/stream"
|
||||
@@ -67,6 +68,16 @@ type Config struct {
|
||||
// -1 = unbegrenzt) – gegen das Durchprobieren von Einladungen.
|
||||
EnrollLimit int
|
||||
|
||||
// Accountant (Spec 5.3): Einladung (nur beim ersten Start) bzw.
|
||||
// Adress-Override; ohne beides und ohne accountant.json meldet der
|
||||
// Server nichts. Interval: Prüfen auf Änderungen (Default 10s),
|
||||
// Heartbeat: Lebenszeichen ohne Änderung (Default 1h).
|
||||
AccountantToken string
|
||||
AccountantURL string
|
||||
AccountantInterval time.Duration
|
||||
AccountantHeartbeat time.Duration
|
||||
Version string // für den Bericht (sultan version)
|
||||
|
||||
interceptorDerived bool // InterceptorURL folgt der gebundenen Lander-Adresse
|
||||
}
|
||||
|
||||
@@ -99,6 +110,12 @@ func (c Config) withDefaults() Config {
|
||||
if c.EnrollLimit == 0 {
|
||||
c.EnrollLimit = 10
|
||||
}
|
||||
if c.AccountantInterval <= 0 {
|
||||
c.AccountantInterval = 10 * time.Second
|
||||
}
|
||||
if c.AccountantHeartbeat <= 0 {
|
||||
c.AccountantHeartbeat = time.Hour
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
}
|
||||
@@ -141,7 +158,8 @@ type Server struct {
|
||||
registry *registry.Registry
|
||||
lander *lander.Lander
|
||||
notifier notify.Target // nil = keine Benachrichtigungen
|
||||
enrollRL *rateLimiter
|
||||
enrollRL *ratelimit.Limiter
|
||||
payee *payee // nil = meldet nicht an einen Accountant
|
||||
|
||||
vmu sync.Mutex
|
||||
violations []time.Time // Zeitpunkte der Policy-Verstöße (für Checkmk)
|
||||
@@ -151,6 +169,7 @@ type Server struct {
|
||||
providerAddr string
|
||||
landerAddr string
|
||||
agentSrv *http.Server
|
||||
payeeRunning bool
|
||||
providerSrv *http.Server
|
||||
landerSrv *http.Server
|
||||
serveErr chan error
|
||||
@@ -199,6 +218,11 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
pay, err := newPayee(cfg, cfg.Logger)
|
||||
if err != nil {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
@@ -209,9 +233,13 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
verifier: &auth.Verifier{Directory: st},
|
||||
serveErr: make(chan error, 3),
|
||||
stopRefresh: make(chan struct{}),
|
||||
payee: pay,
|
||||
}
|
||||
if pay != nil {
|
||||
pay.s = s
|
||||
}
|
||||
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline, SigningKey: master}
|
||||
s.enrollRL = &rateLimiter{limit: cfg.EnrollLimit, window: time.Minute}
|
||||
s.enrollRL = &ratelimit.Limiter{Limit: cfg.EnrollLimit, Window: time.Minute}
|
||||
s.notifier = cfg.Notify
|
||||
if s.notifier == nil {
|
||||
var targets notify.Multi
|
||||
@@ -260,6 +288,12 @@ func loadOrCreateMasterKey(path string) (ed25519.PrivateKey, error) {
|
||||
// Fingerprint ist der Fingerprint des TLS-Zertifikats (für Enrollment-Tokens).
|
||||
func (s *Server) Fingerprint() crypto.Fingerprint { return s.cert.Fingerprint }
|
||||
|
||||
// NodeState liefert den zuletzt gemeldeten Zustand eines verbundenen
|
||||
// Knotens (Kopie, inkl. Zusatzdaten für das Inventar).
|
||||
func (s *Server) NodeState(nodeID string) (registry.NodeState, bool) {
|
||||
return s.registry.State(nodeID)
|
||||
}
|
||||
|
||||
// MasterPublicKey ist der Schlüssel, mit dem Lizenzen geprüft werden.
|
||||
func (s *Server) MasterPublicKey() ed25519.PublicKey {
|
||||
return s.master.Public().(ed25519.PublicKey)
|
||||
@@ -338,6 +372,12 @@ func (s *Server) Start() error {
|
||||
go s.serve(func() error { return landerSrv.Serve(landerLn) })
|
||||
}
|
||||
go s.refreshLoop()
|
||||
if s.payee != nil {
|
||||
s.mu.Lock()
|
||||
s.payeeRunning = true
|
||||
s.mu.Unlock()
|
||||
go s.payee.run()
|
||||
}
|
||||
|
||||
s.log.Info("Sultan-Server gestartet",
|
||||
"agent", s.AgentAddr(), "provider", s.ProviderAddr(), "lander", s.LanderAddr(),
|
||||
@@ -405,6 +445,12 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
errs = append(errs, srv.Shutdown(ctx))
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
payeeRunning := s.payeeRunning
|
||||
s.mu.Unlock()
|
||||
if payeeRunning {
|
||||
s.payee.shutdown() // vor dem Schließen der Datenbank
|
||||
}
|
||||
errs = append(errs, s.store.Close())
|
||||
s.shutdownErr = errors.Join(errs...)
|
||||
s.log.Info("Sultan-Server beendet")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenDB stellt Migrationsmechanik und Zeitformat auch anderen Datenbanken
|
||||
// bereit (z. B. accountant.db).
|
||||
func TestOpenDB_Migrations(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "x.db")
|
||||
v1 := []string{`CREATE TABLE a (id TEXT PRIMARY KEY, at DATETIME NOT NULL);`}
|
||||
db, err := OpenDB(ctx, path, v1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Date(2026, 10, 16, 8, 15, 30, 5, time.FixedZone("CEST", 2*3600))
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO a VALUES ('x', ?)`, FormatTime(at)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got time.Time
|
||||
if err := db.QueryRowContext(ctx, `SELECT at FROM a`).Scan(ScanTime(&got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(at) || got.Location() != time.UTC {
|
||||
t.Errorf("Zeit = %v, erwartet %v in UTC", got, at)
|
||||
}
|
||||
var null time.Time
|
||||
if err := db.QueryRowContext(ctx, `SELECT NULL`).Scan(ScanTime(&null)); err != nil || !null.IsZero() {
|
||||
t.Errorf("NULL = %v, %v", null, err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Neue Migration wird angehängt, bestehende Daten bleiben.
|
||||
v2 := append(v1, `ALTER TABLE a ADD COLUMN note TEXT NOT NULL DEFAULT '';`)
|
||||
db, err = OpenDB(ctx, path, v2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM a WHERE note = ''`).Scan(&n); err != nil || n != 1 {
|
||||
t.Errorf("nach Migration: n=%d err=%v", n, err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Ältere Programmversion verweigert die neuere Datenbank.
|
||||
if _, err := OpenDB(ctx, path, v1); !errors.Is(err, ErrSchemaTooNew) {
|
||||
t.Errorf("ältere Version: %v, erwartet ErrSchemaTooNew", err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ sql.Scanner = ScanTime(nil)
|
||||
+30
-7
@@ -111,6 +111,19 @@ type Store struct {
|
||||
// Open öffnet (bzw. erstellt) die Datenbank unter path und bringt das Schema
|
||||
// auf den aktuellen Stand.
|
||||
func Open(ctx context.Context, path string) (*Store, error) {
|
||||
db, err := OpenDB(ctx, path, migrations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// OpenDB öffnet (bzw. erstellt) eine SQLite-Datenbank mit den Einstellungen
|
||||
// des Servers und wendet migrations an: migrations[i] hebt das Schema von
|
||||
// Version i auf i+1 (Version in PRAGMA user_version). Die Liste darf nur
|
||||
// wachsen; eine neuere Datenbank liefert ErrSchemaTooNew. Für weitere
|
||||
// Datenbanken wie accountant.db.
|
||||
func OpenDB(ctx context.Context, path string, migrations []string) (*sql.DB, error) {
|
||||
// foreign_keys: sonst greift ON DELETE CASCADE nicht (gilt pro Verbindung).
|
||||
// _txlock=immediate: Schreib-Transaktionen holen die Sperre sofort und
|
||||
// warten per busy_timeout, statt beim Upgrade mit SQLITE_BUSY abzubrechen.
|
||||
@@ -120,12 +133,11 @@ func Open(ctx context.Context, path string) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db, now: time.Now}
|
||||
if err := s.migrate(ctx); err != nil {
|
||||
if err := migrate(ctx, db, migrations); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close schließt die Datenbank.
|
||||
@@ -133,13 +145,17 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// SchemaVersion liefert die aktuelle Schema-Version.
|
||||
func (s *Store) SchemaVersion(ctx context.Context) (int, error) {
|
||||
return schemaVersion(ctx, s.db)
|
||||
}
|
||||
|
||||
func schemaVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
|
||||
err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
v, err := s.SchemaVersion(ctx)
|
||||
func migrate(ctx context.Context, db *sql.DB, migrations []string) error {
|
||||
v, err := schemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: Schema-Version lesen: %w", err)
|
||||
}
|
||||
@@ -147,7 +163,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
return fmt.Errorf("%w (Datenbank v%d, bekannt bis v%d)", ErrSchemaTooNew, v, len(migrations))
|
||||
}
|
||||
for i := v; i < len(migrations); i++ {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,6 +188,13 @@ const timeLayout = "2006-01-02T15:04:05.000000000Z"
|
||||
|
||||
func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) }
|
||||
|
||||
// FormatTime formatiert t im Speicherformat (UTC, feste Breite) – für
|
||||
// Datenbanken, die OpenDB nutzen.
|
||||
func FormatTime(t time.Time) string { return formatTime(t) }
|
||||
|
||||
// ScanTime liest einen so gespeicherten Zeitstempel nach t (NULL = Nullzeit).
|
||||
func ScanTime(t *time.Time) sql.Scanner { return dbTime{t} }
|
||||
|
||||
// normTime entfernt Monotonic-Clock und Zeitzone, damit gespeicherte und
|
||||
// gelesene Werte identisch sind.
|
||||
func normTime(t time.Time) time.Time { return t.Round(0).UTC() }
|
||||
|
||||
Reference in new issue
Block a user