store : SQLite (clusters à secrets chiffrés, paramètres, utilisateurs, journal, audit)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
This commit is contained in:
1 parent
e61f2e4740
commit
2bbfb5dfbe
5 files changed
+1079
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user