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)
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,6 +43,7 @@ type Config struct {
|
||||
Token string // Einladung, nur beim ersten Start
|
||||
NodeID string // Default aus dem Hostnamen
|
||||
Hostname string // Default os.Hostname()
|
||||
FQDN string // Default automatisch (detectFQDN); nur Information für das Inventar
|
||||
|
||||
DockerSocket string // Default /var/run/docker.sock; "none" schaltet Docker ab
|
||||
BackendHost string // Adresse, unter der der Ingress die Dienste erreicht
|
||||
@@ -65,6 +67,7 @@ type Agent struct {
|
||||
signer *auth.Signer
|
||||
id Identity
|
||||
serverURL string // tatsächlich genutzte Adresse (Override oder node.json)
|
||||
fqdn string // in Run ermittelt
|
||||
|
||||
mu sync.Mutex
|
||||
fileSpecs []ServiceSpec
|
||||
@@ -144,6 +147,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.fqdn = detectFQDN(ctx, net.DefaultResolver, a.cfg.FQDN, a.cfg.Hostname, host)
|
||||
hc := a.httpClient()
|
||||
a.runCtx = ctx
|
||||
// Abgebrochener Schlüsselwechsel? Vor dem Verbinden abschließen.
|
||||
@@ -153,7 +157,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
a.log.Info("Agent gestartet", "node", a.id.NodeID, "server", a.serverURL, "backend", host,
|
||||
"docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
|
||||
"fqdn", a.fqdn, "docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
|
||||
|
||||
trigger := make(chan struct{}, 1)
|
||||
kick := func() {
|
||||
@@ -235,23 +239,28 @@ func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trig
|
||||
// gesendet (sonst würden alle Container-Routen entfernt).
|
||||
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
|
||||
state := registry.NodeState{Workloads: []registry.Workload{}}
|
||||
var client *docker.Client
|
||||
if a.dockerAvailable() {
|
||||
client := &docker.Client{Socket: a.cfg.DockerSocket}
|
||||
client = &docker.Client{Socket: a.cfg.DockerSocket}
|
||||
containers, err := client.List(ctx)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
// Gestoppte Container nennen in der Liste keine Ports.
|
||||
// Startzeit und Health stehen nur in den Details; gestoppte Container
|
||||
// nennen ihre Ports ebenfalls nur dort.
|
||||
for i, c := range containers {
|
||||
if c.State == "running" || !enabled(c.Labels) {
|
||||
if !enabled(c.Labels) {
|
||||
continue
|
||||
}
|
||||
ports, err := client.PortBindings(ctx, c.ID)
|
||||
d, err := client.Inspect(ctx, c.ID)
|
||||
if err != nil {
|
||||
a.log.Debug("Portzuordnung nicht lesbar", "container", c.Name(), "err", err)
|
||||
continue // inzwischen entfernt o. Ä. → Workloads warnt
|
||||
a.log.Debug("Container-Details nicht lesbar", "container", c.Name(), "err", err)
|
||||
continue // inzwischen entfernt o. Ä. → Workloads warnt ggf.
|
||||
}
|
||||
containers[i].StartedAt, containers[i].Health = d.StartedAt, d.Health
|
||||
if c.State != "running" {
|
||||
containers[i].Ports = d.Ports
|
||||
}
|
||||
containers[i].Ports = ports
|
||||
}
|
||||
ws, down, warnings := Workloads(containers, host)
|
||||
for _, w := range warnings {
|
||||
@@ -265,6 +274,7 @@ func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, e
|
||||
a.mu.Unlock()
|
||||
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
|
||||
state.DownServices = append(state.DownServices, a.health.down()...)
|
||||
state.Host = a.hostInfo(ctx, client)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
||||
+53
-1
@@ -24,6 +24,7 @@ import (
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/docker"
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/server"
|
||||
"sultan/pkg/store"
|
||||
"sultan/pkg/stream"
|
||||
@@ -179,11 +180,18 @@ func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
|
||||
key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
|
||||
bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "HostConfig": map[string]any{"PortBindings": bindings}})
|
||||
state := map[string]any{"Status": c.State, "StartedAt": c.StartedAt}
|
||||
if c.Health != "" {
|
||||
state["Health"] = map[string]string{"Status": c.Health}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "State": state, "HostConfig": map[string]any{"PortBindings": bindings}})
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
|
||||
})
|
||||
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2"}`))
|
||||
})
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
ch := make(chan docker.Event, 8)
|
||||
f.mu.Lock()
|
||||
@@ -872,3 +880,47 @@ func TestServerCandidates(t *testing.T) {
|
||||
t.Errorf("ohne Override: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzdaten für Inventar und Störfall kommen automatisch beim Server an:
|
||||
// Compose-Projekt/-Verzeichnis, Startzeit, Health je Container; FQDN,
|
||||
// Betriebssystem (des Hosts) und Docker-Version je Knoten. Ein Wechsel des
|
||||
// Health-Status wird nachgemeldet.
|
||||
func TestRun_ReportsMetadata(t *testing.T) {
|
||||
s := startServer(t, "", "")
|
||||
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
|
||||
wp := wordpress()
|
||||
wp.Labels["com.docker.compose.project"] = "kunde-a"
|
||||
wp.Labels["com.docker.compose.service"] = "wordpress"
|
||||
wp.Labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
|
||||
wp.StartedAt = started
|
||||
wp.Health = "healthy"
|
||||
dock := newFakeDocker(t, wp)
|
||||
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
|
||||
cfg.FQDN = "webdocker11.lan.r25dc.de"
|
||||
runAgent(t, cfg)
|
||||
|
||||
var st registry.NodeState
|
||||
eventually(t, "Zustand mit Zusatzdaten", func() bool {
|
||||
var ok bool
|
||||
st, ok = s.NodeState("node-1")
|
||||
return ok && len(st.Workloads) == 1 && st.Host != nil
|
||||
})
|
||||
wantMeta := ®istry.WorkloadMeta{
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy",
|
||||
}
|
||||
if !reflect.DeepEqual(st.Workloads[0].Meta, wantMeta) {
|
||||
t.Errorf("Meta = %+v, erwartet %+v", st.Workloads[0].Meta, wantMeta)
|
||||
}
|
||||
if h := st.Host; h.FQDN != "webdocker11.lan.r25dc.de" || h.OS != "Debian GNU/Linux 13 (trixie)" || h.DockerVersion != "29.8.2" {
|
||||
t.Errorf("Host = %+v", h)
|
||||
}
|
||||
|
||||
sick := wp
|
||||
sick.Health = "unhealthy"
|
||||
dock.set("health_status: unhealthy", sick)
|
||||
eventually(t, "Health-Wechsel nachgemeldet", func() bool {
|
||||
st, ok := s.NodeState("node-1")
|
||||
return ok && len(st.Workloads) == 1 && st.Workloads[0].Meta != nil && st.Workloads[0].Meta.Health == "unhealthy"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/docker"
|
||||
"sultan/pkg/registry"
|
||||
)
|
||||
|
||||
// osReleasePath: Quelle des Betriebssystems ohne Docker (systemd-Agent).
|
||||
var osReleasePath = "/etc/os-release"
|
||||
|
||||
// resolver ist der Teil von *net.Resolver, den detectFQDN braucht.
|
||||
type resolver interface {
|
||||
LookupCNAME(ctx context.Context, host string) (string, error)
|
||||
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||
}
|
||||
|
||||
// detectFQDN ermittelt den vollständigen Namen des Knotens für Inventar und
|
||||
// Störfall (Spec 5.3): explizit (--fqdn) > Hostname mit Punkt > Hostname
|
||||
// über die Suchdomain aufgelöst > Reverse-Lookup der Backend-Adresse >
|
||||
// kurzer Hostname. Jede Abfrage ist kurz begrenzt; scheitert alles, bleibt
|
||||
// der Hostname.
|
||||
func detectFQDN(ctx context.Context, res resolver, explicit, hostname, backend string) string {
|
||||
if n := normalizeName(explicit); n != "" {
|
||||
return n
|
||||
}
|
||||
short := normalizeName(hostname)
|
||||
if strings.Contains(short, ".") {
|
||||
return short
|
||||
}
|
||||
lookup := func(f func(context.Context) string) string {
|
||||
lctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
return f(lctx)
|
||||
}
|
||||
if short != "" {
|
||||
if n := lookup(func(c context.Context) string {
|
||||
cname, err := res.LookupCNAME(c, short)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeName(cname)
|
||||
}); strings.Contains(n, ".") {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if net.ParseIP(backend) != nil {
|
||||
if n := lookup(func(c context.Context) string {
|
||||
names, err := res.LookupAddr(c, backend)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
if n := normalizeName(name); strings.Contains(n, ".") {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func normalizeName(s string) string {
|
||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s), "."))
|
||||
}
|
||||
|
||||
// cgnat ist 100.64.0.0/10 – der Bereich, aus dem NetBird Adressen vergibt.
|
||||
var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)}
|
||||
|
||||
// netbirdIP wählt die NetBird-Adresse: IPv4 auf wt0, sonst die erste
|
||||
// IPv4-Adresse aus 100.64.0.0/10 (Interfaces alphabetisch).
|
||||
func netbirdIP(addrs map[string][]net.IP) string {
|
||||
for _, ip := range addrs["wt0"] {
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return ip4.String()
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(addrs))
|
||||
for name := range addrs {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
for _, name := range names {
|
||||
for _, ip := range addrs[name] {
|
||||
if ip4 := ip.To4(); ip4 != nil && cgnat.Contains(ip4) {
|
||||
return ip4.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// interfaceAddrs liest die Adressen aller Interfaces.
|
||||
func interfaceAddrs() map[string][]net.IP {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]net.IP{}
|
||||
for _, ifc := range ifaces {
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
out[ifc.Name] = append(out[ifc.Name], ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// osPrettyName liest PRETTY_NAME (ersatzweise NAME) aus einer os-release-Datei.
|
||||
func osPrettyName(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
var name string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.Trim(value, `"'`)
|
||||
switch key {
|
||||
case "PRETTY_NAME":
|
||||
return value
|
||||
case "NAME":
|
||||
name = value
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// hostInfo sammelt die Angaben zum Knoten. Mit Docker kommt das
|
||||
// Betriebssystem aus der Docker-API (das des Hosts, auch wenn der Agent in
|
||||
// einem Container läuft); Fehler lassen Felder nur leer.
|
||||
func (a *Agent) hostInfo(ctx context.Context, client *docker.Client) *registry.HostInfo {
|
||||
h := ®istry.HostInfo{FQDN: a.fqdn, NetBirdIP: netbirdIP(interfaceAddrs())}
|
||||
if client != nil {
|
||||
info, err := client.Info(ctx)
|
||||
if err != nil {
|
||||
a.log.Debug("Docker-Info nicht lesbar", "err", err)
|
||||
}
|
||||
h.OS, h.DockerVersion = info.OperatingSystem, info.ServerVersion
|
||||
}
|
||||
if h.OS == "" {
|
||||
h.OS = osPrettyName(osReleasePath)
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeResolver beantwortet CNAME- und PTR-Anfragen aus Tabellen.
|
||||
type fakeResolver struct {
|
||||
cname map[string]string
|
||||
ptr map[string][]string
|
||||
}
|
||||
|
||||
func (f fakeResolver) LookupCNAME(_ context.Context, host string) (string, error) {
|
||||
if c, ok := f.cname[host]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return "", errors.New("no such host")
|
||||
}
|
||||
|
||||
func (f fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
|
||||
if p, ok := f.ptr[addr]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return nil, errors.New("no such host")
|
||||
}
|
||||
|
||||
// FQDN: explizit > Hostname mit Punkt > aufgelöster Hostname (Suchdomain) >
|
||||
// Reverse-Lookup der Backend-Adresse > kurzer Hostname.
|
||||
func TestDetectFQDN(t *testing.T) {
|
||||
res := fakeResolver{
|
||||
cname: map[string]string{"webdocker11": "webdocker11.lan.r25dc.de.", "kurz": "kurz"},
|
||||
ptr: map[string][]string{"10.0.0.11": {"backend11.lan.r25dc.de."}},
|
||||
}
|
||||
tests := []struct {
|
||||
name, explicit, hostname, backend, want string
|
||||
}{
|
||||
{"explizit gewinnt", "web.example.de", "webdocker11", "10.0.0.11", "web.example.de"},
|
||||
{"Hostname ist schon FQDN", "", "pi.home.arpa", "10.0.0.11", "pi.home.arpa"},
|
||||
{"über Suchdomain aufgelöst", "", "webdocker11", "10.0.0.11", "webdocker11.lan.r25dc.de"},
|
||||
{"Reverse-Lookup der Backend-Adresse", "", "kurz", "10.0.0.11", "backend11.lan.r25dc.de"},
|
||||
{"nichts auflösbar: kurzer Hostname", "", "solo", "10.9.9.9", "solo"},
|
||||
{"Groß/Klein und Leerraum", " Web.Example.DE ", "x", "", "web.example.de"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := detectFQDN(context.Background(), res, tt.explicit, tt.hostname, tt.backend); got != tt.want {
|
||||
t.Errorf("detectFQDN = %q, erwartet %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NetBird: Interface wt0, sonst eine Adresse aus 100.64.0.0/10 (CGNAT, wie
|
||||
// NetBird sie vergibt).
|
||||
func TestNetBirdIP(t *testing.T) {
|
||||
ip := net.ParseIP
|
||||
tests := []struct {
|
||||
name string
|
||||
addrs map[string][]net.IP
|
||||
want string
|
||||
}{
|
||||
{"wt0", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "wt0": {ip("fe80::1"), ip("100.92.10.4")}}, "100.92.10.4"},
|
||||
{"CGNAT auf anderem Interface", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "nb0": {ip("100.64.3.2")}}, "100.64.3.2"},
|
||||
{"kein Overlay", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "lo": {ip("127.0.0.1")}}, ""},
|
||||
{"100.128.x ist kein CGNAT", map[string][]net.IP{"eth0": {ip("100.128.0.1")}}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := netbirdIP(tt.addrs); got != tt.want {
|
||||
t.Errorf("netbirdIP = %q, erwartet %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne Docker (systemd-Agent) kommt das Betriebssystem aus /etc/os-release.
|
||||
func TestOSPrettyName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "os-release")
|
||||
os.WriteFile(p, []byte("NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nID=debian\n"), 0o644)
|
||||
if got := osPrettyName(p); got != "Debian GNU/Linux 13 (trixie)" {
|
||||
t.Errorf("osPrettyName = %q", got)
|
||||
}
|
||||
os.WriteFile(p, []byte("NAME=Alpine\nID=alpine\n"), 0o644)
|
||||
if got := osPrettyName(p); got != "Alpine" {
|
||||
t.Errorf("ohne PRETTY_NAME: %q, erwartet NAME", got)
|
||||
}
|
||||
if got := osPrettyName(filepath.Join(dir, "fehlt")); got != "" {
|
||||
t.Errorf("fehlende Datei: %q", got)
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -57,7 +57,7 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
|
||||
continue
|
||||
}
|
||||
cand := candidate{
|
||||
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
|
||||
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels, Meta: meta(c)},
|
||||
running: c.State == "running",
|
||||
}
|
||||
for _, proto := range protocols {
|
||||
@@ -108,6 +108,26 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
|
||||
return workloads, slices.Compact(down), warnings
|
||||
}
|
||||
|
||||
// Labels, die Docker Compose an jeden Container setzt.
|
||||
const (
|
||||
labelComposeProject = "com.docker.compose.project"
|
||||
labelComposeService = "com.docker.compose.service"
|
||||
labelComposeDir = "com.docker.compose.project.working_dir"
|
||||
)
|
||||
|
||||
// meta liefert die Zusatzdaten eines Containers (Spec 5.3) – nur aus dem,
|
||||
// was Docker ohnehin kennt, ohne zusätzliche Labels.
|
||||
func meta(c docker.Container) *registry.WorkloadMeta {
|
||||
return ®istry.WorkloadMeta{
|
||||
ComposeProject: c.Labels[labelComposeProject],
|
||||
ComposeService: c.Labels[labelComposeService],
|
||||
ComposeDir: c.Labels[labelComposeDir],
|
||||
State: c.State,
|
||||
StartedAt: c.StartedAt,
|
||||
Health: c.Health,
|
||||
}
|
||||
}
|
||||
|
||||
// labelNames liefert die Namen (Schreibweise aus dem Label) unter prefix,
|
||||
// z. B. die Router aus traefik.http.routers.<name>.…
|
||||
func labelNames(labels map[string]string, prefix string) []string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/docker"
|
||||
"sultan/pkg/registry"
|
||||
@@ -38,6 +39,7 @@ func TestWorkloads_TranslatesPublishedPort(t *testing.T) {
|
||||
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
|
||||
"traefik.http.services.wp.loadbalancer.server.port": "8081",
|
||||
},
|
||||
Meta: ®istry.WorkloadMeta{State: "running"},
|
||||
}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want)
|
||||
@@ -220,3 +222,39 @@ func TestWorkloads_RunningWinsOverStopped(t *testing.T) {
|
||||
t.Errorf("ohne Überschneidung: got=%+v down=%v", got, down)
|
||||
}
|
||||
}
|
||||
|
||||
// Compose-Projekt, -Verzeichnis, Zustand, Startzeit und Health kommen
|
||||
// automatisch aus Docker – ohne zusätzliche Labels.
|
||||
func TestWorkloads_Meta(t *testing.T) {
|
||||
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
|
||||
labels := wpLabels()
|
||||
labels["com.docker.compose.project"] = "kunde-a"
|
||||
labels["com.docker.compose.service"] = "wordpress"
|
||||
labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
|
||||
compose := ctr("kunde-a-wordpress-1", labels, pub(80, 8081))
|
||||
compose.StartedAt = started
|
||||
compose.Health = "healthy"
|
||||
|
||||
plain := ctr("solo", map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.so.rule": "Host(`solo.kunde.de`)",
|
||||
}, pub(80, 8082))
|
||||
plain.State = "exited"
|
||||
|
||||
got, _, warnings := Workloads([]docker.Container{compose, plain}, "100.64.0.42")
|
||||
if len(warnings) != 0 || len(got) != 2 {
|
||||
t.Fatalf("Workloads = %+v, Warnungen %v", got, warnings)
|
||||
}
|
||||
want := map[string]*registry.WorkloadMeta{
|
||||
"kunde-a-wordpress-1": {
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy",
|
||||
},
|
||||
"solo": {State: "exited"},
|
||||
}
|
||||
for _, w := range got {
|
||||
if !reflect.DeepEqual(w.Meta, want[w.Name]) {
|
||||
t.Errorf("%s: Meta = %+v, erwartet %+v", w.Name, w.Meta, want[w.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,10 @@ func shortDuration(d time.Duration) string {
|
||||
|
||||
var umlauts = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", "ß", "ss")
|
||||
|
||||
// ASCII macht Text Checkmk-tauglich (siehe ascii) – auch für andere
|
||||
// Checkmk-Ausgaben, z. B. die des Accountants.
|
||||
func ASCII(s string) string { return ascii(s) }
|
||||
|
||||
// ascii macht Text Checkmk-tauglich: einzeilig, Umlaute umschrieben,
|
||||
// übrige Nicht-ASCII-Zeichen ersetzt.
|
||||
func ascii(s string) string {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
const accountantUsage = `sultan accountant – Zentrale für Inventar und Abrechnung (Spec 5.3).
|
||||
Sultan-Server (Payees) melden ihr Inventar per signiertem Bericht.
|
||||
|
||||
sultan accountant [--addr :8444] [--public-url https://…] [--metrics-addr 127.0.0.1:8082] [--data-dir ./accdata]
|
||||
Accountant starten (nur TLS; Adresse wird gemerkt und steckt in Einladungen;
|
||||
Checkmk: curl http://127.0.0.1:8082/metrics/checkmk)
|
||||
sultan accountant invite --payee ID [--ttl 24h|7d] [--server-url https://…]
|
||||
Einladung für einen Sultan-Server (Payee) erzeugen
|
||||
sultan accountant where <host|mandant> Störfall: wo läuft eine URL (Backend, Container,
|
||||
Compose-Verzeichnis), seit wann, wo vorher? Teiltreffer genügen.
|
||||
sultan accountant routes [--tenant ID] [--payee ID] alle aktuellen Routen
|
||||
sultan accountant customer add --id ID --name NAME [--group G] [--note TEXT] [--cycle m|q|j|offen] [--flat-rate 89,00]
|
||||
sultan accountant customer set --id ID [dieselben Felder] [--no-flat-rate] (nur angegebene Felder ändern)
|
||||
sultan accountant customer list [--group G] [--unclassified]
|
||||
sultan accountant customer map --tenant T --customer ID [--payee P] Mandant (z. B. Tippfehler) zuordnen
|
||||
sultan accountant customer unmap --tenant T [--payee P]
|
||||
sultan accountant customer delete --id ID
|
||||
Kunden entstehen automatisch aus den Mandanten-IDs der Berichte.
|
||||
sultan accountant product add --id ID --name NAME --class included|billable|free|internal [--price 49,00]
|
||||
sultan accountant product set --id ID --from JJJJ-MM [--class …] [--price … | --no-price] (gilt ab Monat)
|
||||
sultan accountant product price --id ID --customer ID --price 39,00 [--from JJJJ-MM] Kundenpreis
|
||||
sultan accountant product list | product delete --id ID
|
||||
Preise sind optional und nicht buchhaltungsrelevant (Lexware führt).
|
||||
sultan accountant assign (--host H | --tenant T --router R [--protocol tcp]) [--product P] [--class C] [--start JJJJ-MM] [--clear]
|
||||
Posten zuordnen – Vorrang vor dem Label sultan.accountant.billing
|
||||
sultan accountant assignments
|
||||
sultan accountant billing --month JJJJ-MM [--out datei.csv] Monatsabrechnung als CSV (Semikolon, Dezimalkomma)
|
||||
sultan accountant payee list
|
||||
sultan accountant payee report --id ID (letzten Bericht als JSON ausgeben)
|
||||
sultan accountant payee delete --id ID (z. B. vor neuer Einladung nach Schlüsselverlust)
|
||||
sultan accountant dump-checkmk [--force] Checkmk-Vorlage nach <data-dir>/checkmk exportieren
|
||||
(anpassen – wirkt sofort, ohne Neustart)
|
||||
|
||||
Verwaltungsbefehle arbeiten direkt auf dem Datenverzeichnis (auch während er läuft).
|
||||
Alle Befehle: --data-dir DIR (Default ./accdata)
|
||||
`
|
||||
|
||||
var accountantCmds = map[string]tokenCmd{
|
||||
"invite": accountantInvite,
|
||||
"payee list": payeeList,
|
||||
"payee report": payeeReport,
|
||||
"payee delete": payeeDelete,
|
||||
"dump-checkmk": dumpCheckmk,
|
||||
"where": accountantWhere,
|
||||
"routes": accountantRoutes,
|
||||
"customer add": customerAdd,
|
||||
"customer set": customerSet,
|
||||
"customer list": customerList,
|
||||
"customer delete": customerDelete,
|
||||
"customer map": customerMap,
|
||||
"customer unmap": customerUnmap,
|
||||
"product add": productAdd,
|
||||
"product set": productSet,
|
||||
"product price": productPrice,
|
||||
"product list": productList,
|
||||
"product delete": productDelete,
|
||||
"assign": assign,
|
||||
"assignments": assignments,
|
||||
"billing": billing,
|
||||
}
|
||||
|
||||
func runAccountant(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
// Ohne Unterbefehl (bzw. nur Flags): Accountant starten.
|
||||
if len(args) == 0 || len(args[0]) > 0 && args[0][0] == '-' {
|
||||
return serveAccountant(ctx, args, stderr)
|
||||
}
|
||||
if args[0] == "help" {
|
||||
fmt.Fprint(stdout, accountantUsage)
|
||||
return 0
|
||||
}
|
||||
name, rest := args[0], args[1:]
|
||||
if (name == "payee" || name == "customer" || name == "product") && len(rest) > 0 {
|
||||
name, rest = name+" "+rest[0], rest[1:]
|
||||
}
|
||||
cmd, ok := accountantCmds[name]
|
||||
if !ok {
|
||||
fmt.Fprintf(stderr, "unbekannter Befehl \"accountant %s\"\n\n%s", name, accountantUsage)
|
||||
return 2
|
||||
}
|
||||
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
|
||||
var ue usageError
|
||||
switch {
|
||||
case err == nil, errors.Is(err, flag.ErrHelp):
|
||||
return 0
|
||||
case errors.As(err, &ue):
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
default:
|
||||
fmt.Fprintln(stderr, "Fehler:", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func serveAccountant(ctx context.Context, args []string, stderr io.Writer) int {
|
||||
cfg, err := parseAccountantFlags(args, stderr, os.Getenv)
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
|
||||
a, err := accountant.New(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
|
||||
return 1
|
||||
}
|
||||
if err := a.Run(ctx); err != nil {
|
||||
fmt.Fprintln(stderr, "Accountant beendet mit Fehler:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseAccountantFlags liest die Flags von `sultan accountant`; jedes Flag ist
|
||||
// auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
|
||||
func parseAccountantFlags(args []string, output io.Writer, getenv func(string) string) (accountant.Config, error) {
|
||||
fs := flag.NewFlagSet("sultan accountant", flag.ContinueOnError)
|
||||
fs.SetOutput(output)
|
||||
var cfg accountant.Config
|
||||
fs.StringVar(&cfg.DataDir, "data-dir", "./accdata", "Datenverzeichnis (accountant.db, server.key/.crt)")
|
||||
fs.StringVar(&cfg.Addr, "addr", ":8444", "Adresse für Payees (nur TLS)")
|
||||
fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Payees den Accountant erreichen (z. B. https://accountant.example.de:8444); mehrfach angebbar; wird gemerkt, die erste steckt in Einladungen")
|
||||
fs.StringVar(&cfg.MetricsAddr, "metrics-addr", "127.0.0.1:8082", `Checkmk-Ausgabe /metrics/checkmk (Klartext, nur Loopback; "none" = aus)`)
|
||||
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
|
||||
fs.DurationVar(&cfg.StaleWarn, "stale-warn", 2*time.Hour, "Checkmk WARN, wenn ein Payee so lange nicht berichtet hat")
|
||||
fs.DurationVar(&cfg.StaleCrit, "stale-crit", 6*time.Hour, "Checkmk CRIT, wenn ein Payee so lange nicht berichtet hat")
|
||||
annotateEnv(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return accountant.Config{}, err
|
||||
}
|
||||
if err := applyEnv(fs, getenv); err != nil {
|
||||
return accountant.Config{}, err
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return accountant.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func accountantFlags(name string, env tokenEnv) (*flag.FlagSet, *string) {
|
||||
fs := flag.NewFlagSet("sultan accountant "+name, flag.ContinueOnError)
|
||||
fs.SetOutput(env.stderr)
|
||||
dir := fs.String("data-dir", "./accdata", "Datenverzeichnis des Accountants")
|
||||
return fs, dir
|
||||
}
|
||||
|
||||
// openLedger öffnet die DB eines bereits initialisierten Datenverzeichnisses
|
||||
// (nie neu anlegen: ein Tippfehler würde sonst eine neue Identität erzeugen).
|
||||
func openLedger(env tokenEnv, dir string) (*ledger.Ledger, error) {
|
||||
for _, f := range []string{"accountant.db", "server.crt"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan accountant` starten oder --data-dir prüfen", dir, f)
|
||||
}
|
||||
}
|
||||
return ledger.Open(env.ctx, filepath.Join(dir, "accountant.db"))
|
||||
}
|
||||
|
||||
func accountantInvite(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("invite", env)
|
||||
payee := fs.String("payee", "", "ID des Sultan-Servers (a–z, 0–9, - und _), z. B. webproxy11")
|
||||
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
|
||||
serverURL := fs.String("server-url", "", "Accountant-Adresse für diese Einladung (Default: --public-url)")
|
||||
if err := parse(fs, args, "payee"); err != nil {
|
||||
return err
|
||||
}
|
||||
ttl, err := parseTTL(*ttlRaw)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
if *serverURL != "" {
|
||||
u, err := crypto.ValidateServerURL(*serverURL)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
*serverURL = u
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if *serverURL == "" {
|
||||
urls, err := accountant.LoadPublicURLs(env.ctx, l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(urls) > 0 {
|
||||
*serverURL = urls[0]
|
||||
}
|
||||
}
|
||||
cert, err := crypto.LoadOrCreateServerCert(*dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := time.Now().Add(ttl)
|
||||
if err := l.AddInvite(env.ctx, tok.Secret, *payee, expires); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(env.stdout, tok.String())
|
||||
fmt.Fprintf(env.stderr, "Einladung für Payee %s, einmalig gültig bis %s.\n", *payee, expires.Format(time.DateTime))
|
||||
if tok.ServerURL != "" {
|
||||
fmt.Fprintf(env.stderr, "Accountant-Adresse %s ist enthalten. Auf dem Sultan-Server:\n SULTAN_ACCOUNTANT_TOKEN=<token> sultan server …\n", tok.ServerURL)
|
||||
} else {
|
||||
fmt.Fprint(env.stderr, "Auf dem Sultan-Server: SULTAN_ACCOUNTANT_TOKEN=<token> sultan server --accountant=https://<accountant>:8444 …\n"+
|
||||
"Tipp: Mit `sultan accountant --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func payeeList(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("payee list", env)
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
payees, err := l.ListPayees(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tHOSTNAME\tIP\tENROLLED\tLAST_REPORT")
|
||||
for _, p := range payees {
|
||||
last := "nie"
|
||||
if !p.LastReport.IsZero() {
|
||||
last = p.LastReport.Local().Format(time.DateTime)
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Hostname, p.IPAddress, p.EnrolledAt.Local().Format(time.DateTime), last)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func payeeReport(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("payee report", env)
|
||||
id := fs.String("id", "", "Payee-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
body, at, err := l.LatestReport(env.ctx, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, body, "", " "); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(env.stdout, buf.String())
|
||||
fmt.Fprintf(env.stderr, "Bericht von %s, empfangen %s\n", *id, at.Local().Format(time.DateTime))
|
||||
return nil
|
||||
}
|
||||
|
||||
func payeeDelete(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("payee delete", env)
|
||||
id := fs.String("id", "", "Payee-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.DeletePayee(env.ctx, *id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Payee %s gelöscht\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func dumpCheckmk(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("dump-checkmk", env)
|
||||
force := fs.Bool("force", false, "vorhandene Vorlage überschreiben")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir) // nur prüfen, ob das Verzeichnis initialisiert ist
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.Close()
|
||||
path, err := accountant.DumpCheckmk(*dir, *force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(env.stdout, path)
|
||||
fmt.Fprintln(env.stderr, "Vorlage geschrieben – Änderungen wirken sofort, ohne Neustart. Fehlerhafte Vorlage: eingebaute greift, Checkmk meldet CRIT.")
|
||||
return nil
|
||||
}
|
||||
|
||||
const shortTime = "2006-01-02 15:04"
|
||||
|
||||
func localTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "?"
|
||||
}
|
||||
return t.Local().Format(shortTime)
|
||||
}
|
||||
|
||||
// splitQuery trennt den Suchbegriff (erstes Argument ohne -) von den Flags;
|
||||
// ein Flag ohne "=" nimmt das folgende Argument als Wert.
|
||||
func splitQuery(args []string) (query string, flags []string) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case strings.HasPrefix(a, "-"):
|
||||
flags = append(flags, a)
|
||||
if !strings.Contains(a, "=") && a != "-h" && a != "--help" && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
case query == "":
|
||||
query = a
|
||||
default:
|
||||
flags = append(flags, a) // parse meldet das überzählige Argument
|
||||
}
|
||||
}
|
||||
return query, flags
|
||||
}
|
||||
|
||||
// routeProblems fasst den Zustand einer Route knapp zusammen ("ok" ohne Befund).
|
||||
func routeProblems(r accountant.ReportRoute, n accountant.ReportNode) string {
|
||||
var p []string
|
||||
if !r.Active {
|
||||
p = append(p, "verworfen")
|
||||
}
|
||||
if r.Down {
|
||||
p = append(p, "gestoppt")
|
||||
}
|
||||
if r.Health == "unhealthy" {
|
||||
p = append(p, "unhealthy")
|
||||
}
|
||||
switch {
|
||||
case n.Maintenance:
|
||||
p = append(p, "Wartung")
|
||||
case !n.Connected:
|
||||
p = append(p, "getrennt")
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return "ok"
|
||||
}
|
||||
return strings.Join(p, ",")
|
||||
}
|
||||
|
||||
func accountantWhere(env tokenEnv, args []string) error {
|
||||
query, rest := splitQuery(args)
|
||||
fs, dir := accountantFlags("where", env)
|
||||
if err := parse(fs, rest); err != nil {
|
||||
return err
|
||||
}
|
||||
if query == "" {
|
||||
return usageError{errors.New("Suchbegriff fehlt: sultan accountant where <host|mandant>")}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
res, err := accountant.Where(env.ctx, l, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(res.Current) == 0 && len(res.Gone) == 0 {
|
||||
return fmt.Errorf("nichts gefunden für %q", query)
|
||||
}
|
||||
out := env.stdout
|
||||
for i, c := range res.Current {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
r, n := c.Route, c.Node
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "URL\t%s (Router %s, %s)\n", strings.Join(r.Hosts, ", "), r.Router, r.Protocol)
|
||||
tenant := r.Tenant
|
||||
if c.TenantName != "" {
|
||||
tenant += " (" + c.TenantName + ")"
|
||||
}
|
||||
fmt.Fprintf(tw, "Mandant\t%s\n", tenant)
|
||||
if c.Customer != nil {
|
||||
cust := c.Customer.ID + " " + c.Customer.Name + " (" + cycleLabel(c.Customer.Cycle)
|
||||
if c.Customer.Group != "" {
|
||||
cust += ", Gruppe " + c.Customer.Group
|
||||
}
|
||||
fmt.Fprintf(tw, "Kunde\t%s)\n", cust)
|
||||
} else {
|
||||
fmt.Fprintf(tw, "Kunde\tnicht zugeordnet\n")
|
||||
}
|
||||
fmt.Fprintf(tw, "Ingress\t%s (Bericht %s)\n", c.Payee, localTime(c.ReportAt))
|
||||
backend := r.Backend
|
||||
if n.NetBirdIP != "" {
|
||||
backend += ", NetBird " + n.NetBirdIP
|
||||
}
|
||||
fmt.Fprintf(tw, "Backend\t%s (%s)\n", cmp.Or(r.BackendFQDN, r.Backend), backend)
|
||||
container := r.Workload
|
||||
if r.ComposeDir != "" {
|
||||
container += ", compose " + r.ComposeDir
|
||||
if r.ComposeProject != "" {
|
||||
container += " (Projekt " + r.ComposeProject + ")"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "Container\t%s\n", container)
|
||||
state := []string{routeProblems(r, n)}
|
||||
if r.State != "" {
|
||||
s := r.State
|
||||
if !r.StartedAt.IsZero() {
|
||||
s += " seit " + localTime(r.StartedAt)
|
||||
}
|
||||
state = append(state, s)
|
||||
}
|
||||
if r.Health != "" {
|
||||
state = append(state, r.Health)
|
||||
}
|
||||
if r.Product != "" {
|
||||
state = append(state, "Produkt "+r.Product)
|
||||
}
|
||||
fmt.Fprintf(tw, "Zustand\t%s\n", strings.Join(state, ", "))
|
||||
node := r.Node
|
||||
if n.Connected {
|
||||
node += " verbunden"
|
||||
} else {
|
||||
node += " getrennt"
|
||||
}
|
||||
for _, v := range []string{n.OS, prefixed("Docker ", n.DockerVersion)} {
|
||||
if v != "" {
|
||||
node += ", " + v
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "Knoten\t%s\n", node)
|
||||
fmt.Fprintf(tw, "Seit\t%s\n", localTime(c.Since))
|
||||
for _, p := range c.Previous {
|
||||
fmt.Fprintf(tw, "Vorher\t%s (%s) %s – %s\n", p.Node, p.Payee, localTime(p.FirstSeen), localTime(p.Ended))
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
if len(res.Gone) > 0 {
|
||||
if len(res.Current) > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintln(out, "Nicht mehr vorhanden:")
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
for _, g := range res.Gone {
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s (%s)\t%s – %s\n", strings.Join(g.Hosts, ", "), g.Tenant, g.Node, g.Payee,
|
||||
localTime(g.FirstSeen), localTime(g.Ended))
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prefixed(prefix, v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + v
|
||||
}
|
||||
|
||||
func accountantRoutes(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("routes", env)
|
||||
tenant := fs.String("tenant", "", "nur Routen dieses Mandanten")
|
||||
payee := fs.String("payee", "", "nur Routen dieses Payees")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
routes, err := accountant.Routes(env.ctx, l, accountant.RoutesFilter{Tenant: *tenant, Payee: *payee})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "HOST\tTENANT\tPAYEE\tNODE\tBACKEND\tSTATE\tSINCE")
|
||||
for _, c := range routes {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", strings.Join(c.Route.Hosts, ","), c.Route.Tenant, c.Payee,
|
||||
c.Route.Node, cmp.Or(c.Route.BackendFQDN, c.Route.Backend), routeProblems(c.Route, c.Node), localTime(c.Since))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// initAccDir initialisiert ein Datenverzeichnis wie der erste Start von
|
||||
// `sultan accountant`.
|
||||
func initAccDir(t *testing.T, publicURLs ...string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "accdata")
|
||||
a, err := accountant.New(context.Background(), accountant.Config{
|
||||
DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: publicURLs, Logger: slog.New(slog.DiscardHandler),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Shutdown(context.Background())
|
||||
return dir
|
||||
}
|
||||
|
||||
func acc(t *testing.T, dir string, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
full := append([]string{"accountant"}, args...)
|
||||
full = append(full, "--data-dir="+dir)
|
||||
return run(context.Background(), full...)
|
||||
}
|
||||
|
||||
func TestAccountantFlags(t *testing.T) {
|
||||
cfg, err := parseAccountantFlags(nil, &bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DataDir != "./accdata" || cfg.Addr != ":8444" || len(cfg.PublicURLs) != 0 || cfg.MetricsAddr != "127.0.0.1:8082" ||
|
||||
cfg.EnrollLimit != 10 || cfg.StaleWarn != 2*time.Hour || cfg.StaleCrit != 6*time.Hour {
|
||||
t.Errorf("Defaults: %+v", cfg)
|
||||
}
|
||||
cfg, err = parseAccountantFlags([]string{"--metrics-addr=none", "--enroll-limit=-1", "--stale-warn=30m", "--stale-crit=1h"},
|
||||
&bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MetricsAddr != "none" || cfg.EnrollLimit != -1 || cfg.StaleWarn != 30*time.Minute || cfg.StaleCrit != time.Hour {
|
||||
t.Errorf("Overrides: %+v", cfg)
|
||||
}
|
||||
env := map[string]string{"SULTAN_PUBLIC_URL": "https://a:8444,https://b:8444"}
|
||||
cfg, err = parseAccountantFlags([]string{"--data-dir=/srv/acc", "--addr=:9444"}, &bytes.Buffer{},
|
||||
func(k string) string { return env[k] })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DataDir != "/srv/acc" || cfg.Addr != ":9444" || strings.Join(cfg.PublicURLs, " ") != "https://a:8444 https://b:8444" {
|
||||
t.Errorf("Overrides: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// Einladung mit gemerkter Adresse und Fingerprint des Accountants; in der
|
||||
// DB liegt nur der Hash.
|
||||
func TestAccountantInvite(t *testing.T) {
|
||||
dir := initAccDir(t, "https://acc.example.de:8444")
|
||||
code, stdout, stderr := acc(t, dir, "invite", "--payee=webproxy11")
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stderr=%s", code, stderr)
|
||||
}
|
||||
tk, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
|
||||
if err != nil {
|
||||
t.Fatalf("Ausgabe ist keine Einladung: %q (%v)", stdout, err)
|
||||
}
|
||||
cert, _ := crypto.LoadOrCreateServerCert(dir)
|
||||
if tk.Fingerprint != cert.Fingerprint || tk.ServerURL != "https://acc.example.de:8444" {
|
||||
t.Errorf("Einladung %+v", tk)
|
||||
}
|
||||
if !strings.Contains(stderr, "webproxy11") || !strings.Contains(stderr, "SULTAN_ACCOUNTANT_TOKEN") {
|
||||
t.Errorf("Hinweis fehlt: %q", stderr)
|
||||
}
|
||||
|
||||
// Gleiche Einladung lässt sich im Ledger einlösen (Hash passt).
|
||||
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
if _, err := l.Enroll(context.Background(), tk.Secret, ledger.Payee{Hostname: "h", PublicKey: "k"}); err != nil {
|
||||
t.Errorf("Einladung nicht einlösbar: %v", err)
|
||||
}
|
||||
|
||||
// --server-url überschreibt, --ttl wird geprüft.
|
||||
code, stdout, _ = acc(t, dir, "invite", "--payee=webproxy12", "--server-url=https://other:8444", "--ttl=7d")
|
||||
if tk, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); code != 0 || tk.ServerURL != "https://other:8444" {
|
||||
t.Errorf("--server-url: code=%d %+v", code, tk)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"invite"}, // --payee fehlt
|
||||
{"invite", "--payee=webproxy13", "--ttl=x"}, // ttl unlesbar
|
||||
{"invite", "--payee=webproxy13", "--server-url=http://klartext"},
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code != 2 {
|
||||
t.Errorf("%v: code=%d, erwartet 2", args, code)
|
||||
}
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "invite", "--payee=webproxy11"); code != 1 || !strings.Contains(stderr, "existiert") {
|
||||
t.Errorf("schon registriert: code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountantPayees(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "ID") || strings.Contains(stdout, "webproxy11") {
|
||||
t.Errorf("leere Liste: code=%d %q", code, stdout)
|
||||
}
|
||||
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "webproxy11.lan.r25dc.de", PublicKey: "k", IPAddress: "10.0.0.5"})
|
||||
l.Close()
|
||||
|
||||
code, stdout, _ := acc(t, dir, "payee", "list")
|
||||
if code != 0 || !strings.Contains(stdout, "webproxy11") || !strings.Contains(stdout, "webproxy11.lan.r25dc.de") ||
|
||||
!strings.Contains(stdout, "10.0.0.5") {
|
||||
t.Errorf("Liste: code=%d %q", code, stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 0 {
|
||||
t.Errorf("delete: code=%d", code)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 1 {
|
||||
t.Errorf("zweites delete: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Tippfehler bei --data-dir legt keine neue Accountant-Identität an.
|
||||
func TestAccountant_UninitializedDataDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "tippfehler")
|
||||
if code, _, stderr := acc(t, dir, "payee", "list"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
|
||||
t.Errorf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "invite", "--payee=x1"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
|
||||
t.Errorf("invite: code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountant_Usage(t *testing.T) {
|
||||
if code, stdout, _ := run(context.Background(), "accountant", "help"); code != 0 || !strings.Contains(stdout, "invite") {
|
||||
t.Errorf("help: code=%d %q", code, stdout)
|
||||
}
|
||||
if code, _, stderr := run(context.Background(), "accountant", "gibts-nicht"); code != 2 || !strings.Contains(stderr, "unbekannt") {
|
||||
t.Errorf("unbekannt: code=%d %q", code, stderr)
|
||||
}
|
||||
if code, stdout, _ := run(context.Background(), "help"); code != 0 || !strings.Contains(stdout, "sultan accountant") {
|
||||
t.Errorf("Hauptusage ohne accountant: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// payee list zeigt den letzten Bericht, payee report seinen Inhalt.
|
||||
func TestAccountantPayeeReport(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "h", PublicKey: "k", IPAddress: "10.0.0.5"})
|
||||
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "LAST_REPORT") || !strings.Contains(stdout, "nie") {
|
||||
t.Errorf("ohne Bericht: code=%d %q", code, stdout)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "payee", "report", "--id=webproxy11"); code != 1 || !strings.Contains(stderr, "nicht gefunden") {
|
||||
t.Errorf("report ohne Bericht: code=%d %q", code, stderr)
|
||||
}
|
||||
l.SaveReport(context.Background(), "webproxy11", []byte(`{"version":"v1","routes":[{"hosts":["kunde.de"]}]}`), nil)
|
||||
l.Close()
|
||||
|
||||
code, stdout, _ := acc(t, dir, "payee", "list")
|
||||
if code != 0 || strings.Contains(stdout, "nie") || !strings.Contains(stdout, time.Now().Format("2006-01-02")) {
|
||||
t.Errorf("mit Bericht: code=%d %q", code, stdout)
|
||||
}
|
||||
code, stdout, _ = acc(t, dir, "payee", "report", "--id=webproxy11")
|
||||
if code != 0 || !strings.Contains(stdout, `"kunde.de"`) || !strings.Contains(stdout, "\n ") {
|
||||
t.Errorf("report: code=%d %q (erwartet eingerücktes JSON)", code, stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "payee", "report"); code != 2 {
|
||||
t.Errorf("report ohne --id: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// dump-checkmk legt die eingebaute Vorlage zum Anpassen ins Datenverzeichnis.
|
||||
func TestAccountantDumpCheckmk(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
code, stdout, _ := acc(t, dir, "dump-checkmk")
|
||||
want := filepath.Join(dir, "checkmk", accountant.CheckmkTemplateName)
|
||||
if code != 0 || strings.TrimSpace(stdout) != want {
|
||||
t.Fatalf("code=%d stdout=%q", code, stdout)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "dump-checkmk"); code != 1 || !strings.Contains(stderr, "--force") {
|
||||
t.Errorf("zweiter Lauf: code=%d %q", code, stderr)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "dump-checkmk", "--force"); code != 0 {
|
||||
t.Errorf("--force: code=%d", code)
|
||||
}
|
||||
// Nicht initialisiertes Verzeichnis: nichts anlegen.
|
||||
if code, _, _ := acc(t, filepath.Join(t.TempDir(), "tippfehler"), "dump-checkmk"); code != 1 {
|
||||
t.Errorf("Tippfehler: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// accReport legt einen Bericht samt Historie ab, wie es der Endpunkt tut.
|
||||
func accReport(t *testing.T, dir, payeeID string, at time.Time, rep accountant.Report) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
l.SetClock(func() time.Time { return at })
|
||||
if list, _ := l.ListPayees(context.Background()); !slices.ContainsFunc(list, func(p ledger.Payee) bool { return p.ID == payeeID }) {
|
||||
l.AddInvite(context.Background(), "tok-"+payeeID, payeeID, at.Add(time.Hour))
|
||||
if _, err := l.Enroll(context.Background(), "tok-"+payeeID, ledger.Payee{Hostname: payeeID, PublicKey: "k-" + payeeID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := accountant.StoreReport(context.Background(), l, payeeID, rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func cliReport(node string, routes ...accountant.ReportRoute) accountant.Report {
|
||||
return accountant.Report{
|
||||
GeneratedAt: time.Now(),
|
||||
Tenants: []accountant.ReportTenant{{ID: "kunde", Name: "Kunde A"}},
|
||||
Nodes: []accountant.ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true,
|
||||
OS: "Debian 13", DockerVersion: "29.8.2"}},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func cliRoute(node, router string, hosts ...string) accountant.ReportRoute {
|
||||
return accountant.ReportRoute{Tenant: "kunde", Node: node, Router: router, Protocol: "http", Hosts: hosts,
|
||||
Workload: "kunde-a-" + router + "-1", Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081",
|
||||
Active: true, ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"}
|
||||
}
|
||||
|
||||
// where: Störfall – wohin springen, seit wann, wo vorher?
|
||||
func TestAccountantWhere(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
|
||||
accReport(t, dir, "webproxy11", t1, cliReport("webdocker07", cliRoute("webdocker07", "wp", "kunde.de"),
|
||||
cliRoute("webdocker07", "olat", "olat.kunde.de")))
|
||||
t2 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local)
|
||||
accReport(t, dir, "webproxy11", t2, cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de")))
|
||||
|
||||
code, stdout, stderr := acc(t, dir, "where", "kunde.de")
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stderr=%s", code, stderr)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"kunde.de, www.kunde.de", "Router wp", "kunde (Kunde A)", "webproxy11", "webdocker11.lan.r25dc.de:8081",
|
||||
"kunde-a-wp-1", "/srv/kunde-a", "healthy", "Debian 13", "2026-10-01 10:00",
|
||||
"webdocker07", "2026-09-01 10:00", // vorher
|
||||
"olat.kunde.de", // verschwunden
|
||||
} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("fehlt %q in:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "where", "gibts-nicht.de"); code != 1 || !strings.Contains(stderr, "nichts gefunden") {
|
||||
t.Errorf("kein Treffer: code=%d %q", code, stderr)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "where"); code != 2 {
|
||||
t.Errorf("ohne Suchbegriff: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountantRoutes(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
down := cliRoute("webdocker11", "olat", "olat.kunde.de")
|
||||
down.Down = true
|
||||
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de"), down))
|
||||
code, stdout, _ := acc(t, dir, "routes")
|
||||
if code != 0 || !strings.Contains(stdout, "HOST") || !strings.Contains(stdout, "webdocker11.lan.r25dc.de:8081") {
|
||||
t.Fatalf("code=%d\n%s", code, stdout)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
||||
if len(lines) != 3 || !strings.HasPrefix(lines[1], "kunde.de") || !strings.Contains(lines[2], "gestoppt") {
|
||||
t.Errorf("Tabelle:\n%s", stdout)
|
||||
}
|
||||
if code, stdout, _ := acc(t, dir, "routes", "--tenant=andere"); code != 0 || strings.Contains(stdout, "kunde.de") {
|
||||
t.Errorf("--tenant: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// Kunden: anlegen, ändern (nur angegebene Felder), filtern, zuordnen.
|
||||
func TestAccountantCustomers(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
if code, _, stderr := acc(t, dir, "customer", "add", "--id=familie", "--name=Familie", "--group=familie", "--cycle=j"); code != 0 {
|
||||
t.Fatalf("add: code=%d %s", code, stderr)
|
||||
}
|
||||
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de")))
|
||||
|
||||
// Automatisch angelegt: kunde.
|
||||
code, stdout, _ := acc(t, dir, "customer", "list", "--unclassified")
|
||||
if code != 0 || !strings.Contains(stdout, "kunde") || strings.Contains(stdout, "familie") || !strings.Contains(stdout, "nicht eingeordnet") {
|
||||
t.Errorf("--unclassified: code=%d\n%s", code, stdout)
|
||||
}
|
||||
|
||||
if code, _, stderr := acc(t, dir, "customer", "set", "--id=kunde", "--cycle=quarterly", "--flat-rate=89,00",
|
||||
"--note=Kumpel – Nebenabrede Bier", "--group=extern"); code != 0 {
|
||||
t.Fatalf("set: code=%d %s", code, stderr)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--name=Kunde A GmbH"); code != 0 { // nur der Name
|
||||
t.Fatal("set --name")
|
||||
}
|
||||
code, stdout, _ = acc(t, dir, "customer", "list")
|
||||
for _, want := range []string{"Kunde A GmbH", "quartalsweise", "89,00", "extern", "Nebenabrede", "jährlich"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("fehlt %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if code, stdout, _ := acc(t, dir, "customer", "list", "--group=familie"); code != 0 || strings.Contains(stdout, "kunde ") || !strings.Contains(stdout, "Familie") {
|
||||
t.Errorf("--group:\n%s", stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--no-flat-rate"); code != 0 {
|
||||
t.Error("--no-flat-rate")
|
||||
}
|
||||
|
||||
// Tippfehler-Mandant auf Nebencluster zuordnen; where zeigt den Kunden.
|
||||
if code, _, stderr := acc(t, dir, "customer", "map", "--tenant=kunde-a", "--payee=webproxy12", "--customer=kunde"); code != 0 {
|
||||
t.Fatalf("map: %d %s", code, stderr)
|
||||
}
|
||||
code, stdout, _ = acc(t, dir, "customer", "list")
|
||||
if !strings.Contains(stdout, "webproxy12/kunde-a") {
|
||||
t.Errorf("Zuordnung nicht gelistet:\n%s", stdout)
|
||||
}
|
||||
if _, stdout, _ := acc(t, dir, "where", "kunde.de"); !strings.Contains(stdout, "Kunde A GmbH") || !strings.Contains(stdout, "quartalsweise") {
|
||||
t.Errorf("where ohne Kunde:\n%s", stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "unmap", "--tenant=kunde-a", "--payee=webproxy12"); code != 0 {
|
||||
t.Error("unmap")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"customer", "add", "--id=x1"}, // --name fehlt
|
||||
{"customer", "add", "--id=x1", "--name=X", "--cycle=w"}, // Zahlweise unbekannt
|
||||
{"customer", "set", "--id=kunde", "--flat-rate=abc"},
|
||||
{"customer", "set", "--id=kunde", "--flat-rate=1", "--no-flat-rate"},
|
||||
{"customer", "map", "--tenant=x"},
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code != 2 {
|
||||
t.Errorf("%v: code=%d, erwartet 2", args, code)
|
||||
}
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "delete", "--id=familie"); code != 0 {
|
||||
t.Error("delete")
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "customer", "set", "--id=familie", "--name=x"); code != 1 {
|
||||
t.Error("set nach delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Katalog: Klasse und Preis mit Gültig-ab, Kundenpreis.
|
||||
func TestAccountantProducts(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
mustAcc := func(args ...string) string {
|
||||
t.Helper()
|
||||
code, stdout, stderr := acc(t, dir, args...)
|
||||
if code != 0 {
|
||||
t.Fatalf("%v: code=%d %s", args, code, stderr)
|
||||
}
|
||||
return stdout
|
||||
}
|
||||
mustAcc("product", "add", "--id=pingvin", "--name=Pingvin Share", "--class=free")
|
||||
mustAcc("product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
|
||||
mustAcc("product", "set", "--id=pingvin", "--from=2027-03", "--class=billable", "--price=5,00")
|
||||
mustAcc("product", "set", "--id=olat", "--from=2027-01", "--price=52,00") // Klasse bleibt
|
||||
mustAcc("customer", "add", "--id=k-1001", "--name=Kunde A")
|
||||
mustAcc("product", "price", "--id=olat", "--customer=k-1001", "--price=39,00", "--from=2026-11")
|
||||
|
||||
out := mustAcc("product", "list")
|
||||
for _, want := range []string{"pingvin", "Pingvin Share", "ab 2027-03: billable 5,00", "olat", "49,00", "ab 2027-01: billable 52,00"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("fehlt %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"product", "add", "--id=x1", "--name=X", "--class=teuer"},
|
||||
{"product", "add", "--id=x1", "--name=X"}, // --class fehlt
|
||||
{"product", "set", "--id=olat", "--from=März"},
|
||||
{"product", "set", "--id=olat", "--from=2027-02", "--price=1", "--no-price"},
|
||||
{"product", "price", "--id=olat", "--customer=k-1001"}, // --price fehlt
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code != 2 && code != 1 {
|
||||
t.Errorf("%v: code=%d, erwartet Fehler", args, code)
|
||||
}
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "product", "set", "--id=gibts-nicht", "--from=2027-01", "--class=free"); code != 1 {
|
||||
t.Errorf("unbekanntes Produkt: code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// assign: Produkt, Klasse, Beginn je Posten – bequem über den Host.
|
||||
func TestAccountantAssign(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable")
|
||||
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de"),
|
||||
cliRoute("webdocker11", "olat", "olat.kunde.de")))
|
||||
if code, _, stderr := acc(t, dir, "assign", "--host=olat.kunde.de", "--product=olat", "--start=2026-11"); code != 0 {
|
||||
t.Fatalf("assign: %d %s", code, stderr)
|
||||
}
|
||||
if code, _, stderr := acc(t, dir, "assign", "--tenant=kunde", "--router=wp", "--class=internal"); code != 0 {
|
||||
t.Fatalf("assign --router: %d %s", code, stderr)
|
||||
}
|
||||
code, stdout, _ := acc(t, dir, "assignments")
|
||||
if code != 0 || !strings.Contains(stdout, "olat.kunde.de") || !strings.Contains(stdout, "2026-11") || !strings.Contains(stdout, "internal") {
|
||||
t.Errorf("assignments:\n%s", stdout)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "assign", "--host=www.kunde.de", "--clear"); code != 0 {
|
||||
t.Error("--clear")
|
||||
}
|
||||
if _, stdout, _ := acc(t, dir, "assignments"); strings.Contains(stdout, "internal") {
|
||||
t.Errorf("nach --clear:\n%s", stdout)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"assign", "--host=gibts-nicht.de", "--class=free"},
|
||||
{"assign", "--host=kunde.de", "--product=gibts-nicht"},
|
||||
{"assign", "--host=kunde.de"}, // nichts zugeordnet
|
||||
{"assign", "--class=free"}, // weder --host noch --tenant/--router
|
||||
} {
|
||||
if code, _, _ := acc(t, dir, args...); code == 0 {
|
||||
t.Errorf("%v akzeptiert", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// billing: Monats-CSV auf stdout bzw. in eine Datei.
|
||||
func TestAccountantBilling(t *testing.T) {
|
||||
dir := initAccDir(t)
|
||||
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
|
||||
r := cliRoute("webdocker11", "olat", "olat.kunde.de")
|
||||
r.Product = "olat"
|
||||
accReport(t, dir, "webproxy11", time.Date(2026, 10, 17, 10, 0, 0, 0, time.Local), cliReport("webdocker11", r))
|
||||
acc(t, dir, "customer", "set", "--id=kunde", "--cycle=q", "--flat-rate=89,00")
|
||||
|
||||
code, stdout, stderr := acc(t, dir, "billing", "--month=2026-10")
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d %s", code, stderr)
|
||||
}
|
||||
for _, want := range []string{"Monat;Kunde", "2026-10;kunde;Kunde A;;quartalsweise;Pauschale", "89,00;267,00",
|
||||
"olat.kunde.de", "billable;olat", "49,00;147,00"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("fehlt %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "2026-10.csv")
|
||||
if code, _, _ := acc(t, dir, "billing", "--month=2026-10", "--out="+out); code != 0 {
|
||||
t.Fatal("--out")
|
||||
}
|
||||
if data, _ := os.ReadFile(out); !strings.Contains(string(data), "olat.kunde.de") {
|
||||
t.Errorf("Datei: %s", data)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "billing"); code != 2 {
|
||||
t.Errorf("ohne --month: code=%d", code)
|
||||
}
|
||||
if code, _, _ := acc(t, dir, "billing", "--month=Oktober"); code != 2 {
|
||||
t.Errorf("ungültiger Monat: code=%d", code)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func parseAgentFlags(args []string, output io.Writer, getenv func(string) string
|
||||
fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)")
|
||||
fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)")
|
||||
fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`)
|
||||
fs.StringVar(&cfg.FQDN, "fqdn", "", "vollständiger Name dieses Hosts fürs Inventar (Default: automatisch über DNS)")
|
||||
annotateEnv(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return agent.Config{}, err
|
||||
|
||||
@@ -20,14 +20,14 @@ func TestAgentFlags(t *testing.T) {
|
||||
cfg, err = parseAgentFlags([]string{
|
||||
"--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul",
|
||||
"--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0",
|
||||
"--config-dir=/srv/sultan.d",
|
||||
"--config-dir=/srv/sultan.d", "--fqdn=webdocker11.lan.r25dc.de",
|
||||
}, &bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" ||
|
||||
cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" ||
|
||||
cfg.ConfigDir != "/srv/sultan.d" {
|
||||
cfg.ConfigDir != "/srv/sultan.d" || cfg.FQDN != "webdocker11.lan.r25dc.de" {
|
||||
t.Errorf("Overrides: %+v", cfg)
|
||||
}
|
||||
if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider
|
||||
// sultan agent Agent: Enrollment, Stream, Docker-Watcher
|
||||
// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten
|
||||
// sultan accountant Zentrale für Inventar und Abrechnung
|
||||
// sultan version
|
||||
//
|
||||
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
|
||||
@@ -31,6 +32,7 @@ Befehle:
|
||||
sultan server Server starten (Registry, Agent-Port, Traefik-Provider)
|
||||
sultan agent Agent starten (Docker-Host oder Bare-Metal)
|
||||
sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten
|
||||
sultan accountant Zentrale für Inventar und Abrechnung starten bzw. verwalten
|
||||
sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben
|
||||
sultan version Version ausgeben
|
||||
sultan help diese Hilfe
|
||||
@@ -58,6 +60,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return runToken(ctx, args[1:], stdout, stderr)
|
||||
case "agent":
|
||||
return runAgent(ctx, args[1:], stderr)
|
||||
case "accountant":
|
||||
return runAccountant(ctx, args[1:], stdout, stderr)
|
||||
case "dump-landing":
|
||||
return runDump(args[1:], stdout, stderr)
|
||||
default:
|
||||
@@ -76,6 +80,7 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int
|
||||
return 2
|
||||
}
|
||||
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
|
||||
cfg.Version = Version
|
||||
s, err := server.New(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
|
||||
@@ -111,6 +116,8 @@ func parseServerFlags(args []string, output io.Writer, getenv func(string) strin
|
||||
fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)")
|
||||
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
|
||||
fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)")
|
||||
fs.StringVar(&cfg.AccountantToken, "accountant-token", "", "Einladung von `sultan accountant invite` (nur beim ersten Start; danach accountant.json)")
|
||||
fs.StringVar(&cfg.AccountantURL, "accountant", "", "Accountant-Adresse, z. B. https://accountant.example.de:8444 (Override; sonst aus der Einladung)")
|
||||
annotateEnv(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return server.Config{}, err
|
||||
|
||||
@@ -236,3 +236,23 @@ func TestServerFlags_EmptyPublicURLIgnored(t *testing.T) {
|
||||
t.Errorf("leere Werte übernommen: %q %q", cfg.PublicURLs, cfg.SlackWebhooks)
|
||||
}
|
||||
}
|
||||
|
||||
// Accountant-Anbindung (Spec 5.3): Einladung und Adresse, auch per Umgebung.
|
||||
func TestServerFlags_Accountant(t *testing.T) {
|
||||
cfg, err := parseServerFlags([]string{"--accountant=https://acc.example:8444", "--accountant-token=sltn_enroll_x.y"},
|
||||
&bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AccountantURL != "https://acc.example:8444" || cfg.AccountantToken != "sltn_enroll_x.y" {
|
||||
t.Errorf("Flags: %+v", cfg)
|
||||
}
|
||||
env := envMap(map[string]string{"SULTAN_ACCOUNTANT_TOKEN": "sltn_enroll_env", "SULTAN_ACCOUNTANT": "https://env:8444"})
|
||||
cfg, err = parseServerFlags(nil, &bytes.Buffer{}, env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AccountantURL != "https://env:8444" || cfg.AccountantToken != "sltn_enroll_env" {
|
||||
t.Errorf("Umgebung: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
// parseCycle versteht m/q/j bzw. monthly/quarterly/yearly (und deutsche
|
||||
// Varianten); "offen" bzw. "none" = noch nicht eingeordnet.
|
||||
func parseCycle(s string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "m", "monthly", "monatlich":
|
||||
return ledger.CycleMonthly, nil
|
||||
case "q", "quarterly", "quartal", "quartalsweise":
|
||||
return ledger.CycleQuarterly, nil
|
||||
case "j", "y", "yearly", "jährlich", "jaehrlich":
|
||||
return ledger.CycleYearly, nil
|
||||
case "offen", "none", "-":
|
||||
return ledger.CycleUnclassified, nil
|
||||
}
|
||||
return "", fmt.Errorf("Zahlweise %q unbekannt (m, q, j oder offen)", s)
|
||||
}
|
||||
|
||||
func cycleLabel(c string) string {
|
||||
switch c {
|
||||
case ledger.CycleMonthly:
|
||||
return "monatlich"
|
||||
case ledger.CycleQuarterly:
|
||||
return "quartalsweise"
|
||||
case ledger.CycleYearly:
|
||||
return "jährlich"
|
||||
}
|
||||
return "nicht eingeordnet"
|
||||
}
|
||||
|
||||
// customerFields sind die Felder, die add und set gemeinsam haben.
|
||||
type customerFields struct {
|
||||
name, group, note, cycle, flat *string
|
||||
noFlat *bool
|
||||
}
|
||||
|
||||
func addCustomerFlags(fs *flag.FlagSet) customerFields {
|
||||
return customerFields{
|
||||
name: fs.String("name", "", "Name"),
|
||||
group: fs.String("group", "", "Kostenverursacher-Gruppe zum Clustern (z. B. extern, intern, familie)"),
|
||||
note: fs.String("note", "", "Notiz, z. B. persönliche Nebenabreden"),
|
||||
cycle: fs.String("cycle", "", "Zahlweise: m (monatlich), q (quartalsweise), j (jährlich), offen"),
|
||||
flat: fs.String("flat-rate", "", "Pauschale je Monat in Euro, z. B. 89,00"),
|
||||
noFlat: fs.Bool("no-flat-rate", false, "keine Pauschale"),
|
||||
}
|
||||
}
|
||||
|
||||
// apply übernimmt die auf der Befehlszeile gesetzten Felder in c.
|
||||
func (f customerFields) apply(fs *flag.FlagSet, c *ledger.Customer) error {
|
||||
set := map[string]bool{}
|
||||
fs.Visit(func(fl *flag.Flag) { set[fl.Name] = true })
|
||||
if set["flat-rate"] && *f.noFlat {
|
||||
return usageError{errors.New("--flat-rate und --no-flat-rate schließen sich aus")}
|
||||
}
|
||||
if set["name"] {
|
||||
c.Name = *f.name
|
||||
}
|
||||
if set["group"] {
|
||||
c.Group = *f.group
|
||||
}
|
||||
if set["note"] {
|
||||
c.Note = *f.note
|
||||
}
|
||||
if set["cycle"] {
|
||||
cycle, err := parseCycle(*f.cycle)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
c.Cycle = cycle
|
||||
}
|
||||
if set["flat-rate"] {
|
||||
cents, err := accountant.ParseMoney(*f.flat)
|
||||
if err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
c.FlatRate = ¢s
|
||||
}
|
||||
if *f.noFlat {
|
||||
c.FlatRate = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerAdd(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer add", env)
|
||||
id := fs.String("id", "", "Kunden-ID (a–z, 0–9, - und _), z. B. Kundennummer")
|
||||
fields := addCustomerFlags(fs)
|
||||
if err := parse(fs, args, "id", "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
c := ledger.Customer{ID: *id}
|
||||
if err := fields.apply(fs, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.AddCustomer(env.ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kunde %s angelegt\n", c.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerSet(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer set", env)
|
||||
id := fs.String("id", "", "Kunden-ID")
|
||||
fields := addCustomerFlags(fs)
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
c, err := l.GetCustomer(env.ctx, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fields.apply(fs, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.UpdateCustomer(env.ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kunde %s geändert\n", c.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerList(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer list", env)
|
||||
group := fs.String("group", "", "nur Kunden dieser Gruppe")
|
||||
unclassified := fs.Bool("unclassified", false, "nur Kunden ohne Zahlweise (Feedback der Buchhaltung fehlt)")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
customers, err := l.ListCustomers(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maps, err := l.ListMappings(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tenants := map[string][]string{}
|
||||
for _, c := range customers {
|
||||
tenants[c.ID] = []string{c.ID} // Mandant mit gleicher ID (sofern nicht anders zugeordnet)
|
||||
}
|
||||
for _, m := range maps {
|
||||
name := m.Tenant
|
||||
if m.Payee != "" {
|
||||
name = m.Payee + "/" + m.Tenant
|
||||
}
|
||||
if !slices.Contains(tenants[m.Customer], name) {
|
||||
tenants[m.Customer] = append(tenants[m.Customer], name)
|
||||
}
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tGROUP\tCYCLE\tFLAT_RATE\tTENANTS\tNOTE")
|
||||
for _, c := range customers {
|
||||
if (*group != "" && c.Group != *group) || (*unclassified && c.Cycle != ledger.CycleUnclassified) {
|
||||
continue
|
||||
}
|
||||
flat := "-"
|
||||
if c.FlatRate != nil {
|
||||
flat = accountant.FormatMoney(*c.FlatRate)
|
||||
}
|
||||
name := c.Name
|
||||
if c.Auto {
|
||||
name += " (automatisch)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", c.ID, name, cmpOrDash(c.Group), cycleLabel(c.Cycle), flat,
|
||||
strings.Join(tenants[c.ID], ","), c.Note)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func cmpOrDash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func customerDelete(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer delete", env)
|
||||
id := fs.String("id", "", "Kunden-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.DeleteCustomer(env.ctx, *id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kunde %s samt Zuordnungen gelöscht\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerMap(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer map", env)
|
||||
tenant := fs.String("tenant", "", "Mandanten-ID, wie sie der Sultan-Server meldet")
|
||||
payee := fs.String("payee", "", "nur auf diesem Payee (Default: auf allen)")
|
||||
customer := fs.String("customer", "", "Kunden-ID")
|
||||
if err := parse(fs, args, "tenant", "customer"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.MapTenant(env.ctx, *payee, *tenant, *customer); err != nil {
|
||||
return err
|
||||
}
|
||||
where := "allen Payees"
|
||||
if *payee != "" {
|
||||
where = "Payee " + *payee
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Mandant %s auf %s → Kunde %s\n", *tenant, where, *customer)
|
||||
if c, err := l.GetCustomer(env.ctx, *tenant); err == nil && c.Auto && c.ID != *customer {
|
||||
fmt.Fprintf(env.stderr, "Hinweis: Kunde %s wurde automatisch angelegt und ist ggf. überflüssig: sultan accountant customer delete --id=%s\n", c.ID, c.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerUnmap(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("customer unmap", env)
|
||||
tenant := fs.String("tenant", "", "Mandanten-ID")
|
||||
payee := fs.String("payee", "", "Payee der Zuordnung (leer = Zuordnung für alle Payees)")
|
||||
if err := parse(fs, args, "tenant"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.UnmapTenant(env.ctx, *payee, *tenant); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Zuordnung von %s entfernt\n", *tenant)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
func classHelp() string {
|
||||
return "Klasse: included (in der Pauschale), billable (Zusatz-Service), free (Give-away), internal"
|
||||
}
|
||||
|
||||
func parseOptionalPrice(raw string, set bool) (*int64, error) {
|
||||
if !set {
|
||||
return nil, nil
|
||||
}
|
||||
cents, err := accountant.ParseMoney(raw)
|
||||
if err != nil {
|
||||
return nil, usageError{err}
|
||||
}
|
||||
return ¢s, nil
|
||||
}
|
||||
|
||||
func productAdd(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product add", env)
|
||||
id := fs.String("id", "", "Produkt-ID (a–z, 0–9, - und _), z. B. olat")
|
||||
name := fs.String("name", "", "Name")
|
||||
class := fs.String("class", "", classHelp())
|
||||
price := fs.String("price", "", "Preis je Monat in Euro (optional, nicht buchhaltungsrelevant), z. B. 49,00")
|
||||
if err := parse(fs, args, "id", "name", "class"); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := parseOptionalPrice(*price, *price != "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.AddProduct(env.ctx, ledger.Product{ID: *id, Name: *name, Class: *class, Price: p}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Produkt %s angelegt\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// productSet: neue Version ab einem Monat; ohne --class bleibt die Klasse,
|
||||
// ohne --price/--no-price der Preis der bis dahin gültigen Version.
|
||||
func productSet(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product set", env)
|
||||
id := fs.String("id", "", "Produkt-ID")
|
||||
from := fs.String("from", "", "gültig ab Monat JJJJ-MM (z. B. 2027-03)")
|
||||
class := fs.String("class", "", classHelp()+" (Default: unverändert)")
|
||||
price := fs.String("price", "", "Preis je Monat in Euro (Default: unverändert)")
|
||||
noPrice := fs.Bool("no-price", false, "ab dann ohne Preis")
|
||||
if err := parse(fs, args, "id", "from"); err != nil {
|
||||
return err
|
||||
}
|
||||
if *price != "" && *noPrice {
|
||||
return usageError{errors.New("--price und --no-price schließen sich aus")}
|
||||
}
|
||||
if !ledger.ValidMonth(*from) {
|
||||
return usageError{fmt.Errorf("--from %q ungültig (JJJJ-MM)", *from)}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
cat, err := l.Catalog(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, ok := cat.At(*id, "", *from)
|
||||
if !ok {
|
||||
return fmt.Errorf("Produkt %s nicht gefunden", *id)
|
||||
}
|
||||
if *class != "" {
|
||||
cur.Class = *class
|
||||
}
|
||||
switch {
|
||||
case *noPrice:
|
||||
cur.Price = nil
|
||||
case *price != "":
|
||||
if cur.Price, err = parseOptionalPrice(*price, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := l.SetProductVersion(env.ctx, *id, *from, cur.Class, cur.Price); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Produkt %s ab %s: %s %s\n", *id, *from, cur.Class, priceText(cur.Price))
|
||||
return nil
|
||||
}
|
||||
|
||||
func priceText(p *int64) string {
|
||||
if p == nil {
|
||||
return "ohne Preis"
|
||||
}
|
||||
return accountant.FormatMoney(*p)
|
||||
}
|
||||
|
||||
func productPrice(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product price", env)
|
||||
id := fs.String("id", "", "Produkt-ID")
|
||||
customer := fs.String("customer", "", "Kunden-ID")
|
||||
price := fs.String("price", "", "Kundenpreis je Monat in Euro, z. B. 39,00")
|
||||
from := fs.String("from", time.Now().Format("2006-01"), "gültig ab Monat JJJJ-MM")
|
||||
if err := parse(fs, args, "id", "customer", "price"); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := parseOptionalPrice(*price, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.SetCustomerPrice(env.ctx, *id, *customer, *from, *p); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Kundenpreis %s für %s ab %s: %s\n", *id, *customer, *from, accountant.FormatMoney(*p))
|
||||
return nil
|
||||
}
|
||||
|
||||
func productList(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product list", env)
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
products, err := l.ListProducts(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tCLASS\tPRICE\tVERSIONS")
|
||||
for _, p := range products {
|
||||
var versions []string
|
||||
for _, v := range p.Versions[1:] {
|
||||
versions = append(versions, fmt.Sprintf("ab %s: %s %s", v.From, v.Class, priceText(v.Price)))
|
||||
}
|
||||
price := "-"
|
||||
if p.Price != nil {
|
||||
price = accountant.FormatMoney(*p.Price)
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Name, p.Class, price, strings.Join(versions, "; "))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func productDelete(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("product delete", env)
|
||||
id := fs.String("id", "", "Produkt-ID")
|
||||
if err := parse(fs, args, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
if err := l.DeleteProduct(env.ctx, *id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Produkt %s gelöscht\n", *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// assign: Zuordnung eines Postens, gefunden über --host oder direkt über
|
||||
// --tenant/--router.
|
||||
func assign(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("assign", env)
|
||||
host := fs.String("host", "", "Host einer Route, z. B. olat.kunde.de")
|
||||
tenant := fs.String("tenant", "", "alternativ: Mandant …")
|
||||
router := fs.String("router", "", "… und Router")
|
||||
protocol := fs.String("protocol", "http", "Protokoll bei --tenant/--router (http oder tcp)")
|
||||
product := fs.String("product", "", "Produkt aus dem Katalog (Vorrang vor dem Label sultan.accountant.billing)")
|
||||
class := fs.String("class", "", classHelp()+" (Vorrang vor dem Produkt)")
|
||||
start := fs.String("start", "", "Abrechnungsbeginn JJJJ-MM (Default: Monat des ersten Auftauchens)")
|
||||
clear := fs.Bool("clear", false, "Zuordnung entfernen")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if (*host == "") == (*tenant == "" || *router == "") {
|
||||
return usageError{errors.New("entweder --host oder --tenant mit --router angeben")}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
key := accountant.ItemKey{Tenant: *tenant, Protocol: *protocol, Router: *router}
|
||||
if *host != "" {
|
||||
keys, err := accountant.ItemsForHost(env.ctx, l, *host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch len(keys) {
|
||||
case 0:
|
||||
return fmt.Errorf("keine Route mit Host %s bekannt", *host)
|
||||
case 1:
|
||||
key = keys[0]
|
||||
default:
|
||||
var names []string
|
||||
for _, k := range keys {
|
||||
names = append(names, k.Tenant+"/"+k.Protocol+"/"+k.Router)
|
||||
}
|
||||
return fmt.Errorf("Host %s gehört zu mehreren Posten (%s) – --tenant und --router angeben", *host, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if *clear {
|
||||
if err := l.DeleteAssignment(env.ctx, key.Tenant, key.Protocol, key.Router); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Zuordnung von %s/%s/%s entfernt\n", key.Tenant, key.Protocol, key.Router)
|
||||
return nil
|
||||
}
|
||||
a := ledger.Assignment{Tenant: key.Tenant, Protocol: key.Protocol, Router: key.Router, Product: *product, Class: *class, Start: *start}
|
||||
if err := l.SetAssignment(env.ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(env.stdout, "Posten %s/%s/%s zugeordnet\n", key.Tenant, key.Protocol, key.Router)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assignments(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("assignments", env)
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
list, err := l.ListAssignments(env.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "TENANT\tROUTER\tHOSTS\tPRODUCT\tCLASS\tSTART")
|
||||
for _, a := range list {
|
||||
var hosts []string
|
||||
spans, _ := l.RouteHistory(env.ctx, ledger.HistoryFilter{Tenant: a.Tenant})
|
||||
for _, s := range spans {
|
||||
if s.Protocol == a.Protocol && s.Router == a.Router {
|
||||
for _, h := range s.Hosts {
|
||||
if !slices.Contains(hosts, h) {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s/%s\t%s\t%s\t%s\t%s\n", a.Tenant, a.Protocol, a.Router, cmpOrDash(strings.Join(hosts, ",")),
|
||||
cmpOrDash(a.Product), cmpOrDash(a.Class), cmpOrDash(a.Start))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func billing(env tokenEnv, args []string) error {
|
||||
fs, dir := accountantFlags("billing", env)
|
||||
month := fs.String("month", "", "Abrechnungsmonat JJJJ-MM")
|
||||
out := fs.String("out", "", "CSV in diese Datei schreiben (Default: stdout)")
|
||||
if err := parse(fs, args, "month"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ledger.ValidMonth(*month) {
|
||||
return usageError{fmt.Errorf("--month %q ungültig (JJJJ-MM)", *month)}
|
||||
}
|
||||
l, err := openLedger(env, *dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
lines, err := accountant.Billing(env.ctx, l, *month)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := env.stdout
|
||||
if *out != "" {
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
if err := accountant.WriteBillCSV(w, lines); err != nil {
|
||||
return err
|
||||
}
|
||||
var open int
|
||||
for _, ln := range lines {
|
||||
if (ln.Class == "" && ln.Item != accountant.ItemFlatRate) || ln.Customer == "" {
|
||||
open++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(env.stderr, "%d Zeilen für %s", len(lines), *month)
|
||||
if open > 0 {
|
||||
fmt.Fprintf(env.stderr, ", davon %d ohne Zuordnung bzw. Kunde (Spalte Hinweis)", open)
|
||||
}
|
||||
fmt.Fprintln(env.stderr)
|
||||
return nil
|
||||
}
|
||||
+71
-13
@@ -1,7 +1,8 @@
|
||||
// Package docker ist ein minimaler Client für die Docker Engine API über
|
||||
// den Unix-Socket – genau das, was der Agent braucht: Container mit Labels
|
||||
// und Ports auflisten, Portzuordnungen gestoppter Container lesen und
|
||||
// Container-Events verfolgen. Bewusst
|
||||
// und Ports auflisten, Details (Portzuordnungen auch gestoppter Container,
|
||||
// Startzeit, Health) lesen, Host-Infos abfragen und Container-Events
|
||||
// verfolgen. Bewusst
|
||||
// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte).
|
||||
package docker
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Container ist ein Eintrag aus GET /containers/json.
|
||||
@@ -27,6 +29,10 @@ type Container struct {
|
||||
State string `json:"State"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Ports []Port `json:"Ports"`
|
||||
|
||||
// Aus Inspect ergänzt (nicht Teil von /containers/json).
|
||||
StartedAt time.Time `json:"-"`
|
||||
Health string `json:"-"`
|
||||
}
|
||||
|
||||
// Name liefert den Containernamen ohne führenden Schrägstrich.
|
||||
@@ -106,19 +112,36 @@ func (c *Client) List(ctx context.Context) ([]Container, error) {
|
||||
|
||||
var validID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
|
||||
// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
|
||||
// (HostConfig.PortBindings) – auch wenn er gestoppt ist. Ohne festen
|
||||
// Host-Port ist PublicPort 0. Sortiert nach "<port>/<proto>".
|
||||
func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
// Details sind die Angaben aus GET /containers/{id}/json, die der Agent
|
||||
// braucht.
|
||||
type Details struct {
|
||||
// Ports: konfigurierte Zuordnungen (HostConfig.PortBindings) – auch bei
|
||||
// gestoppten Containern. Ohne festen Host-Port ist PublicPort 0.
|
||||
// Sortiert nach "<port>/<proto>".
|
||||
Ports []Port
|
||||
// StartedAt: letzter Start; Nullzeit, wenn nie gestartet.
|
||||
StartedAt time.Time
|
||||
// Health: healthy, unhealthy, starting; leer ohne Healthcheck.
|
||||
Health string
|
||||
}
|
||||
|
||||
// Inspect liefert die Details eines Containers.
|
||||
func (c *Client) Inspect(ctx context.Context, id string) (Details, error) {
|
||||
if !validID.MatchString(id) {
|
||||
return nil, fmt.Errorf("docker: ungültige Container-ID %q", id)
|
||||
return Details{}, fmt.Errorf("docker: ungültige Container-ID %q", id)
|
||||
}
|
||||
resp, err := c.get(ctx, "/containers/"+id+"/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Details{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var info struct {
|
||||
State struct {
|
||||
StartedAt time.Time `json:"StartedAt"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
} `json:"State"`
|
||||
HostConfig struct {
|
||||
PortBindings map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
@@ -127,14 +150,20 @@ func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("docker: Container %s: %w", id, err)
|
||||
return Details{}, fmt.Errorf("docker: Container %s: %w", id, err)
|
||||
}
|
||||
var d Details
|
||||
if !info.State.StartedAt.IsZero() {
|
||||
d.StartedAt = info.State.StartedAt.UTC()
|
||||
}
|
||||
if info.State.Health != nil {
|
||||
d.Health = info.State.Health.Status
|
||||
}
|
||||
keys := make([]string, 0, len(info.HostConfig.PortBindings))
|
||||
for k := range info.HostConfig.PortBindings {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var out []Port
|
||||
for _, k := range keys {
|
||||
port, proto, _ := strings.Cut(k, "/")
|
||||
private, err := strconv.ParseUint(port, 10, 16)
|
||||
@@ -146,15 +175,44 @@ func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
}
|
||||
bindings := info.HostConfig.PortBindings[k]
|
||||
if len(bindings) == 0 {
|
||||
out = append(out, Port{PrivatePort: uint16(private), Type: proto})
|
||||
d.Ports = append(d.Ports, Port{PrivatePort: uint16(private), Type: proto})
|
||||
continue
|
||||
}
|
||||
for _, b := range bindings {
|
||||
public, _ := strconv.ParseUint(b.HostPort, 10, 16) // "" → 0
|
||||
out = append(out, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
|
||||
d.Ports = append(d.Ports, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
|
||||
// (siehe Details.Ports) – auch wenn er gestoppt ist.
|
||||
func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
d, err := c.Inspect(ctx, id)
|
||||
return d.Ports, err
|
||||
}
|
||||
|
||||
// Info sind die Host-Angaben aus GET /info. OperatingSystem ist das des
|
||||
// Hosts – auch wenn der Agent selbst in einem Container läuft.
|
||||
type Info struct {
|
||||
Name string `json:"Name"`
|
||||
OperatingSystem string `json:"OperatingSystem"`
|
||||
ServerVersion string `json:"ServerVersion"`
|
||||
}
|
||||
|
||||
// Info fragt die Host-Angaben ab.
|
||||
func (c *Client) Info(ctx context.Context) (Info, error) {
|
||||
resp, err := c.get(ctx, "/info")
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var info Info
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return Info{}, fmt.Errorf("docker: Info: %w", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht.
|
||||
|
||||
@@ -38,6 +38,7 @@ func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Req
|
||||
handler(w, r)
|
||||
})
|
||||
mux.HandleFunc("/containers/{id}/json", handler)
|
||||
mux.HandleFunc("/info", handler)
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastQ <- r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -199,3 +200,63 @@ func TestEvents_StreamEnds(t *testing.T) {
|
||||
t.Error("erwartet Fehler bei beendetem Stream")
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect liefert Ports (auch gestoppter Container), Startzeit und
|
||||
// Health-Status in einem Aufruf.
|
||||
func TestInspect(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.PathValue("id") {
|
||||
case "abc":
|
||||
w.Write([]byte(`{"Id":"abc","State":{"Status":"running","StartedAt":"2026-10-16T08:15:30.123456789Z",
|
||||
"Health":{"Status":"healthy"}},
|
||||
"HostConfig":{"PortBindings":{"80/tcp":[{"HostIp":"","HostPort":"9080"}]}}}`))
|
||||
case "nie":
|
||||
// Nie gestartet, kein Healthcheck: Docker liefert die Nullzeit.
|
||||
w.Write([]byte(`{"Id":"nie","State":{"Status":"created","StartedAt":"0001-01-01T00:00:00Z"},"HostConfig":{}}`))
|
||||
default:
|
||||
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
c := &Client{Socket: f.path}
|
||||
got, err := c.Inspect(context.Background(), "abc")
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect: %v", err)
|
||||
}
|
||||
want := Details{
|
||||
Ports: []Port{{PrivatePort: 80, PublicPort: 9080, Type: "tcp"}},
|
||||
StartedAt: time.Date(2026, 10, 16, 8, 15, 30, 123456789, time.UTC),
|
||||
Health: "healthy",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Inspect =\n%+v\nerwartet\n%+v", got, want)
|
||||
}
|
||||
got, err = c.Inspect(context.Background(), "nie")
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect: %v", err)
|
||||
}
|
||||
if !got.StartedAt.IsZero() || got.Health != "" || len(got.Ports) != 0 {
|
||||
t.Errorf("nie gestartet: %+v", got)
|
||||
}
|
||||
if _, err := c.Inspect(context.Background(), "weg"); err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("unbekannter Container: %v", err)
|
||||
}
|
||||
if _, err := c.Inspect(context.Background(), "../x"); err == nil {
|
||||
t.Error("ungültige ID akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
// Info liefert das Betriebssystem des Hosts (nicht des Agent-Containers) und
|
||||
// die Docker-Version.
|
||||
func TestInfo(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2","NCPU":4}`))
|
||||
})
|
||||
got, err := (&Client{Socket: f.path}).Info(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Info: %v", err)
|
||||
}
|
||||
want := Info{Name: "webdocker11", OperatingSystem: "Debian GNU/Linux 13 (trixie)", ServerVersion: "29.8.2"}
|
||||
if got != want {
|
||||
t.Errorf("Info = %+v, erwartet %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Zahlweisen eines Kunden (Spec 5.3).
|
||||
const (
|
||||
CycleUnclassified = "" // Feedback der Buchhaltung fehlt noch
|
||||
CycleMonthly = "monthly" // monatlich
|
||||
CycleQuarterly = "quarterly" // quartalsweise
|
||||
CycleYearly = "yearly" // jährlich
|
||||
)
|
||||
|
||||
// Customer ist ein Kunde des Hosters; Mandanten der Payees werden ihm
|
||||
// zugeordnet.
|
||||
type Customer struct {
|
||||
ID string
|
||||
Name string
|
||||
Group string // Kostenverursacher-Gruppe zum Clustern (extern, intern, familie …)
|
||||
Note string // persönliche Nebenabreden
|
||||
Cycle string // Cycle*
|
||||
// FlatRate: Pauschale je Monat in Cent; nil = keine Pauschale.
|
||||
FlatRate *int64
|
||||
// Auto: automatisch aus einer Mandanten-ID angelegt und noch nie von Hand
|
||||
// bearbeitet (Name folgt dann dem Mandanten).
|
||||
Auto bool
|
||||
}
|
||||
|
||||
// TenantRef ist ein Mandant aus einem Bericht.
|
||||
type TenantRef struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Mapping ordnet einen Mandanten (auf einem Payee oder, Payee leer, auf
|
||||
// allen) einem Kunden zu.
|
||||
type Mapping struct {
|
||||
Payee string
|
||||
Tenant string
|
||||
Customer string
|
||||
}
|
||||
|
||||
func validCycle(c string) bool {
|
||||
switch c {
|
||||
case CycleUnclassified, CycleMonthly, CycleQuarterly, CycleYearly:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Customer) validate() error {
|
||||
c.Name, c.Group, c.Note = strings.TrimSpace(c.Name), strings.TrimSpace(c.Group), strings.TrimSpace(c.Note)
|
||||
switch {
|
||||
case !policy.ValidID(c.ID):
|
||||
return invalid("Kunden-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", c.ID)
|
||||
case c.Name == "":
|
||||
return invalid("Name fehlt")
|
||||
case !validCycle(c.Cycle):
|
||||
return invalid("Zahlweise %q unbekannt", c.Cycle)
|
||||
case c.FlatRate != nil && *c.FlatRate < 0:
|
||||
return invalid("Pauschale negativ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const customerColumns = `id, name, grp, note, cycle, flat_rate, auto`
|
||||
|
||||
func scanCustomer(row interface{ Scan(...any) error }) (Customer, error) {
|
||||
var c Customer
|
||||
var flat sql.NullInt64
|
||||
err := row.Scan(&c.ID, &c.Name, &c.Group, &c.Note, &c.Cycle, &flat, &c.Auto)
|
||||
if flat.Valid {
|
||||
c.FlatRate = &flat.Int64
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func flatArg(f *int64) any {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
// AddCustomer legt einen Kunden von Hand an.
|
||||
func (l *Ledger) AddCustomer(ctx context.Context, c Customer) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM customers WHERE id = ?`, c.ID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (`+customerColumns+`) VALUES (?, ?, ?, ?, ?, ?, 0)`,
|
||||
c.ID, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateCustomer übernimmt alle Felder; danach gilt der Kunde als von Hand
|
||||
// gepflegt (Auto = false).
|
||||
func (l *Ledger) UpdateCustomer(ctx context.Context, c Customer) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := l.db.ExecContext(ctx, `UPDATE customers SET name = ?, grp = ?, note = ?, cycle = ?, flat_rate = ?, auto = 0
|
||||
WHERE id = ?`, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate), c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCustomer liefert einen Kunden.
|
||||
func (l *Ledger) GetCustomer(ctx context.Context, id string) (Customer, error) {
|
||||
c, err := scanCustomer(l.db.QueryRowContext(ctx, `SELECT `+customerColumns+` FROM customers WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Customer{}, fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// ListCustomers liefert alle Kunden, sortiert nach ID.
|
||||
func (l *Ledger) ListCustomers(ctx context.Context) ([]Customer, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT `+customerColumns+` FROM customers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Customer
|
||||
for rows.Next() {
|
||||
c, err := scanCustomer(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteCustomer löscht einen Kunden samt seinen Zuordnungen.
|
||||
func (l *Ledger) DeleteCustomer(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM customers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureCustomers legt für Mandanten eines Berichts, die keinem Kunden
|
||||
// zugeordnet sind, automatisch einen Kunden mit der Mandanten-ID an.
|
||||
// Automatisch angelegte Kunden übernehmen Namensänderungen des Mandanten;
|
||||
// von Hand gepflegte bleiben unberührt. Ungültige IDs werden übersprungen.
|
||||
func (l *Ledger) EnsureCustomers(ctx context.Context, payeeID string, tenants []TenantRef) error {
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, t := range tenants {
|
||||
if !policy.ValidID(t.ID) {
|
||||
continue
|
||||
}
|
||||
mapped, err := rowExists(ctx, tx, `SELECT 1 FROM customer_map WHERE tenant = ? AND (payee_id = ? OR payee_id = '')`, t.ID, payeeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mapped {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(t.Name)
|
||||
if name == "" {
|
||||
name = t.ID
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (id, name, grp, note, cycle, flat_rate, auto)
|
||||
VALUES (?, ?, '', '', '', NULL, 1)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name WHERE customers.auto = 1`, t.ID, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MapTenant ordnet den Mandanten tenant (auf payee; "" = auf allen Payees)
|
||||
// dem Kunden customer zu; eine bestehende Zuordnung wird ersetzt.
|
||||
func (l *Ledger) MapTenant(ctx context.Context, payee, tenant, customer string) error {
|
||||
if !policy.ValidID(tenant) {
|
||||
return invalid("Mandanten-ID %q ungültig", tenant)
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, customer); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_map (payee_id, tenant, customer_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(payee_id, tenant) DO UPDATE SET customer_id = excluded.customer_id`, payee, tenant, customer)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnmapTenant entfernt eine Zuordnung.
|
||||
func (l *Ledger) UnmapTenant(ctx context.Context, payee, tenant string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM customer_map WHERE payee_id = ? AND tenant = ?`, payee, tenant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Zuordnung %s/%s: %w", payee, tenant, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMappings liefert alle Zuordnungen, sortiert nach Mandant und Payee.
|
||||
func (l *Ledger) ListMappings(ctx context.Context) ([]Mapping, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT payee_id, tenant, customer_id FROM customer_map ORDER BY tenant, payee_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mapping
|
||||
for rows.Next() {
|
||||
var m Mapping
|
||||
if err := rows.Scan(&m.Payee, &m.Tenant, &m.Customer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CustomerResolver löst (Payee, Mandant) zum Kunden auf: Zuordnung für den
|
||||
// Payee > Zuordnung für alle Payees > Kunde mit der Mandanten-ID > "".
|
||||
type CustomerResolver struct {
|
||||
byPayee map[[2]string]string
|
||||
byTenant map[string]string
|
||||
customers map[string]bool
|
||||
}
|
||||
|
||||
// CustomerResolver liest Kunden und Zuordnungen für viele Auflösungen.
|
||||
func (l *Ledger) CustomerResolver(ctx context.Context) (*CustomerResolver, error) {
|
||||
r := &CustomerResolver{byPayee: map[[2]string]string{}, byTenant: map[string]string{}, customers: map[string]bool{}}
|
||||
maps, err := l.ListMappings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range maps {
|
||||
if m.Payee == "" {
|
||||
r.byTenant[m.Tenant] = m.Customer
|
||||
} else {
|
||||
r.byPayee[[2]string{m.Payee, m.Tenant}] = m.Customer
|
||||
}
|
||||
}
|
||||
cs, err := l.ListCustomers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range cs {
|
||||
r.customers[c.ID] = true
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Resolve liefert die Kunden-ID ("" = keinem Kunden zugeordnet).
|
||||
func (r *CustomerResolver) Resolve(payee, tenant string) string {
|
||||
if c, ok := r.byPayee[[2]string{payee, tenant}]; ok {
|
||||
return c
|
||||
}
|
||||
if c, ok := r.byTenant[tenant]; ok {
|
||||
return c
|
||||
}
|
||||
if r.customers[tenant] {
|
||||
return tenant
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Kunden entstehen automatisch aus Mandanten-IDs; vorhandene bleiben unberührt.
|
||||
func TestEnsureCustomers(t *testing.T) {
|
||||
l := open(t)
|
||||
if err := l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde", Name: "Kunde A"}, {ID: "andere", Name: "Andere"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := l.GetCustomer(ctx, "kunde")
|
||||
if err != nil || c.Name != "Kunde A" || !c.Auto || c.Cycle != CycleUnclassified || c.FlatRate != nil {
|
||||
t.Errorf("automatisch angelegt: %+v, %v", c, err)
|
||||
}
|
||||
// Von Hand geändert: ein späterer Bericht überschreibt nichts.
|
||||
c.Name, c.Group, c.Note, c.Cycle = "Kunde A GmbH", "extern", "Kumpel, zahlt in Bier", CycleQuarterly
|
||||
cents := int64(8900)
|
||||
c.FlatRate = ¢s
|
||||
if err := l.UpdateCustomer(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde", Name: "Neuer Name"}})
|
||||
got, _ := l.GetCustomer(ctx, "kunde")
|
||||
if got.Name != "Kunde A GmbH" || got.Auto || got.Group != "extern" || got.Cycle != CycleQuarterly || *got.FlatRate != 8900 {
|
||||
t.Errorf("nach erneutem Bericht: %+v", got)
|
||||
}
|
||||
// Ungültige Mandanten-IDs (sollte es nicht geben) werden übersprungen.
|
||||
if err := l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "Kein Gültiger"}}); err != nil {
|
||||
t.Errorf("ungültige ID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Zuordnung: Payee + Mandant > Mandant auf allen Payees > gleiche ID. Ein
|
||||
// zugeordneter Mandant erzeugt keinen eigenen Kunden mehr.
|
||||
func TestResolveCustomer(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A"})
|
||||
l.AddCustomer(ctx, Customer{ID: "familie", Name: "Familie", Group: "familie"})
|
||||
if err := l.MapTenant(ctx, "", "kunde", "k-1001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.MapTenant(ctx, "webproxy12", "kunde-a", "k-1001"); err != nil { // Tippfehler auf Nebencluster
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde"}, {ID: "neffe"}})
|
||||
l.EnsureCustomers(ctx, "webproxy12", []TenantRef{{ID: "kunde-a"}})
|
||||
|
||||
tests := []struct{ payee, tenant, want string }{
|
||||
{"webproxy11", "kunde", "k-1001"},
|
||||
{"webproxy12", "kunde", "k-1001"},
|
||||
{"webproxy12", "kunde-a", "k-1001"},
|
||||
{"webproxy11", "neffe", "neffe"}, // automatisch
|
||||
{"webproxy11", "unbekannt", ""},
|
||||
}
|
||||
r, err := l.CustomerResolver(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := r.Resolve(tt.payee, tt.tenant); got != tt.want {
|
||||
t.Errorf("Resolve(%s, %s) = %q, erwartet %q", tt.payee, tt.tenant, got, tt.want)
|
||||
}
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, "kunde"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zugeordneter Mandant hat eigenen Kunden erzeugt: %v", err)
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, "kunde-a"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Tippfehler-Mandant hat eigenen Kunden erzeugt: %v", err)
|
||||
}
|
||||
maps, _ := l.ListMappings(ctx)
|
||||
if len(maps) != 2 {
|
||||
t.Errorf("Zuordnungen: %+v", maps)
|
||||
}
|
||||
if err := l.UnmapTenant(ctx, "webproxy12", "kunde-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.UnmapTenant(ctx, "webproxy12", "kunde-a"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zweites Entfernen: %v", err)
|
||||
}
|
||||
if err := l.MapTenant(ctx, "", "x", "gibts-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Zuordnung zu unbekanntem Kunden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomers_CRUD(t *testing.T) {
|
||||
l := open(t)
|
||||
if err := l.AddCustomer(ctx, Customer{ID: "familie", Name: "Familie", Group: "familie", Cycle: CycleYearly}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.AddCustomer(ctx, Customer{ID: "familie", Name: "x"}); !errors.Is(err, store.ErrExists) {
|
||||
t.Errorf("doppelt: %v", err)
|
||||
}
|
||||
for _, bad := range []Customer{{ID: "Ungültig", Name: "x"}, {ID: "ok", Name: ""}, {ID: "ok2", Name: "x", Cycle: "wöchentlich"}} {
|
||||
if err := l.AddCustomer(ctx, bad); !errors.Is(err, store.ErrInvalid) {
|
||||
t.Errorf("%+v: %v", bad, err)
|
||||
}
|
||||
}
|
||||
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A", Group: "extern"})
|
||||
list, _ := l.ListCustomers(ctx)
|
||||
if len(list) != 2 || list[0].ID != "familie" {
|
||||
t.Errorf("Liste: %+v", list)
|
||||
}
|
||||
if err := l.UpdateCustomer(ctx, Customer{ID: "gibts-nicht", Name: "x"}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Update unbekannt: %v", err)
|
||||
}
|
||||
l.MapTenant(ctx, "", "kunde", "k-1001")
|
||||
if err := l.DeleteCustomer(ctx, "k-1001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maps, _ := l.ListMappings(ctx); len(maps) != 0 {
|
||||
t.Errorf("Zuordnungen nach Löschen: %+v", maps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// Package ledger ist die Datenbank des Accountants (accountant.db, Spec
|
||||
// 5.3): registrierte Payees (meldende Sultan-Server), ihre Einladungen und
|
||||
// Einstellungen. der jeweils letzte Bericht je Payee und die Historie (Zeiträume je Ort).
|
||||
//
|
||||
// Bewusst getrennt von sultan.db: andere Lebensdauer (Historie über Jahre)
|
||||
// und anderes Vertrauen (Berichte sind Eingaben fremder Server). Migrationen
|
||||
// und Zeitformat kommen aus pkg/store; Fehler sind die von pkg/store
|
||||
// (ErrNotFound, ErrExists, ErrInvalid, ErrTokenUsed, ErrTokenExpired).
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// migrations: nur anhängen, nie ändern (siehe store.OpenDB).
|
||||
var migrations = []string{
|
||||
`CREATE TABLE payees (
|
||||
id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT NOT NULL,
|
||||
enrolled_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE invites (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payee_id TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
used_at DATETIME
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`,
|
||||
// v2: letzter Bericht je Payee.
|
||||
`CREATE TABLE reports (
|
||||
payee_id TEXT PRIMARY KEY REFERENCES payees(id) ON DELETE CASCADE,
|
||||
received_at DATETIME NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
);`,
|
||||
// v3: Historie – Zeiträume je Ort (Payee, Mandant, Knoten, Protokoll,
|
||||
// Router). Ohne Fremdschlüssel: bleibt, wenn ein Payee gelöscht wird.
|
||||
`CREATE TABLE route_spans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
payee_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
node TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
router TEXT NOT NULL,
|
||||
hosts TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
backend_fqdn TEXT NOT NULL,
|
||||
workload TEXT NOT NULL,
|
||||
compose_dir TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
ended_at DATETIME
|
||||
);
|
||||
CREATE INDEX route_spans_payee_open ON route_spans(payee_id, ended_at);
|
||||
CREATE INDEX route_spans_tenant ON route_spans(tenant);`,
|
||||
// v4: Kunden und die Zuordnung von Mandanten (payee_id '' = alle Payees).
|
||||
`CREATE TABLE customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
cycle TEXT NOT NULL DEFAULT '',
|
||||
flat_rate INTEGER,
|
||||
auto INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE customer_map (
|
||||
payee_id TEXT NOT NULL DEFAULT '',
|
||||
tenant TEXT NOT NULL,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (payee_id, tenant)
|
||||
);`,
|
||||
// v5: Produktkatalog (Klasse/Preis je Monat), Kundenpreise, Zuordnung je
|
||||
// Posten (Mandant, Protokoll, Router).
|
||||
`CREATE TABLE products (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE product_versions (
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
valid_from TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
price INTEGER,
|
||||
PRIMARY KEY (product_id, valid_from)
|
||||
);
|
||||
CREATE TABLE customer_prices (
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
valid_from TEXT NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
PRIMARY KEY (product_id, customer_id, valid_from)
|
||||
);
|
||||
CREATE TABLE assignments (
|
||||
tenant TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
router TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL DEFAULT '',
|
||||
class TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (tenant, protocol, router)
|
||||
);`,
|
||||
}
|
||||
|
||||
// Ledger ist der Zugriff auf accountant.db; parallel nutzbar.
|
||||
type Ledger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Payee ist ein registrierter, meldender Sultan-Server.
|
||||
type Payee struct {
|
||||
ID string
|
||||
Hostname string
|
||||
PublicKey string // auth.EncodePublicKey
|
||||
IPAddress string // beim Enrollment
|
||||
EnrolledAt time.Time
|
||||
LastReport time.Time // Nullzeit = noch kein Bericht
|
||||
}
|
||||
|
||||
// Open öffnet (bzw. erstellt) die Datenbank unter path.
|
||||
func Open(ctx context.Context, path string) (*Ledger, error) {
|
||||
db, err := store.OpenDB(ctx, path, migrations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Ledger{db: db, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Close schließt die Datenbank.
|
||||
func (l *Ledger) Close() error { return l.db.Close() }
|
||||
|
||||
// SetClock ersetzt die Uhr (Tests mit festen Zeitpunkten); nicht parallel
|
||||
// zu anderen Aufrufen verwenden.
|
||||
func (l *Ledger) SetClock(now func() time.Time) { l.now = now }
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", store.ErrInvalid, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// AddInvite hinterlegt eine einmalige Einladung für den Payee payeeID
|
||||
// (gespeichert wird nur der Hash von token). Ein bereits registrierter Payee
|
||||
// muss erst gelöscht werden (ErrExists).
|
||||
func (l *Ledger) AddInvite(ctx context.Context, token, payeeID string, expiresAt time.Time) error {
|
||||
switch {
|
||||
case token == "":
|
||||
return invalid("Token fehlt")
|
||||
case !policy.ValidID(payeeID):
|
||||
return invalid("Payee-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", payeeID)
|
||||
case !expiresAt.After(l.now()):
|
||||
return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Payee %s: %w (erst löschen)", payeeID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO invites (token_hash, payee_id, expires_at, used) VALUES (?, ?, ?, 0)`,
|
||||
hashToken(token), payeeID, store.FormatTime(expiresAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Enroll löst token ein und legt den Payee an; seine ID bestimmt die
|
||||
// Einladung (p.ID wird ersetzt), EnrolledAt setzt der Ledger. Schlägt das
|
||||
// Anlegen fehl, bleibt die Einladung unverbraucht.
|
||||
func (l *Ledger) Enroll(ctx context.Context, token string, p Payee) (Payee, error) {
|
||||
if strings.TrimSpace(p.Hostname) == "" || p.PublicKey == "" {
|
||||
return Payee{}, invalid("Hostname und Schlüssel sind Pflicht")
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
hash := hashToken(token)
|
||||
var payeeID string
|
||||
var expiresAt time.Time
|
||||
var used bool
|
||||
err = tx.QueryRowContext(ctx, `SELECT payee_id, expires_at, used FROM invites WHERE token_hash = ?`, hash).
|
||||
Scan(&payeeID, store.ScanTime(&expiresAt), &used)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return Payee{}, fmt.Errorf("Einladung: %w", store.ErrNotFound)
|
||||
case err != nil:
|
||||
return Payee{}, err
|
||||
case used:
|
||||
return Payee{}, store.ErrTokenUsed
|
||||
case !l.now().Before(expiresAt):
|
||||
return Payee{}, store.ErrTokenExpired
|
||||
}
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ? OR public_key = ?`, payeeID, p.PublicKey); err != nil {
|
||||
return Payee{}, err
|
||||
} else if exists {
|
||||
return Payee{}, fmt.Errorf("Payee %s oder Schlüssel: %w", payeeID, store.ErrExists)
|
||||
}
|
||||
|
||||
now := l.now().Round(0).UTC()
|
||||
p.ID, p.EnrolledAt = payeeID, now
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE invites SET used = 1, used_at = ? WHERE token_hash = ?`,
|
||||
store.FormatTime(now), hash); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payees (id, hostname, public_key, ip_address, enrolled_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Hostname, p.PublicKey, p.IPAddress, store.FormatTime(now)); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
const payeeSelect = `SELECT p.id, p.hostname, p.public_key, p.ip_address, p.enrolled_at, r.received_at
|
||||
FROM payees p LEFT JOIN reports r ON r.payee_id = p.id`
|
||||
|
||||
func scanPayee(row interface{ Scan(...any) error }) (Payee, error) {
|
||||
var p Payee
|
||||
err := row.Scan(&p.ID, &p.Hostname, &p.PublicKey, &p.IPAddress, store.ScanTime(&p.EnrolledAt), store.ScanTime(&p.LastReport))
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ListPayees liefert alle Payees, sortiert nach ID.
|
||||
func (l *Ledger) ListPayees(ctx context.Context) ([]Payee, error) {
|
||||
rows, err := l.db.QueryContext(ctx, payeeSelect+` ORDER BY p.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Payee
|
||||
for rows.Next() {
|
||||
p, err := scanPayee(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPayeeByPublicKey sucht den Payee zu einem Schlüssel (Prüfung signierter
|
||||
// Berichte).
|
||||
func (l *Ledger) GetPayeeByPublicKey(ctx context.Context, publicKey string) (Payee, error) {
|
||||
p, err := scanPayee(l.db.QueryRowContext(ctx, payeeSelect+` WHERE p.public_key = ?`, publicKey))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Payee{}, fmt.Errorf("Payee: %w", store.ErrNotFound)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// DeletePayee entfernt einen Payee (z. B. vor einer neuen Einladung nach
|
||||
// Schlüsselverlust).
|
||||
func (l *Ledger) DeletePayee(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM payees WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Payee %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RouteRecord ist eine Route aus einem Bericht, wie sie in die Historie eingeht.
|
||||
type RouteRecord struct {
|
||||
Tenant, Node, Protocol, Router string
|
||||
Hosts []string
|
||||
Backend, BackendFQDN string
|
||||
Workload, ComposeDir, Product string
|
||||
}
|
||||
|
||||
func (r RouteRecord) key() string {
|
||||
return r.Tenant + "\x00" + r.Node + "\x00" + r.Protocol + "\x00" + r.Router
|
||||
}
|
||||
|
||||
// RouteSpan ist ein Zeitraum an einem Ort.
|
||||
type RouteSpan struct {
|
||||
RouteRecord
|
||||
ID int64
|
||||
Payee string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Ended time.Time // Nullzeit = noch offen
|
||||
}
|
||||
|
||||
// SaveReport ersetzt den letzten Bericht des Payees und führt die Historie
|
||||
// fort (Zeitpunkt: jetzt): neue Orte öffnen einen Zeitraum, bestehende
|
||||
// übernehmen geänderte Angaben (first_seen bleibt), fehlende enden. Ein
|
||||
// Bericht ist vollständig – was fehlt, gibt es beim Payee nicht mehr.
|
||||
func (l *Ledger) SaveReport(ctx context.Context, payeeID string, body []byte, routes []RouteRecord) error {
|
||||
now := store.FormatTime(l.now())
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
return fmt.Errorf("Payee %s: %w", payeeID, store.ErrNotFound)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO reports (payee_id, received_at, body) VALUES (?, ?, ?)
|
||||
ON CONFLICT(payee_id) DO UPDATE SET received_at = excluded.received_at, body = excluded.body`,
|
||||
payeeID, now, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
open := map[string]int64{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, tenant, node, protocol, router FROM route_spans
|
||||
WHERE payee_id = ? AND ended_at IS NULL`, payeeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var r RouteRecord
|
||||
if err := rows.Scan(&id, &r.Tenant, &r.Node, &r.Protocol, &r.Router); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
open[r.key()] = id
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
hosts := normalizeHosts(r.Hosts)
|
||||
if id, ok := open[r.key()]; ok {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET hosts = ?, backend = ?, backend_fqdn = ?,
|
||||
workload = ?, compose_dir = ?, product = ?, last_seen = ? WHERE id = ?`,
|
||||
hosts, r.Backend, r.BackendFQDN, r.Workload, r.ComposeDir, r.Product, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(open, r.key())
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO route_spans (payee_id, tenant, node, protocol, router, hosts,
|
||||
backend, backend_fqdn, workload, compose_dir, product, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
payeeID, r.Tenant, r.Node, r.Protocol, r.Router, hosts, r.Backend, r.BackendFQDN,
|
||||
r.Workload, r.ComposeDir, r.Product, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range open {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET ended_at = ? WHERE id = ?`, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// normalizeHosts speichert Hosts klein, sortiert, ohne Doppelte (JSON).
|
||||
func normalizeHosts(hosts []string) string {
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
out = append(out, strings.ToLower(strings.TrimSpace(h)))
|
||||
}
|
||||
slices.Sort(out)
|
||||
raw, _ := json.Marshal(slices.Compact(out))
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// HistoryFilter schränkt RouteHistory ein; leere Felder filtern nicht.
|
||||
type HistoryFilter struct {
|
||||
Host string // Teiltreffer, ohne Groß-/Kleinschreibung
|
||||
Tenant string
|
||||
Payee string
|
||||
OpenOnly bool
|
||||
}
|
||||
|
||||
// RouteHistory liefert Zeiträume, sortiert nach Beginn, Payee, Knoten, Router.
|
||||
func (l *Ledger) RouteHistory(ctx context.Context, f HistoryFilter) ([]RouteSpan, error) {
|
||||
q := `SELECT id, payee_id, tenant, node, protocol, router, hosts, backend, backend_fqdn, workload,
|
||||
compose_dir, product, first_seen, last_seen, ended_at FROM route_spans WHERE 1=1`
|
||||
var args []any
|
||||
if f.Host != "" {
|
||||
like := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.ToLower(f.Host))
|
||||
q += ` AND hosts LIKE ? ESCAPE '\'`
|
||||
args = append(args, "%"+like+"%")
|
||||
}
|
||||
if f.Tenant != "" {
|
||||
q += ` AND tenant = ?`
|
||||
args = append(args, f.Tenant)
|
||||
}
|
||||
if f.Payee != "" {
|
||||
q += ` AND payee_id = ?`
|
||||
args = append(args, f.Payee)
|
||||
}
|
||||
if f.OpenOnly {
|
||||
q += ` AND ended_at IS NULL`
|
||||
}
|
||||
q += ` ORDER BY first_seen, payee_id, node, router, id`
|
||||
rows, err := l.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RouteSpan
|
||||
for rows.Next() {
|
||||
var sp RouteSpan
|
||||
var hosts string
|
||||
if err := rows.Scan(&sp.ID, &sp.Payee, &sp.Tenant, &sp.Node, &sp.Protocol, &sp.Router, &hosts, &sp.Backend,
|
||||
&sp.BackendFQDN, &sp.Workload, &sp.ComposeDir, &sp.Product, store.ScanTime(&sp.FirstSeen),
|
||||
store.ScanTime(&sp.LastSeen), store.ScanTime(&sp.Ended)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(hosts), &sp.Hosts); err != nil {
|
||||
return nil, fmt.Errorf("Historie %d: Hosts unlesbar: %w", sp.ID, err)
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LatestReport liefert den letzten Bericht des Payees und seinen Eingang.
|
||||
func (l *Ledger) LatestReport(ctx context.Context, payeeID string) ([]byte, time.Time, error) {
|
||||
var body string
|
||||
var at time.Time
|
||||
err := l.db.QueryRowContext(ctx, `SELECT body, received_at FROM reports WHERE payee_id = ?`, payeeID).
|
||||
Scan(&body, store.ScanTime(&at))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, time.Time{}, fmt.Errorf("Bericht von %s: %w", payeeID, store.ErrNotFound)
|
||||
}
|
||||
return []byte(body), at, err
|
||||
}
|
||||
|
||||
// Setting liefert eine Einstellung ("" wenn nicht gesetzt).
|
||||
func (l *Ledger) Setting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := l.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting setzt eine Einstellung.
|
||||
func (l *Ledger) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := l.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func rowExists(ctx context.Context, tx *sql.Tx, query string, args ...any) (bool, error) {
|
||||
var one int
|
||||
err := tx.QueryRowContext(ctx, query, args...).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func open(t *testing.T) *Ledger {
|
||||
t.Helper()
|
||||
l, err := Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
return l
|
||||
}
|
||||
|
||||
func payee(id, key string) Payee {
|
||||
return Payee{ID: id, Hostname: id + ".lan", PublicKey: key, IPAddress: "10.0.0.1"}
|
||||
}
|
||||
|
||||
// Einladung → Enrollment legt den Payee an; die Einladung bestimmt die ID.
|
||||
func TestEnroll(t *testing.T) {
|
||||
l := open(t)
|
||||
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
if err := l.AddInvite(ctx, "sltn_enroll_a", "webproxy11", now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := payee("ignoriert", "key-a")
|
||||
got, err := l.Enroll(ctx, "sltn_enroll_a", p)
|
||||
if err != nil {
|
||||
t.Fatalf("Enroll: %v", err)
|
||||
}
|
||||
want := Payee{ID: "webproxy11", Hostname: "ignoriert.lan", PublicKey: "key-a", IPAddress: "10.0.0.1", EnrolledAt: now}
|
||||
if got != want {
|
||||
t.Errorf("Enroll = %+v, erwartet %+v", got, want)
|
||||
}
|
||||
list, err := l.ListPayees(ctx)
|
||||
if err != nil || len(list) != 1 || list[0] != want {
|
||||
t.Errorf("ListPayees = %+v, %v", list, err)
|
||||
}
|
||||
if byKey, err := l.GetPayeeByPublicKey(ctx, "key-a"); err != nil || byKey != want {
|
||||
t.Errorf("GetPayeeByPublicKey = %+v, %v", byKey, err)
|
||||
}
|
||||
if _, err := l.GetPayeeByPublicKey(ctx, "fremd"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("unbekannter Schlüssel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnroll_Errors(t *testing.T) {
|
||||
l := open(t)
|
||||
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
|
||||
l.AddInvite(ctx, "tok-2", "webproxy12", now.Add(time.Hour))
|
||||
l.AddInvite(ctx, "tok-alt", "webproxy13", now.Add(time.Minute))
|
||||
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := l.Enroll(ctx, "gibts-nicht", payee("x", "key-9")); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("unbekannte Einladung: %v", err)
|
||||
}
|
||||
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-8")); !errors.Is(err, store.ErrTokenUsed) {
|
||||
t.Errorf("verbrauchte Einladung: %v", err)
|
||||
}
|
||||
// Schlüssel schon vergeben: Einladung bleibt unverbraucht.
|
||||
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-1")); !errors.Is(err, store.ErrExists) {
|
||||
t.Errorf("doppelter Schlüssel: %v", err)
|
||||
}
|
||||
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-2")); err != nil {
|
||||
t.Errorf("Einladung nach Fehlschlag verbraucht: %v", err)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, err := l.Enroll(ctx, "tok-alt", payee("x", "key-3")); !errors.Is(err, store.ErrTokenExpired) {
|
||||
t.Errorf("abgelaufene Einladung: %v", err)
|
||||
}
|
||||
if _, err := l.Enroll(ctx, "tok-x", Payee{ID: "x", PublicKey: "k"}); err == nil {
|
||||
t.Error("Payee ohne Hostname akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddInvite_Validation(t *testing.T) {
|
||||
l := open(t)
|
||||
future := time.Now().Add(time.Hour)
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", future)
|
||||
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
|
||||
tests := []struct {
|
||||
name, token, id string
|
||||
expires time.Time
|
||||
want error
|
||||
}{
|
||||
{"ungültige ID", "t", "Web Proxy", future, store.ErrInvalid},
|
||||
{"doppelter Unterstrich", "t", "a__b", future, store.ErrInvalid},
|
||||
{"ohne Token", "", "webproxy12", future, store.ErrInvalid},
|
||||
{"Ablauf in der Vergangenheit", "t", "webproxy12", time.Now().Add(-time.Minute), store.ErrInvalid},
|
||||
{"Payee schon registriert", "t", "webproxy11", future, store.ErrExists},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := l.AddInvite(ctx, tt.token, tt.id, tt.expires); !errors.Is(err, tt.want) {
|
||||
t.Errorf("AddInvite = %v, erwartet %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePayee(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
|
||||
if err := l.DeletePayee(ctx, "webproxy11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := l.ListPayees(ctx); len(list) != 0 {
|
||||
t.Errorf("nach Löschen: %+v", list)
|
||||
}
|
||||
if err := l.DeletePayee(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zweites Löschen: %v", err)
|
||||
}
|
||||
// Neu einladen (z. B. nach Verlust von payee.key).
|
||||
if err := l.AddInvite(ctx, "tok-2", "webproxy11", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Errorf("neue Einladung nach Löschen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
l := open(t)
|
||||
if v, err := l.Setting(ctx, "public_urls"); err != nil || v != "" {
|
||||
t.Errorf("leer: %q, %v", v, err)
|
||||
}
|
||||
l.SetSetting(ctx, "public_urls", `["https://a:8444"]`)
|
||||
l.SetSetting(ctx, "public_urls", `["https://b:8444"]`)
|
||||
if v, _ := l.Setting(ctx, "public_urls"); v != `["https://b:8444"]` {
|
||||
t.Errorf("Setting = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Der jeweils letzte Bericht je Payee; ListPayees zeigt seinen Zeitpunkt,
|
||||
// Löschen des Payees entfernt ihn mit.
|
||||
func TestReports(t *testing.T) {
|
||||
l := open(t)
|
||||
now := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
|
||||
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
|
||||
|
||||
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("ohne Bericht: %v", err)
|
||||
}
|
||||
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":1}`), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":2}`), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, at, err := l.LatestReport(ctx, "webproxy11")
|
||||
if err != nil || string(body) != `{"v":2}` || !at.Equal(now) {
|
||||
t.Errorf("LatestReport = %s, %v, %v", body, at, err)
|
||||
}
|
||||
list, _ := l.ListPayees(ctx)
|
||||
if len(list) != 1 || !list[0].LastReport.Equal(now) {
|
||||
t.Errorf("ListPayees = %+v", list)
|
||||
}
|
||||
if err := l.SaveReport(ctx, "gibts-nicht", []byte(`{}`), nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Bericht für unbekannten Payee: %v", err)
|
||||
}
|
||||
l.DeletePayee(ctx, "webproxy11")
|
||||
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Bericht nach Löschen des Payees: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loc(tenant, node, router string, hosts ...string) RouteRecord {
|
||||
return RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts,
|
||||
Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan:8081", Workload: router, ComposeDir: "/srv/" + router}
|
||||
}
|
||||
|
||||
// Jeder Bericht führt die Historie fort: neue Orte öffnen, fehlende enden,
|
||||
// bestehende behalten first_seen und übernehmen geänderte Angaben.
|
||||
func TestRouteHistory(t *testing.T) {
|
||||
l := open(t)
|
||||
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
now := t1
|
||||
l.now = func() time.Time { return now }
|
||||
for _, id := range []string{"webproxy11", "webproxy12"} {
|
||||
l.AddInvite(ctx, "tok-"+id, id, now.Add(time.Hour))
|
||||
l.Enroll(ctx, "tok-"+id, payee("x", "key-"+id))
|
||||
}
|
||||
|
||||
if err := l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{
|
||||
loc("kunde", "webdocker07", "wp", "kunde.de"), loc("kunde", "webdocker07", "olat", "olat.kunde.de"),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.SaveReport(ctx, "webproxy12", []byte(`{}`), []RouteRecord{loc("andere", "n9", "web", "andere.de")})
|
||||
|
||||
now = t1.Add(24 * time.Hour)
|
||||
moved := loc("kunde", "webdocker11", "wp", "kunde.de", "www.kunde.de") // Umzug + neuer Host
|
||||
olat := loc("kunde", "webdocker07", "olat", "olat.kunde.de", "lms.kunde.de")
|
||||
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat})
|
||||
|
||||
all, err := l.RouteHistory(ctx, HistoryFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type key struct{ payee, node, router string }
|
||||
got := map[key]RouteSpan{}
|
||||
for _, s := range all {
|
||||
got[key{s.Payee, s.Node, s.Router}] = s
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Fatalf("%d Einträge: %+v", len(all), all)
|
||||
}
|
||||
old := got[key{"webproxy11", "webdocker07", "wp"}]
|
||||
if !old.FirstSeen.Equal(t1) || !old.Ended.Equal(now) || !old.LastSeen.Equal(t1) {
|
||||
t.Errorf("alter Ort: %+v", old)
|
||||
}
|
||||
cur := got[key{"webproxy11", "webdocker11", "wp"}]
|
||||
if !cur.FirstSeen.Equal(now) || !cur.Ended.IsZero() || strings.Join(cur.Hosts, ",") != "kunde.de,www.kunde.de" {
|
||||
t.Errorf("neuer Ort: %+v", cur)
|
||||
}
|
||||
o := got[key{"webproxy11", "webdocker07", "olat"}]
|
||||
if !o.FirstSeen.Equal(t1) || !o.LastSeen.Equal(now) || !o.Ended.IsZero() || len(o.Hosts) != 2 {
|
||||
t.Errorf("geänderte Hosts behalten first_seen: %+v", o)
|
||||
}
|
||||
if other := got[key{"webproxy12", "n9", "web"}]; !other.Ended.IsZero() {
|
||||
t.Errorf("Bericht eines Payees beendet Orte eines anderen: %+v", other)
|
||||
}
|
||||
|
||||
// Wieder da: neuer Zeitraum, der alte bleibt beendet.
|
||||
now = t1.Add(48 * time.Hour)
|
||||
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat, loc("kunde", "webdocker07", "wp", "kunde.de")})
|
||||
spans, _ := l.RouteHistory(ctx, HistoryFilter{Host: "kunde.de"})
|
||||
var open07 int
|
||||
for _, s := range spans {
|
||||
if s.Node == "webdocker07" && s.Router == "wp" && s.Ended.IsZero() {
|
||||
open07++
|
||||
}
|
||||
}
|
||||
if open07 != 1 {
|
||||
t.Errorf("Wiederkehr: %+v", spans)
|
||||
}
|
||||
|
||||
// Filter: Host (Teiltreffer), Mandant, Payee; nur offene.
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{Host: "lms"}); len(s) != 1 || s[0].Router != "olat" {
|
||||
t.Errorf("Host-Teiltreffer: %+v", s)
|
||||
}
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{Tenant: "andere"}); len(s) != 1 {
|
||||
t.Errorf("Mandant: %+v", s)
|
||||
}
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{Payee: "webproxy12"}); len(s) != 1 {
|
||||
t.Errorf("Payee: %+v", s)
|
||||
}
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{OpenOnly: true, Tenant: "kunde"}); len(s) != 3 {
|
||||
t.Errorf("nur offene: %d %+v", len(s), s)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Payee wird gelöscht: Die Historie bleibt (Abrechnung, Verlauf).
|
||||
func TestRouteHistory_SurvivesPayeeDelete(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddInvite(ctx, "tok", "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Enroll(ctx, "tok", payee("x", "k"))
|
||||
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{loc("kunde", "n1", "wp", "kunde.de")})
|
||||
l.DeletePayee(ctx, "webproxy11")
|
||||
if s, _ := l.RouteHistory(ctx, HistoryFilter{}); len(s) != 1 {
|
||||
t.Errorf("Historie nach Löschen: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Klassen eines Postens (Spec 5.3).
|
||||
const (
|
||||
ClassIncluded = "included" // in der Pauschale enthalten
|
||||
ClassBillable = "billable" // Zusatz-Service, eigene Position
|
||||
ClassFree = "free" // Give-away, erfasst, derzeit kostenlos
|
||||
ClassInternal = "internal" // keine Rechnung, Schattenpreis
|
||||
)
|
||||
|
||||
// sinceAlways ist der Beginn der ersten Version eines Produkts.
|
||||
const sinceAlways = "0000-00"
|
||||
|
||||
// ValidClass meldet, ob c eine bekannte Klasse ist.
|
||||
func ValidClass(c string) bool {
|
||||
switch c {
|
||||
case ClassIncluded, ClassBillable, ClassFree, ClassInternal:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidMonth meldet, ob m die Form JJJJ-MM hat.
|
||||
func ValidMonth(m string) bool {
|
||||
_, err := time.Parse("2006-01", m)
|
||||
return err == nil && len(m) == 7
|
||||
}
|
||||
|
||||
// Product ist ein Eintrag im Katalog; Class und Price beim Anlegen sind die
|
||||
// erste Version (gilt von Anfang an).
|
||||
type Product struct {
|
||||
ID string
|
||||
Name string
|
||||
Class string
|
||||
Price *int64 // Cent je Monat; nil = kein Preis hinterlegt (optional)
|
||||
Versions []ProductVersion
|
||||
}
|
||||
|
||||
// ProductVersion gilt ab From (JJJJ-MM) bis zur nächsten Version.
|
||||
type ProductVersion struct {
|
||||
From string
|
||||
Class string
|
||||
Price *int64
|
||||
}
|
||||
|
||||
// Assignment legt für einen Posten (Mandant, Protokoll, Router) Produkt,
|
||||
// Klasse und/oder Abrechnungsbeginn fest; sie hat Vorrang vor dem Label.
|
||||
type Assignment struct {
|
||||
Tenant, Protocol, Router string
|
||||
Product string // "" = aus dem Label
|
||||
Class string // "" = aus dem Produkt
|
||||
Start string // JJJJ-MM; "" = Monat des ersten Auftauchens
|
||||
}
|
||||
|
||||
func priceArg(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// AddProduct legt ein Produkt an; Class/Price gelten von Anfang an.
|
||||
func (l *Ledger) AddProduct(ctx context.Context, p Product) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
switch {
|
||||
case !policy.ValidID(p.ID):
|
||||
return invalid("Produkt-ID %q ungültig (a–z, 0–9, - und _)", p.ID)
|
||||
case p.Name == "":
|
||||
return invalid("Name fehlt")
|
||||
case !ValidClass(p.Class):
|
||||
return invalid("Klasse %q unbekannt (included, billable, free, internal)", p.Class)
|
||||
case p.Price != nil && *p.Price < 0:
|
||||
return invalid("Preis negativ")
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM products WHERE id = ?`, p.ID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Produkt %s: %w", p.ID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO products (id, name) VALUES (?, ?)`, p.ID, p.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)`,
|
||||
p.ID, sinceAlways, p.Class, priceArg(p.Price)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SetProductVersion setzt Klasse und Preis eines Produkts ab dem Monat from
|
||||
// (eine bestehende Version desselben Monats wird ersetzt).
|
||||
func (l *Ledger) SetProductVersion(ctx context.Context, product, from, class string, price *int64) error {
|
||||
switch {
|
||||
case !ValidMonth(from):
|
||||
return invalid("Monat %q ungültig (JJJJ-MM)", from)
|
||||
case !ValidClass(class):
|
||||
return invalid("Klasse %q unbekannt", class)
|
||||
case price != nil && *price < 0:
|
||||
return invalid("Preis negativ")
|
||||
}
|
||||
if err := l.requireProduct(ctx, product); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(product_id, valid_from) DO UPDATE SET class = excluded.class, price = excluded.price`,
|
||||
product, from, class, priceArg(price))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCustomerPrice setzt einen Kundenpreis ab dem Monat from; er hat Vorrang
|
||||
// vor dem Katalogpreis.
|
||||
func (l *Ledger) SetCustomerPrice(ctx context.Context, product, customer, from string, price int64) error {
|
||||
switch {
|
||||
case !ValidMonth(from):
|
||||
return invalid("Monat %q ungültig (JJJJ-MM)", from)
|
||||
case price < 0:
|
||||
return invalid("Preis negativ")
|
||||
}
|
||||
if err := l.requireProduct(ctx, product); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, customer); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_prices (product_id, customer_id, valid_from, price) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(product_id, customer_id, valid_from) DO UPDATE SET price = excluded.price`, product, customer, from, price)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *Ledger) requireProduct(ctx context.Context, id string) error {
|
||||
var one int
|
||||
err := l.db.QueryRowContext(ctx, `SELECT 1 FROM products WHERE id = ?`, id).Scan(&one)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProduct löscht ein Produkt samt Versionen und Kundenpreisen.
|
||||
func (l *Ledger) DeleteProduct(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM products WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Catalog sind alle Produkte samt Versionen und Kundenpreisen.
|
||||
type Catalog struct {
|
||||
names map[string]string
|
||||
versions map[string][]ProductVersion // nach From aufsteigend
|
||||
customers map[[2]string][]ProductVersion
|
||||
}
|
||||
|
||||
// Catalog liest den Katalog für viele Abfragen.
|
||||
func (l *Ledger) Catalog(ctx context.Context) (*Catalog, error) {
|
||||
c := &Catalog{names: map[string]string{}, versions: map[string][]ProductVersion{}, customers: map[[2]string][]ProductVersion{}}
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT p.id, p.name, v.valid_from, v.class, v.price FROM products p
|
||||
JOIN product_versions v ON v.product_id = p.id ORDER BY p.id, v.valid_from`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
var v ProductVersion
|
||||
var price sql.NullInt64
|
||||
if err := rows.Scan(&id, &name, &v.From, &v.Class, &price); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if price.Valid {
|
||||
v.Price = &price.Int64
|
||||
}
|
||||
c.names[id] = name
|
||||
c.versions[id] = append(c.versions[id], v)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = l.db.QueryContext(ctx, `SELECT product_id, customer_id, valid_from, price FROM customer_prices
|
||||
ORDER BY product_id, customer_id, valid_from`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var product, customer string
|
||||
var v ProductVersion
|
||||
var price int64
|
||||
if err := rows.Scan(&product, &customer, &v.From, &price); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Price = &price
|
||||
k := [2]string{product, customer}
|
||||
c.customers[k] = append(c.customers[k], v)
|
||||
}
|
||||
return c, rows.Err()
|
||||
}
|
||||
|
||||
func versionAt(vs []ProductVersion, month string) (ProductVersion, bool) {
|
||||
var out ProductVersion
|
||||
found := false
|
||||
for _, v := range vs {
|
||||
if v.From <= month {
|
||||
out, found = v, true
|
||||
}
|
||||
}
|
||||
return out, found
|
||||
}
|
||||
|
||||
// At liefert Klasse und Preis eines Produkts im Monat (JJJJ-MM) für einen
|
||||
// Kunden; der Kundenpreis hat Vorrang vor dem Katalogpreis.
|
||||
func (c *Catalog) At(product, customer, month string) (ProductVersion, bool) {
|
||||
v, ok := versionAt(c.versions[product], month)
|
||||
if !ok {
|
||||
return ProductVersion{}, false
|
||||
}
|
||||
if cp, ok := versionAt(c.customers[[2]string{product, customer}], month); ok {
|
||||
v.Price = cp.Price
|
||||
}
|
||||
v.From = ""
|
||||
return v, true
|
||||
}
|
||||
|
||||
// Name liefert den Namen eines Produkts ("" wenn unbekannt).
|
||||
func (c *Catalog) Name(product string) string { return c.names[product] }
|
||||
|
||||
// ListProducts liefert alle Produkte mit Versionen; Class/Price sind die
|
||||
// des aktuellen Monats.
|
||||
func (l *Ledger) ListProducts(ctx context.Context) ([]Product, error) {
|
||||
cat, err := l.Catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
month := l.now().UTC().Format("2006-01")
|
||||
var out []Product
|
||||
for id, name := range cat.names {
|
||||
p := Product{ID: id, Name: name, Versions: cat.versions[id]}
|
||||
if v, ok := versionAt(p.Versions, month); ok {
|
||||
p.Class, p.Price = v.Class, v.Price
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Product) int { return strings.Compare(a.ID, b.ID) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetAssignment setzt (bzw. ersetzt) die Zuordnung eines Postens.
|
||||
func (l *Ledger) SetAssignment(ctx context.Context, a Assignment) error {
|
||||
switch {
|
||||
case a.Tenant == "" || a.Router == "" || (a.Protocol != "http" && a.Protocol != "tcp"):
|
||||
return invalid("Posten unvollständig (Mandant, Protokoll http/tcp, Router)")
|
||||
case a.Product == "" && a.Class == "" && a.Start == "":
|
||||
return invalid("nichts zugeordnet (Produkt, Klasse oder Beginn angeben)")
|
||||
case a.Class != "" && !ValidClass(a.Class):
|
||||
return invalid("Klasse %q unbekannt", a.Class)
|
||||
case a.Start != "" && !ValidMonth(a.Start):
|
||||
return invalid("Beginn %q ungültig (JJJJ-MM)", a.Start)
|
||||
}
|
||||
if a.Product != "" {
|
||||
if err := l.requireProduct(ctx, a.Product); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO assignments (tenant, protocol, router, product_id, class, start)
|
||||
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(tenant, protocol, router) DO UPDATE SET
|
||||
product_id = excluded.product_id, class = excluded.class, start = excluded.start`,
|
||||
a.Tenant, a.Protocol, a.Router, a.Product, a.Class, a.Start)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAssignments liefert alle Zuordnungen, sortiert nach Mandant, Router.
|
||||
func (l *Ledger) ListAssignments(ctx context.Context) ([]Assignment, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT tenant, protocol, router, product_id, class, start FROM assignments
|
||||
ORDER BY tenant, router, protocol`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Assignment
|
||||
for rows.Next() {
|
||||
var a Assignment
|
||||
if err := rows.Scan(&a.Tenant, &a.Protocol, &a.Router, &a.Product, &a.Class, &a.Start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteAssignment entfernt die Zuordnung eines Postens.
|
||||
func (l *Ledger) DeleteAssignment(ctx context.Context, tenant, protocol, router string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM assignments WHERE tenant = ? AND protocol = ? AND router = ?`,
|
||||
tenant, protocol, router)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Zuordnung %s/%s/%s: %w", tenant, protocol, router, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
func cents(v int64) *int64 { return &v }
|
||||
|
||||
// Klasse und Preis gelten ab einem Monat; Kundenpreise haben Vorrang.
|
||||
func TestProducts_Versions(t *testing.T) {
|
||||
l := open(t)
|
||||
if err := l.AddProduct(ctx, Product{ID: "pingvin", Name: "Pingvin Share", Class: ClassFree}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.AddProduct(ctx, Product{ID: "olat", Name: "OLAT", Class: ClassBillable, Price: cents(4900)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.SetProductVersion(ctx, "pingvin", "2027-03", ClassBillable, cents(500)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A"})
|
||||
if err := l.SetCustomerPrice(ctx, "olat", "k-1001", "2026-11", 3900); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cat, err := l.Catalog(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
product, customer, month string
|
||||
class string
|
||||
price *int64
|
||||
}{
|
||||
{"pingvin", "", "2027-02", ClassFree, nil},
|
||||
{"pingvin", "", "2027-03", ClassBillable, cents(500)},
|
||||
{"pingvin", "", "2028-01", ClassBillable, cents(500)},
|
||||
{"olat", "", "2026-10", ClassBillable, cents(4900)},
|
||||
{"olat", "k-1001", "2026-10", ClassBillable, cents(4900)}, // Kundenpreis erst ab November
|
||||
{"olat", "k-1001", "2026-11", ClassBillable, cents(3900)},
|
||||
{"olat", "andere", "2026-11", ClassBillable, cents(4900)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
v, ok := cat.At(tt.product, tt.customer, tt.month)
|
||||
if !ok || v.Class != tt.class || !samePrice(v.Price, tt.price) {
|
||||
t.Errorf("At(%s, %s, %s) = %+v %v, erwartet %s %v", tt.product, tt.customer, tt.month, v, ok, tt.class, deref(tt.price))
|
||||
}
|
||||
}
|
||||
if _, ok := cat.At("gibts-nicht", "", "2026-10"); ok {
|
||||
t.Error("unbekanntes Produkt gefunden")
|
||||
}
|
||||
list, _ := l.ListProducts(ctx)
|
||||
if len(list) != 2 || list[0].ID != "olat" {
|
||||
t.Errorf("ListProducts: %+v", list)
|
||||
}
|
||||
|
||||
for _, err := range []error{
|
||||
l.AddProduct(ctx, Product{ID: "olat", Name: "x", Class: ClassFree}),
|
||||
} {
|
||||
if !errors.Is(err, store.ErrExists) {
|
||||
t.Errorf("doppelt: %v", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{
|
||||
l.AddProduct(ctx, Product{ID: "x1", Name: "x", Class: "teuer"}),
|
||||
l.AddProduct(ctx, Product{ID: "x2", Name: "", Class: ClassFree}),
|
||||
l.SetProductVersion(ctx, "olat", "2026-13", ClassFree, nil),
|
||||
l.SetCustomerPrice(ctx, "olat", "k-1001", "2026/11", 100),
|
||||
} {
|
||||
if !errors.Is(err, store.ErrInvalid) {
|
||||
t.Errorf("ungültig: %v", err)
|
||||
}
|
||||
}
|
||||
if err := l.SetProductVersion(ctx, "gibts-nicht", "2026-11", ClassFree, nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("unbekanntes Produkt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func samePrice(a, b *int64) bool { return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) }
|
||||
func deref(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Zuordnung je Posten (Mandant, Protokoll, Router).
|
||||
func TestAssignments(t *testing.T) {
|
||||
l := open(t)
|
||||
l.AddProduct(ctx, Product{ID: "olat", Name: "OLAT", Class: ClassBillable})
|
||||
a := Assignment{Tenant: "kunde", Protocol: "http", Router: "olat", Product: "olat", Start: "2026-11"}
|
||||
if err := l.SetAssignment(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2 := Assignment{Tenant: "kunde", Protocol: "http", Router: "share", Class: ClassInternal}
|
||||
l.SetAssignment(ctx, a2)
|
||||
got, err := l.ListAssignments(ctx)
|
||||
if err != nil || len(got) != 2 || got[0] != a || got[1] != a2 {
|
||||
t.Errorf("ListAssignments = %+v, %v", got, err)
|
||||
}
|
||||
a.Start = ""
|
||||
l.SetAssignment(ctx, a) // ersetzt
|
||||
if got, _ := l.ListAssignments(ctx); got[0].Start != "" {
|
||||
t.Errorf("nicht ersetzt: %+v", got[0])
|
||||
}
|
||||
for _, bad := range []Assignment{
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x", Product: "gibts-nicht"},
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x", Class: "teuer"},
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x", Start: "November"},
|
||||
{Tenant: "kunde", Protocol: "http", Router: "x"}, // nichts zugeordnet
|
||||
} {
|
||||
if err := l.SetAssignment(ctx, bad); err == nil {
|
||||
t.Errorf("%+v akzeptiert", bad)
|
||||
}
|
||||
}
|
||||
if err := l.DeleteAssignment(ctx, "kunde", "http", "share"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.DeleteAssignment(ctx, "kunde", "http", "share"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("zweites Löschen: %v", err)
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -1,15 +1,17 @@
|
||||
package server
|
||||
// Package ratelimit begrenzt Versuche je Schlüssel (z. B. Client-IP) –
|
||||
// genutzt für die Enrollment-Endpunkte von Server und Accountant.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter begrenzt Versuche je Schlüssel (Client-IP) auf limit pro
|
||||
// window (gleitendes Fenster). limit < 0 = unbegrenzt.
|
||||
type rateLimiter struct {
|
||||
limit int
|
||||
window time.Duration
|
||||
// Limiter begrenzt Versuche je Schlüssel (Client-IP) auf Limit pro Window
|
||||
// (gleitendes Fenster). Limit < 0 = unbegrenzt. Parallel nutzbar.
|
||||
type Limiter struct {
|
||||
Limit int
|
||||
Window time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -17,22 +19,22 @@ type rateLimiter struct {
|
||||
lastPurge time.Time
|
||||
}
|
||||
|
||||
// allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
|
||||
func (l *rateLimiter) allow(key string) (bool, time.Duration) {
|
||||
if l.limit < 0 {
|
||||
// Allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
|
||||
func (l *Limiter) Allow(key string) (bool, time.Duration) {
|
||||
if l.Limit < 0 {
|
||||
return true, 0
|
||||
}
|
||||
now := time.Now()
|
||||
if l.now != nil {
|
||||
now = l.now()
|
||||
}
|
||||
cutoff := now.Add(-l.window)
|
||||
cutoff := now.Add(-l.Window)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.hits == nil {
|
||||
l.hits = map[string][]time.Time{}
|
||||
}
|
||||
if now.Sub(l.lastPurge) >= l.window { // vergessen, was nicht mehr zählt
|
||||
if now.Sub(l.lastPurge) >= l.Window { // vergessen, was nicht mehr zählt
|
||||
for k, ts := range l.hits {
|
||||
if !ts[len(ts)-1].After(cutoff) {
|
||||
delete(l.hits, k)
|
||||
@@ -46,15 +48,15 @@ func (l *rateLimiter) allow(key string) (bool, time.Duration) {
|
||||
i++
|
||||
}
|
||||
ts = ts[i:]
|
||||
if len(ts) >= l.limit {
|
||||
if len(ts) >= l.Limit {
|
||||
l.hits[key] = ts
|
||||
return false, ts[0].Add(l.window).Sub(now)
|
||||
return false, ts[0].Add(l.Window).Sub(now)
|
||||
}
|
||||
l.hits[key] = append(ts, now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (l *rateLimiter) size() int {
|
||||
func (l *Limiter) size() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.hits)
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package server
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
func TestLimiter(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l := &rateLimiter{limit: 3, window: time.Minute, now: func() time.Time { return now }}
|
||||
l := &Limiter{Limit: 3, Window: time.Minute, now: func() time.Time { return now }}
|
||||
|
||||
for i := range 3 {
|
||||
if ok, _ := l.allow("1.2.3.4"); !ok {
|
||||
if ok, _ := l.Allow("1.2.3.4"); !ok {
|
||||
t.Fatalf("Versuch %d abgelehnt", i+1)
|
||||
}
|
||||
}
|
||||
ok, retry := l.allow("1.2.3.4")
|
||||
ok, retry := l.Allow("1.2.3.4")
|
||||
if ok || retry != time.Minute {
|
||||
t.Errorf("4. Versuch: ok=%v retry=%v, erwartet abgelehnt mit 1 min", ok, retry)
|
||||
}
|
||||
if ok, _ := l.allow("5.6.7.8"); !ok {
|
||||
if ok, _ := l.Allow("5.6.7.8"); !ok {
|
||||
t.Error("andere IP mitbetroffen")
|
||||
}
|
||||
|
||||
now = now.Add(40 * time.Second)
|
||||
if _, retry := l.allow("1.2.3.4"); retry != 20*time.Second {
|
||||
if _, retry := l.Allow("1.2.3.4"); retry != 20*time.Second {
|
||||
t.Errorf("Retry-After nach 40 s: %v, erwartet 20 s", retry)
|
||||
}
|
||||
now = now.Add(20 * time.Second)
|
||||
if ok, _ := l.allow("1.2.3.4"); !ok {
|
||||
if ok, _ := l.Allow("1.2.3.4"); !ok {
|
||||
t.Error("nach Ablauf des Fensters weiter gesperrt")
|
||||
}
|
||||
}
|
||||
|
||||
// Die Zählerliste darf bei vielen IPs nicht unbegrenzt wachsen.
|
||||
func TestRateLimiter_ForgetsOldEntries(t *testing.T) {
|
||||
func TestLimiter_ForgetsOldEntries(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l := &rateLimiter{limit: 1, window: time.Minute, now: func() time.Time { return now }}
|
||||
l := &Limiter{Limit: 1, Window: time.Minute, now: func() time.Time { return now }}
|
||||
for i := range 1000 {
|
||||
l.allow(string(rune('a'+i%26)) + time.Duration(i).String())
|
||||
l.Allow(string(rune('a'+i%26)) + time.Duration(i).String())
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
l.allow("neu")
|
||||
l.Allow("neu")
|
||||
if n := l.size(); n != 1 {
|
||||
t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Disabled(t *testing.T) {
|
||||
l := &rateLimiter{limit: -1, window: time.Minute}
|
||||
func TestLimiter_Disabled(t *testing.T) {
|
||||
l := &Limiter{Limit: -1, Window: time.Minute}
|
||||
for range 100 {
|
||||
if ok, _ := l.allow("x"); !ok {
|
||||
if ok, _ := l.Allow("x"); !ok {
|
||||
t.Fatal("Limit -1 muss unbegrenzt sein")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/policy"
|
||||
@@ -44,6 +46,28 @@ type Workload struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
// Meta: Zusatzdaten für Inventar und Störfall (Spec 5.3); nil bei
|
||||
// sultan.d-Diensten und älteren Agents. Ändert das Routing nie.
|
||||
Meta *WorkloadMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// WorkloadMeta beschreibt, wo und wie ein Container läuft – automatisch
|
||||
// ermittelt, ohne zusätzliche Labels.
|
||||
type WorkloadMeta struct {
|
||||
ComposeProject string `json:"compose_project,omitempty"` // com.docker.compose.project
|
||||
ComposeService string `json:"compose_service,omitempty"` // com.docker.compose.service
|
||||
ComposeDir string `json:"compose_dir,omitempty"` // com.docker.compose.project.working_dir
|
||||
State string `json:"state,omitempty"` // running, exited, …
|
||||
StartedAt time.Time `json:"started_at,omitzero"` // letzter Start
|
||||
Health string `json:"health,omitempty"` // healthy, unhealthy, starting; leer = kein Healthcheck
|
||||
}
|
||||
|
||||
// HostInfo beschreibt den Knoten (Spec 5.3); nil bei älteren Agents.
|
||||
type HostInfo struct {
|
||||
FQDN string `json:"fqdn,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
DockerVersion string `json:"docker_version,omitempty"`
|
||||
NetBirdIP string `json:"netbird_ip,omitempty"`
|
||||
}
|
||||
|
||||
// NodeState ist der vollständige Zustand eines Knotens (Payload von
|
||||
@@ -52,6 +76,29 @@ type NodeState struct {
|
||||
Workloads []Workload `json:"workloads"`
|
||||
// DownServices: Services (Originalnamen), deren Healthcheck fehlschlägt.
|
||||
DownServices []string `json:"down_services,omitempty"`
|
||||
// Host: Angaben zum Knoten; ändern das Routing nie.
|
||||
Host *HostInfo `json:"host_info,omitempty"`
|
||||
}
|
||||
|
||||
// clone liefert eine tiefe Kopie.
|
||||
func (s NodeState) clone() NodeState {
|
||||
out := NodeState{DownServices: slices.Clone(s.DownServices)}
|
||||
if s.Workloads != nil {
|
||||
out.Workloads = make([]Workload, len(s.Workloads))
|
||||
}
|
||||
for i, w := range s.Workloads {
|
||||
w.Labels = maps.Clone(w.Labels)
|
||||
if w.Meta != nil {
|
||||
m := *w.Meta
|
||||
w.Meta = &m
|
||||
}
|
||||
out.Workloads[i] = w
|
||||
}
|
||||
if s.Host != nil {
|
||||
h := *s.Host
|
||||
out.Host = &h
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeContext ist, was die Policy über einen Knoten wissen muss.
|
||||
@@ -179,6 +226,17 @@ func (r *Registry) Nodes() []string {
|
||||
return r.nodeIDs()
|
||||
}
|
||||
|
||||
// State liefert eine Kopie des zuletzt gemeldeten Zustands eines Knotens.
|
||||
func (r *Registry) State(nodeID string) (NodeState, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, ok := r.nodes[nodeID]
|
||||
if !ok {
|
||||
return NodeState{}, false
|
||||
}
|
||||
return entry.state.clone(), true
|
||||
}
|
||||
|
||||
// Handler liefert die Konfiguration für Traefiks HTTP-Provider aus
|
||||
// (?format=json|yaml, Default JSON).
|
||||
func (r *Registry) Handler() http.Handler {
|
||||
|
||||
@@ -441,3 +441,65 @@ func TestRegistry_NodeMaintenanceFromDirectory(t *testing.T) {
|
||||
t.Error("Knotenwartung aus dem Directory nicht angewendet")
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzdaten (Compose, Startzeit, Health, Host-Infos) werden mit dem
|
||||
// Zustand gespeichert, ändern das Routing aber nicht.
|
||||
func TestRegistry_StateKeepsMetadata(t *testing.T) {
|
||||
r, _, _ := newRegistry()
|
||||
plain := wp("wordpress", "100.64.0.1", "kunde.de")
|
||||
if err := r.Update(ctx, "n1", NodeState{Workloads: []Workload{plain}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := json.Marshal(r.Config())
|
||||
|
||||
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
|
||||
withMeta := plain
|
||||
withMeta.Meta = &WorkloadMeta{
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy",
|
||||
}
|
||||
state := NodeState{
|
||||
Workloads: []Workload{withMeta},
|
||||
Host: &HostInfo{
|
||||
FQDN: "webdocker11.lan.r25dc.de", OS: "Debian GNU/Linux 13 (trixie)",
|
||||
DockerVersion: "29.8.2", NetBirdIP: "100.64.0.42",
|
||||
},
|
||||
}
|
||||
if err := r.Update(ctx, "n1", state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after, _ := json.Marshal(r.Config()); string(after) != string(before) {
|
||||
t.Errorf("Zusatzdaten ändern das Routing:\nvorher %s\nnachher %s", before, after)
|
||||
}
|
||||
got, ok := r.State("n1")
|
||||
if !ok || !reflect.DeepEqual(got, state) {
|
||||
t.Errorf("State(n1) = %+v, %v; erwartet %+v", got, ok, state)
|
||||
}
|
||||
if _, ok := r.State("gibts-nicht"); ok {
|
||||
t.Error("State für unbekannten Knoten")
|
||||
}
|
||||
|
||||
// Kopie: Änderungen am Ergebnis wirken nicht auf die Registry zurück.
|
||||
got.Workloads[0].Meta.ComposeDir = "/manipuliert"
|
||||
got.Host.FQDN = "manipuliert"
|
||||
again, _ := r.State("n1")
|
||||
if again.Workloads[0].Meta.ComposeDir != "/srv/kunde-a" || again.Host.FQDN != "webdocker11.lan.r25dc.de" {
|
||||
t.Errorf("State liefert keine Kopie: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Ältere Agents schicken keine Zusatzdaten; JSON ohne die Felder bleibt gültig
|
||||
// und erzeugt keine leeren Objekte.
|
||||
func TestNodeState_JSONWithoutMetadata(t *testing.T) {
|
||||
var st NodeState
|
||||
if err := json.Unmarshal([]byte(`{"workloads":[{"name":"wp","host":"10.0.0.1","labels":{}}]}`), &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Host != nil || st.Workloads[0].Meta != nil {
|
||||
t.Errorf("Zusatzdaten erfunden: %+v", st)
|
||||
}
|
||||
out, _ := json.Marshal(st)
|
||||
if want := `{"workloads":[{"name":"wp","host":"10.0.0.1","labels":{}}]}`; string(out) != want {
|
||||
t.Errorf("JSON = %s, erwartet %s", out, want)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (s *Server) providerRoutes() http.Handler {
|
||||
|
||||
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
|
||||
if ok, retry := s.enrollRL.allow(remoteIP(r)); !ok {
|
||||
if ok, retry := s.enrollRL.Allow(remoteIP(r)); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
|
||||
s.log.Warn("Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
|
||||
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package server
|
||||
|
||||
// Payee: der Server meldet sein Inventar an den Accountant (Spec 5.3,
|
||||
// Roadmap 6c) – mit demselben Sicherheitsmodell wie Agent → Server:
|
||||
// eigener Schlüssel payee.key, Pinning des Accountant-Fingerprints aus der
|
||||
// Einladung, signierte Requests, nur TLS. Ein nicht erreichbarer Accountant
|
||||
// hält den Ingress nie auf: Enrollment und Berichte werden im Hintergrund
|
||||
// wiederholt.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/traefik"
|
||||
)
|
||||
|
||||
// payeeIdentity ist accountant.json im Datenverzeichnis des Servers.
|
||||
type payeeIdentity struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint crypto.Fingerprint `json:"fingerprint"`
|
||||
PayeeID string `json:"payee_id"`
|
||||
}
|
||||
|
||||
type payee struct {
|
||||
s *Server
|
||||
log *slog.Logger
|
||||
dir string
|
||||
key ed25519.PrivateKey
|
||||
token *crypto.EnrollmentToken // nil = schon registriert
|
||||
url string // tatsächlich genutzte Adresse
|
||||
fp crypto.Fingerprint // gepinnter Accountant
|
||||
id *payeeIdentity // nil bis zum Enrollment
|
||||
// conflict: Einladung eines anderen Accountants bei bestehender
|
||||
// Registrierung – es wird nichts gemeldet.
|
||||
conflict error
|
||||
|
||||
interval, heartbeat time.Duration
|
||||
hc *http.Client
|
||||
|
||||
lastHash [32]byte
|
||||
lastSent time.Time
|
||||
lastErr string // nur geänderte Fehler loggen
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// newPayee liest die Accountant-Konfiguration; nil = Server meldet nicht.
|
||||
// Fehler gibt es nur für unbrauchbare Angaben (Tippfehler), nie für einen
|
||||
// nicht erreichbaren Accountant.
|
||||
func newPayee(cfg Config, log *slog.Logger) (*payee, error) {
|
||||
p := &payee{
|
||||
log: log, dir: cfg.DataDir,
|
||||
interval: cfg.AccountantInterval, heartbeat: cfg.AccountantHeartbeat,
|
||||
stop: make(chan struct{}), done: make(chan struct{}),
|
||||
}
|
||||
if raw := strings.TrimSpace(cfg.AccountantToken); raw != "" {
|
||||
tok, err := crypto.ParseEnrollmentToken(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: --accountant-token: %w", err)
|
||||
}
|
||||
p.token = &tok
|
||||
}
|
||||
var override string
|
||||
if cfg.AccountantURL != "" {
|
||||
u, err := crypto.ValidateServerURL(cfg.AccountantURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: --accountant: %w", err)
|
||||
}
|
||||
override = u
|
||||
}
|
||||
if data, err := os.ReadFile(p.path("accountant.json")); err == nil {
|
||||
var id payeeIdentity
|
||||
if err := json.Unmarshal(data, &id); err != nil || id.Fingerprint == "" || id.PayeeID == "" {
|
||||
return nil, fmt.Errorf("server: accountant.json beschädigt – Datei löschen und neu einladen")
|
||||
}
|
||||
p.id = &id
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("server: accountant.json: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case p.token == nil && p.id == nil:
|
||||
if override != "" {
|
||||
return nil, errors.New("server: --accountant ohne Einladung (--accountant-token) und ohne accountant.json")
|
||||
}
|
||||
return nil, nil
|
||||
case p.id != nil:
|
||||
p.fp, p.url = p.id.Fingerprint, cmp.Or(override, p.id.URL)
|
||||
if p.token != nil && p.token.Fingerprint != p.id.Fingerprint {
|
||||
p.conflict = fmt.Errorf("bereits bei einem anderen Accountant registriert (Fingerprint %s) – für den neuen accountant.json und payee.key löschen", p.id.Fingerprint)
|
||||
}
|
||||
p.token = nil // eine stehen gebliebene Einladung desselben Accountants ist harmlos
|
||||
default:
|
||||
p.fp, p.url = p.token.Fingerprint, cmp.Or(override, p.token.ServerURL)
|
||||
if p.url == "" {
|
||||
return nil, errors.New("server: Einladung ohne Accountant-Adresse – --accountant=https://…:8444 angeben")
|
||||
}
|
||||
}
|
||||
|
||||
key, err := loadOrCreateKey(p.path("payee.key"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.key = key
|
||||
p.hc = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(p.fp)},
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *payee) path(name string) string { return filepath.Join(p.dir, name) }
|
||||
|
||||
func loadOrCreateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
key, err := auth.GenerateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := auth.WritePrivateKey(path, key); err != nil {
|
||||
return nil, fmt.Errorf("server: %s anlegen: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
key, err := auth.ReadPrivateKey(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: %s laden: %w", filepath.Base(path), err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// run arbeitet, bis stop geschlossen wird: Enrollment (falls nötig), dann
|
||||
// Bericht bei Änderung bzw. als Lebenszeichen.
|
||||
func (p *payee) run() {
|
||||
defer close(p.done)
|
||||
if p.conflict != nil {
|
||||
p.log.Error("Accountant: Berichte abgeschaltet", "err", p.conflict)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { <-p.stop; cancel() }()
|
||||
|
||||
t := time.NewTicker(p.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if p.id == nil {
|
||||
p.report(p.enroll(ctx), "Accountant: Enrollment fehlgeschlagen – neuer Versuch folgt")
|
||||
}
|
||||
if p.id != nil {
|
||||
p.report(p.maybeSend(ctx), "Accountant: Bericht fehlgeschlagen – neuer Versuch folgt")
|
||||
}
|
||||
select {
|
||||
case <-p.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// report loggt Fehler nur, wenn sie sich ändern (kein Log-Spam bei Ausfall).
|
||||
func (p *payee) report(err error, msg string) {
|
||||
if err == nil {
|
||||
if p.lastErr != "" {
|
||||
p.log.Info("Accountant wieder erreichbar", "url", p.url)
|
||||
}
|
||||
p.lastErr = ""
|
||||
return
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || err.Error() == p.lastErr {
|
||||
return
|
||||
}
|
||||
p.lastErr = err.Error()
|
||||
p.log.Warn(msg, "url", p.url, "err", err)
|
||||
}
|
||||
|
||||
func (p *payee) post(ctx context.Context, path string, body any) (*http.Response, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if err := (&auth.Signer{Key: p.key}).Sign(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.hc.Do(req)
|
||||
}
|
||||
|
||||
func responseError(resp *http.Response) error {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("Accountant antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
|
||||
func (p *payee) enroll(ctx context.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
resp, err := p.post(ctx, accountant.PathEnroll, accountant.EnrollRequest{Token: p.token.Secret, Hostname: cmp.Or(hostname, "sultan-server")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return responseError(resp)
|
||||
}
|
||||
var er accountant.EnrollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil || !policy.ValidID(er.PayeeID) {
|
||||
return fmt.Errorf("Antwort des Accountants unlesbar")
|
||||
}
|
||||
id := payeeIdentity{URL: p.url, Fingerprint: p.fp, PayeeID: er.PayeeID}
|
||||
raw, _ := json.MarshalIndent(id, "", " ")
|
||||
if err := os.WriteFile(p.path("accountant.json"), append(raw, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("accountant.json schreiben: %w", err)
|
||||
}
|
||||
p.id, p.token = &id, nil
|
||||
p.log.Info("Beim Accountant registriert", "payee", er.PayeeID, "url", p.url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeSend sendet den Schnappschuss, wenn er sich geändert hat oder das
|
||||
// Lebenszeichen fällig ist.
|
||||
func (p *payee) maybeSend(ctx context.Context) error {
|
||||
rep, err := p.s.buildReport(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(rep) // ohne Zeitpunkt: nur der Inhalt zählt
|
||||
hash := sha256.Sum256(raw)
|
||||
if hash == p.lastHash && time.Since(p.lastSent) < p.heartbeat {
|
||||
return nil
|
||||
}
|
||||
rep.GeneratedAt = time.Now().UTC()
|
||||
resp, err := p.post(ctx, accountant.PathReport, rep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return responseError(resp)
|
||||
}
|
||||
p.lastHash, p.lastSent = hash, time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *payee) shutdown() {
|
||||
close(p.stop)
|
||||
<-p.done
|
||||
}
|
||||
|
||||
// buildReport erzeugt den Schnappschuss (ohne GeneratedAt).
|
||||
func (s *Server) buildReport(ctx context.Context) (accountant.Report, error) {
|
||||
rep := accountant.Report{Version: s.cfg.Version, Tenants: []accountant.ReportTenant{},
|
||||
Nodes: []accountant.ReportNode{}, Routes: []accountant.ReportRoute{}}
|
||||
tenants, err := s.store.ListTenants(ctx)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
for _, t := range tenants {
|
||||
rep.Tenants = append(rep.Tenants, accountant.ReportTenant{ID: t.ID, Name: t.Name, Tier: string(t.Tier), Status: string(t.Status)})
|
||||
}
|
||||
nodes, err := s.store.ListNodes(ctx, "")
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
active := activeRouters(s.registry.Config())
|
||||
now := time.Now()
|
||||
for _, n := range nodes {
|
||||
rn := accountant.ReportNode{
|
||||
ID: n.ID, Tenant: n.TenantID, Hostname: n.Hostname, IP: n.IPAddress, Status: string(n.Status),
|
||||
Connected: s.hub.Connected(n.ID), Maintenance: n.Maintenance.Active(now), LastSeen: n.LastSeen.UTC(),
|
||||
}
|
||||
if st, ok := s.registry.State(n.ID); ok {
|
||||
if h := st.Host; h != nil {
|
||||
rn.FQDN, rn.OS, rn.DockerVersion, rn.NetBirdIP = h.FQDN, h.OS, h.DockerVersion, h.NetBirdIP
|
||||
}
|
||||
rep.Routes = append(rep.Routes, reportRoutes(n.TenantID, n.ID, st, active)...)
|
||||
}
|
||||
rep.Nodes = append(rep.Nodes, rn)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// activeRouters: ob ein (qualifizierter) Router in der Traefik-Konfiguration steht.
|
||||
func activeRouters(c *traefik.Configuration) func(proto, name string) bool {
|
||||
return func(proto, name string) bool {
|
||||
switch {
|
||||
case c == nil:
|
||||
return false
|
||||
case proto == "tcp":
|
||||
return c.TCP != nil && c.TCP.Routers[name] != nil
|
||||
default:
|
||||
return c.HTTP != nil && c.HTTP.Routers[name] != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportRoutes übersetzt den Zustand eines Knotens in Berichtszeilen, sortiert
|
||||
// nach Workload, Protokoll, Router. Workloads mit unlesbaren Labels fehlen
|
||||
// (die Registry meldet sie als Verstoß).
|
||||
func reportRoutes(tenantID, nodeID string, st registry.NodeState, active func(proto, name string) bool) []accountant.ReportRoute {
|
||||
fqdn := ""
|
||||
if st.Host != nil {
|
||||
fqdn = st.Host.FQDN
|
||||
}
|
||||
down := map[string]bool{}
|
||||
for _, d := range st.DownServices {
|
||||
down[d] = true
|
||||
}
|
||||
prefix := tenantID + "__" + nodeID + "__"
|
||||
var out []accountant.ReportRoute
|
||||
for _, w := range st.Workloads {
|
||||
cfg, err := traefik.FromLabels(w.Host, w.Labels)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
base := accountant.ReportRoute{Tenant: tenantID, Node: nodeID, Workload: w.Name, Product: labelValue(w.Labels, accountant.LabelBilling)}
|
||||
if m := w.Meta; m != nil {
|
||||
base.ComposeProject, base.ComposeService, base.ComposeDir = m.ComposeProject, m.ComposeService, m.ComposeDir
|
||||
base.State, base.StartedAt, base.Health = m.State, m.StartedAt, m.Health
|
||||
}
|
||||
if cfg.HTTP != nil {
|
||||
for name, r := range cfg.HTTP.Routers {
|
||||
route := base
|
||||
route.Router, route.Protocol = name, "http"
|
||||
route.Hosts = sortedHosts(policy.HostsFromRule(r.Rule))
|
||||
if svc := cfg.HTTP.Services[r.Service]; svc != nil && svc.LoadBalancer != nil && len(svc.LoadBalancer.Servers) > 0 {
|
||||
route.Backend = svc.LoadBalancer.Servers[0].URL
|
||||
if u, err := url.Parse(route.Backend); err == nil && fqdn != "" && u.Port() != "" {
|
||||
route.BackendFQDN = net.JoinHostPort(fqdn, u.Port())
|
||||
}
|
||||
}
|
||||
route.Active, route.Down = active("http", prefix+name), down[r.Service]
|
||||
out = append(out, route)
|
||||
}
|
||||
}
|
||||
if cfg.TCP != nil {
|
||||
for name, r := range cfg.TCP.Routers {
|
||||
route := base
|
||||
route.Router, route.Protocol = name, "tcp"
|
||||
route.Hosts = sortedHosts(policy.HostsFromSNIRule(r.Rule))
|
||||
if svc := cfg.TCP.Services[r.Service]; svc != nil && svc.LoadBalancer != nil && len(svc.LoadBalancer.Servers) > 0 {
|
||||
route.Backend = svc.LoadBalancer.Servers[0].Address
|
||||
if _, port, err := net.SplitHostPort(route.Backend); err == nil && fqdn != "" {
|
||||
route.BackendFQDN = net.JoinHostPort(fqdn, port)
|
||||
}
|
||||
}
|
||||
route.Active, route.Down = active("tcp", prefix+name), down[r.Service]
|
||||
out = append(out, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b accountant.ReportRoute) int {
|
||||
return cmp.Or(strings.Compare(a.Workload, b.Workload), strings.Compare(a.Protocol, b.Protocol), strings.Compare(a.Router, b.Router))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedHosts(hosts []string, err error) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
out = append(out, strings.ToLower(h))
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// labelValue sucht einen Label-Wert ohne Beachtung der Groß-/Kleinschreibung.
|
||||
func labelValue(labels map[string]string, key string) string {
|
||||
for k, v := range labels {
|
||||
if strings.EqualFold(k, key) {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/registry"
|
||||
)
|
||||
|
||||
// Aus dem gemeldeten Zustand eines Knotens entstehen die Berichtszeilen:
|
||||
// Hosts, Ziel (IP und FQDN), Compose-Angaben, Produkt, down/aktiv.
|
||||
func TestReportRoutes(t *testing.T) {
|
||||
started := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
|
||||
st := registry.NodeState{
|
||||
Host: ®istry.HostInfo{FQDN: "webdocker11.lan.r25dc.de"},
|
||||
Workloads: []registry.Workload{
|
||||
{
|
||||
Name: "kunde-a-wordpress-1", Host: "10.0.0.11",
|
||||
Labels: map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.wp.rule": "Host(`www.kunde.de`) || Host(`kunde.de`)",
|
||||
"traefik.http.services.wp.loadbalancer.server.port": "8081",
|
||||
"sultan.accountant.billing": "web-hosting",
|
||||
},
|
||||
Meta: ®istry.WorkloadMeta{ComposeProject: "kunde-a", ComposeService: "wordpress",
|
||||
ComposeDir: "/srv/kunde-a", State: "running", StartedAt: started, Health: "healthy"},
|
||||
},
|
||||
{
|
||||
Name: "olat", Host: "10.0.0.11",
|
||||
Labels: map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.olat.rule": "Host(`olat.kunde.de`)",
|
||||
"traefik.http.services.olat.loadbalancer.server.port": "8090",
|
||||
},
|
||||
Meta: ®istry.WorkloadMeta{State: "exited"},
|
||||
},
|
||||
{
|
||||
Name: "ca", Host: "10.0.0.11",
|
||||
Labels: map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.kunde.de`)",
|
||||
"traefik.tcp.routers.ca.entrypoints": "websecure",
|
||||
"traefik.tcp.routers.ca.tls.passthrough": "true",
|
||||
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
|
||||
},
|
||||
},
|
||||
{Name: "kaputt", Host: "10.0.0.11", Labels: map[string]string{"traefik.enable": "vielleicht"}},
|
||||
},
|
||||
DownServices: []string{"olat"},
|
||||
}
|
||||
active := map[string]bool{"http:kunde__webdocker11__wp": true, "tcp:kunde__webdocker11__ca": true}
|
||||
got := reportRoutes("kunde", "webdocker11", st, func(proto, name string) bool { return active[proto+":"+name] })
|
||||
|
||||
want := []accountant.ReportRoute{
|
||||
{Tenant: "kunde", Node: "webdocker11", Workload: "ca", Router: "ca", Protocol: "tcp",
|
||||
Hosts: []string{"ca.kunde.de"}, Backend: "10.0.0.11:9000", BackendFQDN: "webdocker11.lan.r25dc.de:9000", Active: true},
|
||||
{Tenant: "kunde", Node: "webdocker11", Workload: "kunde-a-wordpress-1", Router: "wp", Protocol: "http",
|
||||
Hosts: []string{"kunde.de", "www.kunde.de"}, Backend: "http://10.0.0.11:8081",
|
||||
BackendFQDN: "webdocker11.lan.r25dc.de:8081", Active: true, Product: "web-hosting",
|
||||
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
|
||||
State: "running", StartedAt: started, Health: "healthy"},
|
||||
{Tenant: "kunde", Node: "webdocker11", Workload: "olat", Router: "olat", Protocol: "http",
|
||||
Hosts: []string{"olat.kunde.de"}, Backend: "http://10.0.0.11:8090",
|
||||
BackendFQDN: "webdocker11.lan.r25dc.de:8090", Active: false, Down: true, State: "exited"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("reportRoutes =\n%+v\nerwartet\n%+v", got, want)
|
||||
}
|
||||
|
||||
// Ohne Host-Infos (älterer Agent): kein FQDN-Ziel, Rest bleibt.
|
||||
st.Host = nil
|
||||
for _, r := range reportRoutes("kunde", "webdocker11", st, func(string, string) bool { return true }) {
|
||||
if r.BackendFQDN != "" || r.Backend == "" {
|
||||
t.Errorf("ohne Host-Infos: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/accountant"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/ledger"
|
||||
"sultan/pkg/registry"
|
||||
)
|
||||
|
||||
// startAccountant startet einen echten Accountant; addr "" = freier Port.
|
||||
func startAccountant(t *testing.T, dataDir, addr string) *accountant.Accountant {
|
||||
t.Helper()
|
||||
if dataDir == "" {
|
||||
dataDir = filepath.Join(t.TempDir(), "accdata")
|
||||
}
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:0"
|
||||
}
|
||||
a, err := accountant.New(ctx, accountant.Config{DataDir: dataDir, Addr: addr, MetricsAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Shutdown(ctx) })
|
||||
return a
|
||||
}
|
||||
|
||||
// accInvite erzeugt eine Einladung wie `sultan accountant invite`.
|
||||
func accInvite(t *testing.T, a *accountant.Accountant, payeeID string) string {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
tok, err := crypto.NewEnrollmentToken(a.Fingerprint(), "https://"+a.Addr())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.AddInvite(ctx, tok.Secret, payeeID, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok.String()
|
||||
}
|
||||
|
||||
func latestReport(t *testing.T, a *accountant.Accountant, payeeID string) (accountant.Report, time.Time, bool) {
|
||||
t.Helper()
|
||||
l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
body, at, err := l.LatestReport(ctx, payeeID)
|
||||
if err != nil {
|
||||
return accountant.Report{}, time.Time{}, false
|
||||
}
|
||||
var r accountant.Report
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r, at, true
|
||||
}
|
||||
|
||||
// payeeConfig: Server meldet an den Accountant mit kurzen Intervallen.
|
||||
func payeeConfig(token string, heartbeat time.Duration) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.AccountantToken = token
|
||||
c.AccountantInterval = 20 * time.Millisecond
|
||||
c.AccountantHeartbeat = heartbeat
|
||||
c.Version = "v-test"
|
||||
}
|
||||
}
|
||||
|
||||
func metaState(domain string) registry.NodeState {
|
||||
st := wpState(domain)
|
||||
st.Workloads[0].Meta = ®istry.WorkloadMeta{ComposeProject: "kunde-a", ComposeDir: "/srv/kunde-a", State: "running"}
|
||||
st.Host = ®istry.HostInfo{FQDN: "webdocker11.lan.r25dc.de", OS: "Debian GNU/Linux 13 (trixie)", DockerVersion: "29.8.2"}
|
||||
return st
|
||||
}
|
||||
|
||||
// Der ganze Weg: Einladung → Enrollment beim Start → Bericht mit Mandanten,
|
||||
// Knoten und Routen samt FQDN-Ziel und Compose-Verzeichnis.
|
||||
func TestPayee_EnrollsAndReports(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour))
|
||||
agent, _ := connectedAgent(t, s, "node-1")
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/state", metaState("kunde.de")); r.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("State: %d", r.StatusCode)
|
||||
}
|
||||
|
||||
var rep accountant.Report
|
||||
eventually(t, "Bericht mit Zusatzdaten beim Accountant", func() bool {
|
||||
r, _, ok := latestReport(t, acc, "webproxy11")
|
||||
rep = r
|
||||
return ok && len(r.Routes) == 1 && r.Routes[0].ComposeDir != ""
|
||||
})
|
||||
if rep.Version != "v-test" || rep.GeneratedAt.IsZero() {
|
||||
t.Errorf("Kopf: %+v", rep)
|
||||
}
|
||||
if len(rep.Tenants) != 1 || rep.Tenants[0].ID != "kunde" || rep.Tenants[0].Tier != "pro" || rep.Tenants[0].Status != "active" {
|
||||
t.Errorf("Mandanten: %+v", rep.Tenants)
|
||||
}
|
||||
if len(rep.Nodes) != 1 {
|
||||
t.Fatalf("Knoten: %+v", rep.Nodes)
|
||||
}
|
||||
if n := rep.Nodes[0]; n.ID != "node-1" || n.Tenant != "kunde" || !n.Connected || n.FQDN != "webdocker11.lan.r25dc.de" ||
|
||||
n.OS != "Debian GNU/Linux 13 (trixie)" || n.DockerVersion != "29.8.2" || n.Hostname != "host-node-1" {
|
||||
t.Errorf("Knoten: %+v", n)
|
||||
}
|
||||
if r := rep.Routes[0]; r.Router != "wp" || strings.Join(r.Hosts, ",") != "kunde.de" || r.Backend != "http://100.64.0.42:80" ||
|
||||
r.BackendFQDN != "webdocker11.lan.r25dc.de:80" || !r.Active || r.ComposeProject != "kunde-a" {
|
||||
t.Errorf("Route: %+v", r)
|
||||
}
|
||||
|
||||
// Identität des Payees liegt im Datenverzeichnis des Servers.
|
||||
info, err := os.Stat(filepath.Join(s.cfg.DataDir, "payee.key"))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("payee.key: %v, %v", info, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(s.cfg.DataDir, "accountant.json"))
|
||||
var id payeeIdentity
|
||||
if err := json.Unmarshal(raw, &id); err != nil || id.PayeeID != "webproxy11" || id.Fingerprint != acc.Fingerprint() ||
|
||||
id.URL != "https://"+acc.Addr() {
|
||||
t.Errorf("accountant.json = %s (%v)", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Gesendet wird bei Änderungen und als Lebenszeichen – nicht bei jedem Prüfen.
|
||||
func TestPayee_ReportsChangesNotEveryTick(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour))
|
||||
agent, _ := connectedAgent(t, s, "node-1")
|
||||
|
||||
var first time.Time
|
||||
eventually(t, "erster Bericht", func() bool {
|
||||
r, at, ok := latestReport(t, acc, "webproxy11")
|
||||
first = at
|
||||
return ok && len(r.Routes) == 1
|
||||
})
|
||||
time.Sleep(200 * time.Millisecond) // zehn Prüfintervalle ohne Änderung
|
||||
if _, at, _ := latestReport(t, acc, "webproxy11"); !at.Equal(first) {
|
||||
t.Errorf("ohne Änderung erneut gesendet (%v → %v)", first, at)
|
||||
}
|
||||
|
||||
st := wpState("kunde.de")
|
||||
st.Workloads[0].Labels["traefik.http.routers.wp.rule"] = "Host(`kunde.de`) || Host(`www.kunde.de`)"
|
||||
agent.do(t, http.MethodPost, "/api/v1/state", st)
|
||||
eventually(t, "Änderung nachgemeldet", func() bool {
|
||||
r, _, _ := latestReport(t, acc, "webproxy11")
|
||||
return len(r.Routes) == 1 && len(r.Routes[0].Hosts) == 2
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayee_Heartbeat(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), 100*time.Millisecond))
|
||||
connectedAgent(t, s, "node-1")
|
||||
var first time.Time
|
||||
eventually(t, "erster Bericht", func() bool {
|
||||
_, at, ok := latestReport(t, acc, "webproxy11")
|
||||
first = at
|
||||
return ok
|
||||
})
|
||||
eventually(t, "Lebenszeichen ohne Änderung", func() bool {
|
||||
_, at, _ := latestReport(t, acc, "webproxy11")
|
||||
return at.After(first)
|
||||
})
|
||||
}
|
||||
|
||||
// Nach dem Neustart genügt accountant.json; die Einladung ist verbraucht.
|
||||
func TestPayee_RestartWithoutToken(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
dir := filepath.Join(t.TempDir(), "suldata")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir })
|
||||
var first time.Time
|
||||
eventually(t, "erster Bericht", func() bool {
|
||||
_, at, ok := latestReport(t, acc, "webproxy11")
|
||||
first = at
|
||||
return ok
|
||||
})
|
||||
s.Shutdown(ctx)
|
||||
|
||||
startServer(t, payeeConfig("", 50*time.Millisecond), func(c *Config) { c.DataDir = dir })
|
||||
eventually(t, "Bericht nach Neustart ohne Einladung", func() bool {
|
||||
_, at, _ := latestReport(t, acc, "webproxy11")
|
||||
return at.After(first)
|
||||
})
|
||||
}
|
||||
|
||||
// Ein nicht erreichbarer Accountant hält den Ingress nicht auf; der Server
|
||||
// holt das Enrollment nach.
|
||||
func TestPayee_AccountantDownAtStart(t *testing.T) {
|
||||
accDir := filepath.Join(t.TempDir(), "accdata")
|
||||
acc := startAccountant(t, accDir, "")
|
||||
addr := acc.Addr()
|
||||
token := accInvite(t, acc, "webproxy11")
|
||||
acc.Shutdown(ctx)
|
||||
|
||||
s, logs := startServer(t, payeeConfig(token, time.Hour))
|
||||
if r, err := http.Get("http://" + s.ProviderAddr() + "/healthz"); err != nil || r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Server ohne Accountant nicht bereit: %v", err)
|
||||
}
|
||||
eventually(t, "Warnung im Log", func() bool { return strings.Contains(logs.String(), "Enrollment fehlgeschlagen") })
|
||||
|
||||
acc = startAccountant(t, accDir, addr)
|
||||
eventually(t, "Enrollment nachgeholt", func() bool {
|
||||
_, _, ok := latestReport(t, acc, "webproxy11")
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
// Schon bei Accountant A registriert: eine Einladung von B wird nicht
|
||||
// eingelöst (kein stiller Wechsel), der Server läuft trotzdem.
|
||||
func TestPayee_OtherAccountantRejected(t *testing.T) {
|
||||
a := startAccountant(t, "", "")
|
||||
dir := filepath.Join(t.TempDir(), "suldata")
|
||||
s, _ := startServer(t, payeeConfig(accInvite(t, a, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir })
|
||||
eventually(t, "bei A registriert", func() bool { _, _, ok := latestReport(t, a, "webproxy11"); return ok })
|
||||
s.Shutdown(ctx)
|
||||
|
||||
b := startAccountant(t, "", "")
|
||||
_, logs := startServer(t, payeeConfig(accInvite(t, b, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir })
|
||||
eventually(t, "Fehler im Log", func() bool { return strings.Contains(logs.String(), "anderen Accountant") })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if _, _, ok := latestReport(t, b, "webproxy11"); ok {
|
||||
t.Error("Bericht an B trotz Registrierung bei A")
|
||||
}
|
||||
}
|
||||
|
||||
// --accountant überschreibt die Adresse aus der Einladung.
|
||||
func TestPayee_URLOverride(t *testing.T) {
|
||||
acc := startAccountant(t, "", "")
|
||||
l, _ := ledger.Open(ctx, filepath.Join(acc.DataDir(), "accountant.db"))
|
||||
tok, _ := crypto.NewEnrollmentToken(acc.Fingerprint(), "https://127.0.0.1:1") // falsche Adresse
|
||||
l.AddInvite(ctx, tok.Secret, "webproxy11", time.Now().Add(time.Hour))
|
||||
l.Close()
|
||||
startServer(t, payeeConfig(tok.String(), time.Hour), func(c *Config) { c.AccountantURL = "https://" + acc.Addr() })
|
||||
eventually(t, "Bericht über die überschriebene Adresse", func() bool {
|
||||
_, _, ok := latestReport(t, acc, "webproxy11")
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayee_ConfigErrors(t *testing.T) {
|
||||
noURL, err := crypto.NewEnrollmentToken("PQeFR_XBBVMz70LWWvGsFsVrFT9SZVCvQk-HQvrBaTk", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"Einladung unlesbar": func(c *Config) { c.AccountantToken = "kaputt" },
|
||||
"Adresse ohne TLS": func(c *Config) { c.AccountantURL = "http://acc:8444" },
|
||||
"Einladung ohne jede Adresse": func(c *Config) { c.AccountantToken = noURL.String() },
|
||||
"Adresse ohne Einladung": func(c *Config) { c.AccountantURL = "https://acc:8444" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Config{DataDir: filepath.Join(t.TempDir(), "suldata"), Logger: slog.New(slog.DiscardHandler)}
|
||||
mutate(&cfg)
|
||||
if s, err := New(ctx, cfg); err == nil {
|
||||
s.Shutdown(ctx)
|
||||
t.Error("erwartet Fehler")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne Einladung und ohne accountant.json meldet der Server nichts.
|
||||
func TestPayee_Disabled(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := os.Stat(filepath.Join(s.cfg.DataDir, "payee.key")); !os.IsNotExist(err) {
|
||||
t.Errorf("payee.key ohne Accountant angelegt: %v", err)
|
||||
}
|
||||
}
|
||||
+48
-2
@@ -32,6 +32,7 @@ import (
|
||||
"sultan/pkg/lander"
|
||||
"sultan/pkg/notify"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/ratelimit"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/store"
|
||||
"sultan/pkg/stream"
|
||||
@@ -67,6 +68,16 @@ type Config struct {
|
||||
// -1 = unbegrenzt) – gegen das Durchprobieren von Einladungen.
|
||||
EnrollLimit int
|
||||
|
||||
// Accountant (Spec 5.3): Einladung (nur beim ersten Start) bzw.
|
||||
// Adress-Override; ohne beides und ohne accountant.json meldet der
|
||||
// Server nichts. Interval: Prüfen auf Änderungen (Default 10s),
|
||||
// Heartbeat: Lebenszeichen ohne Änderung (Default 1h).
|
||||
AccountantToken string
|
||||
AccountantURL string
|
||||
AccountantInterval time.Duration
|
||||
AccountantHeartbeat time.Duration
|
||||
Version string // für den Bericht (sultan version)
|
||||
|
||||
interceptorDerived bool // InterceptorURL folgt der gebundenen Lander-Adresse
|
||||
}
|
||||
|
||||
@@ -99,6 +110,12 @@ func (c Config) withDefaults() Config {
|
||||
if c.EnrollLimit == 0 {
|
||||
c.EnrollLimit = 10
|
||||
}
|
||||
if c.AccountantInterval <= 0 {
|
||||
c.AccountantInterval = 10 * time.Second
|
||||
}
|
||||
if c.AccountantHeartbeat <= 0 {
|
||||
c.AccountantHeartbeat = time.Hour
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
}
|
||||
@@ -141,7 +158,8 @@ type Server struct {
|
||||
registry *registry.Registry
|
||||
lander *lander.Lander
|
||||
notifier notify.Target // nil = keine Benachrichtigungen
|
||||
enrollRL *rateLimiter
|
||||
enrollRL *ratelimit.Limiter
|
||||
payee *payee // nil = meldet nicht an einen Accountant
|
||||
|
||||
vmu sync.Mutex
|
||||
violations []time.Time // Zeitpunkte der Policy-Verstöße (für Checkmk)
|
||||
@@ -151,6 +169,7 @@ type Server struct {
|
||||
providerAddr string
|
||||
landerAddr string
|
||||
agentSrv *http.Server
|
||||
payeeRunning bool
|
||||
providerSrv *http.Server
|
||||
landerSrv *http.Server
|
||||
serveErr chan error
|
||||
@@ -199,6 +218,11 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
pay, err := newPayee(cfg, cfg.Logger)
|
||||
if err != nil {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
@@ -209,9 +233,13 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
verifier: &auth.Verifier{Directory: st},
|
||||
serveErr: make(chan error, 3),
|
||||
stopRefresh: make(chan struct{}),
|
||||
payee: pay,
|
||||
}
|
||||
if pay != nil {
|
||||
pay.s = s
|
||||
}
|
||||
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline, SigningKey: master}
|
||||
s.enrollRL = &rateLimiter{limit: cfg.EnrollLimit, window: time.Minute}
|
||||
s.enrollRL = &ratelimit.Limiter{Limit: cfg.EnrollLimit, Window: time.Minute}
|
||||
s.notifier = cfg.Notify
|
||||
if s.notifier == nil {
|
||||
var targets notify.Multi
|
||||
@@ -260,6 +288,12 @@ func loadOrCreateMasterKey(path string) (ed25519.PrivateKey, error) {
|
||||
// Fingerprint ist der Fingerprint des TLS-Zertifikats (für Enrollment-Tokens).
|
||||
func (s *Server) Fingerprint() crypto.Fingerprint { return s.cert.Fingerprint }
|
||||
|
||||
// NodeState liefert den zuletzt gemeldeten Zustand eines verbundenen
|
||||
// Knotens (Kopie, inkl. Zusatzdaten für das Inventar).
|
||||
func (s *Server) NodeState(nodeID string) (registry.NodeState, bool) {
|
||||
return s.registry.State(nodeID)
|
||||
}
|
||||
|
||||
// MasterPublicKey ist der Schlüssel, mit dem Lizenzen geprüft werden.
|
||||
func (s *Server) MasterPublicKey() ed25519.PublicKey {
|
||||
return s.master.Public().(ed25519.PublicKey)
|
||||
@@ -338,6 +372,12 @@ func (s *Server) Start() error {
|
||||
go s.serve(func() error { return landerSrv.Serve(landerLn) })
|
||||
}
|
||||
go s.refreshLoop()
|
||||
if s.payee != nil {
|
||||
s.mu.Lock()
|
||||
s.payeeRunning = true
|
||||
s.mu.Unlock()
|
||||
go s.payee.run()
|
||||
}
|
||||
|
||||
s.log.Info("Sultan-Server gestartet",
|
||||
"agent", s.AgentAddr(), "provider", s.ProviderAddr(), "lander", s.LanderAddr(),
|
||||
@@ -405,6 +445,12 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
errs = append(errs, srv.Shutdown(ctx))
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
payeeRunning := s.payeeRunning
|
||||
s.mu.Unlock()
|
||||
if payeeRunning {
|
||||
s.payee.shutdown() // vor dem Schließen der Datenbank
|
||||
}
|
||||
errs = append(errs, s.store.Close())
|
||||
s.shutdownErr = errors.Join(errs...)
|
||||
s.log.Info("Sultan-Server beendet")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenDB stellt Migrationsmechanik und Zeitformat auch anderen Datenbanken
|
||||
// bereit (z. B. accountant.db).
|
||||
func TestOpenDB_Migrations(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "x.db")
|
||||
v1 := []string{`CREATE TABLE a (id TEXT PRIMARY KEY, at DATETIME NOT NULL);`}
|
||||
db, err := OpenDB(ctx, path, v1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Date(2026, 10, 16, 8, 15, 30, 5, time.FixedZone("CEST", 2*3600))
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO a VALUES ('x', ?)`, FormatTime(at)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got time.Time
|
||||
if err := db.QueryRowContext(ctx, `SELECT at FROM a`).Scan(ScanTime(&got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(at) || got.Location() != time.UTC {
|
||||
t.Errorf("Zeit = %v, erwartet %v in UTC", got, at)
|
||||
}
|
||||
var null time.Time
|
||||
if err := db.QueryRowContext(ctx, `SELECT NULL`).Scan(ScanTime(&null)); err != nil || !null.IsZero() {
|
||||
t.Errorf("NULL = %v, %v", null, err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Neue Migration wird angehängt, bestehende Daten bleiben.
|
||||
v2 := append(v1, `ALTER TABLE a ADD COLUMN note TEXT NOT NULL DEFAULT '';`)
|
||||
db, err = OpenDB(ctx, path, v2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM a WHERE note = ''`).Scan(&n); err != nil || n != 1 {
|
||||
t.Errorf("nach Migration: n=%d err=%v", n, err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Ältere Programmversion verweigert die neuere Datenbank.
|
||||
if _, err := OpenDB(ctx, path, v1); !errors.Is(err, ErrSchemaTooNew) {
|
||||
t.Errorf("ältere Version: %v, erwartet ErrSchemaTooNew", err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ sql.Scanner = ScanTime(nil)
|
||||
+30
-7
@@ -111,6 +111,19 @@ type Store struct {
|
||||
// Open öffnet (bzw. erstellt) die Datenbank unter path und bringt das Schema
|
||||
// auf den aktuellen Stand.
|
||||
func Open(ctx context.Context, path string) (*Store, error) {
|
||||
db, err := OpenDB(ctx, path, migrations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// OpenDB öffnet (bzw. erstellt) eine SQLite-Datenbank mit den Einstellungen
|
||||
// des Servers und wendet migrations an: migrations[i] hebt das Schema von
|
||||
// Version i auf i+1 (Version in PRAGMA user_version). Die Liste darf nur
|
||||
// wachsen; eine neuere Datenbank liefert ErrSchemaTooNew. Für weitere
|
||||
// Datenbanken wie accountant.db.
|
||||
func OpenDB(ctx context.Context, path string, migrations []string) (*sql.DB, error) {
|
||||
// foreign_keys: sonst greift ON DELETE CASCADE nicht (gilt pro Verbindung).
|
||||
// _txlock=immediate: Schreib-Transaktionen holen die Sperre sofort und
|
||||
// warten per busy_timeout, statt beim Upgrade mit SQLITE_BUSY abzubrechen.
|
||||
@@ -120,12 +133,11 @@ func Open(ctx context.Context, path string) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db, now: time.Now}
|
||||
if err := s.migrate(ctx); err != nil {
|
||||
if err := migrate(ctx, db, migrations); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close schließt die Datenbank.
|
||||
@@ -133,13 +145,17 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// SchemaVersion liefert die aktuelle Schema-Version.
|
||||
func (s *Store) SchemaVersion(ctx context.Context) (int, error) {
|
||||
return schemaVersion(ctx, s.db)
|
||||
}
|
||||
|
||||
func schemaVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
|
||||
err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
v, err := s.SchemaVersion(ctx)
|
||||
func migrate(ctx context.Context, db *sql.DB, migrations []string) error {
|
||||
v, err := schemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: Schema-Version lesen: %w", err)
|
||||
}
|
||||
@@ -147,7 +163,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
return fmt.Errorf("%w (Datenbank v%d, bekannt bis v%d)", ErrSchemaTooNew, v, len(migrations))
|
||||
}
|
||||
for i := v; i < len(migrations); i++ {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,6 +188,13 @@ const timeLayout = "2006-01-02T15:04:05.000000000Z"
|
||||
|
||||
func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) }
|
||||
|
||||
// FormatTime formatiert t im Speicherformat (UTC, feste Breite) – für
|
||||
// Datenbanken, die OpenDB nutzen.
|
||||
func FormatTime(t time.Time) string { return formatTime(t) }
|
||||
|
||||
// ScanTime liest einen so gespeicherten Zeitstempel nach t (NULL = Nullzeit).
|
||||
func ScanTime(t *time.Time) sql.Scanner { return dbTime{t} }
|
||||
|
||||
// normTime entfernt Monotonic-Clock und Zeitzone, damit gespeicherte und
|
||||
// gelesene Werte identisch sind.
|
||||
func normTime(t time.Time) time.Time { return t.Round(0).UTC() }
|
||||
|
||||
Reference in new issue
Block a user