Files
sultan/pkg/ledger/ledger.go
T

486 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}