Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1

Merged
claude Bot merged 8 commits from phase-1-ui into main 2026-10-13 16:47:35 +02:00
5 changed files with 1079 additions and 0 deletions
Showing only changes of commit 2bbfb5dfbe - Show all commits

No files matched your search

+158
View File
@@ -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 <dataDir>/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$<iter>$<salt>$<hash>".
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)
}
+192
View File
@@ -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()
}
+446
View File
@@ -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)
}
+101
View File
@@ -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")
}
}
+182
View File
@@ -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
}