Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
447 lines
15 KiB
Go
447 lines
15 KiB
Go
// 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)
|
|
}
|