486 lines
15 KiB
Go
486 lines
15 KiB
Go
// 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
|
||
}
|