From 2bbfb5dfbe6d89766e66988ca5f92342b3313a5a Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 13 Oct 2026 15:58:25 +0200 Subject: [PATCH] =?UTF-8?q?store=20:=20SQLite=20(clusters=20=C3=A0=20secre?= =?UTF-8?q?ts=20chiffr=C3=A9s,=20param=C3=A8tres,=20utilisateurs,=20journa?= =?UTF-8?q?l,=20audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy --- internal/store/crypto.go | 158 +++++++++++++ internal/store/journal.go | 192 +++++++++++++++ internal/store/store.go | 446 +++++++++++++++++++++++++++++++++++ internal/store/store_test.go | 101 ++++++++ internal/store/users.go | 182 ++++++++++++++ 5 files changed, 1079 insertions(+) create mode 100644 internal/store/crypto.go create mode 100644 internal/store/journal.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/users.go diff --git a/internal/store/crypto.go b/internal/store/crypto.go new file mode 100644 index 0000000..0302026 --- /dev/null +++ b/internal/store/crypto.go @@ -0,0 +1,158 @@ +package store + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Box encrypts the token secrets at rest (AES-256-GCM, random nonce, version byte). +type Box struct{ aead cipher.AEAD } + +const boxVersion = 1 + +// NewBox builds a Box from a 32 byte key. +func NewBox(key []byte) (*Box, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key: %d bytes, 32 expected", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Box{aead}, nil +} + +// Seal encrypts a secret: version | nonce | ciphertext+tag. +func (b *Box) Seal(plain []byte) ([]byte, error) { + nonce := make([]byte, b.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + out := append([]byte{boxVersion}, nonce...) + return b.aead.Seal(out, nonce, plain, []byte("pve-confsync token")), nil +} + +// Open decrypts a secret sealed by Seal. An empty blob gives an empty secret. +func (b *Box) Open(blob []byte) ([]byte, error) { + if len(blob) == 0 { + return nil, nil + } + n := b.aead.NonceSize() + if blob[0] != boxVersion || len(blob) < 1+n { + return nil, errors.New("unknown secret format") + } + plain, err := b.aead.Open(nil, blob[1:1+n], blob[1+n:], []byte("pve-confsync token")) + if err != nil { + return nil, errors.New("cannot decrypt (wrong encryption key)") + } + return plain, nil +} + +// LoadKey returns the encryption key of the token secrets, in this order: +// CONFSYNC_SECRET_KEY (64 hex characters or 44 base64 characters), +// CONFSYNC_SECRET_KEY_FILE (same content, docker secret), or /secret.key, +// created on first start. The last case keeps the key next to the database: fine for +// a lab, a docker secret is better in production (a stolen /data then holds no +// usable secret). +func LoadKey(dataDir string) ([]byte, string, error) { + if v := strings.TrimSpace(os.Getenv("CONFSYNC_SECRET_KEY")); v != "" { + k, err := parseKey(v) + return k, "CONFSYNC_SECRET_KEY", err + } + if f := os.Getenv("CONFSYNC_SECRET_KEY_FILE"); f != "" { + raw, err := os.ReadFile(f) + if err != nil { + return nil, "", fmt.Errorf("CONFSYNC_SECRET_KEY_FILE: %w", err) + } + k, err := parseKey(strings.TrimSpace(string(raw))) + return k, f, err + } + path := filepath.Join(dataDir, "secret.key") + if raw, err := os.ReadFile(path); err == nil { + k, err := parseKey(strings.TrimSpace(string(raw))) + return k, path, err + } + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + return nil, "", err + } + if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil { + return nil, "", fmt.Errorf("cannot save the encryption key: %w", err) + } + log.Printf("encryption key created in %s: keep a copy, the token secrets cannot be read without it", path) + return k, path, nil +} + +func parseKey(v string) ([]byte, error) { + if k, err := hex.DecodeString(v); err == nil && len(k) == 32 { + return k, nil + } + if k, err := base64.StdEncoding.DecodeString(v); err == nil && len(k) == 32 { + return k, nil + } + return nil, errors.New("encryption key: 32 bytes expected, as 64 hex or 44 base64 characters (openssl rand -hex 32)") +} + +// ---- passwords ---- + +// PBKDF2-HMAC-SHA256, 600 000 iterations (OWASP 2023 recommendation); the +// standard library only, crypto/pbkdf2 exists since Go 1.24. +const pbkdf2Iter = 600000 + +// HashPassword returns "pbkdf2-sha256$$$". +func HashPassword(pass string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + h, err := pbkdf2.Key(sha256.New, pass, salt, pbkdf2Iter, 32) + if err != nil { + return "", err + } + enc := base64.RawStdEncoding + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iter, enc.EncodeToString(salt), enc.EncodeToString(h)), nil +} + +// CheckPassword compares in constant time. +func CheckPassword(hash, pass string) bool { + p := strings.Split(hash, "$") + if len(p) != 4 || p[0] != "pbkdf2-sha256" { + return false + } + iter, err := strconv.Atoi(p[1]) + if err != nil || iter < 1 { + return false + } + enc := base64.RawStdEncoding + salt, err1 := enc.DecodeString(p[2]) + want, err2 := enc.DecodeString(p[3]) + if err1 != nil || err2 != nil { + return false + } + got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want)) + return err == nil && subtle.ConstantTimeCompare(got, want) == 1 +} + +// RandomPassword returns a password for the first admin account. +func RandomPassword() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/store/journal.go b/internal/store/journal.go new file mode 100644 index 0000000..c84e364 --- /dev/null +++ b/internal/store/journal.go @@ -0,0 +1,192 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" +) + +// Run statuses. +const ( + RunRunning = "running" + RunOK = "ok" // applied and verified, or dry-run without blocking error + RunNoop = "noop" // nothing to do + RunError = "error" // at least one client failed + RunBlocked = "blocked" // dry-run with blocking errors +) + +// Run is one entry of the execution journal: a dry-run or a synchronization. +type Run struct { + ID int64 `json:"id"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + User string `json:"user"` + Mode string `json:"mode"` // dry-run or apply + Scope string `json:"scope"` // global, client or object + Clients []string `json:"clients"` + Objects []string `json:"objects,omitempty"` + Status string `json:"status"` + Summary string `json:"summary"` + Detail json.RawMessage `json:"detail,omitempty"` +} + +// StartRun records a run and returns its id. +func (s *Store) StartRun(ctx context.Context, r Run) (int64, error) { + if r.StartedAt.IsZero() { + r.StartedAt = time.Now() + } + res, err := s.db.ExecContext(ctx, `INSERT INTO runs (started_at, user, mode, scope, clients, objects, status, summary, detail) + VALUES (?,?,?,?,?,?,?,?,?)`, r.StartedAt.UnixMilli(), r.User, r.Mode, r.Scope, + strings.Join(r.Clients, ","), strings.Join(r.Objects, ","), r.Status, r.Summary, detail(r.Detail)) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// UpdateRun stores the progress (detail) of a run; a final status sets finished_at. +func (s *Store) UpdateRun(ctx context.Context, id int64, status, summary string, d json.RawMessage) error { + fin := int64(0) + if status != RunRunning { + fin = time.Now().UnixMilli() + } + _, err := s.db.ExecContext(ctx, `UPDATE runs SET status = ?, summary = ?, detail = ?, finished_at = ? WHERE id = ?`, + status, summary, detail(d), fin, id) + return err +} + +func detail(d json.RawMessage) string { + if len(d) == 0 { + return "{}" + } + return string(d) +} + +// Runs lists the journal, newest first; before (an id) pages backwards. +func (s *Store) Runs(ctx context.Context, limit int, before int64) ([]Run, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + if before <= 0 { + before = 1 << 62 + } + rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, user, mode, scope, clients, objects, status, summary + FROM runs WHERE id < ? ORDER BY id DESC LIMIT ?`, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Run + for rows.Next() { + r, err := scanRun(rows.Scan, false) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// Run returns one run with its detail. +func (s *Store) Run(ctx context.Context, id int64) (Run, error) { + row := s.db.QueryRowContext(ctx, `SELECT id, started_at, finished_at, user, mode, scope, clients, objects, status, summary, detail + FROM runs WHERE id = ?`, id) + r, err := scanRun(row.Scan, true) + if errors.Is(err, sql.ErrNoRows) { + return r, ErrNotFound + } + return r, err +} + +// FailRunning closes the runs left "running" by a stop of the server. +func (s *Store) FailRunning(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `UPDATE runs SET status = 'error', summary = 'interrompu (arrĂȘt du serveur)', finished_at = ? + WHERE status = 'running'`, time.Now().UnixMilli()) + return err +} + +func scanRun(scan func(...any) error, withDetail bool) (Run, error) { + var r Run + var start, fin int64 + var clients, objects, d string + dest := []any{&r.ID, &start, &fin, &r.User, &r.Mode, &r.Scope, &clients, &objects, &r.Status, &r.Summary} + if withDetail { + dest = append(dest, &d) + } + if err := scan(dest...); err != nil { + return r, err + } + r.StartedAt = time.UnixMilli(start).UTC() + if fin > 0 { + t := time.UnixMilli(fin).UTC() + r.FinishedAt = &t + } + r.Clients = split(clients) + r.Objects = split(objects) + if withDetail { + r.Detail = json.RawMessage(d) + } + return r, nil +} + +func split(s string) []string { + if s == "" { + return []string{} + } + return strings.Split(s, ",") +} + +// ---- audit ---- + +// Event is one entry of the audit trail: who did what, from where, with which result. +type Event struct { + ID int64 `json:"id"` + At time.Time `json:"at"` + User string `json:"user"` + Role string `json:"role"` + IP string `json:"ip"` + Action string `json:"action"` + Target string `json:"target"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// Audit records an event. Callers never put secrets in Detail. +func (s *Store) Audit(ctx context.Context, e Event) error { + if e.At.IsZero() { + e.At = time.Now() + } + _, err := s.db.ExecContext(ctx, `INSERT INTO audit (at, user, role, ip, action, target, ok, detail) VALUES (?,?,?,?,?,?,?,?)`, + e.At.UnixMilli(), e.User, e.Role, e.IP, e.Action, e.Target, e.OK, e.Detail) + return err +} + +// Events lists the audit trail, newest first. +func (s *Store) Events(ctx context.Context, limit int, before int64) ([]Event, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + if before <= 0 { + before = 1 << 62 + } + rows, err := s.db.QueryContext(ctx, `SELECT id, at, user, role, ip, action, target, ok, detail FROM audit + WHERE id < ? ORDER BY id DESC LIMIT ?`, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Event + for rows.Next() { + var e Event + var at int64 + if err := rows.Scan(&e.ID, &at, &e.User, &e.Role, &e.IP, &e.Action, &e.Target, &e.OK, &e.Detail); err != nil { + return nil, err + } + e.At = time.UnixMilli(at).UTC() + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..8b393cb --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,446 @@ +// Package store keeps the state of the web UI in SQLite: clusters (token +// secrets encrypted with AES-256-GCM), synchronization settings, per client +// mappings and policies, users, execution journal and audit trail. +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" // pure Go driver: the binary stays static (CGO_ENABLED=0) + + "pve-confsync/internal/adapter" + "pve-confsync/internal/config" + "pve-confsync/internal/model" +) + +// ErrNotFound is returned for an unknown cluster, user or run. +var ErrNotFound = errors.New("not found") + +// Store is the SQLite database. +type Store struct { + db *sql.DB + box *Box +} + +const schema = ` +CREATE TABLE IF NOT EXISTS clusters ( + name TEXT PRIMARY KEY, + reference INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + url TEXT NOT NULL, + token_id TEXT NOT NULL, + secret BLOB, + fingerprint TEXT NOT NULL DEFAULT '', + ca_pem TEXT NOT NULL DEFAULT '', + insecure INTEGER NOT NULL DEFAULT 0, + timeout TEXT NOT NULL DEFAULT '', + mapping TEXT NOT NULL DEFAULT '{}', + policies TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS users ( + name TEXT PRIMARY KEY, + role TEXT NOT NULL, + password TEXT NOT NULL, + epoch INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_login INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL DEFAULT 0, + user TEXT NOT NULL, + mode TEXT NOT NULL, + scope TEXT NOT NULL, + clients TEXT NOT NULL DEFAULT '', + objects TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS runs_started ON runs(started_at); +CREATE TABLE IF NOT EXISTS audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + user TEXT NOT NULL, + role TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + ok INTEGER NOT NULL DEFAULT 1, + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS audit_at ON audit(at); +` + +// Open opens (and creates) the database. key encrypts the token secrets. +func Open(path string, key []byte) (*Store, error) { + box, err := NewBox(key) + if err != nil { + return nil, err + } + // WAL: the UI reads while a synchronization writes the journal. + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // one writer; SQLite serializes anyway + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("%s: %w", path, err) + } + s := &Store{db: db, box: box} + if err := s.checkKey(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close closes the database. +func (s *Store) Close() error { return s.db.Close() } + +// checkKey refuses to start with a key that does not decrypt the stored secrets +// (lost or changed CONFSYNC_SECRET_KEY), instead of failing later on each cluster. +func (s *Store) checkKey() error { + var blob []byte + err := s.db.QueryRow(`SELECT secret FROM clusters WHERE secret IS NOT NULL AND length(secret) > 0 LIMIT 1`).Scan(&blob) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if _, err := s.box.Open(blob); err != nil { + return errors.New("the encryption key does not decrypt the stored token secrets (CONFSYNC_SECRET_KEY changed?)") + } + return nil +} + +// ---- clusters ---- + +// Cluster is a cluster as stored. The secret is never returned to the UI: +// HasSecret only says whether one is set. +type Cluster struct { + Name string `json:"name"` + Reference bool `json:"reference"` + Position int `json:"position"` + URL string `json:"url"` + TokenID string `json:"tokenId"` + Secret string `json:"secret,omitempty"` // write only + HasSecret bool `json:"hasSecret"` + Fingerprint string `json:"fingerprint"` + CAPEM string `json:"caPem"` + Insecure bool `json:"insecure"` + Timeout string `json:"timeout"` + Mapping map[model.Kind]map[string]map[string]any `json:"mapping"` + Policies map[model.Kind]map[string]adapter.Policy `json:"policies"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Clusters returns every cluster, reference first, without secrets. +func (s *Store) Clusters(ctx context.Context) ([]Cluster, error) { + rows, err := s.db.QueryContext(ctx, `SELECT name, reference, position, url, token_id, length(secret) > 0, + fingerprint, ca_pem, insecure, timeout, mapping, policies, updated_at + FROM clusters ORDER BY reference DESC, position, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Cluster + for rows.Next() { + var c Cluster + var hasSecret sql.NullBool + var mapping, policies string + var updated int64 + if err := rows.Scan(&c.Name, &c.Reference, &c.Position, &c.URL, &c.TokenID, &hasSecret, + &c.Fingerprint, &c.CAPEM, &c.Insecure, &c.Timeout, &mapping, &policies, &updated); err != nil { + return nil, err + } + c.HasSecret = hasSecret.Bool + c.UpdatedAt = time.Unix(updated, 0).UTC() + _ = json.Unmarshal([]byte(mapping), &c.Mapping) + _ = json.Unmarshal([]byte(policies), &c.Policies) + out = append(out, c) + } + return out, rows.Err() +} + +// SaveCluster creates or updates a cluster. An empty Secret keeps the stored one. +// oldName renames a cluster (empty: same name). Setting Reference on a cluster +// makes it the only reference. +func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) error { + c.Name = strings.TrimSpace(c.Name) + if c.Name == "" || c.URL == "" || c.TokenID == "" { + return errors.New("name, url and token id are required") + } + if oldName == "" { + oldName = c.Name + } + mapping, _ := json.Marshal(nonNil(c.Mapping)) + policies, _ := json.Marshal(nonNilP(c.Policies)) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var exists bool + var secret []byte + err = tx.QueryRowContext(ctx, `SELECT 1, secret FROM clusters WHERE name = ?`, oldName).Scan(&exists, &secret) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if c.Secret != "" { + if secret, err = s.box.Seal([]byte(c.Secret)); err != nil { + return err + } + } + if len(secret) == 0 { + return errors.New("the token secret is required") + } + if c.Reference { + if _, err := tx.ExecContext(ctx, `UPDATE clusters SET reference = 0`); err != nil { + return err + } + } + now := time.Now().Unix() + if exists { + _, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, reference=?, position=?, url=?, token_id=?, secret=?, + fingerprint=?, ca_pem=?, insecure=?, timeout=?, mapping=?, policies=?, updated_at=? WHERE name=?`, + c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout, + string(mapping), string(policies), now, oldName) + } else { + _, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, reference, position, url, token_id, secret, fingerprint, + ca_pem, insecure, timeout, mapping, policies, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout, + string(mapping), string(policies), now) + } + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return fmt.Errorf("cluster %q already exists", c.Name) + } + return err + } + return tx.Commit() +} + +// DeleteCluster removes a cluster. +func (s *Store) DeleteCluster(ctx context.Context, name string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM clusters WHERE name = ?`, name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func nonNil(m map[model.Kind]map[string]map[string]any) any { + if m == nil { + return map[string]any{} + } + return m +} + +func nonNilP(m map[model.Kind]map[string]adapter.Policy) any { + if m == nil { + return map[string]any{} + } + return m +} + +// ---- settings ---- + +// Settings are the global synchronization settings (the rest of the YAML file). +type Settings struct { + Kinds []model.Kind `json:"kinds"` + Objects map[model.Kind]config.Selection `json:"objects"` + Policies map[model.Kind]map[string]adapter.Policy `json:"policies"` + Prune bool `json:"prune"` +} + +// Settings returns the global settings (all kinds by default). +func (s *Store) Settings(ctx context.Context) (Settings, error) { + var st Settings + var raw string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = 'sync'`).Scan(&raw) + switch { + case errors.Is(err, sql.ErrNoRows): + case err != nil: + return st, err + default: + if err := json.Unmarshal([]byte(raw), &st); err != nil { + return st, err + } + } + if len(st.Kinds) == 0 { + st.Kinds = append(st.Kinds, model.Kinds...) + } + return st, nil +} + +// SaveSettings replaces the global settings. +func (s *Store) SaveSettings(ctx context.Context, st Settings) error { + b, err := json.Marshal(st) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('sync', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, string(b)) + return err +} + +// ---- configuration for the engine ---- + +// Config builds the engine configuration, secrets decrypted. It fails when no +// reference is defined. +func (s *Store) Config(ctx context.Context) (*config.Config, error) { + st, err := s.Settings(ctx) + if err != nil { + return nil, err + } + cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune} + rows, err := s.db.QueryContext(ctx, `SELECT name, reference, url, token_id, secret, fingerprint, ca_pem, + insecure, timeout, mapping, policies FROM clusters ORDER BY reference DESC, position, name`) + if err != nil { + return nil, err + } + defer rows.Close() + hasRef := false + for rows.Next() { + var ref bool + var blob []byte + var mapping, policies string + var cl config.Cluster + if err := rows.Scan(&cl.Name, &ref, &cl.URL, &cl.TokenID, &blob, &cl.Fingerprint, &cl.CAPEM, + &cl.Insecure, &cl.Timeout, &mapping, &policies); err != nil { + return nil, err + } + secret, err := s.box.Open(blob) + if err != nil { + return nil, fmt.Errorf("cluster %s: token secret: %w", cl.Name, err) + } + cl.Secret = string(secret) + if ref { + cfg.Reference, hasRef = cl, true + continue + } + c := config.Client{Cluster: cl} + _ = json.Unmarshal([]byte(mapping), &c.Mapping) + _ = json.Unmarshal([]byte(policies), &c.Policies) + cfg.Clients = append(cfg.Clients, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + if !hasRef { + return nil, ErrNoReference + } + return cfg, cfg.Validate() +} + +// ErrNoReference: nothing can be compared before a reference cluster is defined. +var ErrNoReference = errors.New("no reference cluster defined") + +// ExportConfig returns the configuration as the CLI YAML file, without secrets: +// each cluster gets a token_secret_env placeholder to fill. +func (s *Store) ExportConfig(ctx context.Context) (*config.Config, error) { + st, err := s.Settings(ctx) + if err != nil { + return nil, err + } + cls, err := s.Clusters(ctx) + if err != nil { + return nil, err + } + cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune} + for _, c := range cls { + cl := config.Cluster{Name: c.Name, URL: c.URL, TokenID: c.TokenID, Fingerprint: c.Fingerprint, + Insecure: c.Insecure, Timeout: c.Timeout, TokenSecretEnv: EnvName(c.Name)} + if c.Reference { + cfg.Reference = cl + continue + } + cfg.Clients = append(cfg.Clients, config.Client{Cluster: cl, Mapping: c.Mapping, Policies: c.Policies}) + } + return cfg, nil +} + +// EnvName is the environment variable proposed for the secret of a cluster in an export. +func EnvName(cluster string) string { + var b strings.Builder + b.WriteString("PVE_") + for _, r := range strings.ToUpper(cluster) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + b.WriteString("_SECRET") + return b.String() +} + +// ImportConfig loads a CLI configuration: settings are replaced, clusters are +// created or updated (the ones absent from the file are kept unless replace is +// set). Secrets are read from token_secret_env / token_secret_file when they +// are reachable from the server; otherwise the stored secret is kept. +// It returns warnings for the clusters left without a secret. +func (s *Store) ImportConfig(ctx context.Context, cfg *config.Config, replace bool) ([]string, error) { + var warns []string + st := Settings{Kinds: cfg.Kinds, Objects: cfg.Objects, Policies: cfg.Policies, Prune: cfg.Prune} + existing, err := s.Clusters(ctx) + if err != nil { + return nil, err + } + have := map[string]Cluster{} + for _, c := range existing { + have[c.Name] = c + } + save := func(cl config.Cluster, ref bool, pos int, mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error { + c := Cluster{Name: cl.Name, Reference: ref, Position: pos, URL: cl.URL, TokenID: cl.TokenID, + Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: cl.Timeout, Mapping: mapping, Policies: pol} + if cl.CAFile != "" { + warns = append(warns, fmt.Sprintf("%s: ca_file %s not imported, paste the CA certificate in the cluster settings", cl.Name, cl.CAFile)) + } + if sec, err := cl.ResolveSecret(); err == nil && sec != "" { + c.Secret = sec + } else if !have[cl.Name].HasSecret { + warns = append(warns, fmt.Sprintf("%s: token secret not readable from the server, cluster not imported (%v)", cl.Name, err)) + return nil + } + return s.SaveCluster(ctx, "", c) + } + if err := save(cfg.Reference, true, 0, nil, nil); err != nil { + return warns, err + } + keep := map[string]bool{cfg.Reference.Name: true} + for i, c := range cfg.Clients { + keep[c.Name] = true + if err := save(c.Cluster, false, i+1, c.Mapping, c.Policies); err != nil { + return warns, err + } + } + if replace { + for name := range have { + if !keep[name] { + if err := s.DeleteCluster(ctx, name); err != nil { + return warns, err + } + } + } + } + return warns, s.SaveSettings(ctx, st) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..636c870 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" +) + +func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } + +func TestSecretsEncryptedAndKeyChecked(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "t.db") + st, err := Open(path, testKey(1)) + if err != nil { + t.Fatal(err) + } + if err := st.SaveCluster(ctx, "", Cluster{Name: "ref", Reference: true, URL: "https://ref:8006", TokenID: "a@pve!ro", Secret: "s3cr3t-uuid"}); err != nil { + t.Fatal(err) + } + var blob []byte + if err := st.db.QueryRowContext(ctx, `SELECT secret FROM clusters WHERE name = 'ref'`).Scan(&blob); err != nil { + t.Fatal(err) + } + if bytes.Contains(blob, []byte("s3cr3t")) { + t.Fatal("secret stored in clear") + } + cls, _ := st.Clusters(ctx) + if len(cls) != 1 || cls[0].Secret != "" || !cls[0].HasSecret { + t.Fatalf("listing must hide the secret: %+v", cls) + } + // an empty secret on update keeps the stored one + c := cls[0] + c.URL = "https://ref2:8006" + if err := st.SaveCluster(ctx, "ref", c); err != nil { + t.Fatal(err) + } + cfg, err := st.Config(ctx) + if err != nil { + t.Fatal(err) + } + if s, _ := cfg.Reference.ResolveSecret(); s != "s3cr3t-uuid" { + t.Fatalf("secret after update: %q", s) + } + exp, _ := st.ExportConfig(ctx) + if exp.Reference.Secret != "" || exp.Reference.TokenSecretEnv != "PVE_REF_SECRET" { + t.Fatalf("export: %+v", exp.Reference) + } + st.Close() + if _, err := Open(path, testKey(2)); err == nil { + t.Fatal("a wrong key must be refused") + } +} + +func TestUsers(t *testing.T) { + ctx := context.Background() + st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1)) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.SaveUser(ctx, "admin", RoleAdmin, "short"); err == nil { + t.Fatal("short password accepted") + } + if err := st.SaveUser(ctx, "admin", RoleAdmin, "long-enough"); err != nil { + t.Fatal(err) + } + if _, ok := st.Authenticate(ctx, "admin", "wrong-pass"); ok { + t.Fatal("wrong password accepted") + } + u, ok := st.Authenticate(ctx, "admin", "long-enough") + if !ok || u.Role != RoleAdmin { + t.Fatalf("login: %+v %v", u, ok) + } + if err := st.SaveUser(ctx, "admin", RoleViewer, ""); err == nil { + t.Fatal("last admin demoted") + } + if err := st.DeleteUser(ctx, "admin"); err == nil { + t.Fatal("last admin deleted") + } + // a new password changes the epoch (open sessions are invalidated) + if err := st.SaveUser(ctx, "admin", RoleAdmin, "another-one"); err != nil { + t.Fatal(err) + } + u2, _ := st.User(ctx, "admin") + if u2.Epoch == u.Epoch { + t.Fatal("epoch unchanged after a password change") + } +} + +func TestPasswordHash(t *testing.T) { + h, err := HashPassword("p@ssw0rd!") + if err != nil { + t.Fatal(err) + } + if strings.Contains(h, "p@ssw0rd") || !CheckPassword(h, "p@ssw0rd!") || CheckPassword(h, "p@ssw0rd") { + t.Fatal("hash check") + } +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..c0da6de --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,182 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Roles, from the most to the least privileged. +const ( + RoleAdmin = "admin" // everything, including clusters, settings and users + RoleOperator = "operator" // dry-run and synchronization + RoleViewer = "viewer" // read only +) + +// ValidRole tells whether r is a known role. +func ValidRole(r string) bool { return r == RoleAdmin || r == RoleOperator || r == RoleViewer } + +// RoleRank orders the roles: admin 3, operator 2, viewer 1, unknown 0. +func RoleRank(r string) int { + switch r { + case RoleAdmin: + return 3 + case RoleOperator: + return 2 + case RoleViewer: + return 1 + } + return 0 +} + +// User is a local account (password hash never returned). +type User struct { + Name string `json:"name"` + Role string `json:"role"` + Epoch int64 `json:"-"` // changes with the password: ends the sessions + CreatedAt time.Time `json:"createdAt"` + LastLogin time.Time `json:"lastLogin"` + hash string +} + +// Users lists the local accounts. +func (s *Store) Users(ctx context.Context) ([]User, error) { + rows, err := s.db.QueryContext(ctx, `SELECT name, role, epoch, created_at, last_login FROM users ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + var c, l int64 + if err := rows.Scan(&u.Name, &u.Role, &u.Epoch, &c, &l); err != nil { + return nil, err + } + u.CreatedAt = time.Unix(c, 0).UTC() + if l > 0 { + u.LastLogin = time.Unix(l, 0).UTC() + } + out = append(out, u) + } + return out, rows.Err() +} + +// User returns one account. +func (s *Store) User(ctx context.Context, name string) (User, error) { + var u User + var c, l int64 + err := s.db.QueryRowContext(ctx, `SELECT name, role, password, epoch, created_at, last_login FROM users WHERE name = ?`, name). + Scan(&u.Name, &u.Role, &u.hash, &u.Epoch, &c, &l) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + u.CreatedAt = time.Unix(c, 0).UTC() + if l > 0 { + u.LastLogin = time.Unix(l, 0).UTC() + } + return u, err +} + +// CountUsers returns the number of local accounts. +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n) + return n, err +} + +// Authenticate checks a password and records the login. +func (s *Store) Authenticate(ctx context.Context, name, pass string) (User, bool) { + u, err := s.User(ctx, name) + if err != nil { + // same cost as a real check: the answer time does not tell whether the user exists + CheckPassword(dummyHash, pass) + return User{}, false + } + if !CheckPassword(u.hash, pass) { + return User{}, false + } + _, _ = s.db.ExecContext(ctx, `UPDATE users SET last_login = ? WHERE name = ?`, time.Now().Unix(), name) + return u, true +} + +var dummyHash, _ = HashPassword("dummy password") + +// SaveUser creates an account or changes its role and/or password (empty: unchanged). +func (s *Store) SaveUser(ctx context.Context, name, role, pass string) error { + name = strings.TrimSpace(name) + if name == "" || strings.ContainsAny(name, " \t\r\n") { + return errors.New("invalid user name") + } + if !ValidRole(role) { + return fmt.Errorf("unknown role %q", role) + } + if pass != "" && len(pass) < 8 { + return errors.New("password: 8 characters minimum") + } + var hash string + if pass != "" { + var err error + if hash, err = HashPassword(pass); err != nil { + return err + } + } + now := time.Now() + _, err := s.User(ctx, name) + switch { + case errors.Is(err, ErrNotFound): + if pass == "" { + return errors.New("a password is required for a new user") + } + _, err = s.db.ExecContext(ctx, `INSERT INTO users (name, role, password, epoch, created_at) VALUES (?,?,?,?,?)`, + name, role, hash, now.UnixNano(), now.Unix()) + return err + case err != nil: + return err + } + if err := s.keepAnAdmin(ctx, name, role); err != nil { + return err + } + if pass == "" { + _, err = s.db.ExecContext(ctx, `UPDATE users SET role = ? WHERE name = ?`, role, name) + } else { + _, err = s.db.ExecContext(ctx, `UPDATE users SET role = ?, password = ?, epoch = ? WHERE name = ?`, role, hash, now.UnixNano(), name) + } + return err +} + +// DeleteUser removes an account (never the last administrator). +func (s *Store) DeleteUser(ctx context.Context, name string) error { + if err := s.keepAnAdmin(ctx, name, ""); err != nil { + return err + } + res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE name = ?`, name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// keepAnAdmin refuses a change that would leave no local administrator +// (OIDC may be down: a local admin is the way back in). +func (s *Store) keepAnAdmin(ctx context.Context, name, newRole string) error { + if newRole == RoleAdmin { + return nil + } + var n int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND name <> ?`, name).Scan(&n); err != nil { + return err + } + var cur string + _ = s.db.QueryRowContext(ctx, `SELECT role FROM users WHERE name = ?`, name).Scan(&cur) + if cur == RoleAdmin && n == 0 { + return errors.New("this is the last local administrator") + } + return nil +}