// 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 }