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