Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+333
View File
@@ -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
}
+260
View File
@@ -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)
}
}
+296
View File
@@ -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()
}
+203
View File
@@ -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)")
}
}
+329
View File
@@ -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)))
}
+271
View File
@@ -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, "&lt;script&gt;") {
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())
}
}
+60
View File
@@ -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)
}
}
}
+45
View File
@@ -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)
}
+137
View File
@@ -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)
}
+144
View File
@@ -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)
}
}
+236
View File
@@ -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
}
+136
View File
@@ -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)
}
}