Full web : clusters gérés dans l'UI, primaire et cibles, comptes pve

This commit is contained in:
claude Bot committed 2026-10-23 17:23:14 +02:00
1 parent 3e9cb563be
commit ec3b7c9a01
5 files changed
+617 -453

No files matched your search

+345
View File
@@ -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
}
+55 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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))