Files

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)
}