software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
package store
import (
"context"
"time"
)
// PurgeLeases löscht Leases, die vor mehr als olderThan abgelaufen sind.
func (s *Store) PurgeLeases(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE valid_until <= ?`, formatTime(s.now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// PurgeNodeCommands löscht zugestellte Befehle, die älter als olderThan sind.
func (s *Store) PurgeNodeCommands(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM node_commands WHERE sent_at IS NOT NULL AND sent_at <= ?`,
formatTime(s.now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Notified meldet, ob zum Ereignis key schon benachrichtigt wurde.
func (s *Store) Notified(ctx context.Context, key string) bool {
var one int
return s.db.QueryRowContext(ctx, `SELECT 1 FROM notifications WHERE key = ?`, key).Scan(&one) == nil
}
// RecordNotification vermerkt den Versand zu key (mehrfach unschädlich).
func (s *Store) RecordNotification(ctx context.Context, key string) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO notifications (key, sent_at) VALUES (?, ?)`,
key, formatTime(s.now()))
return err
}
// MarkNodeSeen setzt last_seen auf jetzt (z. B. beim Trennen: "offline seit").
func (s *Store) MarkNodeSeen(ctx context.Context, nodeID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ? WHERE id = ?`, formatTime(s.now()), nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
// NotificationKeys liefert die vermerkten Ereignisse mit dem Präfix prefix.
func (s *Store) NotificationKeys(ctx context.Context, prefix string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key FROM notifications WHERE substr(key, 1, length(?)) = ? ORDER BY key`,
prefix, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Lease ist die aktuelle Lease eines Knotens. Document ist das signierte
// JSON (license.Lease); die Zeiten liegen zusätzlich als Spalten vor, damit
// der Server ohne Parsen entscheiden kann.
type Lease struct {
NodeID string
IssuedAt time.Time
RenewAfter time.Time
ValidUntil time.Time
Document string
}
// PutLease speichert bzw. ersetzt die Lease eines Knotens.
func (s *Store) PutLease(ctx context.Context, l Lease) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO leases (node_id, issued_at, renew_after, valid_until, document)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(node_id) DO UPDATE SET issued_at = excluded.issued_at, renew_after = excluded.renew_after,
valid_until = excluded.valid_until, document = excluded.document`,
l.NodeID, formatTime(l.IssuedAt), formatTime(l.RenewAfter), formatTime(l.ValidUntil), l.Document)
if sqliteCode(err) != 0 {
return fmt.Errorf("Lease für %s: Knoten: %w", l.NodeID, ErrNotFound)
}
return err
}
// GetLease liest die Lease eines Knotens.
func (s *Store) GetLease(ctx context.Context, nodeID string) (Lease, error) {
l := Lease{NodeID: nodeID}
err := s.db.QueryRowContext(ctx, `SELECT issued_at, renew_after, valid_until, document FROM leases WHERE node_id = ?`, nodeID).
Scan(dbTime{&l.IssuedAt}, dbTime{&l.RenewAfter}, dbTime{&l.ValidUntil}, &l.Document)
if errors.Is(err, sql.ErrNoRows) {
return Lease{}, fmt.Errorf("Lease für %s: %w", nodeID, ErrNotFound)
}
return l, err
}
// NodeCommand ist ein Befehl in der Warteschlange für einen Knoten. Die CLI
// (eigener Prozess) reiht ein, der Server stellt zu.
type NodeCommand struct {
ID int64
NodeID string
Command string
Reason string
CreatedAt time.Time
}
// EnqueueNodeCommand reiht einen Befehl für nodeID ein.
func (s *Store) EnqueueNodeCommand(ctx context.Context, nodeID, command, reason string) error {
if blank(command) {
return invalid("Befehl fehlt")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO node_commands (node_id, command, reason, created_at) VALUES (?, ?, ?, ?)`,
nodeID, command, reason, formatTime(s.now()))
return mapConstraint(err, "Befehl für "+nodeID)
}
// PendingNodeCommands liefert die noch nicht zugestellten Befehle in Reihenfolge.
func (s *Store) PendingNodeCommands(ctx context.Context, nodeID string) ([]NodeCommand, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, command, reason, created_at FROM node_commands
WHERE node_id = ? AND sent_at IS NULL ORDER BY id`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NodeCommand
for rows.Next() {
c := NodeCommand{NodeID: nodeID}
if err := rows.Scan(&c.ID, &c.Command, &c.Reason, dbTime{&c.CreatedAt}); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// MarkNodeCommandSent vermerkt die Zustellung.
func (s *Store) MarkNodeCommandSent(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `UPDATE node_commands SET sent_at = ? WHERE id = ?`, formatTime(s.now()), id)
return requireAffected(res, err, fmt.Sprintf("Befehl %d", id))
}
// UpdateNodePublicKey tauscht den Schlüssel eines Knotens (rotate_key).
func (s *Store) UpdateNodePublicKey(ctx context.Context, nodeID, publicKey string) error {
if blank(publicKey) {
return invalid("Public Key fehlt")
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET public_key = ? WHERE id = ?`, publicKey, nodeID)
if err != nil {
return mapConstraint(err, "Knoten "+nodeID)
}
return requireAffected(res, nil, "Knoten "+nodeID)
}
+61
View File
@@ -0,0 +1,61 @@
package store
import (
"context"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Maintenance ist eine angemeldete Wartung eines Knotens.
type Maintenance struct {
Since time.Time
Until time.Time // Nullwert = unbefristet
Reason string // intern (Log, Monitoring) – nie öffentlich
PublicReason string // optional, ersetzt den Standardtext der Wartungsseite
}
// Active meldet, ob die Wartung zum Zeitpunkt now gilt (nil = keine).
func (m *Maintenance) Active(now time.Time) bool {
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
}
const (
maxPublicReason = 300
maxReason = 500
)
// validText: einzeilig, ohne Steuerzeichen, höchstens max Zeichen.
func validText(s string, max int) bool {
return utf8.RuneCountInString(s) <= max && !strings.ContainsFunc(s, unicode.IsControl)
}
// SetNodeMaintenance meldet eine Wartung an (Since = jetzt). m.Until muss
// in der Zukunft liegen oder Nullwert (unbefristet) sein.
func (s *Store) SetNodeMaintenance(ctx context.Context, nodeID string, m Maintenance) error {
now := s.now()
switch {
case !m.Until.IsZero() && !m.Until.After(now):
return invalid("Wartungsende liegt nicht in der Zukunft")
case !validText(m.PublicReason, maxPublicReason):
return invalid("öffentlicher Grund: einzeilig, höchstens %d Zeichen", maxPublicReason)
case !validText(m.Reason, maxReason):
return invalid("Grund: einzeilig, höchstens %d Zeichen", maxReason)
}
var until any
if !m.Until.IsZero() {
until = formatTime(m.Until)
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = ?, maintenance_until = ?,
maintenance_reason = ?, maintenance_public_reason = ? WHERE id = ?`,
formatTime(now), until, m.Reason, m.PublicReason, nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
// ClearNodeMaintenance beendet die Wartung eines Knotens.
func (s *Store) ClearNodeMaintenance(ctx context.Context, nodeID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = NULL, maintenance_until = NULL,
maintenance_reason = '', maintenance_public_reason = '' WHERE id = ?`, nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
+79
View File
@@ -0,0 +1,79 @@
package store
import (
"errors"
"testing"
"time"
)
func TestNodeMaintenance(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Fatalf("neuer Knoten mit Wartung: %+v", n.Maintenance)
}
until := clock.now().Add(4 * time.Hour)
err := s.SetNodeMaintenance(ctx, "n1", Maintenance{
Until: until, Reason: "Kernel-Update", PublicReason: "Wir spielen ein Sicherheitsupdate ein.",
})
if err != nil {
t.Fatalf("SetNodeMaintenance: %v", err)
}
n, _ := s.GetNode(ctx, "n1")
m := n.Maintenance
if m == nil || !m.Since.Equal(clock.now()) || !m.Until.Equal(until) ||
m.Reason != "Kernel-Update" || m.PublicReason != "Wir spielen ein Sicherheitsupdate ein." {
t.Errorf("Wartung: %+v", m)
}
if n.Status != NodeOnline {
t.Errorf("Wartung darf den Verbindungsstatus nicht ändern: %s", n.Status)
}
if nodes, _ := s.ListNodes(ctx, ""); nodes[0].Maintenance == nil {
t.Error("ListNodes ohne Wartungsdaten")
}
// Unbefristet: Until bleibt Nullwert.
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Reason: "Umbau"}); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
t.Errorf("unbefristet: %+v", n.Maintenance)
}
if err := s.ClearNodeMaintenance(ctx, "n1"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Errorf("nach Clear: %+v", n.Maintenance)
}
if err := s.SetNodeMaintenance(ctx, "fehlt", Maintenance{}); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.ClearNodeMaintenance(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Until: clock.now().Add(-time.Minute)}); !errors.Is(err, ErrInvalid) {
t.Errorf("Ende in der Vergangenheit: %v", err)
}
}
func TestMaintenanceActive(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
m *Maintenance
want bool
}{
{"keine", nil, false},
{"unbefristet", &Maintenance{Since: now.Add(-time.Hour)}, true},
{"läuft", &Maintenance{Since: now.Add(-time.Hour), Until: now.Add(time.Minute)}, true},
{"abgelaufen", &Maintenance{Since: now.Add(-time.Hour), Until: now}, false},
}
for _, tt := range tests {
if got := tt.m.Active(now); got != tt.want {
t.Errorf("%s: Active = %v", tt.name, got)
}
}
}
+11 -2
View File
@@ -30,6 +30,9 @@ type Node struct {
LastSeen time.Time
IPAddress string
Status NodeStatus
// Maintenance ist die angemeldete Wartung (nil = keine). Sie ist eine
// eigene Dimension: Ein Knoten in Wartung kann verbunden sein oder nicht.
Maintenance *Maintenance
}
func (n *Node) normalize(now time.Time) error {
@@ -79,11 +82,17 @@ func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error)
return n, nil
}
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status,
maintenance_since, maintenance_until, maintenance_reason, maintenance_public_reason`
func scanNode(row interface{ Scan(...any) error }) (Node, error) {
var n Node
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
var m Maintenance
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status,
dbTime{&m.Since}, dbTime{&m.Until}, &m.Reason, &m.PublicReason)
if err == nil && !m.Since.IsZero() {
n.Maintenance = &m
}
return n, err
}
+29
View File
@@ -0,0 +1,29 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
)
// Setting liest eine Server-Einstellung (ErrNotFound, wenn nicht gesetzt).
func (s *Store) Setting(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("Einstellung %s: %w", key, ErrNotFound)
}
return v, err
}
// SetSetting setzt bzw. überschreibt eine Server-Einstellung.
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
if blank(key) {
return invalid("Schlüssel fehlt")
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value)
return err
}
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"errors"
"path/filepath"
"testing"
)
func TestSettings(t *testing.T) {
s, _ := openTest(t)
if _, err := s.Setting(ctx, "public_url"); !errors.Is(err, ErrNotFound) {
t.Errorf("fehlende Einstellung: erwartet ErrNotFound, bekommen %v", err)
}
if err := s.SetSetting(ctx, "public_url", "https://a.example:8443"); err != nil {
t.Fatal(err)
}
if err := s.SetSetting(ctx, "public_url", "https://b.example:8443"); err != nil {
t.Fatal(err)
}
if v, err := s.Setting(ctx, "public_url"); err != nil || v != "https://b.example:8443" {
t.Errorf("Setting = %q, %v", v, err)
}
if err := s.SetSetting(ctx, "", "x"); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Schlüssel: %v", err)
}
}
func TestSettings_SurviveReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "sultan.db")
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
s.SetSetting(ctx, "public_url", "https://a.example:8443")
s.Close()
s, _ = Open(ctx, path)
defer s.Close()
if v, _ := s.Setting(ctx, "public_url"); v != "https://a.example:8443" {
t.Errorf("nach Neustart: %q", v)
}
if v, _ := s.SchemaVersion(ctx); v < 2 {
t.Errorf("Schema-Version %d, erwartet mindestens 2", v)
}
}
+38
View File
@@ -64,6 +64,41 @@ var migrations = []string{
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
// v2: Server-Einstellungen, die auch `sultan token` (eigener Prozess) braucht.
`CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`,
// v3: Wartung pro Knoten (eigene Dimension neben online/offline/drained).
`ALTER TABLE nodes ADD COLUMN maintenance_since DATETIME;
ALTER TABLE nodes ADD COLUMN maintenance_until DATETIME;
ALTER TABLE nodes ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE nodes ADD COLUMN maintenance_public_reason TEXT NOT NULL DEFAULT '';`,
// v4: Leases, Befehlswarteschlange, Features und Zahlungsstatus, Einlösezeitpunkt.
`CREATE TABLE leases (
node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
issued_at DATETIME NOT NULL,
renew_after DATETIME NOT NULL,
valid_until DATETIME NOT NULL,
document TEXT NOT NULL
);
CREATE TABLE node_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
command TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
sent_at DATETIME
);
CREATE INDEX node_commands_pending ON node_commands(node_id, sent_at);
ALTER TABLE tenants ADD COLUMN features TEXT NOT NULL DEFAULT '[]';
ALTER TABLE tenants ADD COLUMN payment_overdue_since DATETIME;
ALTER TABLE enrollment_tokens ADD COLUMN used_at DATETIME;`,
// v5: versandte Benachrichtigungen (jedes Ereignis genau einmal).
`CREATE TABLE notifications (
key TEXT PRIMARY KEY,
sent_at DATETIME NOT NULL
);`,
}
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
@@ -147,6 +182,9 @@ type dbTime struct{ t *time.Time }
func (d dbTime) Scan(src any) error {
switch v := src.(type) {
case nil: // NULL = nicht gesetzt
*d.t = time.Time{}
return nil
case time.Time:
*d.t = normTime(v)
return nil
+49 -19
View File
@@ -50,8 +50,12 @@ type Tenant struct {
AllowedDomains []string
ValidUntil time.Time
Status TenantStatus
CreatedAt time.Time
UpdatedAt time.Time
Features []string // freigeschaltete Lizenz-Features (z. B. custom_middleware)
// PaymentOverdueSince: seit wann eine Zahlung offen ist (Nullwert = nichts
// offen); daraus berechnet die Policy die Mahnstufe.
PaymentOverdueSince time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// normalize setzt Defaults (Tier community, Status active, leere
@@ -66,6 +70,9 @@ func (t *Tenant) normalize() error {
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
t.ValidUntil = normTime(t.ValidUntil)
switch {
@@ -89,6 +96,11 @@ func (t *Tenant) normalize() error {
return invalid("leerer Eintrag in allowed_domains")
}
}
for _, f := range t.Features {
if blank(f) {
return invalid("leerer Eintrag in features")
}
}
return nil
}
@@ -98,40 +110,47 @@ func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
now := normTime(s.now())
t.CreatedAt, t.UpdatedAt = now, now
t.PaymentOverdueSince = time.Time{}
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
_, err := s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at, features)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now), string(features))
if err != nil {
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
}
return t, nil
}
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at,
features, payment_overdue_since`
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
var t Tenant
var domains string
var domains, features string
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt},
&features, dbTime{&t.PaymentOverdueSince})
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
}
if err := json.Unmarshal([]byte(features), &t.Features); err != nil {
return Tenant{}, fmt.Errorf("store: features von %s: %w", t.ID, err)
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
return t, nil
}
@@ -168,15 +187,14 @@ func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?,
features = ?
WHERE id = ?`,
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
formatTime(s.now()), t.ID)
formatTime(s.now()), string(features), t.ID)
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
return Tenant{}, err
}
@@ -193,6 +211,18 @@ func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantSta
return requireAffected(res, err, "Mandant "+id)
}
// SetTenantPayment setzt, seit wann eine Zahlung offen ist (Nullwert =
// bezahlt). Grundlage der Mahnstufen.
func (s *Store) SetTenantPayment(ctx context.Context, id string, overdueSince time.Time) error {
var v any
if !overdueSince.IsZero() {
v = formatTime(overdueSince)
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET payment_overdue_since = ?, updated_at = ? WHERE id = ?`,
v, formatTime(s.now()), id)
return requireAffected(res, err, "Mandant "+id)
}
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
+8 -6
View File
@@ -62,7 +62,8 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
return Node{}, ErrTokenExpired
}
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1, used_at = ? WHERE token_hash = ?`,
formatTime(s.now()), hash); err != nil {
return Node{}, err
}
n.TenantID = tenantID
@@ -76,11 +77,12 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
return node, nil
}
// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
// liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
// PurgeEnrollmentTokens löscht Einladungen, die vor mehr als olderThan
// eingelöst wurden oder abgelaufen sind (0 = sofort), und liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := formatTime(s.now().Add(-olderThan))
res, err := s.db.ExecContext(ctx, `DELETE FROM enrollment_tokens
WHERE (used = 1 AND (used_at IS NULL OR used_at <= ?)) OR expires_at <= ?`, cutoff, cutoff)
if err != nil {
return 0, err
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestPurgeEnrollmentTokens(t *testing.T) {
}
clock.advance(time.Minute)
n, err := s.PurgeEnrollmentTokens(ctx)
n, err := s.PurgeEnrollmentTokens(ctx, 0)
if err != nil {
t.Fatal(err)
}
+155
View File
@@ -0,0 +1,155 @@
package store
import (
"errors"
"reflect"
"testing"
"time"
)
func TestLeases(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("ohne Lease: %v", err)
}
l := Lease{
NodeID: "n1",
IssuedAt: clock.now(),
RenewAfter: clock.now().Add(7 * 24 * time.Hour),
ValidUntil: clock.now().Add(14 * 24 * time.Hour),
Document: `{"node_id":"n1"}`,
}
if err := s.PutLease(ctx, l); err != nil {
t.Fatalf("PutLease: %v", err)
}
got, err := s.GetLease(ctx, "n1")
if err != nil || !reflect.DeepEqual(got, l) {
t.Errorf("GetLease = %+v, %v", got, err)
}
l.ValidUntil = l.ValidUntil.Add(time.Hour)
if err := s.PutLease(ctx, l); err != nil {
t.Fatal(err)
}
if got, _ := s.GetLease(ctx, "n1"); !got.ValidUntil.Equal(l.ValidUntil) {
t.Error("PutLease ersetzt nicht")
}
if err := s.PutLease(ctx, Lease{NodeID: "fehlt", Document: "{}"}); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
s.DeleteNode(ctx, "n1")
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Error("Lease nach Löschen des Knotens noch da")
}
}
func TestTenantFeaturesAndPayment(t *testing.T) {
s, clock := openTest(t)
in := sampleTenant("t1")
in.Features = []string{"custom_middleware", "websockets"}
created, err := s.CreateTenant(ctx, in)
if err != nil {
t.Fatal(err)
}
got, _ := s.GetTenant(ctx, "t1")
if !reflect.DeepEqual(got.Features, []string{"custom_middleware", "websockets"}) || !reflect.DeepEqual(got, created) {
t.Errorf("Features: %+v", got.Features)
}
if !got.PaymentOverdueSince.IsZero() {
t.Error("neuer Mandant mit offener Zahlung")
}
if def, _ := s.CreateTenant(ctx, sampleTenant("t2")); def.Features == nil || len(def.Features) != 0 {
t.Errorf("Default-Features: %#v", def.Features)
}
due := clock.now().Add(-10 * 24 * time.Hour)
if err := s.SetTenantPayment(ctx, "t1", due); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.Equal(due) {
t.Errorf("offen seit: %v", got.PaymentOverdueSince)
}
if err := s.SetTenantPayment(ctx, "t1", time.Time{}); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.IsZero() {
t.Error("bezahlt: Fälligkeit nicht gelöscht")
}
if err := s.SetTenantPayment(ctx, "fehlt", due); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Mandant: %v", err)
}
bad := sampleTenant("t3")
bad.Features = []string{"ok", " "}
if _, err := s.CreateTenant(ctx, bad); !errors.Is(err, ErrInvalid) {
t.Errorf("leeres Feature: %v", err)
}
}
func TestEnroll_RecordsUsedAt(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
clock.advance(time.Minute)
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
t.Fatal(err)
}
var usedAt time.Time
if err := s.db.QueryRowContext(ctx, "SELECT used_at FROM enrollment_tokens").Scan(dbTime{&usedAt}); err != nil {
t.Fatal(err)
}
if !usedAt.Equal(clock.now()) {
t.Errorf("used_at = %v, erwartet %v", usedAt, clock.now())
}
}
func TestNodeCommands(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if err := s.EnqueueNodeCommand(ctx, "n1", "resync", ""); err != nil {
t.Fatal(err)
}
clock.advance(time.Second)
s.EnqueueNodeCommand(ctx, "n1", "drain", "Umzug")
cmds, err := s.PendingNodeCommands(ctx, "n1")
if err != nil || len(cmds) != 2 || cmds[0].Command != "resync" || cmds[1].Command != "drain" || cmds[1].Reason != "Umzug" {
t.Fatalf("ausstehend: %+v %v", cmds, err)
}
if err := s.MarkNodeCommandSent(ctx, cmds[0].ID); err != nil {
t.Fatal(err)
}
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 || cmds[0].Command != "drain" {
t.Errorf("nach Versand: %+v", cmds)
}
if err := s.EnqueueNodeCommand(ctx, "fehlt", "resync", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.EnqueueNodeCommand(ctx, "n1", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Befehl: %v", err)
}
}
func TestUpdateNodePublicKey(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
mustNode(t, s, "n2", "t1", "k2")
if err := s.UpdateNodePublicKey(ctx, "n1", "k1-neu"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNodeByPublicKey(ctx, "k1-neu"); n.ID != "n1" {
t.Errorf("neuer Schlüssel nicht gefunden: %+v", n)
}
if _, err := s.GetNodeByPublicKey(ctx, "k1"); !errors.Is(err, ErrNotFound) {
t.Error("alter Schlüssel noch gültig")
}
if err := s.UpdateNodePublicKey(ctx, "n1", "k2"); !errors.Is(err, ErrExists) {
t.Errorf("fremder Schlüssel: %v", err)
}
if err := s.UpdateNodePublicKey(ctx, "fehlt", "x"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"testing"
"time"
)
// Aufräumen erst nach einer Frist (Job: 10 Tage), CLI-purge sofort (0).
func TestPurgeEnrollmentTokens_Retention(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
start := clock.now()
s.AddEnrollmentToken(ctx, "alt-benutzt", "t1", start.Add(time.Hour))
s.AddEnrollmentToken(ctx, "jung-benutzt", "t1", start.Add(30*24*time.Hour))
s.AddEnrollmentToken(ctx, "alt-abgelaufen", "t1", start.Add(time.Hour))
s.AddEnrollmentToken(ctx, "gueltig", "t1", start.Add(30*24*time.Hour))
s.Enroll(ctx, "alt-benutzt", sampleNode("n1", "k1"))
clock.advance(9 * 24 * time.Hour)
s.Enroll(ctx, "jung-benutzt", sampleNode("n2", "k2"))
clock.advance(2 * 24 * time.Hour) // jetzt: alt-* vor > 10 Tagen benutzt/abgelaufen
n, err := s.PurgeEnrollmentTokens(ctx, 10*24*time.Hour)
if err != nil || n != 2 {
t.Errorf("gelöscht %d (%v), erwartet 2 (alt-benutzt, alt-abgelaufen)", n, err)
}
if n, _ := s.PurgeEnrollmentTokens(ctx, 0); n != 1 {
t.Errorf("sofort: %d gelöscht, erwartet 1 (jung-benutzt)", n)
}
}
func TestPurgeLeasesAndCommands(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
mustNode(t, s, "n2", "t1", "k2")
now := clock.now()
s.PutLease(ctx, Lease{NodeID: "n1", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(time.Hour), Document: "{}"})
s.PutLease(ctx, Lease{NodeID: "n2", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(20 * 24 * time.Hour), Document: "{}"})
s.EnqueueNodeCommand(ctx, "n1", "resync", "")
s.EnqueueNodeCommand(ctx, "n1", "drain", "")
cmds, _ := s.PendingNodeCommands(ctx, "n1")
s.MarkNodeCommandSent(ctx, cmds[0].ID)
clock.advance(11 * 24 * time.Hour)
if n, err := s.PurgeLeases(ctx, 10*24*time.Hour); err != nil || n != 1 {
t.Errorf("Leases: %d %v", n, err)
}
if _, err := s.GetLease(ctx, "n2"); err != nil {
t.Error("gültige Lease gelöscht")
}
if n, err := s.PurgeNodeCommands(ctx, 10*24*time.Hour); err != nil || n != 1 {
t.Errorf("Befehle: %d %v", n, err)
}
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 {
t.Error("nicht zugestellter Befehl gelöscht")
}
}
func TestNotificationsOnce(t *testing.T) {
s, _ := openTest(t)
if s.Notified(ctx, "contract:t1:2026-12-15:14") {
t.Error("neu: schon benachrichtigt")
}
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
t.Fatal(err)
}
if !s.Notified(ctx, "contract:t1:2026-12-15:14") {
t.Error("nicht vermerkt")
}
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
t.Errorf("doppelt vermerken: %v", err)
}
}
func TestMarkNodeSeen(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
clock.advance(time.Hour)
if err := s.MarkNodeSeen(ctx, "n1"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); !n.LastSeen.Equal(clock.now()) {
t.Errorf("last_seen = %v", n.LastSeen)
}
}
func TestNotificationKeysWithPrefix(t *testing.T) {
s, _ := openTest(t)
for _, k := range []string{"offline:n1:a", "offline:n1:b", "offline:n10:c", "contract:x"} {
s.RecordNotification(ctx, k)
}
got, err := s.NotificationKeys(ctx, "offline:n1:")
if err != nil || len(got) != 2 || got[0] != "offline:n1:a" || got[1] != "offline:n1:b" {
t.Errorf("Schlüssel: %v %v", got, err)
}
}