Files
sultan/pkg/store/store.go
T
br c8df9d7651 erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
2026-10-01 22:22:13 +02:00

215 lines
6.4 KiB
Go

// Package store hält den persistenten Zustand des Sultan-Servers (Mandanten,
// Knoten, Enrollment-Tokens) in SQLite, Schema gemäß Spec Abschnitt 4.3.
//
// Verwendet wird der reine Go-Treiber modernc.org/sqlite (kein cgo), damit
// sultan ein statisch gelinktes Single Binary bleibt.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
var (
// ErrNotFound: Der Datensatz (oder ein referenzierter Datensatz) existiert nicht.
ErrNotFound = errors.New("store: nicht gefunden")
// ErrExists: Ein Datensatz mit derselben ID bzw. demselben eindeutigen Wert existiert bereits.
ErrExists = errors.New("store: existiert bereits")
// ErrInvalid: Die Eingabe verletzt eine Validierungsregel.
ErrInvalid = errors.New("store: ungültige Eingabe")
// ErrTokenUsed: Der Enrollment-Token wurde bereits eingelöst.
ErrTokenUsed = errors.New("store: Token bereits verwendet")
// ErrTokenExpired: Der Enrollment-Token ist abgelaufen.
ErrTokenExpired = errors.New("store: Token abgelaufen")
// ErrSchemaTooNew: Die Datenbank stammt von einer neueren sultan-Version.
ErrSchemaTooNew = errors.New("store: Datenbankschema neuer als diese Programmversion")
)
// migrations[i] hebt das Schema von Version i auf i+1. Bestehende Einträge
// dürfen nie verändert, nur neue angehängt werden.
var migrations = []string{
`CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'community',
max_routes INTEGER NOT NULL DEFAULT 5,
allowed_domains TEXT NOT NULL,
valid_until DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
public_key TEXT NOT NULL UNIQUE,
last_seen DATETIME NOT NULL,
ip_address TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'online'
);
CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
CREATE TABLE enrollment_tokens (
token_hash TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
}
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
// Nutzung aus mehreren Goroutinen geeignet.
type Store struct {
db *sql.DB
now func() time.Time
}
// 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) {
// 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.
dsn := "file:" + path +
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
s := &Store{db: db, now: time.Now}
if err := s.migrate(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close schließt die Datenbank.
func (s *Store) Close() error { return s.db.Close() }
// SchemaVersion liefert die aktuelle Schema-Version.
func (s *Store) SchemaVersion(ctx context.Context) (int, error) {
var v int
err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
return v, err
}
func (s *Store) migrate(ctx context.Context) error {
v, err := s.SchemaVersion(ctx)
if err != nil {
return fmt.Errorf("store: Schema-Version lesen: %w", err)
}
if v > len(migrations) {
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)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("store: Migration auf v%d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// timeLayout hat feste Breite, damit Zeitstempel als Text korrekt sortieren
// und sich in SQL vergleichen lassen.
const timeLayout = "2006-01-02T15:04:05.000000000Z"
func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) }
// 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() }
// dbTime liest einen Zeitstempel unabhängig davon, ob der Treiber ihn als
// Text oder bereits als time.Time liefert.
type dbTime struct{ t *time.Time }
func (d dbTime) Scan(src any) error {
switch v := src.(type) {
case time.Time:
*d.t = normTime(v)
return nil
case string:
return d.parse(v)
case []byte:
return d.parse(string(v))
default:
return fmt.Errorf("store: unerwarteter Zeittyp %T", src)
}
}
func (d dbTime) parse(s string) error {
t, err := time.Parse(timeLayout, s)
if err != nil {
return fmt.Errorf("store: Zeitstempel %q: %w", s, err)
}
*d.t = t
return nil
}
// execer ist die gemeinsame Teilmenge von *sql.DB und *sql.Tx.
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func sqliteCode(err error) int {
var se *sqlite.Error
if errors.As(err, &se) {
return se.Code()
}
return 0
}
// mapConstraint übersetzt SQLite-Constraint-Fehler in die Fehler des Packages.
func mapConstraint(err error, what string) error {
switch sqliteCode(err) {
case sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY, sqlite3.SQLITE_CONSTRAINT_UNIQUE:
return fmt.Errorf("%s: %w", what, ErrExists)
case sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY:
return fmt.Errorf("%s: Mandant: %w", what, ErrNotFound)
}
return err
}
func blank(s string) bool { return strings.TrimSpace(s) == "" }
func invalid(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...))
}
// requireAffected liefert ErrNotFound, wenn res keine Zeile betroffen hat.
func requireAffected(res sql.Result, err error, what string) error {
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%s: %w", what, ErrNotFound)
}
return nil
}