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 = ) { 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 }