erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
1 parent
9d5789c5d7
commit
c8df9d7651
41 files changed
+6455
-36
No files matched your search
@@ -0,0 +1,214 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user