Full web : clusters gérés dans l'UI, primaire et cibles, comptes pve
This commit is contained in:
1 parent
3e9cb563be
commit
ec3b7c9a01
5 files changed
+617
-453
No files matched your search
@@ -0,0 +1,345 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/pve"
|
||||
)
|
||||
|
||||
// Access control objects of user.cfg, through /access (pve-access-control,
|
||||
// src/PVE/API2/Group.pm, Role.pm, User.pm). Only the accounts of the pve realm
|
||||
// (Proxmox VE authentication server) are replicated: pam accounts are Linux
|
||||
// accounts of each node, the other realms (LDAP, AD, OpenID) are synchronized
|
||||
// from their directory.
|
||||
|
||||
// ---- groups: /access/groups ----
|
||||
|
||||
type group struct{}
|
||||
|
||||
func (group) Kind() model.Kind { return model.KindGroup }
|
||||
func (group) SDN() bool { return false }
|
||||
func (group) Singleton() bool { return false }
|
||||
|
||||
func (group) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
|
||||
var raw []map[string]any
|
||||
if err := c.Get(ctx, "access/groups", nil, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var objs []model.Object
|
||||
for _, r := range raw {
|
||||
// members come from the "groups" field of the accounts
|
||||
f := dropEmpty(normalizeAPI(r, "groupid", "users"))
|
||||
objs = append(objs, model.Object{Kind: model.KindGroup, ID: pve.Str(r["groupid"]), Fields: f})
|
||||
}
|
||||
return objs, nil, nil
|
||||
}
|
||||
|
||||
func (group) DefaultPolicy(string) Policy { return Sync }
|
||||
func (group) Canonicalize(map[string]any) {}
|
||||
func (group) Forbidden(f string) bool { return f == "users" }
|
||||
func (group) Immutable(string) bool { return false }
|
||||
func (group) Required(model.Object) []string { return nil }
|
||||
|
||||
func (group) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
f := form(o.Fields, nil, "")
|
||||
f.Set("groupid", o.ID)
|
||||
return c.Post(ctx, "access/groups", f, nil)
|
||||
}
|
||||
|
||||
func (group) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, _ string) error {
|
||||
return c.Put(ctx, "access/groups/"+url.PathEscape(t.ID), form(blank(set, unset), nil, ""), nil)
|
||||
}
|
||||
|
||||
func (group) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
return c.Delete(ctx, "access/groups/"+url.PathEscape(o.ID), nil, nil)
|
||||
}
|
||||
|
||||
// ---- roles: /access/roles (custom roles only) ----
|
||||
|
||||
type role struct{}
|
||||
|
||||
func (role) Kind() model.Kind { return model.KindRole }
|
||||
func (role) SDN() bool { return false }
|
||||
func (role) Singleton() bool { return false }
|
||||
|
||||
func (role) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
|
||||
var raw []map[string]any
|
||||
if err := c.Get(ctx, "access/roles", nil, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var objs []model.Object
|
||||
for _, r := range raw {
|
||||
if pve.Str(r["special"]) == "1" { // built-in roles (Administrator, PVEAuditor…)
|
||||
continue
|
||||
}
|
||||
f := normalizeAPI(r, "roleid", "special")
|
||||
role{}.Canonicalize(f)
|
||||
objs = append(objs, model.Object{Kind: model.KindRole, ID: pve.Str(r["roleid"]), Fields: f})
|
||||
}
|
||||
return objs, nil, nil
|
||||
}
|
||||
|
||||
func (role) DefaultPolicy(string) Policy { return Sync }
|
||||
func (role) Canonicalize(f map[string]any) { sortList(f, "privs") }
|
||||
func (role) Forbidden(string) bool { return false }
|
||||
func (role) Immutable(string) bool { return false }
|
||||
func (role) Required(model.Object) []string { return nil }
|
||||
|
||||
func (role) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
f := form(o.Fields, nil, "")
|
||||
f.Set("roleid", o.ID)
|
||||
return c.Post(ctx, "access/roles", f, nil)
|
||||
}
|
||||
|
||||
// Update replaces the privileges (no "append").
|
||||
func (role) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, _ string) error {
|
||||
return c.Put(ctx, "access/roles/"+url.PathEscape(t.ID), form(blank(set, unset), nil, ""), nil)
|
||||
}
|
||||
|
||||
func (role) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
return c.Delete(ctx, "access/roles/"+url.PathEscape(o.ID), nil, nil)
|
||||
}
|
||||
|
||||
// ---- accounts of the pve realm: /access/users ----
|
||||
|
||||
// PVERealm is the realm whose accounts are replicated (type "pve").
|
||||
const PVERealm = "pve"
|
||||
|
||||
type user struct{}
|
||||
|
||||
func (user) Kind() model.Kind { return model.KindUser }
|
||||
func (user) SDN() bool { return false }
|
||||
func (user) Singleton() bool { return false }
|
||||
|
||||
func (user) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
|
||||
var raw []map[string]any
|
||||
if err := c.Get(ctx, "access/users", url.Values{"full": {"1"}}, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var objs []model.Object
|
||||
for _, r := range raw {
|
||||
id := pve.Str(r["userid"])
|
||||
if !strings.HasSuffix(id, "@"+PVERealm) {
|
||||
continue
|
||||
}
|
||||
// tokens hold no secret here, but are specific to each cluster (their secret is)
|
||||
f := dropEmpty(normalizeAPI(r, "userid", "tokens", "realm-type", "totp-locked", "tfa-locked-until"))
|
||||
user{}.Canonicalize(f)
|
||||
objs = append(objs, model.Object{Kind: model.KindUser, ID: id, Fields: f})
|
||||
}
|
||||
return objs, nil, nil
|
||||
}
|
||||
|
||||
func (user) DefaultPolicy(string) Policy { return Sync }
|
||||
|
||||
// Canonicalize: "groups" is a comma separated list; expire 0 means no expiration (absent).
|
||||
func (user) Canonicalize(f map[string]any) {
|
||||
if l, ok := f["groups"].([]string); ok {
|
||||
f["groups"] = strings.Join(l, ",")
|
||||
}
|
||||
sortList(f, "groups")
|
||||
if f["expire"] == "0" {
|
||||
delete(f, "expire")
|
||||
}
|
||||
}
|
||||
|
||||
// Forbidden: the password is never set through the API (only its hash is copied, see password).
|
||||
func (user) Forbidden(f string) bool { return f == "password" || f == "tokens" }
|
||||
func (user) Immutable(string) bool { return false }
|
||||
func (user) Required(model.Object) []string { return nil }
|
||||
|
||||
func (user) Parents(o model.Object) []string {
|
||||
g, _ := o.Fields["groups"].(string)
|
||||
var out []string
|
||||
for _, x := range strings.Split(g, ",") {
|
||||
if x = strings.TrimSpace(x); x != "" {
|
||||
out = append(out, string(model.KindGroup)+"/"+x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (user) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
f := form(o.Fields, nil, "")
|
||||
f.Set("userid", o.ID)
|
||||
return c.Post(ctx, "access/users", f, nil)
|
||||
}
|
||||
|
||||
// Update: PUT /access/users/{userid} has no "delete" parameter; a removed field
|
||||
// is written empty (expire 0, groups "" removes every membership).
|
||||
func (user) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, _ string) error {
|
||||
f := blank(set, unset)
|
||||
if _, ok := f["expire"]; ok && f["expire"] == "" {
|
||||
f["expire"] = "0"
|
||||
}
|
||||
if _, ok := f["enable"]; ok && f["enable"] == "" {
|
||||
f["enable"] = "1"
|
||||
}
|
||||
return c.Put(ctx, "access/users/"+url.PathEscape(t.ID), form(f, nil, ""), nil)
|
||||
}
|
||||
|
||||
func (user) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
if o.ID == "root@pam" { // never, whatever the selection
|
||||
return errors.New("root@pam cannot be deleted")
|
||||
}
|
||||
return c.Delete(ctx, "access/users/"+url.PathEscape(o.ID), nil, nil)
|
||||
}
|
||||
|
||||
// ---- password hashes: /etc/pve/priv/shadow.cfg over SSH ----
|
||||
// The API only takes clear passwords (PUT /access/password); the hashes are
|
||||
// copied as they are, so the accounts keep their password on every cluster.
|
||||
// Format (src/PVE/Auth/PVE.pm): one "user:hash:" line per account, the user
|
||||
// without its realm. Writes go through the pmxcfs lock of the file, like PVE
|
||||
// (PVE::Auth::PVE::lock_shadow_config, cfs_read_file / cfs_write_file).
|
||||
|
||||
type password struct{}
|
||||
|
||||
const shadowRead = "cat /etc/pve/priv/shadow.cfg 2>/dev/null || true"
|
||||
|
||||
// shadowWrite reads "set\tuser\thash" / "del\tuser" lines on stdin: the hashes
|
||||
// never appear on a command line.
|
||||
const shadowWrite = `perl -e '
|
||||
use strict; use warnings;
|
||||
use PVE::Cluster;
|
||||
use PVE::Auth::PVE;
|
||||
my @ops;
|
||||
while (my $l = <STDIN>) { chomp $l; push @ops, [split(/\t/, $l, 3)]; }
|
||||
PVE::Auth::PVE::lock_shadow_config(sub {
|
||||
my $cfg = PVE::Cluster::cfs_read_file("priv/shadow.cfg");
|
||||
for my $o (@ops) {
|
||||
my ($op, $u, $h) = @$o;
|
||||
die "invalid user\n" if !defined($u) || $u !~ m/^[^\s:]+$/;
|
||||
if ($op eq "set") {
|
||||
die "invalid hash\n" if !defined($h) || $h !~ m/^[^\s:]+$/;
|
||||
$cfg->{users}->{$u}->{shadow} = $h;
|
||||
} elsif ($op eq "del") {
|
||||
delete $cfg->{users}->{$u};
|
||||
} else {
|
||||
die "invalid operation\n";
|
||||
}
|
||||
}
|
||||
PVE::Cluster::cfs_write_file("priv/shadow.cfg", $cfg);
|
||||
}, "confsync");
|
||||
print "ok\n";
|
||||
' # confsync-shadow-write`
|
||||
|
||||
func (password) Kind() model.Kind { return model.KindPassword }
|
||||
func (password) SDN() bool { return false }
|
||||
func (password) Singleton() bool { return false }
|
||||
|
||||
// List reads the hashes of the known accounts of the pve realm (the accounts
|
||||
// are read before, see KindDeps): orphan lines are left aside.
|
||||
func (password) List(ctx context.Context, c *pve.Client, _ ReadMode, known []model.Object) ([]model.Object, []string, error) {
|
||||
sh, err := c.Shell()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := sh.Run(ctx, shadowRead, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
users := ids(known, model.KindUser)
|
||||
var objs []model.Object
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
p := strings.Split(line, ":")
|
||||
if len(p) != 3 || p[0] == "" || p[1] == "" {
|
||||
continue
|
||||
}
|
||||
id := p[0] + "@" + PVERealm
|
||||
if !users[id] {
|
||||
continue
|
||||
}
|
||||
objs = append(objs, model.Object{Kind: model.KindPassword, ID: id, Fields: map[string]any{"hash": p[1]}})
|
||||
}
|
||||
sort.Slice(objs, func(i, j int) bool { return objs[i].ID < objs[j].ID })
|
||||
return objs, nil, nil
|
||||
}
|
||||
|
||||
func (password) DefaultPolicy(string) Policy { return Sync }
|
||||
func (password) Canonicalize(map[string]any) {}
|
||||
func (password) Forbidden(f string) bool { return f != "hash" }
|
||||
func (password) Immutable(string) bool { return false }
|
||||
func (password) Required(model.Object) []string { return []string{"hash"} }
|
||||
func (password) Sensitive(f string) bool { return f == "hash" }
|
||||
|
||||
func (password) Parents(o model.Object) []string {
|
||||
return []string{string(model.KindUser) + "/" + o.ID}
|
||||
}
|
||||
|
||||
func shadowUser(id string) (string, error) {
|
||||
u, ok := strings.CutSuffix(id, "@"+PVERealm)
|
||||
if !ok || u == "" {
|
||||
return "", fmt.Errorf("%s: not an account of the %s realm", id, PVERealm)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (password) write(ctx context.Context, c *pve.Client, line string) error {
|
||||
sh, err := c.Shell()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := sh.Run(ctx, shadowWrite, []byte(line+"\n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "ok" {
|
||||
return errors.New("shadow.cfg: unexpected answer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p password) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
return p.Update(ctx, c, o, o.Fields, nil, "")
|
||||
}
|
||||
|
||||
func (p password) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, _ []string, _ string) error {
|
||||
u, err := shadowUser(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, _ := set["hash"].(string)
|
||||
if h == "" {
|
||||
return errors.New("empty password hash")
|
||||
}
|
||||
return p.write(ctx, c, "set\t"+u+"\t"+h)
|
||||
}
|
||||
|
||||
func (p password) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
|
||||
u, err := shadowUser(o.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.write(ctx, c, "del\t"+u)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// dropEmpty removes empty values: PVE returns some optional fields empty.
|
||||
func dropEmpty(f map[string]any) map[string]any {
|
||||
for k, v := range f {
|
||||
if v == "" {
|
||||
delete(f, k)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// blank merges the fields to remove as empty values (APIs without "delete").
|
||||
func blank(set map[string]any, unset []string) map[string]any {
|
||||
out := make(map[string]any, len(set)+len(unset))
|
||||
for k, v := range set {
|
||||
out[k] = v
|
||||
}
|
||||
for _, k := range unset {
|
||||
out[k] = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -5,6 +5,8 @@ package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
@@ -60,7 +62,59 @@ type Adapter interface {
|
||||
|
||||
// All returns the adapters in dependency order.
|
||||
func All() []Adapter {
|
||||
return []Adapter{datacenter{}, zone{}, vnet{}, subnet{}}
|
||||
return []Adapter{datacenter{}, group{}, role{}, user{}, password{}, zone{}, vnet{}, subnet{}}
|
||||
}
|
||||
|
||||
// KindDeps: replicating a kind needs these kinds too (a password needs its
|
||||
// account on the target, an account its groups).
|
||||
var KindDeps = map[model.Kind][]model.Kind{
|
||||
model.KindPassword: {model.KindUser},
|
||||
model.KindUser: {model.KindGroup},
|
||||
}
|
||||
|
||||
// Sensitive is implemented by adapters whose fields must never be shown nor
|
||||
// journaled (password hashes): they are replaced by Redact.
|
||||
type Sensitive interface {
|
||||
Sensitive(field string) bool
|
||||
}
|
||||
|
||||
// Parented is implemented by adapters whose objects need other objects on the
|
||||
// target ("kind/id" keys): an account needs its groups, a password its account.
|
||||
type Parented interface {
|
||||
Parents(o model.Object) []string
|
||||
}
|
||||
|
||||
// IsSensitive tells whether a field of a kind is secret.
|
||||
func IsSensitive(k model.Kind, field string) bool {
|
||||
a, err := ByKind(k)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s, ok := a.(Sensitive)
|
||||
return ok && s.Sensitive(field)
|
||||
}
|
||||
|
||||
// Redact replaces a secret value by a short digest: two different hashes stay
|
||||
// distinguishable in a diff, the value itself is never shown.
|
||||
func Redact(v any) any {
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return v
|
||||
}
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return "<masqué " + hex.EncodeToString(sum[:3]) + ">"
|
||||
}
|
||||
|
||||
// Parents returns the keys of the objects o needs on the target.
|
||||
func Parents(o model.Object) []string {
|
||||
a, err := ByKind(o.Kind)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := a.(Parented); ok {
|
||||
return p.Parents(o)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByKind returns the adapter of a kind.
|
||||
|
||||
+58
-83
@@ -1,81 +1,60 @@
|
||||
// Package config loads the YAML configuration of the CLI (phase 0): clusters,
|
||||
// field policies, per client mappings and object selection.
|
||||
// Token secrets are never written in this file: they are read from an
|
||||
// environment variable or a file (docker secret), see README.
|
||||
// Package config is the synchronization configuration given to the engine:
|
||||
// the primary cluster, the targets with their mappings and policies, the
|
||||
// replicated kinds and the object selection. It is built from the database of
|
||||
// the web UI (internal/store); credentials are decrypted only in memory.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/pve"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
// Cluster is the connection to one PVE cluster.
|
||||
// Cluster is the connection to one PVE cluster: the API with the root@pam
|
||||
// credentials and SSH as root on one node for what the API does not cover.
|
||||
type Cluster struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
TokenID string `yaml:"token_id"`
|
||||
TokenSecretEnv string `yaml:"token_secret_env,omitempty"`
|
||||
TokenSecretFile string `yaml:"token_secret_file,omitempty"`
|
||||
CAFile string `yaml:"ca_file,omitempty"`
|
||||
Fingerprint string `yaml:"fingerprint,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Timeout string `yaml:"timeout,omitempty"`
|
||||
|
||||
// Set by the web UI from its database, never read from nor written to YAML.
|
||||
Secret string `yaml:"-"` // token secret, decrypted
|
||||
CAPEM string `yaml:"-"` // CA bundle, PEM
|
||||
Name string
|
||||
URL string
|
||||
User string // API user, root@pam
|
||||
Password string // decrypted, never logged
|
||||
Fingerprint string // pinned TLS certificate (SHA-256)
|
||||
CAPEM string // CA bundle, PEM
|
||||
Insecure bool
|
||||
Timeout string
|
||||
SSHHost string // empty: the host of URL
|
||||
SSHPort int
|
||||
SSHHostKey string // pinned host key (authorized_keys format)
|
||||
}
|
||||
|
||||
// Client is a target cluster with its mapping (kind -> id -> field -> value)
|
||||
// and its own policy overrides.
|
||||
type Client struct {
|
||||
Cluster `yaml:",inline"`
|
||||
Mapping map[model.Kind]map[string]map[string]any `yaml:"mapping,omitempty"`
|
||||
Policies map[model.Kind]map[string]adapter.Policy `yaml:"policies,omitempty"`
|
||||
Cluster
|
||||
Mapping map[model.Kind]map[string]map[string]any
|
||||
Policies map[model.Kind]map[string]adapter.Policy
|
||||
}
|
||||
|
||||
// Selection filters the objects of a kind with glob patterns on their id.
|
||||
type Selection struct {
|
||||
Include []string `yaml:"include,omitempty"`
|
||||
Exclude []string `yaml:"exclude,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the whole file.
|
||||
// Config is the whole synchronization: Reference is the primary cluster.
|
||||
type Config struct {
|
||||
Reference Cluster `yaml:"reference"`
|
||||
Clients []Client `yaml:"clients"`
|
||||
Kinds []model.Kind `yaml:"kinds,omitempty"`
|
||||
Objects map[model.Kind]Selection `yaml:"objects,omitempty"`
|
||||
Policies map[model.Kind]map[string]adapter.Policy `yaml:"policies,omitempty"`
|
||||
Prune bool `yaml:"prune,omitempty"`
|
||||
}
|
||||
|
||||
// Load reads and validates a configuration file.
|
||||
func Load(file string) (*Config, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true) // typos must fail, not be silently ignored
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", file, err)
|
||||
}
|
||||
if len(c.Kinds) == 0 {
|
||||
c.Kinds = append(c.Kinds, model.Kinds...)
|
||||
}
|
||||
return &c, c.validate()
|
||||
Reference Cluster
|
||||
Clients []Client
|
||||
Kinds []model.Kind
|
||||
Objects map[model.Kind]Selection
|
||||
Policies map[model.Kind]map[string]adapter.Policy
|
||||
Prune bool
|
||||
}
|
||||
|
||||
// Validate checks a configuration built elsewhere (web UI database, YAML import).
|
||||
@@ -93,14 +72,16 @@ func (c *Config) validate() error {
|
||||
errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name))
|
||||
}
|
||||
seen[cl.Name] = true
|
||||
if cl.Secret == "" && cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" {
|
||||
errs = append(errs, fmt.Errorf("cluster %s: token_secret_env or token_secret_file is required", cl.Name))
|
||||
}
|
||||
}
|
||||
for _, k := range c.Kinds {
|
||||
if _, err := adapter.ByKind(k); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
for _, d := range adapter.KindDeps[k] {
|
||||
if !c.HasKind(d) {
|
||||
errs = append(errs, fmt.Errorf("%s needs %s", k, d))
|
||||
}
|
||||
}
|
||||
}
|
||||
check := func(where string, p map[model.Kind]map[string]adapter.Policy) {
|
||||
for k, fields := range p {
|
||||
@@ -151,45 +132,39 @@ func (c *Config) Client(name string) (*Client, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// PVE builds the API client of a cluster, resolving its token secret.
|
||||
// PVE builds the API client of a cluster, with its SSH access when the host
|
||||
// key is recorded.
|
||||
func (cl Cluster) PVE() (*pve.Client, error) {
|
||||
secret, err := cl.secret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var timeout time.Duration
|
||||
var err error
|
||||
if cl.Timeout != "" {
|
||||
if timeout, err = time.ParseDuration(cl.Timeout); err != nil {
|
||||
return nil, fmt.Errorf("cluster %s: timeout: %w", cl.Name, err)
|
||||
}
|
||||
}
|
||||
return pve.New(pve.Options{
|
||||
Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret,
|
||||
CAFile: cl.CAFile, CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout,
|
||||
})
|
||||
o := pve.Options{
|
||||
Name: cl.Name, BaseURL: cl.URL, User: cl.User, Password: cl.Password,
|
||||
CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout,
|
||||
}
|
||||
if cl.SSHHostKey != "" {
|
||||
sh, err := sshx.New(cl.SSHOptions())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cluster %s: %w", cl.Name, err)
|
||||
}
|
||||
o.Shell = sh
|
||||
}
|
||||
return pve.New(o)
|
||||
}
|
||||
|
||||
// ResolveSecret reads the token secret from the environment variable or file of
|
||||
// the YAML configuration (used when a YAML file is imported in the web UI).
|
||||
func (cl Cluster) ResolveSecret() (string, error) { return cl.secret() }
|
||||
|
||||
func (cl Cluster) secret() (string, error) {
|
||||
if cl.Secret != "" {
|
||||
return cl.Secret, nil
|
||||
}
|
||||
if cl.TokenSecretEnv != "" {
|
||||
if v := os.Getenv(cl.TokenSecretEnv); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
if cl.TokenSecretFile == "" {
|
||||
return "", fmt.Errorf("cluster %s: environment variable %s is empty", cl.Name, cl.TokenSecretEnv)
|
||||
// SSHOptions is the SSH access of the cluster: root on SSHHost, or on the host of the API URL.
|
||||
func (cl Cluster) SSHOptions() sshx.Options {
|
||||
host := cl.SSHHost
|
||||
if host == "" {
|
||||
if u, err := url.Parse(cl.URL); err == nil {
|
||||
host = u.Hostname()
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(cl.TokenSecretFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cluster %s: token secret: %w", cl.Name, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
return sshx.Options{Host: host, Port: cl.SSHPort, User: "root", Password: cl.Password, HostKey: cl.SSHHostKey}
|
||||
}
|
||||
|
||||
// Policy resolves the policy of a field for a client: client policy, then
|
||||
|
||||
+16
-12
@@ -1,6 +1,6 @@
|
||||
// Package model holds the generic, normalized representation of synchronized objects.
|
||||
// Field values are canonical strings or sorted string lists, so that two clusters
|
||||
// can be compared field by field and the result serialized to YAML without loss.
|
||||
// can be compared field by field.
|
||||
package model
|
||||
|
||||
import (
|
||||
@@ -15,32 +15,36 @@ type Kind string
|
||||
|
||||
const (
|
||||
KindDatacenter Kind = "datacenter-options"
|
||||
KindGroup Kind = "pve-group" // groups of user.cfg
|
||||
KindRole Kind = "pve-role" // custom roles of user.cfg
|
||||
KindUser Kind = "pve-user" // accounts of the pve realm
|
||||
KindPassword Kind = "pve-password" // their password hashes (SSH, priv/shadow.cfg)
|
||||
KindZone Kind = "sdn-zone"
|
||||
KindVNet Kind = "sdn-vnet"
|
||||
KindSubnet Kind = "sdn-subnet"
|
||||
)
|
||||
|
||||
// Kinds lists the kinds in dependency order (creation order).
|
||||
var Kinds = []Kind{KindDatacenter, KindZone, KindVNet, KindSubnet}
|
||||
var Kinds = []Kind{KindDatacenter, KindGroup, KindRole, KindUser, KindPassword, KindZone, KindVNet, KindSubnet}
|
||||
|
||||
// Object is one configuration object of a cluster.
|
||||
// Field values are either string or []string (see Normalize).
|
||||
type Object struct {
|
||||
Kind Kind `yaml:"kind" json:"kind"`
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Fields map[string]any `yaml:"fields" json:"fields"`
|
||||
Kind Kind `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
// Key is the unique identity of an object inside a cluster.
|
||||
func (o Object) Key() string { return string(o.Kind) + "/" + o.ID }
|
||||
|
||||
// Snapshot is the exported state of one cluster (YAML, versionable in Git).
|
||||
// Snapshot is the state read from one cluster.
|
||||
type Snapshot struct {
|
||||
Cluster string `yaml:"cluster"`
|
||||
PVEVersion string `yaml:"pve_version,omitempty"`
|
||||
ExportedAt time.Time `yaml:"exported_at"`
|
||||
Objects []Object `yaml:"objects"`
|
||||
Warnings []string `yaml:"warnings,omitempty"`
|
||||
Cluster string `json:"cluster"`
|
||||
PVEVersion string `json:"pveVersion,omitempty"`
|
||||
ExportedAt time.Time `json:"readAt"`
|
||||
Objects []Object `json:"objects"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Sort orders objects by kind (dependency order) then by id.
|
||||
@@ -57,7 +61,7 @@ func Sort(objs []Object) {
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize turns a YAML/JSON decoded field map into canonical values:
|
||||
// Normalize turns a JSON decoded field map into canonical values:
|
||||
// scalars become strings, lists become sorted []string.
|
||||
func Normalize(fields map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(fields))
|
||||
|
||||
Reference in new issue
Block a user