Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user