Full web : fichiers restants
This commit is contained in:
1 parent
ec3b7c9a01
commit
4826fa5b82
26 files changed
+2537
-793
No files matched your search
+301
-176
@@ -1,6 +1,7 @@
|
||||
// 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 keeps the state of the web UI in SQLite: clusters (root
|
||||
// passwords encrypted with AES-256-GCM, pinned TLS certificates and SSH host
|
||||
// keys), synchronization settings, per target mappings and policies, users,
|
||||
// execution journal and audit trail.
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned for an unknown cluster, user or run.
|
||||
@@ -28,20 +31,29 @@ type Store struct {
|
||||
box *Box
|
||||
}
|
||||
|
||||
// schemaVersion 2: clusters reached with the root credentials (API + SSH),
|
||||
// roles primary / target / disabled. Version 1 (API tokens) is not migrated:
|
||||
// its clusters table is dropped, the clusters are added again from the UI.
|
||||
const schemaVersion = 2
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS clusters (
|
||||
name TEXT PRIMARY KEY,
|
||||
reference INTEGER NOT NULL DEFAULT 0,
|
||||
role TEXT NOT NULL DEFAULT 'target',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
secret BLOB,
|
||||
api_user TEXT NOT NULL DEFAULT 'root@pam',
|
||||
password BLOB,
|
||||
tls TEXT NOT NULL DEFAULT 'fp',
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
ca_pem TEXT NOT NULL DEFAULT '',
|
||||
insecure INTEGER NOT NULL DEFAULT 0,
|
||||
timeout TEXT NOT NULL DEFAULT '',
|
||||
ssh_host TEXT NOT NULL DEFAULT '',
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
ssh_hostkey TEXT NOT NULL DEFAULT '',
|
||||
mapping TEXT NOT NULL DEFAULT '{}',
|
||||
policies TEXT NOT NULL DEFAULT '{}',
|
||||
last_check TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -96,6 +108,10 @@ func Open(path string, key []byte) (*Store, error) {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // one writer; SQLite serializes anyway
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
@@ -108,6 +124,29 @@ func Open(path string, key []byte) (*Store, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// migrate drops the clusters of the first schema (API tokens, not reusable).
|
||||
func migrate(db *sql.DB) error {
|
||||
var v int
|
||||
if err := db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
if v >= schemaVersion {
|
||||
return nil
|
||||
}
|
||||
var old int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM pragma_table_info('clusters') WHERE name = 'token_id'`).Scan(&old); err != nil {
|
||||
return err
|
||||
}
|
||||
if old > 0 {
|
||||
log.Printf("database: clusters of the previous version (API tokens) dropped, add them again with their root credentials")
|
||||
if _, err := db.Exec(`DROP TABLE clusters; DROP TABLE IF EXISTS settings`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
@@ -115,7 +154,7 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
// (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)
|
||||
err := s.db.QueryRow(`SELECT password FROM clusters WHERE password IS NOT NULL AND length(password) > 0 LIMIT 1`).Scan(&blob)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
@@ -123,67 +162,176 @@ func (s *Store) checkKey() error {
|
||||
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 errors.New("the encryption key does not decrypt the stored passwords (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"`
|
||||
// Cluster roles: one primary (the source, never written), targets (replicated)
|
||||
// and disabled clusters (kept, not read).
|
||||
const (
|
||||
ClusterPrimary = "primary"
|
||||
ClusterTarget = "target"
|
||||
ClusterDisabled = "disabled"
|
||||
)
|
||||
|
||||
// TLS modes of a cluster.
|
||||
const (
|
||||
TLSPin = "fp" // pinned certificate (recorded on the first connection when empty)
|
||||
TLSCA = "ca" // CA bundle
|
||||
TLSSystem = "sys" // system authorities
|
||||
TLSInsecure = "insecure" // no verification (lab only)
|
||||
)
|
||||
|
||||
// Check is the result of the last connection test of a cluster.
|
||||
type Check struct {
|
||||
At time.Time `json:"at"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Nodes []string `json:"nodes,omitempty"`
|
||||
API bool `json:"api"`
|
||||
SSH bool `json:"ssh"`
|
||||
}
|
||||
|
||||
// Clusters returns every cluster, reference first, without secrets.
|
||||
// Cluster is a cluster as stored. The password is never returned to the UI:
|
||||
// HasPassword only says whether one is set.
|
||||
type Cluster struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Position int `json:"position"`
|
||||
URL string `json:"url"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"` // write only
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
TLS string `json:"tls"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CAPEM string `json:"caPem"`
|
||||
Timeout string `json:"timeout"`
|
||||
SSHHost string `json:"sshHost"`
|
||||
SSHPort int `json:"sshPort"`
|
||||
SSHHostKey string `json:"sshHostKey"` // read only (pinned by the connection test)
|
||||
SSHFingerprint string `json:"sshFingerprint"` // read only
|
||||
Mapping map[model.Kind]map[string]map[string]any `json:"mapping"`
|
||||
Policies map[model.Kind]map[string]adapter.Policy `json:"policies"`
|
||||
LastCheck *Check `json:"lastCheck,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
const clusterCols = `name, role, position, url, api_user, password, tls, fingerprint, ca_pem, timeout,
|
||||
ssh_host, ssh_port, ssh_hostkey, mapping, policies, last_check, updated_at`
|
||||
|
||||
func scanCluster(scan func(...any) error) (Cluster, []byte, error) {
|
||||
var c Cluster
|
||||
var blob []byte
|
||||
var mapping, policies, check string
|
||||
var updated int64
|
||||
err := scan(&c.Name, &c.Role, &c.Position, &c.URL, &c.User, &blob, &c.TLS, &c.Fingerprint, &c.CAPEM, &c.Timeout,
|
||||
&c.SSHHost, &c.SSHPort, &c.SSHHostKey, &mapping, &policies, &check, &updated)
|
||||
if err != nil {
|
||||
return c, nil, err
|
||||
}
|
||||
c.HasPassword = len(blob) > 0
|
||||
c.SSHFingerprint = sshx.Fingerprint(c.SSHHostKey)
|
||||
c.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
|
||||
_ = json.Unmarshal([]byte(policies), &c.Policies)
|
||||
if check != "" {
|
||||
c.LastCheck = &Check{}
|
||||
_ = json.Unmarshal([]byte(check), c.LastCheck)
|
||||
}
|
||||
return c, blob, nil
|
||||
}
|
||||
|
||||
// Clusters returns every cluster, primary first, without passwords.
|
||||
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`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters
|
||||
ORDER BY role = 'primary' 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 {
|
||||
c, _, err := scanCluster(rows.Scan)
|
||||
if 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.
|
||||
// Cluster returns one cluster with its decrypted connection (for a test).
|
||||
func (s *Store) Cluster(ctx context.Context, name string) (Cluster, config.Cluster, error) {
|
||||
c, blob, err := scanCluster(s.db.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, name).Scan)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c, config.Cluster{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return c, config.Cluster{}, err
|
||||
}
|
||||
cc, err := s.connection(c, blob)
|
||||
return c, cc, err
|
||||
}
|
||||
|
||||
func (s *Store) connection(c Cluster, blob []byte) (config.Cluster, error) {
|
||||
pass, err := s.box.Open(blob)
|
||||
if err != nil {
|
||||
return config.Cluster{}, fmt.Errorf("cluster %s: password: %w", c.Name, err)
|
||||
}
|
||||
cc := config.Cluster{Name: c.Name, URL: c.URL, User: c.User, Password: string(pass), Timeout: c.Timeout,
|
||||
SSHHost: c.SSHHost, SSHPort: c.SSHPort, SSHHostKey: c.SSHHostKey}
|
||||
switch c.TLS {
|
||||
case TLSPin:
|
||||
cc.Fingerprint = c.Fingerprint
|
||||
case TLSCA:
|
||||
cc.CAPEM = c.CAPEM
|
||||
case TLSInsecure:
|
||||
cc.Insecure = true
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
// ValidClusterRole tells whether r is a cluster role.
|
||||
func ValidClusterRole(r string) bool {
|
||||
return r == ClusterPrimary || r == ClusterTarget || r == ClusterDisabled
|
||||
}
|
||||
|
||||
// SaveCluster creates or updates a cluster. An empty Password keeps the stored
|
||||
// one. oldName renames a cluster (empty: same name). A primary cluster makes
|
||||
// the previous primary a target. The pinned TLS certificate and SSH host key
|
||||
// are forgotten when the address they belong to changes.
|
||||
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")
|
||||
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
|
||||
if c.Name == "" || c.URL == "" {
|
||||
return errors.New("name and url are required")
|
||||
}
|
||||
if c.User == "" {
|
||||
c.User = "root@pam"
|
||||
}
|
||||
if c.Role == "" {
|
||||
c.Role = ClusterTarget
|
||||
}
|
||||
if !ValidClusterRole(c.Role) {
|
||||
return fmt.Errorf("role %q: primary, target or disabled expected", c.Role)
|
||||
}
|
||||
switch c.TLS {
|
||||
case "":
|
||||
c.TLS = TLSPin
|
||||
case TLSPin, TLSCA, TLSSystem, TLSInsecure:
|
||||
default:
|
||||
return fmt.Errorf("tls %q: fp, ca, sys or insecure expected", c.TLS)
|
||||
}
|
||||
if c.TLS == TLSCA && strings.TrimSpace(c.CAPEM) == "" {
|
||||
return errors.New("the CA certificate (PEM) is required")
|
||||
}
|
||||
if c.SSHPort == 0 {
|
||||
c.SSHPort = 22
|
||||
}
|
||||
if oldName == "" {
|
||||
oldName = c.Name
|
||||
@@ -195,36 +343,47 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
|
||||
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)
|
||||
old, blob, err := scanCluster(tx.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, oldName).Scan)
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if c.Secret != "" {
|
||||
if secret, err = s.box.Seal([]byte(c.Secret)); err != nil {
|
||||
if c.Password != "" {
|
||||
if blob, err = s.box.Seal([]byte(c.Password)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(secret) == 0 {
|
||||
return errors.New("the token secret is required")
|
||||
if len(blob) == 0 {
|
||||
return errors.New("the root password is required")
|
||||
}
|
||||
if c.Reference {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET reference = 0`); err != nil {
|
||||
hostKey := ""
|
||||
if exists {
|
||||
hostKey = old.SSHHostKey
|
||||
if old.URL != c.URL && c.Fingerprint == old.Fingerprint {
|
||||
c.Fingerprint = "" // another server: pinned again on the next test
|
||||
}
|
||||
if sshTarget(old) != sshTarget(c) {
|
||||
hostKey = ""
|
||||
}
|
||||
}
|
||||
if c.Role == ClusterPrimary {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET role = 'target' WHERE role = 'primary'`); 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)
|
||||
_, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, role=?, position=?, url=?, api_user=?, password=?, tls=?,
|
||||
fingerprint=?, ca_pem=?, timeout=?, ssh_host=?, ssh_port=?, ssh_hostkey=?, mapping=?, policies=?, updated_at=?
|
||||
WHERE name=?`,
|
||||
c.Name, c.Role, c.Position, c.URL, c.User, blob, c.TLS, c.Fingerprint, c.CAPEM, c.Timeout,
|
||||
c.SSHHost, c.SSHPort, hostKey, 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)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, role, position, url, api_user, password, tls, fingerprint,
|
||||
ca_pem, timeout, ssh_host, ssh_port, ssh_hostkey, mapping, policies, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
c.Name, c.Role, c.Position, c.URL, c.User, blob, c.TLS, c.Fingerprint, c.CAPEM, c.Timeout,
|
||||
c.SSHHost, c.SSHPort, "", string(mapping), string(policies), now)
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
@@ -235,6 +394,68 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// sshTarget is the SSH endpoint of a cluster, to detect a change of node.
|
||||
func sshTarget(c Cluster) string {
|
||||
host := c.SSHHost
|
||||
if host == "" {
|
||||
host = c.URL
|
||||
}
|
||||
return fmt.Sprintf("%s|%d", host, c.SSHPort)
|
||||
}
|
||||
|
||||
// SetRole changes the role of a cluster (a new primary demotes the previous one).
|
||||
func (s *Store) SetRole(ctx context.Context, name, role string) error {
|
||||
if !ValidClusterRole(role) {
|
||||
return fmt.Errorf("role %q: primary, target or disabled expected", role)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if role == ClusterPrimary {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET role = 'target' WHERE role = 'primary' AND name <> ?`, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `UPDATE clusters SET role = ?, updated_at = ? WHERE name = ?`, role, time.Now().Unix(), name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Pin records the TLS fingerprint and/or the SSH host key seen on the first
|
||||
// connection (empty values are left unchanged), and the result of the test.
|
||||
func (s *Store) Pin(ctx context.Context, name, fingerprint, hostKey string, check *Check) error {
|
||||
var chk []byte
|
||||
if check != nil {
|
||||
chk, _ = json.Marshal(check)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE clusters SET
|
||||
fingerprint = CASE WHEN ? <> '' THEN ? ELSE fingerprint END,
|
||||
ssh_hostkey = CASE WHEN ? <> '' THEN ? ELSE ssh_hostkey END,
|
||||
last_check = CASE WHEN ? <> '' THEN ? ELSE last_check END
|
||||
WHERE name = ?`, fingerprint, fingerprint, hostKey, hostKey, string(chk), string(chk), name)
|
||||
return err
|
||||
}
|
||||
|
||||
// Forget clears the pinned TLS fingerprint and SSH host key (pinned again on the next test).
|
||||
func (s *Store) Forget(ctx context.Context, name string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE clusters SET fingerprint = CASE WHEN tls = 'fp' THEN '' ELSE fingerprint END,
|
||||
ssh_hostkey = '' WHERE name = ?`, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -304,43 +525,38 @@ func (s *Store) SaveSettings(ctx context.Context, st Settings) error {
|
||||
|
||||
// ---- configuration for the engine ----
|
||||
|
||||
// Config builds the engine configuration, secrets decrypted. It fails when no
|
||||
// reference is defined.
|
||||
// Config builds the engine configuration, passwords decrypted: the primary
|
||||
// cluster and the targets (disabled clusters are left aside). It fails when no
|
||||
// primary 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`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters ORDER BY 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 {
|
||||
c, blob, err := scanCluster(rows.Scan)
|
||||
if 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
|
||||
if c.Role == ClusterDisabled {
|
||||
continue
|
||||
}
|
||||
c := config.Client{Cluster: cl}
|
||||
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
|
||||
_ = json.Unmarshal([]byte(policies), &c.Policies)
|
||||
cfg.Clients = append(cfg.Clients, c)
|
||||
cc, err := s.connection(c, blob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Role == ClusterPrimary {
|
||||
cfg.Reference, hasRef = cc, true
|
||||
continue
|
||||
}
|
||||
cfg.Clients = append(cfg.Clients, config.Client{Cluster: cc, Mapping: c.Mapping, Policies: c.Policies})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -351,96 +567,5 @@ func (s *Store) Config(ctx context.Context) (*config.Config, error) {
|
||||
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)
|
||||
}
|
||||
// ErrNoReference: nothing can be compared before a primary cluster is chosen.
|
||||
var ErrNoReference = errors.New("no primary cluster defined")
|
||||
Reference in new issue
Block a user