Accountant: Inventar, where, Checkmk, Abrechnung

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

No files matched your search

+485
View File
@@ -0,0 +1,485 @@
// Package ledger ist die Datenbank des Accountants (accountant.db, Spec
// 5.3): registrierte Payees (meldende Sultan-Server), ihre Einladungen und
// Einstellungen. der jeweils letzte Bericht je Payee und die Historie (Zeiträume je Ort).
//
// Bewusst getrennt von sultan.db: andere Lebensdauer (Historie über Jahre)
// und anderes Vertrauen (Berichte sind Eingaben fremder Server). Migrationen
// und Zeitformat kommen aus pkg/store; Fehler sind die von pkg/store
// (ErrNotFound, ErrExists, ErrInvalid, ErrTokenUsed, ErrTokenExpired).
package ledger
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// migrations: nur anhängen, nie ändern (siehe store.OpenDB).
var migrations = []string{
`CREATE TABLE payees (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
public_key TEXT NOT NULL UNIQUE,
ip_address TEXT NOT NULL,
enrolled_at DATETIME NOT NULL
);
CREATE TABLE invites (
token_hash TEXT PRIMARY KEY,
payee_id TEXT NOT NULL,
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
used_at DATETIME
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`,
// v2: letzter Bericht je Payee.
`CREATE TABLE reports (
payee_id TEXT PRIMARY KEY REFERENCES payees(id) ON DELETE CASCADE,
received_at DATETIME NOT NULL,
body TEXT NOT NULL
);`,
// v3: Historie – Zeiträume je Ort (Payee, Mandant, Knoten, Protokoll,
// Router). Ohne Fremdschlüssel: bleibt, wenn ein Payee gelöscht wird.
`CREATE TABLE route_spans (
id INTEGER PRIMARY KEY,
payee_id TEXT NOT NULL,
tenant TEXT NOT NULL,
node TEXT NOT NULL,
protocol TEXT NOT NULL,
router TEXT NOT NULL,
hosts TEXT NOT NULL,
backend TEXT NOT NULL,
backend_fqdn TEXT NOT NULL,
workload TEXT NOT NULL,
compose_dir TEXT NOT NULL,
product TEXT NOT NULL,
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
ended_at DATETIME
);
CREATE INDEX route_spans_payee_open ON route_spans(payee_id, ended_at);
CREATE INDEX route_spans_tenant ON route_spans(tenant);`,
// v4: Kunden und die Zuordnung von Mandanten (payee_id '' = alle Payees).
`CREATE TABLE customers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
grp TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
cycle TEXT NOT NULL DEFAULT '',
flat_rate INTEGER,
auto INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE customer_map (
payee_id TEXT NOT NULL DEFAULT '',
tenant TEXT NOT NULL,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
PRIMARY KEY (payee_id, tenant)
);`,
// v5: Produktkatalog (Klasse/Preis je Monat), Kundenpreise, Zuordnung je
// Posten (Mandant, Protokoll, Router).
`CREATE TABLE products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE product_versions (
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
valid_from TEXT NOT NULL,
class TEXT NOT NULL,
price INTEGER,
PRIMARY KEY (product_id, valid_from)
);
CREATE TABLE customer_prices (
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
valid_from TEXT NOT NULL,
price INTEGER NOT NULL,
PRIMARY KEY (product_id, customer_id, valid_from)
);
CREATE TABLE assignments (
tenant TEXT NOT NULL,
protocol TEXT NOT NULL,
router TEXT NOT NULL,
product_id TEXT NOT NULL DEFAULT '',
class TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL DEFAULT '',
PRIMARY KEY (tenant, protocol, router)
);`,
}
// Ledger ist der Zugriff auf accountant.db; parallel nutzbar.
type Ledger struct {
db *sql.DB
now func() time.Time
}
// Payee ist ein registrierter, meldender Sultan-Server.
type Payee struct {
ID string
Hostname string
PublicKey string // auth.EncodePublicKey
IPAddress string // beim Enrollment
EnrolledAt time.Time
LastReport time.Time // Nullzeit = noch kein Bericht
}
// Open öffnet (bzw. erstellt) die Datenbank unter path.
func Open(ctx context.Context, path string) (*Ledger, error) {
db, err := store.OpenDB(ctx, path, migrations)
if err != nil {
return nil, err
}
return &Ledger{db: db, now: time.Now}, nil
}
// Close schließt die Datenbank.
func (l *Ledger) Close() error { return l.db.Close() }
// SetClock ersetzt die Uhr (Tests mit festen Zeitpunkten); nicht parallel
// zu anderen Aufrufen verwenden.
func (l *Ledger) SetClock(now func() time.Time) { l.now = now }
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func invalid(format string, args ...any) error {
return fmt.Errorf("%w: %s", store.ErrInvalid, fmt.Sprintf(format, args...))
}
// AddInvite hinterlegt eine einmalige Einladung für den Payee payeeID
// (gespeichert wird nur der Hash von token). Ein bereits registrierter Payee
// muss erst gelöscht werden (ErrExists).
func (l *Ledger) AddInvite(ctx context.Context, token, payeeID string, expiresAt time.Time) error {
switch {
case token == "":
return invalid("Token fehlt")
case !policy.ValidID(payeeID):
return invalid("Payee-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", payeeID)
case !expiresAt.After(l.now()):
return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
return err
} else if exists {
return fmt.Errorf("Payee %s: %w (erst löschen)", payeeID, store.ErrExists)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO invites (token_hash, payee_id, expires_at, used) VALUES (?, ?, ?, 0)`,
hashToken(token), payeeID, store.FormatTime(expiresAt)); err != nil {
return err
}
return tx.Commit()
}
// Enroll löst token ein und legt den Payee an; seine ID bestimmt die
// Einladung (p.ID wird ersetzt), EnrolledAt setzt der Ledger. Schlägt das
// Anlegen fehl, bleibt die Einladung unverbraucht.
func (l *Ledger) Enroll(ctx context.Context, token string, p Payee) (Payee, error) {
if strings.TrimSpace(p.Hostname) == "" || p.PublicKey == "" {
return Payee{}, invalid("Hostname und Schlüssel sind Pflicht")
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return Payee{}, err
}
defer tx.Rollback()
hash := hashToken(token)
var payeeID string
var expiresAt time.Time
var used bool
err = tx.QueryRowContext(ctx, `SELECT payee_id, expires_at, used FROM invites WHERE token_hash = ?`, hash).
Scan(&payeeID, store.ScanTime(&expiresAt), &used)
switch {
case errors.Is(err, sql.ErrNoRows):
return Payee{}, fmt.Errorf("Einladung: %w", store.ErrNotFound)
case err != nil:
return Payee{}, err
case used:
return Payee{}, store.ErrTokenUsed
case !l.now().Before(expiresAt):
return Payee{}, store.ErrTokenExpired
}
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ? OR public_key = ?`, payeeID, p.PublicKey); err != nil {
return Payee{}, err
} else if exists {
return Payee{}, fmt.Errorf("Payee %s oder Schlüssel: %w", payeeID, store.ErrExists)
}
now := l.now().Round(0).UTC()
p.ID, p.EnrolledAt = payeeID, now
if _, err := tx.ExecContext(ctx, `UPDATE invites SET used = 1, used_at = ? WHERE token_hash = ?`,
store.FormatTime(now), hash); err != nil {
return Payee{}, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO payees (id, hostname, public_key, ip_address, enrolled_at) VALUES (?, ?, ?, ?, ?)`,
p.ID, p.Hostname, p.PublicKey, p.IPAddress, store.FormatTime(now)); err != nil {
return Payee{}, err
}
if err := tx.Commit(); err != nil {
return Payee{}, err
}
return p, nil
}
const payeeSelect = `SELECT p.id, p.hostname, p.public_key, p.ip_address, p.enrolled_at, r.received_at
FROM payees p LEFT JOIN reports r ON r.payee_id = p.id`
func scanPayee(row interface{ Scan(...any) error }) (Payee, error) {
var p Payee
err := row.Scan(&p.ID, &p.Hostname, &p.PublicKey, &p.IPAddress, store.ScanTime(&p.EnrolledAt), store.ScanTime(&p.LastReport))
return p, err
}
// ListPayees liefert alle Payees, sortiert nach ID.
func (l *Ledger) ListPayees(ctx context.Context) ([]Payee, error) {
rows, err := l.db.QueryContext(ctx, payeeSelect+` ORDER BY p.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Payee
for rows.Next() {
p, err := scanPayee(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetPayeeByPublicKey sucht den Payee zu einem Schlüssel (Prüfung signierter
// Berichte).
func (l *Ledger) GetPayeeByPublicKey(ctx context.Context, publicKey string) (Payee, error) {
p, err := scanPayee(l.db.QueryRowContext(ctx, payeeSelect+` WHERE p.public_key = ?`, publicKey))
if errors.Is(err, sql.ErrNoRows) {
return Payee{}, fmt.Errorf("Payee: %w", store.ErrNotFound)
}
return p, err
}
// DeletePayee entfernt einen Payee (z. B. vor einer neuen Einladung nach
// Schlüsselverlust).
func (l *Ledger) DeletePayee(ctx context.Context, id string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM payees WHERE id = ?`, id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Payee %s: %w", id, store.ErrNotFound)
}
return nil
}
// RouteRecord ist eine Route aus einem Bericht, wie sie in die Historie eingeht.
type RouteRecord struct {
Tenant, Node, Protocol, Router string
Hosts []string
Backend, BackendFQDN string
Workload, ComposeDir, Product string
}
func (r RouteRecord) key() string {
return r.Tenant + "\x00" + r.Node + "\x00" + r.Protocol + "\x00" + r.Router
}
// RouteSpan ist ein Zeitraum an einem Ort.
type RouteSpan struct {
RouteRecord
ID int64
Payee string
FirstSeen time.Time
LastSeen time.Time
Ended time.Time // Nullzeit = noch offen
}
// SaveReport ersetzt den letzten Bericht des Payees und führt die Historie
// fort (Zeitpunkt: jetzt): neue Orte öffnen einen Zeitraum, bestehende
// übernehmen geänderte Angaben (first_seen bleibt), fehlende enden. Ein
// Bericht ist vollständig – was fehlt, gibt es beim Payee nicht mehr.
func (l *Ledger) SaveReport(ctx context.Context, payeeID string, body []byte, routes []RouteRecord) error {
now := store.FormatTime(l.now())
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
return err
} else if !exists {
return fmt.Errorf("Payee %s: %w", payeeID, store.ErrNotFound)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO reports (payee_id, received_at, body) VALUES (?, ?, ?)
ON CONFLICT(payee_id) DO UPDATE SET received_at = excluded.received_at, body = excluded.body`,
payeeID, now, string(body)); err != nil {
return err
}
open := map[string]int64{}
rows, err := tx.QueryContext(ctx, `SELECT id, tenant, node, protocol, router FROM route_spans
WHERE payee_id = ? AND ended_at IS NULL`, payeeID)
if err != nil {
return err
}
for rows.Next() {
var id int64
var r RouteRecord
if err := rows.Scan(&id, &r.Tenant, &r.Node, &r.Protocol, &r.Router); err != nil {
rows.Close()
return err
}
open[r.key()] = id
}
if err := rows.Close(); err != nil {
return err
}
for _, r := range routes {
hosts := normalizeHosts(r.Hosts)
if id, ok := open[r.key()]; ok {
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET hosts = ?, backend = ?, backend_fqdn = ?,
workload = ?, compose_dir = ?, product = ?, last_seen = ? WHERE id = ?`,
hosts, r.Backend, r.BackendFQDN, r.Workload, r.ComposeDir, r.Product, now, id); err != nil {
return err
}
delete(open, r.key())
continue
}
if _, err := tx.ExecContext(ctx, `INSERT INTO route_spans (payee_id, tenant, node, protocol, router, hosts,
backend, backend_fqdn, workload, compose_dir, product, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
payeeID, r.Tenant, r.Node, r.Protocol, r.Router, hosts, r.Backend, r.BackendFQDN,
r.Workload, r.ComposeDir, r.Product, now, now); err != nil {
return err
}
}
for _, id := range open {
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET ended_at = ? WHERE id = ?`, now, id); err != nil {
return err
}
}
return tx.Commit()
}
// normalizeHosts speichert Hosts klein, sortiert, ohne Doppelte (JSON).
func normalizeHosts(hosts []string) string {
out := make([]string, 0, len(hosts))
for _, h := range hosts {
out = append(out, strings.ToLower(strings.TrimSpace(h)))
}
slices.Sort(out)
raw, _ := json.Marshal(slices.Compact(out))
return string(raw)
}
// HistoryFilter schränkt RouteHistory ein; leere Felder filtern nicht.
type HistoryFilter struct {
Host string // Teiltreffer, ohne Groß-/Kleinschreibung
Tenant string
Payee string
OpenOnly bool
}
// RouteHistory liefert Zeiträume, sortiert nach Beginn, Payee, Knoten, Router.
func (l *Ledger) RouteHistory(ctx context.Context, f HistoryFilter) ([]RouteSpan, error) {
q := `SELECT id, payee_id, tenant, node, protocol, router, hosts, backend, backend_fqdn, workload,
compose_dir, product, first_seen, last_seen, ended_at FROM route_spans WHERE 1=1`
var args []any
if f.Host != "" {
like := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.ToLower(f.Host))
q += ` AND hosts LIKE ? ESCAPE '\'`
args = append(args, "%"+like+"%")
}
if f.Tenant != "" {
q += ` AND tenant = ?`
args = append(args, f.Tenant)
}
if f.Payee != "" {
q += ` AND payee_id = ?`
args = append(args, f.Payee)
}
if f.OpenOnly {
q += ` AND ended_at IS NULL`
}
q += ` ORDER BY first_seen, payee_id, node, router, id`
rows, err := l.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RouteSpan
for rows.Next() {
var sp RouteSpan
var hosts string
if err := rows.Scan(&sp.ID, &sp.Payee, &sp.Tenant, &sp.Node, &sp.Protocol, &sp.Router, &hosts, &sp.Backend,
&sp.BackendFQDN, &sp.Workload, &sp.ComposeDir, &sp.Product, store.ScanTime(&sp.FirstSeen),
store.ScanTime(&sp.LastSeen), store.ScanTime(&sp.Ended)); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(hosts), &sp.Hosts); err != nil {
return nil, fmt.Errorf("Historie %d: Hosts unlesbar: %w", sp.ID, err)
}
out = append(out, sp)
}
return out, rows.Err()
}
// LatestReport liefert den letzten Bericht des Payees und seinen Eingang.
func (l *Ledger) LatestReport(ctx context.Context, payeeID string) ([]byte, time.Time, error) {
var body string
var at time.Time
err := l.db.QueryRowContext(ctx, `SELECT body, received_at FROM reports WHERE payee_id = ?`, payeeID).
Scan(&body, store.ScanTime(&at))
if errors.Is(err, sql.ErrNoRows) {
return nil, time.Time{}, fmt.Errorf("Bericht von %s: %w", payeeID, store.ErrNotFound)
}
return []byte(body), at, err
}
// Setting liefert eine Einstellung ("" wenn nicht gesetzt).
func (l *Ledger) Setting(ctx context.Context, key string) (string, error) {
var v string
err := l.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
// SetSetting setzt eine Einstellung.
func (l *Ledger) SetSetting(ctx context.Context, key, value string) error {
_, err := l.db.ExecContext(ctx,
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
func rowExists(ctx context.Context, tx *sql.Tx, query string, args ...any) (bool, error) {
var one int
err := tx.QueryRowContext(ctx, query, args...).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}