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)
}
}
+18 -8
View File
@@ -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
View File
@@ -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 := &registry.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"
})
}
+165
View File
@@ -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 := &registry.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
}
+96
View File
@@ -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
View File
@@ -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 &registry.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 {
+38
View File
@@ -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: &registry.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])
}
}
}
+4
View File
@@ -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 {
+516
View File
@@ -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()
}
+471
View File
@@ -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)
}
}
+1
View File
@@ -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
+2 -2
View File
@@ -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 {
+7
View File
@@ -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
+20
View File
@@ -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)
}
}
+267
View File
@@ -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 = &cents
}
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
}
+319
View File
@@ -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 &cents, 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
View File
@@ -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.
+61
View File
@@ -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)
}
}
+299
View File
@@ -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 ""
}
+118
View File
@@ -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 = &cents
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)
}
}
+485
View File
@@ -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
}
+280
View File
@@ -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)
}
}
+326
View File
@@ -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
}
+124
View File
@@ -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
View File
@@ -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)
+15 -15
View File
@@ -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")
}
}
+58
View File
@@ -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 {
+62
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+401
View File
@@ -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 ""
}
+79
View File
@@ -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: &registry.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: &registry.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: &registry.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)
}
}
}
+284
View File
@@ -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 = &registry.WorkloadMeta{ComposeProject: "kunde-a", ComposeDir: "/srv/kunde-a", State: "running"}
st.Host = &registry.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
View File
@@ -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")
+55
View File
@@ -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
View File
@@ -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() }