346 lines
11 KiB
Go
346 lines
11 KiB
Go
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
|
|
}
|