Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -0,0 +1,333 @@
|
||||
// Package accountant ist `sultan accountant` (Spec 5.3): die Zentrale, an
|
||||
// die Sultan-Server (Payees) ihr Inventar melden – für Störfall
|
||||
// (URL → Backend-Host, Compose-Verzeichnis), Mandanten-Übersicht und
|
||||
// Abrechnung.
|
||||
//
|
||||
// Gleiches Sicherheitsmodell wie Agent → Server: eigenes selbstsigniertes
|
||||
// Zertifikat (der Payee pinnt den Fingerprint aus der Einladung), nur TLS
|
||||
// 1.3, jeder Request Ed25519-signiert. Im Datenverzeichnis (Default
|
||||
// ./accdata) liegen accountant.db und server.crt/server.key.
|
||||
//
|
||||
// Umgesetzt: Datenverzeichnis, Zertifikat, Datenbank, Enrollment der
|
||||
// Payees, Empfang ihrer Berichte (letzter Stand je Payee) und
|
||||
// Checkmk-Ausgabe auf einem Loopback-Port. Historie und Abrechnung folgen
|
||||
// (Roadmap 6d).
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
"sultan/pkg/ratelimit"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// PathEnroll ist der Enrollment-Endpunkt für Payees.
|
||||
const PathEnroll = "/api/v1/enroll"
|
||||
|
||||
// SettingPublicURLs: gemerkte --public-url (JSON-Liste); die erste steckt in
|
||||
// Einladungen.
|
||||
const SettingPublicURLs = "public_urls"
|
||||
|
||||
// EnrollRequest ist der Body von POST /api/v1/enroll (signiert mit dem
|
||||
// neuen payee.key).
|
||||
type EnrollRequest struct {
|
||||
Token string `json:"token"` // sltn_enroll_<secret>, ohne Fingerprint/URL
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// EnrollResponse ist die Antwort auf ein erfolgreiches Enrollment.
|
||||
type EnrollResponse struct {
|
||||
PayeeID string `json:"payee_id"`
|
||||
}
|
||||
|
||||
// Config ist die Konfiguration; Nullwerte bekommen Defaults.
|
||||
type Config struct {
|
||||
DataDir string // Default ./accdata
|
||||
Addr string // Default :8444 (nur TLS)
|
||||
PublicURLs []string // Adressen für Einladungen; werden gemerkt
|
||||
// MetricsAddr: Checkmk-Ausgabe (Klartext, nur Loopback), Default
|
||||
// 127.0.0.1:8082; "none" schaltet ab.
|
||||
MetricsAddr string
|
||||
// EnrollLimit: Enrollment-Versuche pro Minute und Client-IP (Default 10,
|
||||
// -1 = unbegrenzt) – gegen Fluten (Log, Datenbank).
|
||||
EnrollLimit int
|
||||
// StaleWarn/StaleCrit: ab welchem Alter des letzten Berichts ein Payee
|
||||
// (und seine URLs) WARN bzw. CRIT ist (Default 2h/6h).
|
||||
StaleWarn, StaleCrit time.Duration
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
c.DataDir = cmp.Or(c.DataDir, "./accdata")
|
||||
c.Addr = cmp.Or(c.Addr, ":8444")
|
||||
c.MetricsAddr = cmp.Or(c.MetricsAddr, "127.0.0.1:8082")
|
||||
if c.EnrollLimit == 0 {
|
||||
c.EnrollLimit = 10
|
||||
}
|
||||
if c.StaleWarn <= 0 {
|
||||
c.StaleWarn = 2 * time.Hour
|
||||
}
|
||||
if c.StaleCrit <= 0 {
|
||||
c.StaleCrit = 6 * time.Hour
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Accountant ist ein startbereiter Accountant.
|
||||
type Accountant struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
ledger *ledger.Ledger
|
||||
cert crypto.ServerCert
|
||||
verifier *auth.Verifier
|
||||
// reportVerifier: wie verifier, aber für große Berichte.
|
||||
reportVerifier *auth.Verifier
|
||||
|
||||
enrollRL *ratelimit.Limiter
|
||||
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
metricsAddr string
|
||||
srv *http.Server
|
||||
metricsSrv *http.Server
|
||||
serveErr chan error
|
||||
shutdownOnce sync.Once
|
||||
shutdownErr error
|
||||
}
|
||||
|
||||
// New legt das Datenverzeichnis an (0700) und lädt bzw. erzeugt Datenbank
|
||||
// und Zertifikat. Lauschen beginnt erst mit Start.
|
||||
func New(ctx context.Context, cfg Config) (*Accountant, error) {
|
||||
cfg = cfg.withDefaults()
|
||||
var urls []string
|
||||
for _, raw := range cfg.PublicURLs {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
u, err := crypto.ValidateServerURL(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("accountant: --public-url: %w", err)
|
||||
}
|
||||
urls = append(urls, u)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("accountant: Datenverzeichnis: %w", err)
|
||||
}
|
||||
l, err := ledger.Open(ctx, filepath.Join(cfg.DataDir, "accountant.db"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(urls) > 0 {
|
||||
raw, _ := json.Marshal(urls)
|
||||
if err := l.SetSetting(ctx, SettingPublicURLs, string(raw)); err != nil {
|
||||
l.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir)
|
||||
if err != nil {
|
||||
l.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Accountant{
|
||||
cfg: cfg,
|
||||
log: cfg.Logger,
|
||||
ledger: l,
|
||||
cert: cert,
|
||||
verifier: &auth.Verifier{},
|
||||
reportVerifier: &auth.Verifier{MaxBodySize: maxReportSize},
|
||||
enrollRL: &ratelimit.Limiter{Limit: cfg.EnrollLimit, Window: time.Minute},
|
||||
serveErr: make(chan error, 2),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadPublicURLs liefert die gemerkten Adressen (für `accountant invite`).
|
||||
func LoadPublicURLs(ctx context.Context, l *ledger.Ledger) ([]string, error) {
|
||||
raw, err := l.Setting(ctx, SettingPublicURLs)
|
||||
if err != nil || raw == "" {
|
||||
return nil, err
|
||||
}
|
||||
var urls []string
|
||||
if err := json.Unmarshal([]byte(raw), &urls); err != nil {
|
||||
return nil, fmt.Errorf("accountant: Einstellung %s: %w", SettingPublicURLs, err)
|
||||
}
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// Fingerprint ist der Fingerprint des TLS-Zertifikats (für Einladungen).
|
||||
func (a *Accountant) Fingerprint() crypto.Fingerprint { return a.cert.Fingerprint }
|
||||
|
||||
// DataDir ist das Datenverzeichnis.
|
||||
func (a *Accountant) DataDir() string { return a.cfg.DataDir }
|
||||
|
||||
// Addr ist die tatsächlich gebundene Adresse ("" vor Start).
|
||||
func (a *Accountant) Addr() string { a.mu.Lock(); defer a.mu.Unlock(); return a.addr }
|
||||
|
||||
// MetricsAddr ist die gebundene Adresse der Checkmk-Ausgabe ("" wenn aus).
|
||||
func (a *Accountant) MetricsAddr() string { a.mu.Lock(); defer a.mu.Unlock(); return a.metricsAddr }
|
||||
|
||||
// Start bindet die Ports und beginnt zu bedienen; er kehrt sofort zurück.
|
||||
func (a *Accountant) Start() error {
|
||||
ln, err := net.Listen("tcp", a.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("accountant: Port %s: %w", a.cfg.Addr, err)
|
||||
}
|
||||
var metricsLn net.Listener
|
||||
if a.cfg.MetricsAddr != "none" {
|
||||
if metricsLn, err = net.Listen("tcp", a.cfg.MetricsAddr); err != nil {
|
||||
ln.Close()
|
||||
return fmt.Errorf("accountant: Metrik-Port %s: %w", a.cfg.MetricsAddr, err)
|
||||
}
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: a.routes(),
|
||||
TLSConfig: crypto.ServerTLSConfig(a.cert),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(a.log.Handler(), slog.LevelDebug),
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.addr, a.srv = ln.Addr().String(), srv
|
||||
if metricsLn != nil {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /metrics/checkmk", a.handleCheckmk)
|
||||
a.metricsAddr = metricsLn.Addr().String()
|
||||
a.metricsSrv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
}
|
||||
metricsSrv := a.metricsSrv
|
||||
a.mu.Unlock()
|
||||
go a.serve(func() error { return srv.ServeTLS(ln, "", "") })
|
||||
if metricsSrv != nil {
|
||||
go a.serve(func() error { return metricsSrv.Serve(metricsLn) })
|
||||
}
|
||||
a.log.Info("Sultan-Accountant gestartet", "addr", a.Addr(), "metrics", a.MetricsAddr(),
|
||||
"Fingerprint", string(a.cert.Fingerprint), "data_dir", a.cfg.DataDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Accountant) serve(fn func() error) {
|
||||
if err := fn(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
a.serveErr <- err
|
||||
}
|
||||
}
|
||||
|
||||
// Run startet den Accountant und fährt ihn herunter, sobald ctx endet oder
|
||||
// der Port ausfällt.
|
||||
func (a *Accountant) Run(ctx context.Context) error {
|
||||
if err := a.Start(); err != nil {
|
||||
a.Shutdown(context.Background())
|
||||
return err
|
||||
}
|
||||
var runErr error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case runErr = <-a.serveErr:
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return errors.Join(runErr, a.Shutdown(sctx))
|
||||
}
|
||||
|
||||
// Shutdown beendet den HTTP-Server und schließt die Datenbank; mehrfacher
|
||||
// Aufruf ist erlaubt.
|
||||
func (a *Accountant) Shutdown(ctx context.Context) error {
|
||||
a.shutdownOnce.Do(func() {
|
||||
a.mu.Lock()
|
||||
srv, metricsSrv := a.srv, a.metricsSrv
|
||||
a.mu.Unlock()
|
||||
var errs []error
|
||||
for _, s := range []*http.Server{srv, metricsSrv} {
|
||||
if s != nil {
|
||||
errs = append(errs, s.Shutdown(ctx))
|
||||
}
|
||||
}
|
||||
errs = append(errs, a.ledger.Close())
|
||||
a.shutdownErr = errors.Join(errs...)
|
||||
})
|
||||
return a.shutdownErr
|
||||
}
|
||||
|
||||
func (a *Accountant) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST "+PathEnroll, a.handleEnroll)
|
||||
mux.HandleFunc("POST "+PathReport, a.handleReport)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (a *Accountant) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
|
||||
if ok, retry := a.enrollRL.Allow(remoteIP(r)); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
|
||||
a.log.Warn("Payee-Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
|
||||
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
pub, err := a.verifier.VerifyUnregistered(r)
|
||||
if err != nil {
|
||||
code := auth.StatusCode(err)
|
||||
http.Error(w, http.StatusText(code), code)
|
||||
return
|
||||
}
|
||||
var req EnrollRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger Body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Hostname) == "" {
|
||||
http.Error(w, "hostname fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p, err := a.ledger.Enroll(r.Context(), req.Token, ledger.Payee{
|
||||
Hostname: req.Hostname,
|
||||
PublicKey: auth.EncodePublicKey(pub),
|
||||
IPAddress: remoteIP(r),
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrTokenUsed), errors.Is(err, store.ErrTokenExpired):
|
||||
// Keine Details nach außen, ob unbekannt, verbraucht oder abgelaufen.
|
||||
a.log.Warn("Payee-Enrollment abgelehnt", "ip", remoteIP(r), "err", err)
|
||||
http.Error(w, "Einladung ungültig", http.StatusForbidden)
|
||||
return
|
||||
case errors.Is(err, store.ErrExists):
|
||||
http.Error(w, "Payee oder Schlüssel bereits registriert", http.StatusConflict)
|
||||
return
|
||||
case errors.Is(err, store.ErrInvalid):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
case err != nil:
|
||||
a.log.Error("Payee-Enrollment", "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.log.Info("Payee registriert", "payee", p.ID, "hostname", p.Hostname, "ip", p.IPAddress)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(EnrollResponse{PayeeID: p.ID})
|
||||
}
|
||||
|
||||
func remoteIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func start(t *testing.T, dataDir string) *Accountant {
|
||||
t.Helper()
|
||||
if dataDir == "" {
|
||||
dataDir = filepath.Join(t.TempDir(), "accdata")
|
||||
}
|
||||
a, err := New(ctx, Config{DataDir: dataDir, Addr: "127.0.0.1:0", MetricsAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Shutdown(ctx) })
|
||||
return a
|
||||
}
|
||||
|
||||
// invite legt eine Einladung direkt im Ledger an (wie `sultan accountant invite`).
|
||||
func invite(t *testing.T, a *Accountant, payeeID string) crypto.EnrollmentToken {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
tok, err := crypto.NewEnrollmentToken(a.Fingerprint(), "https://"+a.Addr())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.AddInvite(ctx, tok.Secret, payeeID, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// enroll spielt den Payee: gepinnt, signiert.
|
||||
func enroll(t *testing.T, a *Accountant, key ed25519.PrivateKey, body any) *http.Response {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://"+a.Addr()+PathEnroll, bytes.NewReader(raw))
|
||||
if key != nil {
|
||||
if err := (&auth.Signer{Key: key}).Sign(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.Fingerprint())}}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func payees(t *testing.T, a *Accountant) []ledger.Payee {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
list, err := l.ListPayees(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func TestNew_CreatesDataDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "accdata")
|
||||
a := start(t, dir)
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || info.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("Datenverzeichnis: %v, %v", info, err)
|
||||
}
|
||||
for _, f := range []string{"accountant.db", "server.crt", "server.key"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
t.Errorf("%s fehlt: %v", f, err)
|
||||
}
|
||||
}
|
||||
fp := a.Fingerprint()
|
||||
a.Shutdown(ctx)
|
||||
|
||||
// Neustart: gleiche Identität.
|
||||
b := start(t, dir)
|
||||
if b.Fingerprint() != fp {
|
||||
t.Errorf("Fingerprint nach Neustart %s, vorher %s", b.Fingerprint(), fp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_PublicURLsRemembered(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "accdata")
|
||||
a, err := New(ctx, Config{DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: []string{"https://acc.example.de:8444", ""},
|
||||
Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Shutdown(ctx)
|
||||
l, _ := ledger.Open(ctx, filepath.Join(dir, "accountant.db"))
|
||||
defer l.Close()
|
||||
urls, err := LoadPublicURLs(ctx, l)
|
||||
if err != nil || len(urls) != 1 || urls[0] != "https://acc.example.de:8444" {
|
||||
t.Errorf("LoadPublicURLs = %v, %v", urls, err)
|
||||
}
|
||||
if _, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"http://klartext:8444"}}); err == nil {
|
||||
t.Error("http-URL akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnroll(t *testing.T) {
|
||||
a := start(t, "")
|
||||
tok := invite(t, a, "webproxy11")
|
||||
key, _ := auth.GenerateKey()
|
||||
|
||||
resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: "webproxy11.lan.r25dc.de"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Status %d", resp.StatusCode)
|
||||
}
|
||||
var er EnrollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil || er.PayeeID != "webproxy11" {
|
||||
t.Errorf("Antwort %+v, %v", er, err)
|
||||
}
|
||||
list := payees(t, a)
|
||||
if len(list) != 1 || list[0].ID != "webproxy11" || list[0].Hostname != "webproxy11.lan.r25dc.de" ||
|
||||
list[0].PublicKey != auth.EncodePublicKey(key.Public().(ed25519.PublicKey)) || list[0].IPAddress != "127.0.0.1" {
|
||||
t.Errorf("Payees = %+v", list)
|
||||
}
|
||||
|
||||
// Einladung ist verbraucht.
|
||||
other, _ := auth.GenerateKey()
|
||||
if resp := enroll(t, a, other, EnrollRequest{Token: tok.Secret, Hostname: "x"}); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("verbrauchte Einladung: Status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnroll_Errors(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key, _ := auth.GenerateKey()
|
||||
tok := invite(t, a, "webproxy11")
|
||||
tests := []struct {
|
||||
name string
|
||||
key ed25519.PrivateKey
|
||||
body any
|
||||
want int
|
||||
}{
|
||||
{"unsigniert", nil, EnrollRequest{Token: tok.Secret, Hostname: "h"}, http.StatusUnauthorized},
|
||||
{"unbekannte Einladung", key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}, http.StatusForbidden},
|
||||
{"ohne Hostname", key, EnrollRequest{Token: tok.Secret}, http.StatusBadRequest},
|
||||
{"kein JSON", key, "kaputt", http.StatusBadRequest},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if resp := enroll(t, a, tt.key, tt.body); resp.StatusCode != tt.want {
|
||||
t.Errorf("Status %d, erwartet %d", resp.StatusCode, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(payees(t, a)) != 0 {
|
||||
t.Error("Payee trotz Fehler angelegt")
|
||||
}
|
||||
// Einladung ist nach den Fehlversuchen noch gültig.
|
||||
if resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: "h"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("gültige Einladung danach: Status %d", resp.StatusCode)
|
||||
}
|
||||
// Derselbe Schlüssel ein zweites Mal: Konflikt.
|
||||
tok2 := invite(t, a, "webproxy12")
|
||||
if resp := enroll(t, a, key, EnrollRequest{Token: tok2.Secret, Hostname: "h"}); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("doppelter Schlüssel: Status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Kein Klartext: Der Port spricht nur TLS.
|
||||
func TestPlaintextRejected(t *testing.T) {
|
||||
a := start(t, "")
|
||||
resp, err := http.Post("http://"+a.Addr()+PathEnroll, "application/json", bytes.NewReader([]byte(`{}`)))
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("Klartext beantwortet mit %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_StopsOnCancel(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(rctx) }()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("Run: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run endet nicht")
|
||||
}
|
||||
}
|
||||
|
||||
// Fluten am Enrollment-Endpunkt werden je IP gebremst (Log und Datenbank
|
||||
// schonen); Raten ist bei 256 Bit Zufall ohnehin aussichtslos.
|
||||
func TestEnroll_RateLimited(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", EnrollLimit: 3,
|
||||
MetricsAddr: "none", Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Shutdown(ctx) })
|
||||
key, _ := auth.GenerateKey()
|
||||
for i := range 3 {
|
||||
if r := enroll(t, a, key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}); r.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("Versuch %d: %d", i+1, r.StatusCode)
|
||||
}
|
||||
}
|
||||
r := enroll(t, a, key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"})
|
||||
if r.StatusCode != http.StatusTooManyRequests || r.Header.Get("Retry-After") == "" {
|
||||
t.Errorf("4. Versuch: %d, Retry-After %q", r.StatusCode, r.Header.Get("Retry-After"))
|
||||
}
|
||||
// Auch unsignierte Fluten zählen.
|
||||
if r := enroll(t, a, nil, EnrollRequest{}); r.StatusCode != http.StatusTooManyRequests {
|
||||
t.Errorf("unsigniert: %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
c := Config{}.withDefaults()
|
||||
if c.DataDir != "./accdata" || c.Addr != ":8444" || c.MetricsAddr != "127.0.0.1:8082" || c.EnrollLimit != 10 ||
|
||||
c.StaleWarn != 2*time.Hour || c.StaleCrit != 6*time.Hour {
|
||||
t.Errorf("Defaults: %+v", c)
|
||||
}
|
||||
if c := (Config{EnrollLimit: -1}).withDefaults(); c.EnrollLimit != -1 {
|
||||
t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package accountant
|
||||
|
||||
// Monatsabrechnung (Spec 5.3, Roadmap 6d Schritt 4): je Kunde die
|
||||
// Pauschale und je Posten (Mandant, Protokoll, Router über alle Payees und
|
||||
// Knoten) Klasse, Produkt, Betrag – als CSV für die Buchhaltung (Lexware
|
||||
// Office). Preise sind optional und nicht buchhaltungsrelevant.
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// ItemFlatRate ist der Posten-Name der Pauschale eines Kunden.
|
||||
const ItemFlatRate = "Pauschale"
|
||||
|
||||
// BillLine ist eine Zeile der Monatsabrechnung.
|
||||
type BillLine struct {
|
||||
Month string // JJJJ-MM
|
||||
Customer string // "" = Kunde unbekannt
|
||||
CustomerName string
|
||||
Group string
|
||||
Cycle string // ledger.Cycle*
|
||||
Item string // ItemFlatRate oder <mandant>/<protokoll>/<router>
|
||||
Hosts string // durch Leerzeichen getrennt
|
||||
Tenant string
|
||||
Class string // ledger.Class*; "" = nicht zugeordnet
|
||||
Product string
|
||||
From, To string // JJJJ-MM-TT; To leer = läuft weiter
|
||||
Amount *int64 // Monatsbetrag in Cent; nil = unbekannt (kein Preis/keine Zuordnung)
|
||||
PeriodAmount *int64 // Rechnungsbetrag je Zahlungsperiode (×3 bzw. ×12); nil = unbekannt
|
||||
Shadow *int64 // Schattenpreis (free/internal): was es kosten würde
|
||||
Note string
|
||||
CustomerNote string
|
||||
}
|
||||
|
||||
// Billing berechnet die Abrechnung eines Monats (JJJJ-MM, Ortszeit) aus der
|
||||
// ganzen Historie.
|
||||
func Billing(ctx context.Context, l *ledger.Ledger, month string) ([]BillLine, error) {
|
||||
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return billFrom(ctx, l, month, spans, time.Local)
|
||||
}
|
||||
|
||||
type item struct {
|
||||
key ItemKey
|
||||
first time.Time
|
||||
ended time.Time // Nullzeit = läuft
|
||||
open bool // mindestens ein Ort läuft noch
|
||||
latest ledger.RouteSpan
|
||||
}
|
||||
|
||||
func cycleFactor(c string) (int64, string) {
|
||||
switch c {
|
||||
case ledger.CycleMonthly:
|
||||
return 1, ""
|
||||
case ledger.CycleQuarterly:
|
||||
return 3, "Quartalszahler: Rechnung = 3 × Monatsbetrag"
|
||||
case ledger.CycleYearly:
|
||||
return 12, "Jahreszahler: Monatsbetrag = 1/12 der Jahresrechnung"
|
||||
}
|
||||
return 0, "noch nicht in Zahlweise (m, q, j) eingeordnet!"
|
||||
}
|
||||
|
||||
func ptr(v int64) *int64 { return &v }
|
||||
|
||||
func billFrom(ctx context.Context, l *ledger.Ledger, month string, spans []ledger.RouteSpan, loc *time.Location) ([]BillLine, error) {
|
||||
start, err := time.ParseInLocation("2006-01", month, loc)
|
||||
if err != nil || !ledger.ValidMonth(month) {
|
||||
return nil, fmt.Errorf("Monat %q ungültig (JJJJ-MM)", month)
|
||||
}
|
||||
end := start.AddDate(0, 1, 0)
|
||||
daysInMonth := int64(end.Add(-time.Hour).Day())
|
||||
|
||||
customers, err := l.ListCustomers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := map[string]ledger.Customer{}
|
||||
for _, c := range customers {
|
||||
byID[c.ID] = c
|
||||
}
|
||||
resolver, err := l.CustomerResolver(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cat, err := l.Catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignList, err := l.ListAssignments(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assigns := map[ItemKey]ledger.Assignment{}
|
||||
for _, a := range assignList {
|
||||
assigns[ItemKey{a.Tenant, a.Protocol, a.Router}] = a
|
||||
}
|
||||
|
||||
// Orte zu Posten zusammenfassen.
|
||||
items := map[ItemKey]*item{}
|
||||
for _, s := range spans {
|
||||
k := ItemKey{s.Tenant, s.Protocol, s.Router}
|
||||
it := items[k]
|
||||
if it == nil {
|
||||
it = &item{key: k, first: s.FirstSeen, latest: s}
|
||||
items[k] = it
|
||||
}
|
||||
if s.FirstSeen.Before(it.first) {
|
||||
it.first = s.FirstSeen
|
||||
}
|
||||
if s.Ended.IsZero() {
|
||||
it.open = true
|
||||
} else if s.Ended.After(it.ended) {
|
||||
it.ended = s.Ended
|
||||
}
|
||||
if !s.FirstSeen.Before(it.latest.FirstSeen) {
|
||||
it.latest = s
|
||||
}
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.open {
|
||||
it.ended = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
var lines []BillLine
|
||||
activeCustomers := map[string]bool{}
|
||||
for _, it := range items {
|
||||
a, assigned := assigns[it.key]
|
||||
startMonth := time.Date(it.first.In(loc).Year(), it.first.In(loc).Month(), 1, 0, 0, 0, 0, loc)
|
||||
if assigned && a.Start != "" {
|
||||
if t, err := time.ParseInLocation("2006-01", a.Start, loc); err == nil {
|
||||
startMonth = t
|
||||
}
|
||||
}
|
||||
if start.Before(startMonth) || (!it.ended.IsZero() && it.ended.Before(start)) {
|
||||
continue // nicht in diesem Monat
|
||||
}
|
||||
custID := resolver.Resolve(it.latest.Payee, it.key.Tenant)
|
||||
cust, known := byID[custID]
|
||||
ln := BillLine{
|
||||
Month: month, Customer: custID, CustomerName: cust.Name, Group: cust.Group, Cycle: cust.Cycle,
|
||||
Item: it.key.Tenant + "/" + it.key.Protocol + "/" + it.key.Router,
|
||||
Hosts: strings.Join(it.latest.Hosts, " "), Tenant: it.key.Tenant,
|
||||
From: it.first.In(loc).Format(time.DateOnly), CustomerNote: cust.Note,
|
||||
}
|
||||
var notes []string
|
||||
if !known {
|
||||
ln.Customer = ""
|
||||
notes = append(notes, "Kunde unbekannt – sultan accountant customer map")
|
||||
} else {
|
||||
activeCustomers[custID] = true
|
||||
}
|
||||
|
||||
// Klasse und Produkt: Zuordnung > Produkt (Zuordnung, sonst Label) > Pauschale > nicht zugeordnet.
|
||||
ln.Product = it.latest.Product
|
||||
if assigned && a.Product != "" {
|
||||
ln.Product = a.Product
|
||||
}
|
||||
var price *int64
|
||||
if ln.Product != "" {
|
||||
if v, ok := cat.At(ln.Product, custID, month); ok {
|
||||
ln.Class, price = v.Class, v.Price
|
||||
} else {
|
||||
notes = append(notes, "Produkt "+ln.Product+" nicht im Katalog")
|
||||
}
|
||||
}
|
||||
if assigned && a.Class != "" {
|
||||
ln.Class = a.Class
|
||||
}
|
||||
if ln.Class == "" && ln.Product == "" && known && cust.FlatRate != nil {
|
||||
ln.Class = ledger.ClassIncluded
|
||||
}
|
||||
|
||||
switch ln.Class {
|
||||
case ledger.ClassBillable:
|
||||
if price != nil {
|
||||
ln.Amount = ptr(*price)
|
||||
} else {
|
||||
notes = append(notes, "kein Preis hinterlegt")
|
||||
}
|
||||
case ledger.ClassIncluded:
|
||||
ln.Amount = ptr(0)
|
||||
case ledger.ClassFree, ledger.ClassInternal:
|
||||
ln.Amount, ln.Shadow = ptr(0), price
|
||||
case "":
|
||||
if !slices.ContainsFunc(notes, func(n string) bool { return strings.Contains(n, "nicht im Katalog") }) {
|
||||
notes = append(notes, "nicht zugeordnet – Label sultan.accountant.billing oder sultan accountant assign")
|
||||
}
|
||||
}
|
||||
|
||||
if !it.ended.IsZero() && it.ended.Before(end) {
|
||||
ln.To = it.ended.In(loc).Format(time.DateOnly)
|
||||
used := int64(it.ended.In(loc).Day())
|
||||
n := fmt.Sprintf("beendet am %s (Ultimo abgerechnet)", it.ended.In(loc).Format("02.01."))
|
||||
if ln.Amount != nil && *ln.Amount > 0 {
|
||||
n += fmt.Sprintf("; tagesgenau %d/%d Tage = %s", used, daysInMonth, FormatMoney((*ln.Amount*used+daysInMonth/2)/daysInMonth))
|
||||
}
|
||||
notes = append(notes, n)
|
||||
}
|
||||
factor, cycleNote := cycleFactor(cust.Cycle)
|
||||
if known && cycleNote != "" {
|
||||
notes = append(notes, cycleNote)
|
||||
}
|
||||
if ln.Amount != nil && factor > 0 {
|
||||
ln.PeriodAmount = ptr(*ln.Amount * factor)
|
||||
}
|
||||
ln.Note = strings.Join(notes, "; ")
|
||||
lines = append(lines, ln)
|
||||
}
|
||||
|
||||
// Pauschale je Kunde.
|
||||
for _, c := range customers {
|
||||
if c.FlatRate == nil {
|
||||
continue
|
||||
}
|
||||
ln := BillLine{Month: month, Customer: c.ID, CustomerName: c.Name, Group: c.Group, Cycle: c.Cycle,
|
||||
Item: ItemFlatRate, Amount: ptr(*c.FlatRate), CustomerNote: c.Note}
|
||||
var notes []string
|
||||
factor, cycleNote := cycleFactor(c.Cycle)
|
||||
if factor > 0 {
|
||||
ln.PeriodAmount = ptr(*c.FlatRate * factor)
|
||||
}
|
||||
if cycleNote != "" {
|
||||
notes = append(notes, cycleNote)
|
||||
}
|
||||
if !activeCustomers[c.ID] {
|
||||
notes = append(notes, "keine aktiven URLs in diesem Monat")
|
||||
}
|
||||
ln.Note = strings.Join(notes, "; ")
|
||||
lines = append(lines, ln)
|
||||
}
|
||||
|
||||
slices.SortFunc(lines, func(a, b BillLine) int {
|
||||
if (a.Customer == "") != (b.Customer == "") {
|
||||
if a.Customer == "" {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if c := strings.Compare(a.Customer, b.Customer); c != 0 {
|
||||
return c
|
||||
}
|
||||
if (a.Item == ItemFlatRate) != (b.Item == ItemFlatRate) {
|
||||
if a.Item == ItemFlatRate {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return cmp.Or(strings.Compare(a.Hosts, b.Hosts), strings.Compare(a.Item, b.Item))
|
||||
})
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
var cycleNames = map[string]string{
|
||||
ledger.CycleMonthly: "monatlich", ledger.CycleQuarterly: "quartalsweise", ledger.CycleYearly: "jährlich",
|
||||
ledger.CycleUnclassified: "nicht eingeordnet",
|
||||
}
|
||||
|
||||
// WriteBillCSV schreibt die Abrechnung als CSV (UTF-8 mit BOM, Semikolon,
|
||||
// Dezimalkomma) – so liest sie Excel bzw. LibreOffice ohne Importdialog.
|
||||
func WriteBillCSV(w io.Writer, lines []BillLine) error {
|
||||
if _, err := io.WriteString(w, "\uFEFF"); err != nil {
|
||||
return err
|
||||
}
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Comma = ';'
|
||||
money := func(p *int64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatMoney(*p)
|
||||
}
|
||||
cw.Write([]string{"Monat", "Kunde", "Kundenname", "Gruppe", "Zahlweise", "Posten", "Hosts", "Mandant", "Klasse", "Produkt",
|
||||
"Von", "Bis", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"})
|
||||
for _, l := range lines {
|
||||
cycle := cycleNames[l.Cycle]
|
||||
if l.Customer == "" {
|
||||
cycle = ""
|
||||
}
|
||||
cw.Write([]string{l.Month, l.Customer, l.CustomerName, l.Group, cycle, l.Item, l.Hosts, l.Tenant, l.Class, l.Product,
|
||||
l.From, l.To, money(l.Amount), money(l.PeriodAmount), money(l.Shadow), l.Note, l.CustomerNote})
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
func cents(v int64) *int64 { return &v }
|
||||
|
||||
func span(payee, tenant, node, router, product string, first, ended time.Time, hosts ...string) ledger.RouteSpan {
|
||||
return ledger.RouteSpan{
|
||||
RouteRecord: ledger.RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts, Product: product},
|
||||
Payee: payee, FirstSeen: first, LastSeen: first, Ended: ended,
|
||||
}
|
||||
}
|
||||
|
||||
func billingFixture(t *testing.T) (*ledger.Ledger, []ledger.RouteSpan) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
flat := int64(8900)
|
||||
for _, c := range []ledger.Customer{
|
||||
{ID: "kunde", Name: "Kunde A", Group: "extern", Cycle: ledger.CycleQuarterly, FlatRate: &flat, Note: "Kumpel"},
|
||||
{ID: "jahr", Name: "Jahreszahler", Cycle: ledger.CycleYearly},
|
||||
{ID: "familie", Name: "Familie", Group: "familie", Cycle: ledger.CycleMonthly},
|
||||
{ID: "offen", Name: "Noch offen"},
|
||||
} {
|
||||
if err := l.AddCustomer(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
l.MapTenant(ctx, "", "kunde-a", "kunde") // zweiter Mandant desselben Kunden
|
||||
l.AddProduct(ctx, ledger.Product{ID: "olat", Name: "OLAT", Class: ledger.ClassBillable, Price: cents(4900)})
|
||||
l.AddProduct(ctx, ledger.Product{ID: "pingvin", Name: "Pingvin", Class: ledger.ClassFree, Price: cents(500)})
|
||||
l.SetProductVersion(ctx, "pingvin", "2027-03", ledger.ClassBillable, cents(500))
|
||||
l.AddProduct(ctx, ledger.Product{ID: "hochzeit", Name: "Landingpage", Class: ledger.ClassInternal, Price: cents(1500)})
|
||||
l.SetCustomerPrice(ctx, "olat", "jahr", "2026-01", 3900)
|
||||
l.SetAssignment(ctx, ledger.Assignment{Tenant: "kunde", Protocol: "http", Router: "spaet", Start: "2026-12"})
|
||||
l.SetAssignment(ctx, ledger.Assignment{Tenant: "kunde", Protocol: "http", Router: "wp", Class: ledger.ClassIncluded})
|
||||
|
||||
open := time.Time{}
|
||||
spans := []ledger.RouteSpan{
|
||||
// Kunde A: Website (Umzug webdocker07 → webdocker11, ein Posten), OLAT ab Mitte Oktober,
|
||||
// gekündigter Backup-Dienst, später beginnender Dienst, Pingvin, unbekanntes Produkt.
|
||||
span("p1", "kunde", "webdocker07", "wp", "", day(2026, 1, 5), day(2026, 9, 30), "kunde.de"),
|
||||
span("p1", "kunde", "webdocker11", "wp", "", day(2026, 9, 30), open, "kunde.de", "www.kunde.de"),
|
||||
span("p1", "kunde", "webdocker11", "olat", "olat", day(2026, 10, 17), open, "olat.kunde.de"),
|
||||
span("p2", "kunde-a", "nb1", "backup", "olat", day(2026, 3, 1), day(2026, 10, 14), "backup.kunde.de"),
|
||||
span("p1", "kunde", "webdocker11", "spaet", "olat", day(2026, 10, 2), open, "spaet.kunde.de"),
|
||||
span("p1", "kunde", "webdocker11", "share", "pingvin", day(2026, 5, 1), open, "share.kunde.de"),
|
||||
span("p1", "kunde", "webdocker11", "alt", "gibts-nicht", day(2026, 5, 1), open, "alt.kunde.de"),
|
||||
span("p1", "kunde", "webdocker11", "weg", "olat", day(2026, 5, 1), day(2026, 9, 3), "weg.kunde.de"),
|
||||
// Jahreszahler mit Kundenpreis.
|
||||
span("p1", "jahr", "n2", "olat", "olat", day(2026, 2, 1), open, "olat.jahr.de"),
|
||||
// Familie: intern mit Schattenpreis.
|
||||
span("p1", "familie", "n3", "hochzeit", "hochzeit", day(2026, 6, 1), open, "hochzeit.example.de"),
|
||||
// Ohne Pauschale, ohne Label, Zahlweise offen.
|
||||
span("p1", "offen", "n4", "web", "", day(2026, 6, 1), open, "offen.de"),
|
||||
// Mandant ohne Kunden.
|
||||
span("p1", "fremd", "n5", "web", "", day(2026, 6, 1), open, "fremd.de"),
|
||||
}
|
||||
return l, spans
|
||||
}
|
||||
|
||||
func lineFor(t *testing.T, lines []BillLine, item string) BillLine {
|
||||
t.Helper()
|
||||
for _, l := range lines {
|
||||
if l.Item == item {
|
||||
return l
|
||||
}
|
||||
}
|
||||
t.Fatalf("Posten %q fehlt", item)
|
||||
return BillLine{}
|
||||
}
|
||||
|
||||
func amount(p *int64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatMoney(*p)
|
||||
}
|
||||
|
||||
func TestBill_October(t *testing.T) {
|
||||
l, spans := billingFixture(t)
|
||||
lines, err := billFrom(ctx, l, "2026-10", spans, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
item, customer, class, product, amount, period, shadow, note string
|
||||
}{
|
||||
{ItemFlatRate, "kunde", "", "", "89,00", "267,00", "", "Quartalszahler"},
|
||||
{"kunde/http/wp", "kunde", ledger.ClassIncluded, "", "0,00", "0,00", "", ""},
|
||||
{"kunde/http/olat", "kunde", ledger.ClassBillable, "olat", "49,00", "147,00", "", ""}, // Mitte Oktober: voller Monat
|
||||
{"kunde-a/http/backup", "kunde", ledger.ClassBillable, "olat", "49,00", "147,00", "", "tagesgenau 14/31"},
|
||||
{"kunde/http/share", "kunde", ledger.ClassFree, "pingvin", "0,00", "0,00", "5,00", ""},
|
||||
{"kunde/http/alt", "kunde", "", "gibts-nicht", "", "", "", "nicht im Katalog"},
|
||||
{"jahr/http/olat", "jahr", ledger.ClassBillable, "olat", "39,00", "468,00", "", "Jahreszahler"},
|
||||
{"familie/http/hochzeit", "familie", ledger.ClassInternal, "hochzeit", "0,00", "0,00", "15,00", ""},
|
||||
{"offen/http/web", "offen", "", "", "", "", "", "nicht zugeordnet"},
|
||||
{"fremd/http/web", "", "", "", "", "", "", "Kunde unbekannt"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ln := lineFor(t, lines, tt.item)
|
||||
if ln.Customer != tt.customer || ln.Class != tt.class || ln.Product != tt.product || amount(ln.Amount) != tt.amount ||
|
||||
amount(ln.PeriodAmount) != tt.period || amount(ln.Shadow) != tt.shadow || !strings.Contains(ln.Note, tt.note) {
|
||||
t.Errorf("%s:\n %+v\nerwartet Kunde %q Klasse %q Produkt %q Betrag %q Periode %q Schatten %q Hinweis ~%q",
|
||||
tt.item, ln, tt.customer, tt.class, tt.product, tt.amount, tt.period, tt.shadow, tt.note)
|
||||
}
|
||||
}
|
||||
// Gegenrechnung: 49,00 × 14/31 = 22,13.
|
||||
if ln := lineFor(t, lines, "kunde-a/http/backup"); !strings.Contains(ln.Note, "22,13") || ln.To != "2026-10-14" {
|
||||
t.Errorf("Kündigung: %+v", ln)
|
||||
}
|
||||
if ln := lineFor(t, lines, "offen/http/web"); !strings.Contains(ln.Note, "Zahlweise") {
|
||||
t.Errorf("Vermerk Zahlweise fehlt: %q", ln.Note)
|
||||
}
|
||||
if ln := lineFor(t, lines, "kunde/http/wp"); ln.Hosts != "kunde.de www.kunde.de" || ln.From != "2026-01-05" || ln.Group != "extern" || ln.CustomerNote != "Kumpel" {
|
||||
t.Errorf("Website (Umzug = ein Posten): %+v", ln)
|
||||
}
|
||||
for _, ln := range lines {
|
||||
switch ln.Item {
|
||||
case "kunde/http/spaet", "kunde/http/weg":
|
||||
t.Errorf("%s darf im Oktober nicht erscheinen: %+v", ln.Item, ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beginn per Zuordnung im Dezember; Pingvin ab März 2027 kostenpflichtig;
|
||||
// beendete Posten verschwinden im Folgemonat.
|
||||
func TestBill_LaterMonths(t *testing.T) {
|
||||
l, spans := billingFixture(t)
|
||||
dec, _ := billFrom(ctx, l, "2026-12", spans, time.UTC)
|
||||
if ln := lineFor(t, dec, "kunde/http/spaet"); amount(ln.Amount) != "49,00" {
|
||||
t.Errorf("Beginn Dezember: %+v", ln)
|
||||
}
|
||||
for _, ln := range dec {
|
||||
if ln.Item == "kunde-a/http/backup" {
|
||||
t.Errorf("gekündigter Posten im Dezember: %+v", ln)
|
||||
}
|
||||
}
|
||||
mar, _ := billFrom(ctx, l, "2027-03", spans, time.UTC)
|
||||
if ln := lineFor(t, mar, "kunde/http/share"); ln.Class != ledger.ClassBillable || amount(ln.Amount) != "5,00" {
|
||||
t.Errorf("Pingvin ab März: %+v", ln)
|
||||
}
|
||||
jan, _ := billFrom(ctx, l, "2026-01", spans, time.UTC)
|
||||
for _, ln := range jan {
|
||||
if ln.Item == "kunde/http/olat" {
|
||||
t.Errorf("Posten vor seinem Beginn: %+v", ln)
|
||||
}
|
||||
}
|
||||
if _, err := billFrom(ctx, l, "Oktober", spans, time.UTC); err == nil {
|
||||
t.Error("ungültiger Monat akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
// CSV: Semikolon, Kopfzeile, Dezimalkomma; Pauschale je Kunde vor seinen Posten.
|
||||
func TestWriteBillCSV(t *testing.T) {
|
||||
l, spans := billingFixture(t)
|
||||
lines, _ := billFrom(ctx, l, "2026-10", spans, time.UTC)
|
||||
var buf bytes.Buffer
|
||||
if err := WriteBillCSV(&buf, lines); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(strings.TrimPrefix(buf.String(), "\uFEFF")))
|
||||
r.Comma = ';'
|
||||
rows, err := r.ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("CSV unlesbar: %v\n%s", err, buf.String())
|
||||
}
|
||||
head := strings.Join(rows[0], ";")
|
||||
for _, col := range []string{"Monat", "Kunde", "Gruppe", "Zahlweise", "Posten", "Hosts", "Klasse", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"} {
|
||||
if !strings.Contains(head, col) {
|
||||
t.Errorf("Spalte %s fehlt: %s", col, head)
|
||||
}
|
||||
}
|
||||
if len(rows) != len(lines)+1 {
|
||||
t.Errorf("%d Zeilen, erwartet %d", len(rows), len(lines)+1)
|
||||
}
|
||||
var kundeRows []string
|
||||
for _, row := range rows[1:] {
|
||||
if row[1] == "kunde" {
|
||||
kundeRows = append(kundeRows, row[5])
|
||||
}
|
||||
}
|
||||
if len(kundeRows) == 0 || kundeRows[0] != ItemFlatRate {
|
||||
t.Errorf("Pauschale nicht zuerst: %v", kundeRows)
|
||||
}
|
||||
if !strings.HasPrefix(buf.String(), "\uFEFF") {
|
||||
t.Error("ohne BOM (Excel erkennt UTF-8 sonst nicht)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package accountant
|
||||
|
||||
// Checkmk-Ausgabe des Accountants (Spec 5.3, Roadmap 6e): ein Service je URL
|
||||
// (Störfall: wohin springen?) und je Payee (Frische der Berichte), im
|
||||
// Local-Check-Format. Der Text kommt aus einer text/template-Vorlage:
|
||||
// eingebaut, überschreibbar durch <data-dir>/checkmk/checkmk.tmpl (wirkt
|
||||
// sofort). Werte aus Berichten sind Fremdeingaben (Containername,
|
||||
// Compose-Verzeichnis stammen vom Kunden-Host): Sie sind bereinigt
|
||||
// (einzeilig, ASCII, ohne | und ") und HTML-escaped; nur bereinigt stehen
|
||||
// sie unter .Unescaped – falls in Checkmk das HTML-Escaping abgeschaltet ist,
|
||||
// öffnet deren Ausgabe Script-Injektion in die Oberfläche.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/checkmk"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// CheckmkTemplateName ist der Dateiname der eigenen Vorlage in <data-dir>/checkmk.
|
||||
const CheckmkTemplateName = "checkmk.tmpl"
|
||||
|
||||
// Checkmk-Status.
|
||||
const (
|
||||
cmkOK = 0
|
||||
cmkWarn = 1
|
||||
cmkCrit = 2
|
||||
)
|
||||
|
||||
// defaultCheckmkTemplate ist die eingebaute Vorlage (sultan accountant
|
||||
// dump-checkmk exportiert sie zum Anpassen). Jede Zeile:
|
||||
// <status> "<service>" - <OK|WARN|CRIT> - <text>
|
||||
const defaultCheckmkTemplate = `{{- /*
|
||||
Checkmk-Vorlage des Sultan-Accountants (text/template).
|
||||
Eine Zeile je Service: <status> "<service>" - <OK|WARN|CRIT> - <text>
|
||||
.Payees: .Service .State .Problem .ID .Hostname .Age .LastReport
|
||||
.URLs: .Service .State .Problem .Host .URL .Tenant .Payee .Node .Router .Workload
|
||||
.Backend .BackendFQDN .BackendURL .ComposeProject .ComposeDir .Health .Product
|
||||
Werte sind bereinigt und HTML-escaped; .Unescaped.<Feld> nur bereinigt.
|
||||
Funktionen: status (0/1/2 → OK/WARN/CRIT)
|
||||
*/ -}}
|
||||
{{- range .Payees -}}
|
||||
{{.State}} "{{.Service}}" - {{status .State}} - {{with .Problem}}{{.}}; {{end}}letzter Bericht {{.Age}}{{with .Hostname}} von {{.}}{{end}}
|
||||
{{end -}}
|
||||
{{- range .URLs -}}
|
||||
{{.State}} "{{.Service}}" - {{status .State}} - {{with .Problem}}{{.}}; {{end}}{{.URL}} -> {{or .BackendURL .Backend}} ({{.Tenant}} auf {{.Node}} via {{.Payee}}{{with .ComposeDir}}, compose {{.}}{{end}}{{with .Health}}, {{.}}{{end}}{{with .Product}}, Produkt {{.}}{{end}})
|
||||
{{end -}}
|
||||
`
|
||||
|
||||
// CheckmkData ist der Inhalt, den die Vorlage sieht.
|
||||
type CheckmkData struct {
|
||||
Now time.Time
|
||||
Payees []CheckmkPayee
|
||||
URLs []CheckmkURL
|
||||
}
|
||||
|
||||
// PayeeFields sind die Textfelder eines Payees.
|
||||
type PayeeFields struct {
|
||||
ID string
|
||||
Hostname string
|
||||
Problem string
|
||||
}
|
||||
|
||||
// CheckmkPayee ist der Frische-Service eines Payees.
|
||||
type CheckmkPayee struct {
|
||||
PayeeFields // bereinigt und HTML-escaped
|
||||
Unescaped PayeeFields // nur bereinigt
|
||||
Service string // Name des Checkmk-Services
|
||||
State int // 0 OK, 1 WARN, 2 CRIT
|
||||
Age string // z. B. "vor 5 min", "nie"
|
||||
LastReport time.Time // Nullzeit = nie
|
||||
}
|
||||
|
||||
// URLFields sind die Textfelder einer URL.
|
||||
type URLFields struct {
|
||||
Host, URL string
|
||||
Tenant, Payee, Node string
|
||||
Router, Workload string
|
||||
Backend, BackendFQDN, BackendURL string
|
||||
ComposeProject, ComposeDir string
|
||||
Health, Product string
|
||||
Problem string
|
||||
}
|
||||
|
||||
// CheckmkURL ist der Service einer URL.
|
||||
type CheckmkURL struct {
|
||||
URLFields // bereinigt und HTML-escaped
|
||||
Unescaped URLFields // nur bereinigt
|
||||
Service string
|
||||
State int
|
||||
}
|
||||
|
||||
// clean macht Fremdtext einzeilig, ASCII und frei von Trennzeichen des
|
||||
// Local-Check-Formats.
|
||||
func clean(s string) string {
|
||||
return strings.NewReplacer("|", "/", `"`, "'").Replace(checkmk.ASCII(s))
|
||||
}
|
||||
|
||||
func escapeFields[T any](v T, f func(*T, func(string) string)) T {
|
||||
f(&v, html.EscapeString)
|
||||
return v
|
||||
}
|
||||
|
||||
func (u *URLFields) apply(fn func(string) string) {
|
||||
for _, p := range []*string{&u.Host, &u.URL, &u.Tenant, &u.Payee, &u.Node, &u.Router, &u.Workload,
|
||||
&u.Backend, &u.BackendFQDN, &u.BackendURL, &u.ComposeProject, &u.ComposeDir, &u.Health, &u.Product, &u.Problem} {
|
||||
*p = fn(*p)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PayeeFields) apply(fn func(string) string) {
|
||||
p.ID, p.Hostname, p.Problem = fn(p.ID), fn(p.Hostname), fn(p.Problem)
|
||||
}
|
||||
|
||||
func age(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "vor weniger als 1 min"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("vor %d min", int(d/time.Minute))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("vor %d h", int(d/time.Hour))
|
||||
default:
|
||||
return fmt.Sprintf("vor %d Tagen", int(d/(24*time.Hour)))
|
||||
}
|
||||
}
|
||||
|
||||
// freshness bewertet das Alter eines Berichts.
|
||||
func freshness(last, now time.Time, warn, crit time.Duration) (int, string) {
|
||||
switch d := now.Sub(last); {
|
||||
case last.IsZero():
|
||||
return cmkWarn, "noch kein Bericht"
|
||||
case d > crit:
|
||||
return cmkCrit, "Bericht veraltet (" + age(d) + ")"
|
||||
case d > warn:
|
||||
return cmkWarn, "Bericht veraltet (" + age(d) + ")"
|
||||
default:
|
||||
return cmkOK, ""
|
||||
}
|
||||
}
|
||||
|
||||
// buildCheckmk bewertet Payees und URLs. reports enthält den letzten Bericht
|
||||
// je Payee-ID (fehlt bei Payees ohne Bericht).
|
||||
func buildCheckmk(payees []ledger.Payee, reports map[string]Report, now time.Time, staleWarn, staleCrit time.Duration) CheckmkData {
|
||||
d := CheckmkData{Now: now}
|
||||
for _, p := range payees {
|
||||
state, problem := freshness(p.LastReport, now, staleWarn, staleCrit)
|
||||
raw := PayeeFields{ID: clean(p.ID), Hostname: clean(p.Hostname), Problem: problem}
|
||||
cp := CheckmkPayee{
|
||||
Unescaped: raw, PayeeFields: escapeFields(raw, (*PayeeFields).apply),
|
||||
Service: "Sultan Payee " + raw.ID, State: state, Age: "nie", LastReport: p.LastReport,
|
||||
}
|
||||
if !p.LastReport.IsZero() {
|
||||
cp.Age = age(now.Sub(p.LastReport))
|
||||
}
|
||||
d.Payees = append(d.Payees, cp)
|
||||
|
||||
rep, ok := reports[p.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nodes := map[string]ReportNode{}
|
||||
for _, n := range rep.Nodes {
|
||||
nodes[n.ID] = n
|
||||
}
|
||||
for _, r := range rep.Routes {
|
||||
for _, host := range r.Hosts {
|
||||
d.URLs = append(d.URLs, urlService(p.ID, host, r, nodes[r.Node], state, problem))
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(d.Payees, func(a, b CheckmkPayee) int { return strings.Compare(a.Service, b.Service) })
|
||||
|
||||
// Eindeutige Servicenamen: derselbe Host mehrfach → Zusatz (payee/knoten).
|
||||
slices.SortFunc(d.URLs, func(a, b CheckmkURL) int {
|
||||
return cmp.Or(strings.Compare(a.Unescaped.Host, b.Unescaped.Host), strings.Compare(a.Unescaped.Payee, b.Unescaped.Payee),
|
||||
strings.Compare(a.Unescaped.Node, b.Unescaped.Node), strings.Compare(a.Unescaped.Router, b.Unescaped.Router))
|
||||
})
|
||||
seen := map[string]bool{}
|
||||
for i := range d.URLs {
|
||||
u := &d.URLs[i]
|
||||
u.Service = "Sultan URL " + u.Unescaped.Host
|
||||
if seen[u.Service] {
|
||||
u.Service += " (" + u.Unescaped.Payee + "/" + u.Unescaped.Node + "/" + u.Unescaped.Router + ")"
|
||||
}
|
||||
seen[u.Service] = true
|
||||
}
|
||||
slices.SortFunc(d.URLs, func(a, b CheckmkURL) int { return strings.Compare(a.Service, b.Service) })
|
||||
return d
|
||||
}
|
||||
|
||||
func urlService(payeeID, host string, r ReportRoute, node ReportNode, payeeState int, payeeProblem string) CheckmkURL {
|
||||
raw := URLFields{
|
||||
Host: clean(host), Tenant: clean(r.Tenant), Payee: clean(payeeID), Node: clean(r.Node),
|
||||
Router: clean(r.Router), Workload: clean(r.Workload), Backend: clean(r.Backend), BackendFQDN: clean(r.BackendFQDN),
|
||||
ComposeProject: clean(r.ComposeProject), ComposeDir: clean(r.ComposeDir), Health: clean(r.Health), Product: clean(r.Product),
|
||||
}
|
||||
if r.Protocol == "tcp" {
|
||||
raw.URL, raw.BackendURL = raw.Host, raw.BackendFQDN
|
||||
} else {
|
||||
raw.URL = "https://" + raw.Host
|
||||
if raw.BackendFQDN != "" {
|
||||
raw.BackendURL = "http://" + raw.BackendFQDN
|
||||
}
|
||||
}
|
||||
|
||||
state := cmkOK
|
||||
var problems []string
|
||||
if !r.Active {
|
||||
problems = append(problems, "von Policy verworfen")
|
||||
}
|
||||
if r.Down {
|
||||
problems = append(problems, "gestoppt/Wartungsseite")
|
||||
}
|
||||
if r.Health == "unhealthy" {
|
||||
problems = append(problems, "unhealthy")
|
||||
}
|
||||
if !node.Connected && !node.Maintenance {
|
||||
problems = append(problems, "Knoten getrennt")
|
||||
}
|
||||
switch {
|
||||
case node.Maintenance:
|
||||
// Angekündigte Wartung: kein Alarm, aber sichtbar.
|
||||
problems = append([]string{"Wartung"}, problems...)
|
||||
case len(problems) > 0:
|
||||
state = cmkWarn
|
||||
}
|
||||
if payeeState > state {
|
||||
state = payeeState
|
||||
problems = append([]string{"Bericht von " + payeeID + ": " + payeeProblem}, problems...)
|
||||
}
|
||||
raw.Problem = clean(strings.Join(problems, ", "))
|
||||
return CheckmkURL{Unescaped: raw, URLFields: escapeFields(raw, (*URLFields).apply), State: state}
|
||||
}
|
||||
|
||||
var cmkFuncs = template.FuncMap{
|
||||
"status": func(s int) string {
|
||||
switch s {
|
||||
case cmkOK:
|
||||
return "OK"
|
||||
case cmkWarn:
|
||||
return "WARN"
|
||||
default:
|
||||
return "CRIT"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var builtinCheckmk = template.Must(template.New(CheckmkTemplateName).Funcs(cmkFuncs).Parse(defaultCheckmkTemplate))
|
||||
|
||||
// renderCheckmk rendert d mit der eigenen Vorlage aus dataDir (falls
|
||||
// vorhanden), sonst mit der eingebauten. Eine fehlerhafte eigene Vorlage
|
||||
// fällt auf die eingebaute zurück und erscheint als CRIT-Service.
|
||||
func renderCheckmk(dataDir string, d CheckmkData) string {
|
||||
path := filepath.Join(dataDir, "checkmk", CheckmkTemplateName)
|
||||
var problem error
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
t, err := template.New(CheckmkTemplateName).Funcs(cmkFuncs).Parse(string(raw))
|
||||
if err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err = t.Execute(&buf, d); err == nil {
|
||||
return buf.String()
|
||||
}
|
||||
}
|
||||
problem = err
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
problem = err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if problem != nil {
|
||||
fmt.Fprintf(&buf, "%d \"Sultan Accountant Vorlage\" - CRIT - %s fehlerhaft, eingebaute Vorlage aktiv: %s\n",
|
||||
cmkCrit, CheckmkTemplateName, clean(problem.Error()))
|
||||
}
|
||||
if err := builtinCheckmk.Execute(&buf, d); err != nil {
|
||||
fmt.Fprintf(&buf, "%d \"Sultan Accountant Vorlage\" - CRIT - eingebaute Vorlage: %s\n", cmkCrit, clean(err.Error()))
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// DumpCheckmk schreibt die eingebaute Vorlage nach <dataDir>/checkmk zum
|
||||
// Anpassen; eine vorhandene Datei bleibt ohne force unangetastet.
|
||||
func DumpCheckmk(dataDir string, force bool) (string, error) {
|
||||
dir := filepath.Join(dataDir, "checkmk")
|
||||
path := filepath.Join(dir, CheckmkTemplateName)
|
||||
if _, err := os.Stat(path); err == nil && !force {
|
||||
return "", fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", path, os.ErrExist)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, os.WriteFile(path, []byte(defaultCheckmkTemplate), 0o644)
|
||||
}
|
||||
|
||||
func (a *Accountant) handleCheckmk(w http.ResponseWriter, r *http.Request) {
|
||||
payees, err := a.ledger.ListPayees(r.Context())
|
||||
if err != nil {
|
||||
a.log.Error("Checkmk: Payees lesen", "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reports := map[string]Report{}
|
||||
for _, p := range payees {
|
||||
body, _, err := a.ledger.LatestReport(r.Context(), p.ID)
|
||||
if err != nil {
|
||||
continue // noch kein Bericht
|
||||
}
|
||||
var rep Report
|
||||
if err := json.Unmarshal(body, &rep); err != nil {
|
||||
a.log.Error("Checkmk: Bericht unlesbar", "payee", p.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
reports[p.ID] = rep
|
||||
}
|
||||
d := buildCheckmk(payees, reports, time.Now(), a.cfg.StaleWarn, a.cfg.StaleCrit)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte(renderCheckmk(a.cfg.DataDir, d)))
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
var cmkNow = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func cmkReport(routes ...ReportRoute) Report {
|
||||
return Report{
|
||||
GeneratedAt: cmkNow,
|
||||
Nodes: []ReportNode{
|
||||
{ID: "webdocker11", Tenant: "kunde", Connected: true},
|
||||
{ID: "alt", Tenant: "kunde", Connected: false},
|
||||
{ID: "wartung", Tenant: "kunde", Connected: false, Maintenance: true},
|
||||
},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func okRoute(host string) ReportRoute {
|
||||
return ReportRoute{Tenant: "kunde", Node: "webdocker11", Workload: "wp", Router: "wp", Protocol: "http",
|
||||
Hosts: []string{host}, Backend: "http://10.0.0.11:8081", BackendFQDN: "webdocker11.lan:8081",
|
||||
Active: true, ComposeDir: "/srv/kunde-a", Health: "healthy", State: "running"}
|
||||
}
|
||||
|
||||
func findURL(t *testing.T, d CheckmkData, service string) CheckmkURL {
|
||||
t.Helper()
|
||||
for _, u := range d.URLs {
|
||||
if u.Service == service {
|
||||
return u
|
||||
}
|
||||
}
|
||||
t.Fatalf("Service %q fehlt in %+v", service, d.URLs)
|
||||
return CheckmkURL{}
|
||||
}
|
||||
|
||||
// Zustände: OK, WARN bei Problemen der Route, CRIT bei veraltetem Bericht;
|
||||
// Wartung dämpft auf OK.
|
||||
func TestBuildCheckmk_States(t *testing.T) {
|
||||
inactive := okRoute("fremd.de")
|
||||
inactive.Active = false
|
||||
down := okRoute("olat.kunde.de")
|
||||
down.Down = true
|
||||
sick := okRoute("krank.kunde.de")
|
||||
sick.Health = "unhealthy"
|
||||
gone := okRoute("weg.kunde.de")
|
||||
gone.Node = "alt"
|
||||
maint := okRoute("pause.kunde.de")
|
||||
maint.Node, maint.Down = "wartung", true
|
||||
multi := okRoute("kunde.de")
|
||||
multi.Hosts = []string{"kunde.de", "www.kunde.de"}
|
||||
|
||||
payees := []ledger.Payee{
|
||||
{ID: "frisch", Hostname: "webproxy11", LastReport: cmkNow.Add(-time.Minute)},
|
||||
{ID: "lahm", Hostname: "webproxy12", LastReport: cmkNow.Add(-3 * time.Hour)},
|
||||
{ID: "tot", Hostname: "webproxy13", LastReport: cmkNow.Add(-7 * time.Hour)},
|
||||
{ID: "neu", Hostname: "webproxy14"},
|
||||
}
|
||||
reports := map[string]Report{
|
||||
"frisch": cmkReport(okRoute("ok.kunde.de"), inactive, down, sick, gone, maint, multi),
|
||||
"lahm": cmkReport(okRoute("lahm.kunde.de")),
|
||||
"tot": cmkReport(okRoute("tot.kunde.de")),
|
||||
}
|
||||
d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
|
||||
wantPayees := map[string]int{"frisch": 0, "lahm": 1, "tot": 2, "neu": 1}
|
||||
for _, p := range d.Payees {
|
||||
if p.State != wantPayees[p.ID] {
|
||||
t.Errorf("Payee %s: Status %d, erwartet %d (%+v)", p.ID, p.State, wantPayees[p.ID], p)
|
||||
}
|
||||
}
|
||||
if len(d.Payees) != 4 {
|
||||
t.Errorf("Payees: %+v", d.Payees)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
service, problem string
|
||||
state int
|
||||
}{
|
||||
{"Sultan URL ok.kunde.de", "", 0},
|
||||
{"Sultan URL fremd.de", "von Policy verworfen", 1},
|
||||
{"Sultan URL olat.kunde.de", "gestoppt", 1},
|
||||
{"Sultan URL krank.kunde.de", "unhealthy", 1},
|
||||
{"Sultan URL weg.kunde.de", "Knoten getrennt", 1},
|
||||
{"Sultan URL pause.kunde.de", "Wartung", 0},
|
||||
{"Sultan URL kunde.de", "", 0},
|
||||
{"Sultan URL www.kunde.de", "", 0},
|
||||
{"Sultan URL lahm.kunde.de", "Bericht", 1},
|
||||
{"Sultan URL tot.kunde.de", "Bericht", 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
u := findURL(t, d, tt.service)
|
||||
if u.State != tt.state || (tt.problem == "") != (u.Problem == "") || !strings.Contains(u.Problem, tt.problem) {
|
||||
t.Errorf("%s: Status %d Problem %q, erwartet %d %q", tt.service, u.State, u.Problem, tt.state, tt.problem)
|
||||
}
|
||||
}
|
||||
u := findURL(t, d, "Sultan URL ok.kunde.de")
|
||||
if u.URL != "https://ok.kunde.de" || u.BackendURL != "http://webdocker11.lan:8081" || u.Payee != "frisch" ||
|
||||
u.Tenant != "kunde" || u.ComposeDir != "/srv/kunde-a" {
|
||||
t.Errorf("Felder: %+v", u)
|
||||
}
|
||||
for i := 1; i < len(d.URLs); i++ {
|
||||
if d.URLs[i-1].Service > d.URLs[i].Service {
|
||||
t.Errorf("nicht sortiert: %s vor %s", d.URLs[i-1].Service, d.URLs[i].Service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derselbe Host zweimal (z. B. zwei Payees): eindeutige Servicenamen.
|
||||
func TestBuildCheckmk_DuplicateHosts(t *testing.T) {
|
||||
payees := []ledger.Payee{{ID: "a", LastReport: cmkNow}, {ID: "b", LastReport: cmkNow}}
|
||||
reports := map[string]Report{"a": cmkReport(okRoute("kunde.de")), "b": cmkReport(okRoute("kunde.de"))}
|
||||
d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
if len(d.URLs) != 2 || d.URLs[0].Service == d.URLs[1].Service {
|
||||
t.Errorf("Servicenamen nicht eindeutig: %+v", d.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
// Werte vom Kunden-Host sind Fremdeingaben: einzeilig, ASCII, ohne | und ",
|
||||
// HTML-escaped; Unescaped nur bereinigt.
|
||||
func TestBuildCheckmk_Sanitizes(t *testing.T) {
|
||||
evil := okRoute("kunde.de")
|
||||
evil.ComposeDir = "/srv/<script>alert(1)</script>\n2 \"Fake\" - OK |x"
|
||||
evil.Tenant = "küche"
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "a", Hostname: "<b>h</b>", LastReport: cmkNow}},
|
||||
map[string]Report{"a": cmkReport(evil)}, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
u := d.URLs[0]
|
||||
for _, s := range []string{u.ComposeDir, u.Unescaped.ComposeDir, d.Payees[0].Hostname} {
|
||||
if strings.ContainsAny(s, "\n\r|\"") {
|
||||
t.Errorf("nicht bereinigt: %q", s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(u.ComposeDir, "<script>") || !strings.Contains(u.ComposeDir, "<script>") {
|
||||
t.Errorf("nicht escaped: %q", u.ComposeDir)
|
||||
}
|
||||
if !strings.Contains(u.Unescaped.ComposeDir, "<script>") {
|
||||
t.Errorf("Unescaped verändert: %q", u.Unescaped.ComposeDir)
|
||||
}
|
||||
if u.Tenant != "kueche" {
|
||||
t.Errorf("Umlaut: %q", u.Tenant)
|
||||
}
|
||||
}
|
||||
|
||||
var cmkLine = regexp.MustCompile(`^[0-2] "[^"|]+" - (OK|WARN|CRIT) - [ -~]*$`)
|
||||
|
||||
// Die eingebaute Vorlage liefert gültige Local-Check-Zeilen mit Links.
|
||||
func TestRenderCheckmk_Default(t *testing.T) {
|
||||
down := okRoute("olat.kunde.de")
|
||||
down.Down = true
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "webproxy11", Hostname: "webproxy11.lan", LastReport: cmkNow.Add(-5 * time.Minute)}},
|
||||
map[string]Report{"webproxy11": cmkReport(okRoute("kunde.de"), down)}, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
out := renderCheckmk(t.TempDir(), d)
|
||||
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("%d Zeilen:\n%s", len(lines), out)
|
||||
}
|
||||
for _, l := range lines {
|
||||
if !cmkLine.MatchString(l) {
|
||||
t.Errorf("ungültige Zeile: %q", l)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`0 "Sultan Payee webproxy11" - OK - `,
|
||||
`0 "Sultan URL kunde.de" - OK - `,
|
||||
"https://kunde.de", "http://webdocker11.lan:8081", "/srv/kunde-a", "kunde", "webdocker11",
|
||||
`1 "Sultan URL olat.kunde.de" - WARN - gestoppt`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("fehlt %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eigene Vorlage in <data-dir>/checkmk hat Vorrang und wirkt sofort; eine
|
||||
// defekte fällt auf die eingebaute zurück und meldet sich als CRIT.
|
||||
func TestRenderCheckmk_CustomTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "p", LastReport: cmkNow}}, map[string]Report{"p": cmkReport(okRoute("kunde.de"))},
|
||||
cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
os.MkdirAll(filepath.Join(dir, "checkmk"), 0o755)
|
||||
tmpl := filepath.Join(dir, "checkmk", CheckmkTemplateName)
|
||||
|
||||
os.WriteFile(tmpl, []byte(`{{range .URLs}}{{.State}} "{{.Service}}" - {{status .State}} - eigen {{.Unescaped.Host}}{{"\n"}}{{end}}`), 0o644)
|
||||
if out := renderCheckmk(dir, d); out != "0 \"Sultan URL kunde.de\" - OK - eigen kunde.de\n" {
|
||||
t.Errorf("eigene Vorlage: %q", out)
|
||||
}
|
||||
|
||||
os.WriteFile(tmpl, []byte(`{{range .URLs}`), 0o644)
|
||||
out := renderCheckmk(dir, d)
|
||||
if !strings.Contains(out, `2 "Sultan Accountant Vorlage" - CRIT - `) || !strings.Contains(out, `"Sultan URL kunde.de"`) {
|
||||
t.Errorf("defekte Vorlage: %q", out)
|
||||
}
|
||||
os.WriteFile(tmpl, []byte(`{{.GibtsNicht}}`), 0o644)
|
||||
if out := renderCheckmk(dir, d); !strings.Contains(out, `2 "Sultan Accountant Vorlage"`) {
|
||||
t.Errorf("Ausführungsfehler: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpCheckmk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, err := DumpCheckmk(dir, false)
|
||||
if err != nil || path != filepath.Join(dir, "checkmk", CheckmkTemplateName) {
|
||||
t.Fatalf("DumpCheckmk = %q, %v", path, err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != defaultCheckmkTemplate {
|
||||
t.Error("Inhalt weicht von der eingebauten Vorlage ab")
|
||||
}
|
||||
if _, err := DumpCheckmk(dir, false); err == nil {
|
||||
t.Error("vorhandene Datei überschrieben")
|
||||
}
|
||||
if _, err := DumpCheckmk(dir, true); err != nil {
|
||||
t.Errorf("--force: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ende zu Ende: Bericht eines Payees erscheint auf dem Loopback-Port.
|
||||
func TestCheckmkEndpoint(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "127.0.0.1:0",
|
||||
Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Shutdown(ctx) })
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
rep := cmkReport(okRoute("kunde.de"))
|
||||
rep.GeneratedAt = time.Now()
|
||||
body, _ := json.Marshal(rep)
|
||||
postReport(t, a, key, body)
|
||||
|
||||
resp, err := http.Get("http://" + a.MetricsAddr() + "/metrics/checkmk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") ||
|
||||
!strings.Contains(string(out), `0 "Sultan URL kunde.de"`) || !strings.Contains(string(out), `0 "Sultan Payee webproxy11"`) {
|
||||
t.Errorf("Status %d, %q:\n%s", resp.StatusCode, resp.Header.Get("Content-Type"), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckmkEndpoint_Disabled(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "none",
|
||||
Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Shutdown(ctx)
|
||||
if a.MetricsAddr() != "" {
|
||||
t.Errorf("MetricsAddr = %q, erwartet leer", a.MetricsAddr())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// Ein Bericht legt für neue Mandanten automatisch Kunden an.
|
||||
func TestReport_CreatesCustomers(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
body, _ := json.Marshal(whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de")))
|
||||
postReport(t, a, key, body)
|
||||
l, _ := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
defer l.Close()
|
||||
for _, id := range []string{"kunde", "andere"} {
|
||||
if c, err := l.GetCustomer(ctx, id); err != nil || !c.Auto || c.Cycle != ledger.CycleUnclassified {
|
||||
t.Errorf("Kunde %s: %+v, %v", id, c, err)
|
||||
}
|
||||
}
|
||||
if c, _ := l.GetCustomer(ctx, "kunde"); c.Name != "Kunde A" {
|
||||
t.Errorf("Name aus dem Mandanten: %q", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// where zeigt den Kunden – auch über eine Zuordnung.
|
||||
func TestWhere_Customer(t *testing.T) {
|
||||
l, _ := whereLedger(t)
|
||||
l.AddCustomer(ctx, ledger.Customer{ID: "k-1001", Name: "Kunde A GmbH", Group: "extern"})
|
||||
l.MapTenant(ctx, "", "kunde", "k-1001")
|
||||
save(t, l, "webproxy11", whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de")))
|
||||
res, err := Where(ctx, l, "kunde.de")
|
||||
if err != nil || len(res.Current) != 1 {
|
||||
t.Fatalf("%+v, %v", res, err)
|
||||
}
|
||||
if c := res.Current[0].Customer; c == nil || c.ID != "k-1001" || c.Group != "extern" {
|
||||
t.Errorf("Kunde: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoney(t *testing.T) {
|
||||
for in, want := range map[string]int64{"89": 8900, "89,00": 8900, "89.5": 8950, "1.234,56": 123456, "0,99": 99, " 12,30 ": 1230} {
|
||||
if got, err := ParseMoney(in); err != nil || got != want {
|
||||
t.Errorf("ParseMoney(%q) = %d, %v; erwartet %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "abc", "1,234", "-5", "1,2,3"} {
|
||||
if _, err := ParseMoney(bad); err == nil {
|
||||
t.Errorf("ParseMoney(%q) ohne Fehler", bad)
|
||||
}
|
||||
}
|
||||
for in, want := range map[int64]string{8900: "89,00", 99: "0,99", 123456: "1234,56", 0: "0,00"} {
|
||||
if got := FormatMoney(in); got != want {
|
||||
t.Errorf("FormatMoney(%d) = %q, erwartet %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseMoney liest einen Betrag in Euro (z. B. "89", "89,00", "89.5",
|
||||
// "1.234,56") und liefert Cent. Mehr als zwei Nachkommastellen und negative
|
||||
// Beträge sind Fehler.
|
||||
func ParseMoney(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
orig := s
|
||||
if strings.Contains(s, ",") {
|
||||
s = strings.ReplaceAll(s, ".", "") // Tausenderpunkte
|
||||
s = strings.Replace(s, ",", ".", 1)
|
||||
}
|
||||
whole, frac, _ := strings.Cut(s, ".")
|
||||
if whole == "" || len(frac) > 2 || strings.ContainsAny(frac, ".,") {
|
||||
return 0, fmt.Errorf("Betrag %q nicht lesbar (z. B. 89,00)", orig)
|
||||
}
|
||||
for len(frac) < 2 {
|
||||
frac += "0"
|
||||
}
|
||||
euros, err := strconv.ParseUint(whole, 10, 40)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Betrag %q nicht lesbar (z. B. 89,00)", orig)
|
||||
}
|
||||
cents, err := strconv.ParseUint(frac, 10, 8)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Betrag %q nicht lesbar (z. B. 89,00)", orig)
|
||||
}
|
||||
return int64(euros)*100 + int64(cents), nil
|
||||
}
|
||||
|
||||
// FormatMoney formatiert Cent als Euro mit Dezimalkomma ("89,00"), wie
|
||||
// Lexware Office es beim Import erwartet.
|
||||
func FormatMoney(cents int64) string {
|
||||
sign := ""
|
||||
if cents < 0 {
|
||||
sign, cents = "-", -cents
|
||||
}
|
||||
return fmt.Sprintf("%s%d,%02d", sign, cents/100, cents%100)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/ledger"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// PathReport ist der Endpunkt für Berichte der Payees.
|
||||
const PathReport = "/api/v1/report"
|
||||
|
||||
// maxReportSize begrenzt einen Bericht (signierter Body). Ein Eintrag je
|
||||
// Route ist einige hundert Bytes groß; 8 MiB reichen für zehntausende Routen.
|
||||
const maxReportSize = 8 << 20
|
||||
|
||||
// Report ist der vollständige Schnappschuss eines Payees (Spec 5.3) – kein
|
||||
// Delta: Was fehlt, gibt es beim Payee nicht mehr.
|
||||
type Report struct {
|
||||
Version string `json:"version"` // sultan-Version des Payees
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Tenants []ReportTenant `json:"tenants"`
|
||||
Nodes []ReportNode `json:"nodes"`
|
||||
Routes []ReportRoute `json:"routes"`
|
||||
}
|
||||
|
||||
// ReportTenant ist ein Mandant des Payees.
|
||||
type ReportTenant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tier string `json:"tier"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ReportNode ist ein Knoten (Backend-Host) des Payees.
|
||||
type ReportNode struct {
|
||||
ID string `json:"id"`
|
||||
Tenant string `json:"tenant"`
|
||||
Hostname string `json:"hostname"`
|
||||
FQDN string `json:"fqdn,omitempty"`
|
||||
IP string `json:"ip"` // Absender-IP beim Server
|
||||
Status string `json:"status"`
|
||||
Connected bool `json:"connected"`
|
||||
Maintenance bool `json:"maintenance,omitempty"`
|
||||
LastSeen time.Time `json:"last_seen,omitzero"`
|
||||
OS string `json:"os,omitempty"`
|
||||
DockerVersion string `json:"docker_version,omitempty"`
|
||||
NetBirdIP string `json:"netbird_ip,omitempty"`
|
||||
}
|
||||
|
||||
// ReportRoute ist ein gemeldeter Router mit seinem Ziel.
|
||||
type ReportRoute struct {
|
||||
Tenant string `json:"tenant"`
|
||||
Node string `json:"node"`
|
||||
Workload string `json:"workload"` // Container bzw. sultan.d/<name>
|
||||
Router string `json:"router"` // Name aus den Labels
|
||||
Protocol string `json:"protocol"` // http oder tcp
|
||||
Hosts []string `json:"hosts"`
|
||||
// Backend wie Traefik es erreicht: http://<ip>:<port> bzw. <ip>:<port> (tcp).
|
||||
Backend string `json:"backend"`
|
||||
// BackendFQDN: <fqdn>:<port> – zum Springen auf den Host im Störfall.
|
||||
BackendFQDN string `json:"backend_fqdn,omitempty"`
|
||||
// Active: Router steht in der Traefik-Konfiguration (nicht von der Policy
|
||||
// verworfen, z. B. fremde Domain oder Routenlimit).
|
||||
Active bool `json:"active"`
|
||||
// Down: Service gestoppt bzw. Healthcheck fehlgeschlagen (Wartungsseite).
|
||||
Down bool `json:"down,omitempty"`
|
||||
Product string `json:"product,omitempty"` // Label sultan.accountant.billing
|
||||
|
||||
ComposeProject string `json:"compose_project,omitempty"`
|
||||
ComposeService string `json:"compose_service,omitempty"`
|
||||
ComposeDir string `json:"compose_dir,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitzero"`
|
||||
Health string `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// LabelBilling ordnet einer Route ein Produkt zu (Spec 5.3).
|
||||
const LabelBilling = "sultan.accountant.billing"
|
||||
|
||||
func (a *Accountant) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
pub, err := a.reportVerifier.VerifyUnregistered(r)
|
||||
if err != nil {
|
||||
code := auth.StatusCode(err)
|
||||
http.Error(w, http.StatusText(code), code)
|
||||
return
|
||||
}
|
||||
p, err := a.ledger.GetPayeeByPublicKey(r.Context(), auth.EncodePublicKey(pub))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.log.Error("Payee nachschlagen", "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var rep Report
|
||||
if err := json.NewDecoder(r.Body).Decode(&rep); err != nil {
|
||||
http.Error(w, "ungültiger Bericht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rep.GeneratedAt.IsZero() {
|
||||
http.Error(w, "generated_at fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := StoreReport(r.Context(), a.ledger, p.ID, rep); err != nil {
|
||||
a.log.Error("Bericht speichern", "payee", p.ID, "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.log.Debug("Bericht empfangen", "payee", p.ID, "routes", len(rep.Routes), "nodes", len(rep.Nodes))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// StoreReport legt einen Bericht ab: letzter Stand (normalisiert: nur
|
||||
// bekannte Felder), Historie und automatisch angelegte Kunden für neue
|
||||
// Mandanten.
|
||||
func StoreReport(ctx context.Context, l *ledger.Ledger, payeeID string, rep Report) error {
|
||||
body, err := json.Marshal(rep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.SaveReport(ctx, payeeID, body, RouteRecords(rep)); err != nil {
|
||||
return err
|
||||
}
|
||||
tenants := make([]ledger.TenantRef, 0, len(rep.Tenants))
|
||||
for _, t := range rep.Tenants {
|
||||
tenants = append(tenants, ledger.TenantRef{ID: t.ID, Name: t.Name})
|
||||
}
|
||||
return l.EnsureCustomers(ctx, payeeID, tenants)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// enrolledPayee registriert einen Payee und liefert seinen Schlüssel.
|
||||
func enrolledPayee(t *testing.T, a *Accountant, id string) ed25519.PrivateKey {
|
||||
t.Helper()
|
||||
key, _ := auth.GenerateKey()
|
||||
tok := invite(t, a, id)
|
||||
if resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: id + ".lan"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Enrollment: %d", resp.StatusCode)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func postReport(t *testing.T, a *Accountant, key ed25519.PrivateKey, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://"+a.Addr()+PathReport, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != nil {
|
||||
if err := (&auth.Signer{Key: key}).Sign(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.Fingerprint())}}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func latest(t *testing.T, a *Accountant, payeeID string) (Report, time.Time, bool) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
body, at, err := l.LatestReport(ctx, payeeID)
|
||||
if err != nil {
|
||||
return Report{}, time.Time{}, false
|
||||
}
|
||||
var r Report
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatalf("gespeicherter Bericht unlesbar: %v", err)
|
||||
}
|
||||
return r, at, true
|
||||
}
|
||||
|
||||
func sampleReport() Report {
|
||||
return Report{
|
||||
Version: "v0.1.0",
|
||||
GeneratedAt: time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC),
|
||||
Tenants: []ReportTenant{{ID: "kunde", Name: "Kunde A", Tier: "pro", Status: "active"}},
|
||||
Nodes: []ReportNode{{ID: "webdocker11", Tenant: "kunde", Hostname: "webdocker11", FQDN: "webdocker11.lan.r25dc.de",
|
||||
IP: "10.0.0.11", Status: "online", Connected: true}},
|
||||
Routes: []ReportRoute{{Tenant: "kunde", Node: "webdocker11", Workload: "kunde-a-wordpress-1", Router: "wp",
|
||||
Protocol: "http", Hosts: []string{"kunde.de"}, Backend: "http://10.0.0.11:8081",
|
||||
BackendFQDN: "webdocker11.lan.r25dc.de:8081", Active: true, ComposeDir: "/srv/kunde-a"}},
|
||||
}
|
||||
}
|
||||
|
||||
// Ein registrierter Payee meldet seinen Schnappschuss; gespeichert wird der
|
||||
// letzte.
|
||||
func TestReport_StoresLatest(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
first := sampleReport()
|
||||
body, _ := json.Marshal(first)
|
||||
if resp := postReport(t, a, key, body); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("Status %d", resp.StatusCode)
|
||||
}
|
||||
got, _, ok := latest(t, a, "webproxy11")
|
||||
if !ok || got.Routes[0].BackendFQDN != "webdocker11.lan.r25dc.de:8081" || got.Nodes[0].FQDN != "webdocker11.lan.r25dc.de" {
|
||||
t.Errorf("gespeichert: %+v", got)
|
||||
}
|
||||
|
||||
second := sampleReport()
|
||||
second.Routes = nil
|
||||
body, _ = json.Marshal(second)
|
||||
postReport(t, a, key, body)
|
||||
if got, _, _ := latest(t, a, "webproxy11"); len(got.Routes) != 0 {
|
||||
t.Errorf("Bericht nicht ersetzt: %+v", got.Routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReport_Errors(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
stranger, _ := auth.GenerateKey()
|
||||
valid, _ := json.Marshal(sampleReport())
|
||||
tests := []struct {
|
||||
name string
|
||||
key ed25519.PrivateKey
|
||||
body []byte
|
||||
want int
|
||||
}{
|
||||
{"unsigniert", nil, valid, http.StatusUnauthorized},
|
||||
{"nicht registrierter Schlüssel", stranger, valid, http.StatusUnauthorized},
|
||||
{"kein JSON", key, []byte("kaputt"), http.StatusBadRequest},
|
||||
{"kein Zeitpunkt", key, []byte(`{"tenants":[]}`), http.StatusBadRequest},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if resp := postReport(t, a, tt.key, tt.body); resp.StatusCode != tt.want {
|
||||
t.Errorf("Status %d, erwartet %d", resp.StatusCode, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, ok := latest(t, a, "webproxy11"); ok {
|
||||
t.Error("fehlerhafter Bericht gespeichert")
|
||||
}
|
||||
}
|
||||
|
||||
// Große Hoster: Berichte bis 8 MiB werden angenommen.
|
||||
func TestReport_LargeBody(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
r := sampleReport()
|
||||
for i := 0; i < 6000; i++ {
|
||||
r.Routes = append(r.Routes, r.Routes[0])
|
||||
}
|
||||
body, _ := json.Marshal(r)
|
||||
if len(body) < 1<<20 {
|
||||
t.Fatalf("Testbericht nur %d Bytes", len(body))
|
||||
}
|
||||
if resp := postReport(t, a, key, body); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("großer Bericht: Status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package accountant
|
||||
|
||||
// Abfragen auf dem Accountant (Spec 5.3, Roadmap 6d Schritt 1): where – wo
|
||||
// läuft eine URL, seit wann, wo vorher? – und routes – alle aktuellen
|
||||
// Routen. Aktuell = letzter Bericht je Payee; seit wann/vorher = Historie.
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// RouteRecords übersetzt die Routen eines Berichts für die Historie.
|
||||
func RouteRecords(rep Report) []ledger.RouteRecord {
|
||||
out := make([]ledger.RouteRecord, 0, len(rep.Routes))
|
||||
for _, r := range rep.Routes {
|
||||
out = append(out, ledger.RouteRecord{
|
||||
Tenant: r.Tenant, Node: r.Node, Protocol: r.Protocol, Router: r.Router, Hosts: r.Hosts,
|
||||
Backend: r.Backend, BackendFQDN: r.BackendFQDN, Workload: r.Workload, ComposeDir: r.ComposeDir, Product: r.Product,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentRoute ist eine Route aus dem letzten Bericht eines Payees.
|
||||
type CurrentRoute struct {
|
||||
Payee string
|
||||
ReportAt time.Time // Eingang des Berichts
|
||||
Route ReportRoute
|
||||
Node ReportNode // Knoten der Route (leer, falls nicht im Bericht)
|
||||
TenantName string
|
||||
// Customer: zugeordneter Kunde (nil = keinem Kunden zugeordnet).
|
||||
Customer *ledger.Customer
|
||||
Since time.Time // Beginn des Zeitraums an diesem Ort (Historie)
|
||||
// Previous: frühere, beendete Orte derselben Route (Mandant, Protokoll,
|
||||
// Router), neueste zuerst.
|
||||
Previous []ledger.RouteSpan
|
||||
}
|
||||
|
||||
// WhereResult ist das Ergebnis von Where.
|
||||
type WhereResult struct {
|
||||
Current []CurrentRoute
|
||||
// Gone: passende Routen, die es nirgends mehr gibt (neueste zuerst).
|
||||
Gone []ledger.RouteSpan
|
||||
}
|
||||
|
||||
// RoutesFilter schränkt Routes ein; leere Felder filtern nicht.
|
||||
type RoutesFilter struct {
|
||||
Tenant string
|
||||
Payee string
|
||||
}
|
||||
|
||||
func spanKey(tenant, node, protocol, router string) string {
|
||||
return tenant + "\x00" + node + "\x00" + protocol + "\x00" + router
|
||||
}
|
||||
|
||||
func routeKey(tenant, protocol, router string) string {
|
||||
return tenant + "\x00" + protocol + "\x00" + router
|
||||
}
|
||||
|
||||
// currentRoutes liest die letzten Berichte aller Payees und ergänzt den
|
||||
// Beginn je Ort aus der Historie.
|
||||
func currentRoutes(ctx context.Context, l *ledger.Ledger, keep func(ReportRoute) bool) ([]CurrentRoute, error) {
|
||||
payees, err := l.ListPayees(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
open, err := l.RouteHistory(ctx, ledger.HistoryFilter{OpenOnly: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver, err := l.CustomerResolver(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customers := map[string]*ledger.Customer{}
|
||||
cs, err := l.ListCustomers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cs {
|
||||
customers[cs[i].ID] = &cs[i]
|
||||
}
|
||||
since := map[string]time.Time{}
|
||||
for _, s := range open {
|
||||
since[s.Payee+"\x00"+spanKey(s.Tenant, s.Node, s.Protocol, s.Router)] = s.FirstSeen
|
||||
}
|
||||
var out []CurrentRoute
|
||||
for _, p := range payees {
|
||||
body, at, err := l.LatestReport(ctx, p.ID)
|
||||
if err != nil {
|
||||
continue // noch kein Bericht
|
||||
}
|
||||
var rep Report
|
||||
if err := json.Unmarshal(body, &rep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes := map[string]ReportNode{}
|
||||
for _, n := range rep.Nodes {
|
||||
nodes[n.ID] = n
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, t := range rep.Tenants {
|
||||
names[t.ID] = t.Name
|
||||
}
|
||||
for _, r := range rep.Routes {
|
||||
if !keep(r) {
|
||||
continue
|
||||
}
|
||||
out = append(out, CurrentRoute{
|
||||
Payee: p.ID, ReportAt: at, Route: r, Node: nodes[r.Node], TenantName: names[r.Tenant],
|
||||
Customer: customers[resolver.Resolve(p.ID, r.Tenant)],
|
||||
Since: since[p.ID+"\x00"+spanKey(r.Tenant, r.Node, r.Protocol, r.Router)],
|
||||
})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b CurrentRoute) int {
|
||||
return cmp.Or(strings.Compare(firstHost(a.Route.Hosts), firstHost(b.Route.Hosts)),
|
||||
strings.Compare(a.Payee, b.Payee), strings.Compare(a.Route.Node, b.Route.Node), strings.Compare(a.Route.Router, b.Route.Router))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstHost(hosts []string) string {
|
||||
if len(hosts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hosts[0]
|
||||
}
|
||||
|
||||
// matches: ein Host enthält query, oder query ist die Mandanten-ID.
|
||||
func matches(query, tenant string, hosts []string) bool {
|
||||
if strings.EqualFold(tenant, query) {
|
||||
return true
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if strings.Contains(strings.ToLower(h), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Where sucht Routen, deren Host query enthält (ohne Groß-/Kleinschreibung)
|
||||
// oder deren Mandant query heißt – aktuell mit früheren Orten, dazu
|
||||
// passende Routen, die es nicht mehr gibt.
|
||||
func Where(ctx context.Context, l *ledger.Ledger, query string) (WhereResult, error) {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
var res WhereResult
|
||||
if query == "" {
|
||||
return res, nil
|
||||
}
|
||||
cur, err := currentRoutes(ctx, l, func(r ReportRoute) bool { return matches(query, r.Tenant, r.Hosts) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
all, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
ended := map[string][]ledger.RouteSpan{}
|
||||
for _, s := range all {
|
||||
if !s.Ended.IsZero() {
|
||||
k := routeKey(s.Tenant, s.Protocol, s.Router)
|
||||
ended[k] = append(ended[k], s)
|
||||
}
|
||||
}
|
||||
newestFirst := func(a, b ledger.RouteSpan) int { return b.Ended.Compare(a.Ended) }
|
||||
alive := map[string]bool{}
|
||||
for i := range cur {
|
||||
r := cur[i].Route
|
||||
k := routeKey(r.Tenant, r.Protocol, r.Router)
|
||||
alive[k] = true
|
||||
for _, s := range ended[k] {
|
||||
if s.Payee == cur[i].Payee && s.Node == r.Node {
|
||||
continue // derselbe Ort früher einmal – kein Umzug
|
||||
}
|
||||
cur[i].Previous = append(cur[i].Previous, s)
|
||||
}
|
||||
slices.SortFunc(cur[i].Previous, newestFirst)
|
||||
}
|
||||
res.Current = cur
|
||||
for k, spans := range ended {
|
||||
if alive[k] {
|
||||
continue
|
||||
}
|
||||
for _, s := range spans {
|
||||
if matches(query, s.Tenant, s.Hosts) {
|
||||
res.Gone = append(res.Gone, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(res.Gone, newestFirst)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Routes liefert alle aktuellen Routen, sortiert nach Host.
|
||||
func Routes(ctx context.Context, l *ledger.Ledger, f RoutesFilter) ([]CurrentRoute, error) {
|
||||
cur, err := currentRoutes(ctx, l, func(r ReportRoute) bool { return f.Tenant == "" || r.Tenant == f.Tenant })
|
||||
if err != nil || f.Payee == "" {
|
||||
return cur, err
|
||||
}
|
||||
return slices.DeleteFunc(cur, func(c CurrentRoute) bool { return c.Payee != f.Payee }), nil
|
||||
}
|
||||
|
||||
// ItemKey bezeichnet einen Posten: Mandant, Protokoll, Router (über alle
|
||||
// Payees und Knoten – ein Umzug bleibt derselbe Posten).
|
||||
type ItemKey struct {
|
||||
Tenant, Protocol, Router string
|
||||
}
|
||||
|
||||
// ItemsForHost liefert die Posten, zu denen host gehört (exakter Host, aus
|
||||
// der Historie; ohne Groß-/Kleinschreibung), sortiert.
|
||||
func ItemsForHost(ctx context.Context, l *ledger.Ledger, host string) ([]ItemKey, error) {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{Host: host})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ItemKey
|
||||
for _, s := range spans {
|
||||
k := ItemKey{s.Tenant, s.Protocol, s.Router}
|
||||
if slices.Contains(s.Hosts, host) && !slices.Contains(out, k) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b ItemKey) int {
|
||||
return cmp.Or(strings.Compare(a.Tenant, b.Tenant), strings.Compare(a.Router, b.Router), strings.Compare(a.Protocol, b.Protocol))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// whereLedger: zwei Payees; kunde.de zieht von webdocker07 nach webdocker11 um,
|
||||
// olat.kunde.de verschwindet, andere.de liegt auf dem zweiten Payee.
|
||||
func whereLedger(t *testing.T) (*ledger.Ledger, func(time.Time)) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
now := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
setNow := func(at time.Time) { now = at }
|
||||
l.SetClock(func() time.Time { return now })
|
||||
for _, id := range []string{"webproxy11", "webproxy12"} {
|
||||
l.AddInvite(ctx, "tok-"+id, id, now.Add(time.Hour))
|
||||
if _, err := l.Enroll(ctx, "tok-"+id, ledger.Payee{Hostname: id + ".lan", PublicKey: "key-" + id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return l, setNow
|
||||
}
|
||||
|
||||
func save(t *testing.T, l *ledger.Ledger, payeeID string, rep Report) {
|
||||
t.Helper()
|
||||
if err := StoreReport(ctx, l, payeeID, rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func whereReport(node string, routes ...ReportRoute) Report {
|
||||
return Report{
|
||||
GeneratedAt: time.Now(),
|
||||
Tenants: []ReportTenant{{ID: "kunde", Name: "Kunde A"}, {ID: "andere", Name: "Andere GmbH"}},
|
||||
Nodes: []ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true, OS: "Debian 13",
|
||||
DockerVersion: "29.8.2", NetBirdIP: "100.92.10.4"}},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func route(tenant, node, router string, hosts ...string) ReportRoute {
|
||||
return ReportRoute{Tenant: tenant, Node: node, Router: router, Protocol: "http", Hosts: hosts, Workload: "kunde-a-" + router + "-1",
|
||||
Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081", Active: true,
|
||||
ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"}
|
||||
}
|
||||
|
||||
func TestWhere(t *testing.T) {
|
||||
l, setNow := whereLedger(t)
|
||||
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
save(t, l, "webproxy11", whereReport("webdocker07", route("kunde", "webdocker07", "wp", "kunde.de"),
|
||||
route("kunde", "webdocker07", "olat", "olat.kunde.de")))
|
||||
andere := whereReport("n9", route("andere", "n9", "web", "andere.de"))
|
||||
save(t, l, "webproxy12", andere)
|
||||
|
||||
t2 := t1.Add(30 * 24 * time.Hour)
|
||||
setNow(t2)
|
||||
save(t, l, "webproxy11", whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de", "www.kunde.de")))
|
||||
|
||||
res, err := Where(ctx, l, "kunde.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Current) != 1 {
|
||||
t.Fatalf("aktuell: %+v", res.Current)
|
||||
}
|
||||
h := res.Current[0]
|
||||
if h.Payee != "webproxy11" || h.Route.Router != "wp" || h.TenantName != "Kunde A" || h.Node.FQDN != "webdocker11.lan.r25dc.de" ||
|
||||
h.Route.ComposeDir != "/srv/kunde-a" || !h.Since.Equal(t2) || !h.ReportAt.Equal(t2) {
|
||||
t.Errorf("Treffer: %+v", h)
|
||||
}
|
||||
if len(h.Previous) != 1 || h.Previous[0].Node != "webdocker07" || !h.Previous[0].Ended.Equal(t2) || !h.Previous[0].FirstSeen.Equal(t1) {
|
||||
t.Errorf("vorher: %+v", h.Previous)
|
||||
}
|
||||
// olat.kunde.de passt (Teiltreffer "kunde.de"), ist aber verschwunden.
|
||||
if len(res.Gone) != 1 || res.Gone[0].Router != "olat" || !res.Gone[0].Ended.Equal(t2) {
|
||||
t.Errorf("verschwunden: %+v", res.Gone)
|
||||
}
|
||||
|
||||
// Teiltreffer und Mandant.
|
||||
if r, _ := Where(ctx, l, "WWW.Kunde"); len(r.Current) != 1 {
|
||||
t.Errorf("Teiltreffer: %+v", r)
|
||||
}
|
||||
if r, _ := Where(ctx, l, "andere"); len(r.Current) != 1 || r.Current[0].Payee != "webproxy12" {
|
||||
t.Errorf("Mandant: %+v", r)
|
||||
}
|
||||
if r, _ := Where(ctx, l, "gibts-nicht.de"); len(r.Current)+len(r.Gone) != 0 {
|
||||
t.Errorf("kein Treffer erwartet: %+v", r)
|
||||
}
|
||||
if r, _ := Where(ctx, l, "_"); len(r.Current)+len(r.Gone) != 0 {
|
||||
t.Errorf("Platzhalter wirkt als Joker: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Der Bericht geht über den Endpunkt in die Historie ein.
|
||||
func TestReport_RecordsHistory(t *testing.T) {
|
||||
a := start(t, "")
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
body, _ := json.Marshal(whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de")))
|
||||
postReport(t, a, key, body)
|
||||
l, _ := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
defer l.Close()
|
||||
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
|
||||
if err != nil || len(spans) != 1 || spans[0].Payee != "webproxy11" || spans[0].BackendFQDN != "webdocker11.lan.r25dc.de:8081" {
|
||||
t.Errorf("Historie: %+v, %v", spans, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes(t *testing.T) {
|
||||
l, _ := whereLedger(t)
|
||||
save(t, l, "webproxy11", whereReport("webdocker11", route("kunde", "webdocker11", "wp", "kunde.de"),
|
||||
route("kunde", "webdocker11", "olat", "olat.kunde.de")))
|
||||
save(t, l, "webproxy12", whereReport("n9", route("andere", "n9", "web", "andere.de")))
|
||||
|
||||
all, err := Routes(ctx, l, RoutesFilter{})
|
||||
if err != nil || len(all) != 3 {
|
||||
t.Fatalf("Routes = %+v, %v", all, err)
|
||||
}
|
||||
if all[0].Route.Hosts[0] != "andere.de" || all[2].Route.Hosts[0] != "olat.kunde.de" {
|
||||
t.Errorf("nicht nach Host sortiert: %v %v %v", all[0].Route.Hosts, all[1].Route.Hosts, all[2].Route.Hosts)
|
||||
}
|
||||
if r, _ := Routes(ctx, l, RoutesFilter{Tenant: "kunde"}); len(r) != 2 {
|
||||
t.Errorf("--tenant: %+v", r)
|
||||
}
|
||||
if r, _ := Routes(ctx, l, RoutesFilter{Payee: "webproxy12"}); len(r) != 1 || r[0].Since.IsZero() {
|
||||
t.Errorf("--payee: %+v", r)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user