215 lines
6.4 KiB
Go
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
|
|
}
|