Full web : fichiers restants

This commit is contained in:
cedric committed 2026-10-23 17:43:20 +02:00
1 parent ec3b7c9a01
commit 4826fa5b82
26 files changed
+2537 -793

No files matched your search

+36 -12
View File
@@ -1,7 +1,7 @@
// Package demo starts fake PVE clusters (pvetest) and fills a database with
// them, to try the web UI without a Proxmox cluster: a reference, two clients
// with differences (missing objects, different fields, a missing mapping) and
// a PVE 8 cluster that is refused.
// Package demo starts fake PVE clusters (pvetest: API and SSH) and fills a
// database with them, to try the web UI without a Proxmox cluster: a primary,
// two targets with differences (missing objects, different fields, accounts
// and passwords, a missing mapping) and a PVE 8 cluster that is refused.
package demo
import (
@@ -11,6 +11,7 @@ import (
"pve-confsync/internal/adapter"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
"pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/store"
)
@@ -50,6 +51,14 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
ref.Commit()
ref.Groups["admins"] = map[string]any{"comment": "Administrateurs"}
ref.Groups["ops"] = map[string]any{"comment": "Exploitation"}
ref.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
ref.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
ref.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
ref.Users["carol@pam"] = map[string]any{"enable": 1, "comment": "compte Linux, non répliqué"}
ref.Shadow["alice"] = "$5$Kd1Xa9$Q8yJdemoAliceHashOnlyForTheDemo0123456789AB"
ref.Shadow["bob"] = "$5$Pq7Zr2$W3vLdemoBobHashOnlyForTheDemo0123456789ABCDE"
// site-a: tags and an MTU behind, the VXLAN zone and the dev network missing
a := pvetest.New("9.0.10")
@@ -65,6 +74,9 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
a.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"}
a.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"}
a.Commit()
a.Groups["admins"] = map[string]any{"comment": "Admins"}
a.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@old.example.org", "groups": "admins"}
a.Shadow["alice"] = "$5$OldOld$oldAliceHashOnSiteA0123456789ABCDEFGHIJKLMNOP"
// site-b: nearly in line, but no mapping for the bridge of the "stor" zone
// (blocking error) and a local zone absent from the reference (kept, no prune)
@@ -84,21 +96,33 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
b.Commit()
b.Groups["admins"] = map[string]any{"comment": "Administrateurs"}
b.Groups["ops"] = map[string]any{"comment": "Exploitation"}
b.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
b.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
b.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
b.Shadow["alice"] = ref.Shadow["alice"]
b.Shadow["bob"] = ref.Shadow["bob"]
// legacy: PVE 8, refused by the version check
old := pvetest.New("8.4.1")
c := &Clusters{servers: []*pvetest.Server{ref, a, b, old}}
save := func(s *pvetest.Server, name, token string, refer bool, pos int,
save := func(s *pvetest.Server, name, role string, pos int,
mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error {
sum := sha256.Sum256(s.Certificate().Raw)
return st.SaveCluster(ctx, "", store.Cluster{Name: name, Reference: refer, Position: pos, URL: s.URL,
TokenID: token, Secret: "demo-" + name, Fingerprint: hex.EncodeToString(sum[:]),
Mapping: mapping, Policies: pol})
host, port := s.SSHAddr()
err := st.SaveCluster(ctx, "", store.Cluster{Name: name, Role: role, Position: pos, URL: s.URL,
User: "root@pam", Password: pvetest.Password, TLS: store.TLSPin, Fingerprint: pve.FormatFingerprint(hex.EncodeToString(sum[:])),
SSHHost: host, SSHPort: port, Mapping: mapping, Policies: pol})
if err != nil {
return err
}
return st.Pin(ctx, name, "", s.SSHHostKey(), nil)
}
steps := []error{
save(ref, "ref-rennes", "confsync@pve!ro", true, 0, nil, nil),
save(a, "site-a", "confsync@pve!rw", false, 1, map[model.Kind]map[string]map[string]any{
save(ref, "pve-rennes", store.ClusterPrimary, 0, nil, nil),
save(a, "site-a", store.ClusterTarget, 1, map[model.Kind]map[string]map[string]any{
model.KindDatacenter: {"datacenter": {"next-id": "lower=2000,upper=2999"}},
model.KindZone: {
"vlanz": {"bridge": "vmbr1"},
@@ -106,13 +130,13 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
"vxz": {"peers": "192.168.10.1,192.168.10.2"},
},
}, nil),
save(b, "site-b", "confsync@pve!rw", false, 2, map[model.Kind]map[string]map[string]any{
save(b, "site-b", store.ClusterTarget, 2, map[model.Kind]map[string]map[string]any{
model.KindZone: {
"vlanz": {"bridge": "vmbr0"},
"vxz": {"peers": "172.16.0.1,172.16.0.2"},
},
}, map[model.Kind]map[string]adapter.Policy{model.KindZone: {"mtu": adapter.Ignore}}),
save(old, "legacy-pve8", "confsync@pve!rw", false, 3, nil, nil),
save(old, "legacy-pve8", store.ClusterTarget, 3, nil, nil),
st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds,
Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}),
}
+137
View File
@@ -0,0 +1,137 @@
package engine
import (
"context"
"encoding/json"
"strings"
"testing"
"pve-confsync/internal/adapter"
"pve-confsync/internal/config"
"pve-confsync/internal/model"
"pve-confsync/internal/pve/pvetest"
)
const aliceHash = "$5$salt$aliceHashValue"
func accessRef(t *testing.T) *pvetest.Server {
ref := pvetest.New("9.1.4")
t.Cleanup(ref.Close)
ref.Groups["admins"] = map[string]any{"comment": "Admins"}
ref.Groups["ops"] = map[string]any{"comment": "Ops"}
ref.Roles["Ops"] = "VM.PowerMgmt,Sys.Audit"
ref.Users["alice@pve"] = map[string]any{"enable": 1, "email": "alice@example.org", "groups": "ops,admins"}
ref.Users["bob@pve"] = map[string]any{"enable": 1, "groups": "ops", "expire": 0}
ref.Users["carol@pam"] = map[string]any{"enable": 1}
ref.Shadow["alice"] = aliceHash
ref.Shadow["bob"] = "$5$salt$bobHash"
ref.Shadow["orphan"] = "$5$salt$orphan" // no account: left aside
return ref
}
var accessKinds = []model.Kind{model.KindGroup, model.KindRole, model.KindUser, model.KindPassword}
func TestAccountsAndPasswords(t *testing.T) {
ctx := context.Background()
ref := accessRef(t)
cli := pvetest.New("9.0.10")
defer cli.Close()
cli.Users["alice@pve"] = map[string]any{"enable": 1, "email": "old@example.org", "firstname": "Al"}
cli.Shadow["alice"] = "$5$salt$oldHash"
cfg := &config.Config{Kinds: accessKinds, Reference: config.Cluster{Name: "ref"}, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}}}
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
cl := &cfg.Clients[0]
src, err := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
if err != nil {
t.Fatal(err)
}
for _, o := range src.Objects {
if o.ID == "carol@pam" || o.ID == "orphan@pve" || o.ID == "Administrator" || o.ID == "root@pam" {
t.Fatalf("%s must not be read", o.Key())
}
}
cc := client(t, cli, "site")
cur, err := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent)
if err != nil {
t.Fatal(err)
}
plan := MakePlan(cfg, cl, src.Objects, cur.Objects)
if len(plan.Errors) > 0 {
t.Fatal(plan.Errors)
}
// 2 groups + 1 role + bob created, alice updated + 2 passwords
if len(plan.Changes) != 7 {
t.Fatalf("changes = %+v", plan.Changes)
}
b, _ := json.Marshal(plan)
if strings.Contains(string(b), "aliceHash") || strings.Contains(string(b), "oldHash") {
t.Fatalf("password hash in the plan JSON: %s", b)
}
res := Apply(ctx, cc, plan)
if res.Err != nil {
t.Fatalf("apply: %v (%+v)", res.Err, res.Steps)
}
if cli.Shadow["alice"] != aliceHash || cli.Shadow["bob"] != "$5$salt$bobHash" {
t.Fatalf("shadow = %v", cli.Shadow)
}
a := cli.Users["alice@pve"]
if a["email"] != "alice@example.org" || a["groups"] != "admins,ops" || a["firstname"] != nil {
t.Fatalf("alice = %v", a)
}
if cli.Roles["Ops"] != "Sys.Audit,VM.PowerMgmt" {
t.Fatalf("roles = %v", cli.Roles)
}
replan := func(actual []model.Object) Plan { return MakePlan(cfg, cl, src.Objects, actual) }
if err := Verify(ctx, cc, replan, cfg.Kinds); err != nil {
t.Fatalf("verify: %v", err)
}
}
func TestPasswordSelectionPullsAccountAndGroups(t *testing.T) {
ctx := context.Background()
ref := accessRef(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
cfg := &config.Config{Kinds: accessKinds, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}}}
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cur, _ := Read(ctx, client(t, cli, "site"), cfg.Kinds, adapter.ReadCurrent)
p := Filter(MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects), []string{"pve-password/bob@pve"})
var keys []string
for _, c := range p.Changes {
keys = append(keys, c.Key())
}
if got := strings.Join(keys, " "); got != "pve-group/ops pve-user/bob@pve pve-password/bob@pve" {
t.Fatalf("selection = %s", got)
}
}
func TestMissingParentIsAnError(t *testing.T) {
ctx := context.Background()
ref := accessRef(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
// groups excluded from the selection: alice cannot be created in them
cfg := &config.Config{Kinds: accessKinds, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}},
Objects: map[model.Kind]config.Selection{model.KindGroup: {Exclude: []string{"*"}}}}
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cur, _ := Read(ctx, client(t, cli, "site"), cfg.Kinds, adapter.ReadCurrent)
plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects)
if len(plan.Errors) != 3 || !strings.Contains(strings.Join(plan.Errors, "\n"), "pve-user/alice@pve: pve-group/admins absent") {
t.Fatalf("errors = %v", plan.Errors)
}
}
func TestPasswordsNeedSSH(t *testing.T) {
s := pvetest.New("9.1.0")
defer s.Close()
c, err := config.Cluster{Name: "c", URL: s.URL, Password: pvetest.Password, Insecure: true}.PVE()
if err != nil {
t.Fatal(err)
}
if _, err := Read(context.Background(), c, accessKinds, adapter.ReadCurrent); err == nil || !strings.Contains(err.Error(), "SSH") {
t.Fatalf("err = %v", err)
}
}
+78 -11
View File
@@ -4,6 +4,7 @@ package engine
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
@@ -42,6 +43,9 @@ func Read(ctx context.Context, c *pve.Client, kinds []model.Kind, mode adapter.R
if need[model.KindVNet] {
need[model.KindZone] = true
}
if need[model.KindPassword] {
need[model.KindUser] = true
}
var all []model.Object
for _, a := range adapter.All() {
if !need[a.Kind()] {
@@ -74,21 +78,43 @@ const (
// Change is one planned action. Set holds the new values, Old the current ones.
type Change struct {
Kind model.Kind `json:"kind" yaml:"kind"`
ID string `json:"id" yaml:"id"`
Op Op `json:"op" yaml:"op"`
Set map[string]any `json:"set,omitempty" yaml:"set,omitempty"`
Unset []string `json:"unset,omitempty" yaml:"unset,omitempty"`
Old map[string]any `json:"old,omitempty" yaml:"old,omitempty"`
Target model.Object `json:"-" yaml:"-"`
Kind model.Kind `json:"kind"`
ID string `json:"id"`
Op Op `json:"op"`
Set map[string]any `json:"set,omitempty"`
Unset []string `json:"unset,omitempty"`
Old map[string]any `json:"old,omitempty"`
Target model.Object `json:"-"`
}
// MarshalJSON hides the secret fields (password hashes): plans are shown in the
// UI and kept in the journal, the real values only live in memory until applied.
func (c Change) MarshalJSON() ([]byte, error) {
type plain Change
red := func(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
if adapter.IsSensitive(c.Kind, k) {
v = adapter.Redact(v)
}
out[k] = v
}
return out
}
p := plain(c)
p.Set, p.Old = red(c.Set), red(c.Old)
return json.Marshal(p)
}
// Plan is the list of changes for one client, in execution order.
type Plan struct {
Client string `json:"client" yaml:"client"`
Changes []Change `json:"changes" yaml:"changes"`
Errors []string `json:"errors,omitempty" yaml:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"`
Client string `json:"client"`
Changes []Change `json:"changes"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// HasSDN tells whether the plan writes SDN objects (lock + reload needed).
@@ -187,6 +213,7 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
}
}
}
checkParents(&plan, cl.Name, actual)
// deletions last, dependents first (subnets, vnets, zones)
sort.SliceStable(deletes, func(i, j int) bool {
if deletes[i].Kind != deletes[j].Kind {
@@ -198,6 +225,46 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
return plan
}
// checkParents blocks the objects whose parents (groups of an account, account
// of a password) are neither on the target nor created by the plan.
func checkParents(plan *Plan, client string, actual []model.Object) {
have := map[string]bool{}
for _, o := range actual {
have[o.Key()] = true
}
for _, c := range plan.Changes {
if c.Op == OpCreate {
have[c.Key()] = true
}
}
for _, c := range plan.Changes {
if c.Op == OpDelete {
continue
}
for _, p := range adapter.Parents(Desired(c)) {
if !have[p] {
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: %s absent on target %s and not replicated", c.Key(), p, client))
}
}
}
}
// Desired is the object as it will be after the change (fields of the target
// with the new values).
func Desired(c Change) model.Object {
f := map[string]any{}
for k, v := range c.Target.Fields {
f[k] = v
}
for _, k := range c.Unset {
delete(f, k)
}
for k, v := range c.Set {
f[k] = v
}
return model.Object{Kind: c.Kind, ID: c.ID, Fields: f}
}
func kindRank(k model.Kind) int {
for i, x := range model.Kinds {
if x == k {
+9 -3
View File
@@ -14,17 +14,23 @@ import (
"pve-confsync/internal/pve/pvetest"
)
// client connects to a fake cluster like the web UI does: API as root@pam,
// pinned certificate, SSH with the pinned host key.
func client(t *testing.T, s *pvetest.Server, name string) *pve.Client {
t.Helper()
sum := sha256.Sum256(s.Certificate().Raw)
c, err := pve.New(pve.Options{Name: name, BaseURL: s.URL, TokenID: "confsync@pve!t", TokenSecret: "secret",
Fingerprint: hex.EncodeToString(sum[:])})
host, port := s.SSHAddr()
c, err := config.Cluster{Name: name, URL: s.URL, User: "root@pam", Password: pvetest.Password,
Fingerprint: hex.EncodeToString(sum[:]), SSHHost: host, SSHPort: port, SSHHostKey: s.SSHHostKey()}.PVE()
if err != nil {
t.Fatal(err)
}
return c
}
// sdnKinds: the kinds of the SDN and datacenter tests.
var sdnKinds = []model.Kind{model.KindDatacenter, model.KindZone, model.KindVNet, model.KindSubnet}
// reference: two zones (vlan, vxlan), one unsupported (evpn), vnets, subnets, tags.
func reference(t *testing.T) *pvetest.Server {
ref := pvetest.New("9.1.4")
@@ -49,7 +55,7 @@ func reference(t *testing.T) *pvetest.Server {
func testConfig() *config.Config {
return &config.Config{
Kinds: model.Kinds,
Kinds: sdnKinds,
Clients: []config.Client{{
Cluster: config.Cluster{Name: "site-a"},
Mapping: map[model.Kind]map[string]map[string]any{
+9
View File
@@ -7,6 +7,7 @@ import (
"sort"
"strings"
"pve-confsync/internal/adapter"
"pve-confsync/internal/model"
)
@@ -50,6 +51,11 @@ func Filter(p Plan, keys []string) Plan {
if v, ok := c.Set["vnet"].(string); ok && byKey[string(model.KindVNet)+"/"+v].Op == OpCreate {
add(string(model.KindVNet) + "/" + v)
}
for _, p := range adapter.Parents(Desired(c)) {
if byKey[p].Op == OpCreate {
add(p)
}
}
case OpDelete:
// children deleted in the same plan must go first
for _, d := range p.Changes {
@@ -79,6 +85,9 @@ func Filter(p Plan, keys []string) Plan {
if v, ok := c.Set["vnet"].(string); ok {
blocking[string(model.KindVNet)+"/"+v] = true
}
for _, p := range adapter.Parents(Desired(c)) {
blocking[p] = true
}
}
out := Plan{Client: p.Client, Warnings: p.Warnings}
// errors about other objects ("kind/id: ...") do not block this selection
+180 -43
View File
@@ -1,6 +1,8 @@
// Package pve is a minimal Proxmox VE API client: API token auth, generic JSON,
// form-encoded writes, task polling and version check. No typed SDK on purpose:
// objects stay generic (map[string]any) and are interpreted by the adapters.
// Package pve is a minimal Proxmox VE API client: ticket auth with the root@pam
// credentials, generic JSON, form-encoded writes, task polling and version check.
// No typed SDK on purpose: objects stay generic (map[string]any) and are
// interpreted by the adapters. What the API does not cover (the password hashes
// of the pve realm) goes through a Shell, an SSH session on a node.
package pve
import (
@@ -15,10 +17,10 @@ import (
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
@@ -26,23 +28,42 @@ import (
type Options struct {
Name string // cluster name in the config (for messages only)
BaseURL string // https://host:8006
TokenID string // user@realm!tokenname
TokenSecret string // never logged
CAFile string // PEM bundle to trust (optional)
CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set
User string // user@realm, root@pam by default
Password string // never logged
CAPEM []byte // PEM bundle to trust (optional)
Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
Insecure bool // skip TLS verification (lab only)
Timeout time.Duration
Shell Shell // SSH access to a node (optional)
}
// Shell runs a command on a node of the cluster (SSH), stdin fed with in.
type Shell interface {
Run(ctx context.Context, cmd string, in []byte) ([]byte, error)
}
// ticketTTL: PVE tickets live 2 hours (pve-access-control AccessControl.pm,
// $ticket_lifetime); a new one is requested well before.
const ticketTTL = 90 * time.Minute
// Client talks to the PVE API of one cluster.
type Client struct {
name string
base *url.URL
auth string
http *http.Client
name string
base *url.URL
user string
password string
http *http.Client
shell Shell
mu sync.Mutex
ticket string
csrf string
ticketAt time.Time
}
// ErrNoShell: the operation needs an SSH access to a node.
var ErrNoShell = errors.New("SSH access required")
// APIError is a non-2xx answer of the API.
type APIError struct {
Method, Path string
@@ -75,13 +96,40 @@ func New(o Options) (*Client, error) {
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL)
}
if o.TokenID == "" || o.TokenSecret == "" {
return nil, fmt.Errorf("cluster %s: token id and secret are required", o.Name)
if o.User == "" {
o.User = "root@pam"
}
if o.Password == "" {
return nil, fmt.Errorf("cluster %s: the password of %s is required", o.Name, o.User)
}
tlsCfg, err := TLSConfig(o.Name, o.CAPEM, o.Fingerprint, o.Insecure)
if err != nil {
return nil, err
}
timeout := o.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
return &Client{
name: o.Name,
base: u,
user: o.User,
password: o.Password,
shell: o.Shell,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
},
}, nil
}
// TLSConfig builds the TLS settings of a cluster: certificate pinning (self-signed
// PVE certificates), a CA bundle, the system authorities, or no verification.
func TLSConfig(name string, caPEM []byte, fingerprint string, insecure bool) (*tls.Config, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
switch {
case o.Fingerprint != "":
want := normalizeFingerprint(o.Fingerprint)
case fingerprint != "":
want := normalizeFingerprint(fingerprint)
// Self-signed PVE certificates: pin the leaf certificate instead of a CA.
tlsCfg.InsecureSkipVerify = true
tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error {
@@ -90,49 +138,112 @@ func New(o Options) (*Client, error) {
}
sum := sha256.Sum256(raw[0])
if got := hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("certificate fingerprint mismatch (got %s)", got)
return fmt.Errorf("certificate fingerprint mismatch (got %s)", FormatFingerprint(got))
}
return nil
}
case o.CAFile != "" || len(o.CAPEM) > 0:
pem, src := o.CAPEM, "the CA certificate"
if o.CAFile != "" {
var err error
if pem, err = os.ReadFile(o.CAFile); err != nil {
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
}
src = o.CAFile
}
case len(caPEM) > 0:
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src)
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("cluster %s: no certificate in the CA bundle", name)
}
tlsCfg.RootCAs = pool
case o.Insecure:
case insecure:
tlsCfg.InsecureSkipVerify = true
}
timeout := o.Timeout
if timeout == 0 {
timeout = 30 * time.Second
return tlsCfg, nil
}
// PeerFingerprint connects to the API and returns the SHA-256 fingerprint of the
// certificate it presents, without verifying it: the administrator pins it on
// the first connection (trust on first use) and sees it in the cluster list.
func PeerFingerprint(ctx context.Context, baseURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return "", fmt.Errorf("invalid url %q", baseURL)
}
return &Client{
name: o.Name,
base: u,
auth: "PVEAPIToken=" + o.TokenID + "=" + o.TokenSecret,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
},
}, nil
host := u.Host
if u.Port() == "" {
host += ":8006"
}
d := tls.Dialer{Config: &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}}
conn, err := d.DialContext(ctx, "tcp", host)
if err != nil {
return "", err
}
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return "", errors.New("no peer certificate")
}
sum := sha256.Sum256(certs[0].Raw)
return FormatFingerprint(hex.EncodeToString(sum[:])), nil
}
// FormatFingerprint prints a SHA-256 fingerprint like PVE does ("AA:BB:...").
func FormatFingerprint(fp string) string {
fp = strings.ToUpper(normalizeFingerprint(fp))
var b strings.Builder
for i := 0; i < len(fp); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(fp[i:min(i+2, len(fp))])
}
return b.String()
}
// Name returns the cluster name given in the options.
func (c *Client) Name() string { return c.name }
// Shell returns the SSH access to a node, or ErrNoShell.
func (c *Client) Shell() (Shell, error) {
if c.shell == nil {
return nil, fmt.Errorf("cluster %s: %w", c.name, ErrNoShell)
}
return c.shell, nil
}
func normalizeFingerprint(s string) string {
return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s))
}
// login gets a ticket and a CSRF token (POST /access/ticket). Accounts with a
// second factor are refused: the tool cannot answer the TFA challenge.
func (c *Client) login(ctx context.Context) error {
var res struct {
Ticket string `json:"ticket"`
CSRF string `json:"CSRFPreventionToken"`
NeedTFA any `json:"NeedTFA"`
}
f := url.Values{"username": {c.user}, "password": {c.password}}
if err := c.send(ctx, http.MethodPost, "access/ticket", nil, f, &res, false); err != nil {
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
return fmt.Errorf("cluster %s: authentication of %s failed", c.name, c.user)
}
return err
}
if Str(res.NeedTFA) == "1" {
return fmt.Errorf("cluster %s: %s has a second factor (TFA), not supported", c.name, c.user)
}
if res.Ticket == "" {
return fmt.Errorf("cluster %s: no ticket in the login answer", c.name)
}
c.ticket, c.csrf, c.ticketAt = res.Ticket, res.CSRF, time.Now()
return nil
}
// Login checks the credentials (and renews the ticket when needed).
func (c *Client) Login(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ticket != "" && time.Since(c.ticketAt) < ticketTTL {
return nil
}
return c.login(ctx)
}
// Get reads path (relative to /api2/json) and decodes "data" into out.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.do(ctx, http.MethodGet, path, query, nil, out)
@@ -153,6 +264,25 @@ func (c *Client) Delete(ctx context.Context, path string, params url.Values, out
}
func (c *Client) do(ctx context.Context, method, path string, query, form url.Values, out any) error {
if err := c.Login(ctx); err != nil {
return err
}
err := c.send(ctx, method, path, query, form, out, true)
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
// ticket expired early (key rotation on the cluster): log in again once
c.mu.Lock()
c.ticket = ""
c.mu.Unlock()
if err := c.Login(ctx); err != nil {
return err
}
err = c.send(ctx, method, path, query, form, out, true)
}
return err
}
func (c *Client) send(ctx context.Context, method, path string, query, form url.Values, out any, auth bool) error {
u := *c.base
u.Path = "/api2/json/" + strings.TrimLeft(path, "/")
if len(query) > 0 {
@@ -166,14 +296,21 @@ func (c *Client) do(ctx context.Context, method, path string, query, form url.Va
if err != nil {
return err
}
req.Header.Set("Authorization", c.auth)
if auth {
c.mu.Lock()
req.AddCookie(&http.Cookie{Name: "PVEAuthCookie", Value: c.ticket})
if method != http.MethodGet {
req.Header.Set("CSRFPreventionToken", c.csrf)
}
c.mu.Unlock()
}
req.Header.Set("Accept", "application/json")
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := c.http.Do(req)
if err != nil {
// url.Error contains the URL only, never the Authorization header.
// url.Error contains the URL only, never the ticket nor the password.
return fmt.Errorf("cluster %s: %w", c.name, err)
}
defer resp.Body.Close()
+187
View File
@@ -0,0 +1,187 @@
package pvetest
import (
"net/http"
"sort"
"strings"
)
// access fakes /access/groups, /access/roles and /access/users (user.cfg).
func (s *Server) access(w http.ResponseWriter, r *http.Request, method string, seg []string) {
f := r.PostForm
if method == http.MethodDelete {
f = r.URL.Query()
}
switch {
case len(seg) == 2 && seg[1] == "groups" && method == "GET":
var out []any
for _, id := range sortedKeys(s.Groups) {
o := map[string]any{"groupid": id, "users": strings.Join(s.members(id), ",")}
for k, v := range s.Groups[id] {
o[k] = v
}
out = append(out, o)
}
reply(w, out)
case len(seg) == 2 && seg[1] == "groups" && method == "POST":
id := f.Get("groupid")
if s.Groups[id] != nil {
fail(w, 500, "create group failed: group '"+id+"' already exists")
return
}
s.Groups[id] = setFields(map[string]any{}, f, "groupid")
reply(w, nil)
case len(seg) == 3 && seg[1] == "groups":
g := s.Groups[seg[2]]
if g == nil {
fail(w, 500, "group '"+seg[2]+"' does not exist")
return
}
switch method {
case "PUT":
setFields(g, f)
case "DELETE":
delete(s.Groups, seg[2])
for _, u := range s.Users {
u["groups"] = strings.Join(remove(split(u["groups"]), seg[2]), ",")
}
}
reply(w, nil)
case len(seg) == 2 && seg[1] == "roles" && method == "GET":
out := []any{
map[string]any{"roleid": "Administrator", "privs": "Sys.Audit,Sys.Modify", "special": 1},
map[string]any{"roleid": "PVEAuditor", "privs": "Sys.Audit", "special": 1},
}
for _, id := range sortedKeys(s.Roles) {
out = append(out, map[string]any{"roleid": id, "privs": s.Roles[id], "special": 0})
}
reply(w, out)
case len(seg) == 2 && seg[1] == "roles" && method == "POST":
s.Roles[f.Get("roleid")] = f.Get("privs")
reply(w, nil)
case len(seg) == 3 && seg[1] == "roles":
if _, ok := s.Roles[seg[2]]; !ok {
fail(w, 500, "role '"+seg[2]+"' does not exist")
return
}
if method == "DELETE" {
delete(s.Roles, seg[2])
} else {
s.Roles[seg[2]] = f.Get("privs")
}
reply(w, nil)
case len(seg) == 2 && seg[1] == "users" && method == "GET":
var out []any
for _, id := range sortedKeys(s.Users) {
realm := id[strings.LastIndex(id, "@")+1:]
o := map[string]any{"userid": id, "realm-type": realm, "tokens": []any{}}
for k, v := range s.Users[id] {
o[k] = v
}
out = append(out, o)
}
reply(w, out)
case len(seg) == 2 && seg[1] == "users" && method == "POST":
id := f.Get("userid")
if s.Users[id] != nil {
fail(w, 500, "create user failed: user '"+id+"' already exists")
return
}
if !s.groupsExist(w, f.Get("groups")) {
return
}
s.Users[id] = setFields(map[string]any{"enable": 1}, f, "userid", "password")
reply(w, nil)
case len(seg) == 3 && seg[1] == "users":
u := s.Users[seg[2]]
if u == nil {
fail(w, 500, "user '"+seg[2]+"' does not exist")
return
}
switch method {
case "PUT":
if !s.groupsExist(w, f.Get("groups")) {
return
}
setFields(u, f)
case "DELETE":
delete(s.Users, seg[2])
delete(s.Shadow, strings.TrimSuffix(seg[2], "@pve"))
}
reply(w, nil)
default:
fail(w, 501, "not implemented in fake: "+method+" "+strings.Join(seg, "/"))
}
}
func (s *Server) groupsExist(w http.ResponseWriter, groups string) bool {
for _, g := range split(groups) {
if s.Groups[g] == nil {
fail(w, 500, "no such group '"+g+"'")
return false
}
}
return true
}
func (s *Server) members(group string) []string {
var out []string
for id, u := range s.Users {
for _, g := range split(u["groups"]) {
if g == group {
out = append(out, id)
}
}
}
sort.Strings(out)
return out
}
// setFields copies form values; an empty value removes the field (like user.cfg).
func setFields(m map[string]any, f map[string][]string, skip ...string) map[string]any {
sk := map[string]bool{}
for _, k := range skip {
sk[k] = true
}
for k, v := range f {
if sk[k] || len(v) == 0 {
continue
}
if v[0] == "" {
delete(m, k)
continue
}
m[k] = v[0]
}
return m
}
func split(v any) []string {
s, _ := v.(string)
var out []string
for _, x := range strings.Split(s, ",") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
func remove(l []string, x string) []string {
out := l[:0]
for _, e := range l {
if e != x {
out = append(out, e)
}
}
return out
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+53 -21
View File
@@ -1,7 +1,9 @@
// Package pvetest is an in-memory fake of the subset of the PVE 9.x API used by
// pve-confsync, for tests. It mimics the behaviours that matter for the engine:
// parsed datacenter options on GET, property strings on PUT, SDN pending/running
// configurations, global SDN lock with token, rollback and reload task.
// pve-confsync, for tests and the demo. It mimics the behaviours that matter for
// the engine: ticket login of root@pam, parsed datacenter options on GET,
// property strings on PUT, SDN pending/running configurations, global SDN lock
// with token, rollback and reload task, groups, roles and accounts of user.cfg,
// and an SSH server serving /etc/pve/priv/shadow.cfg (see ssh.go).
package pvetest
import (
@@ -43,29 +45,47 @@ func (s SDN) clone() SDN {
return c
}
// Password of root@pam on the fake clusters (API and SSH).
const Password = "root-secret"
// Server is a fake PVE cluster.
type Server struct {
*httptest.Server
mu sync.Mutex
Version string // pve-manager version, e.g. 9.1.4
Options map[string]string // datacenter.cfg as written (property strings)
Pending SDN
Running SDN
Lock string
Perms map[string]map[string]any
FailOn string // "METHOD path" answering 500, to test rollbacks
Calls []string // "METHOD path" of every write
locks int
mu sync.Mutex
Version string // pve-manager version, e.g. 9.1.4
Password string // root@pam password
Options map[string]string // datacenter.cfg as written (property strings)
Pending SDN
Running SDN
Lock string
Groups map[string]map[string]any // groupid -> fields (comment)
Roles map[string]string // custom roles: roleid -> privs
Users map[string]map[string]any // userid -> fields (groups as "a,b")
Shadow map[string]string // user without realm -> hash (priv/shadow.cfg)
FailOn string // "METHOD path" answering 500, to test rollbacks
Calls []string // "METHOD path" of every write, "SSH write" for shadow.cfg
locks int
ssh *sshServer
}
// New starts a fake cluster.
const ticket, csrf = "PVE:root@pam:FAKE::sig", "fake-csrf"
// New starts a fake cluster (API in HTTPS and SSH).
func New(version string) *Server {
s := &Server{Version: version, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
Perms: map[string]map[string]any{}}
s := &Server{Version: version, Password: Password, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
Groups: map[string]map[string]any{}, Roles: map[string]string{}, Shadow: map[string]string{},
Users: map[string]map[string]any{"root@pam": {"enable": 1, "email": "root@localhost"}}}
s.Server = httptest.NewTLSServer(http.HandlerFunc(s.handle))
s.ssh = startSSH(s)
return s
}
// Close stops the API and the SSH server.
func (s *Server) Close() {
s.ssh.close()
s.Server.Close()
}
// Commit makes the pending SDN configuration the running one (like an apply).
func (s *Server) Commit() {
s.mu.Lock()
@@ -87,12 +107,24 @@ func fail(w http.ResponseWriter, code int, msg string) {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if !strings.HasPrefix(r.Header.Get("Authorization"), "PVEAPIToken=") {
_ = r.ParseForm()
p := strings.TrimPrefix(r.URL.Path, "/api2/json/")
if r.Method == http.MethodPost && p == "access/ticket" {
if r.PostForm.Get("username") != "root@pam" || r.PostForm.Get("password") != s.Password {
fail(w, 401, "authentication failure")
return
}
reply(w, map[string]any{"ticket": ticket, "CSRFPreventionToken": csrf, "username": "root@pam"})
return
}
if c, err := r.Cookie("PVEAuthCookie"); err != nil || c.Value != ticket {
fail(w, 401, "no ticket")
return
}
_ = r.ParseForm()
p := strings.TrimPrefix(r.URL.Path, "/api2/json/")
if r.Method != http.MethodGet && r.Header.Get("CSRFPreventionToken") != csrf {
fail(w, 401, "Permission denied - invalid csrf token")
return
}
seg := strings.Split(p, "/")
key := r.Method + " " + p
if r.Method != http.MethodGet {
@@ -130,8 +162,8 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
reply(w, out)
case r.Method == "GET" && len(seg) == 5 && seg[0] == "nodes" && seg[2] == "tasks" && seg[4] == "status":
reply(w, map[string]any{"status": "stopped", "exitstatus": "OK"})
case key == "GET access/permissions":
reply(w, s.Perms)
case strings.HasPrefix(p, "access/"):
s.access(w, r, r.Method, seg)
case key == "GET cluster/options":
reply(w, s.parsedOptions())
case key == "PUT cluster/options":
+143
View File
@@ -0,0 +1,143 @@
package pvetest
import (
"bufio"
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"strings"
"golang.org/x/crypto/ssh"
)
// sshServer answers the commands pve-confsync runs on a node: pveversion, the
// read of priv/shadow.cfg and its locked write (adapter password).
type sshServer struct {
ln net.Listener
key ssh.PublicKey
}
func startSSH(s *Server) *sshServer {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
signer, _ := ssh.NewSignerFromKey(priv)
cfg := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c.User() == "root" && string(pass) == s.Password {
return nil, nil
}
return nil, fmt.Errorf("denied")
},
}
cfg.AddHostKey(signer)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
srv := &sshServer{ln: ln, key: signer.PublicKey()}
go func() {
for {
nc, err := ln.Accept()
if err != nil {
return
}
go s.serveSSH(nc, cfg)
}
}()
return srv
}
func (x *sshServer) close() { x.ln.Close() }
// SSHAddr returns the host and port of the fake SSH server.
func (s *Server) SSHAddr() (string, int) {
a := s.ssh.ln.Addr().(*net.TCPAddr)
return a.IP.String(), a.Port
}
// SSHHostKey returns the host key of the fake node (authorized_keys format).
func (s *Server) SSHHostKey() string {
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.ssh.key)))
}
func (s *Server) serveSSH(nc net.Conn, cfg *ssh.ServerConfig) {
_, chans, reqs, err := ssh.NewServerConn(nc, cfg)
if err != nil {
nc.Close()
return
}
go ssh.DiscardRequests(reqs)
for nch := range chans {
if nch.ChannelType() != "session" {
_ = nch.Reject(ssh.UnknownChannelType, "session only")
continue
}
ch, reqs, err := nch.Accept()
if err != nil {
continue
}
go func() {
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
_ = req.Reply(false, nil)
continue
}
var p struct{ Cmd string }
_ = ssh.Unmarshal(req.Payload, &p)
_ = req.Reply(true, nil)
status := s.exec(p.Cmd, ch, ch, ch.Stderr())
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, uint32(status))
_, _ = ch.SendRequest("exit-status", false, b)
return
}
}()
}
}
func (s *Server) exec(cmd string, in io.Reader, out, errw io.Writer) int {
switch {
case strings.HasPrefix(cmd, "pveversion"):
fmt.Fprintf(out, "pve-manager/%s/abcdef (running kernel: 6.14.8-2-pve)\n", s.Version)
return 0
case strings.HasPrefix(cmd, "cat /etc/pve/priv/shadow.cfg"):
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range sortedKeys(s.Shadow) {
fmt.Fprintf(out, "%s:%s:\n", u, s.Shadow[u])
}
return 0
case strings.Contains(cmd, "confsync-shadow-write"):
data, _ := io.ReadAll(in)
s.mu.Lock()
defer s.mu.Unlock()
s.Calls = append(s.Calls, "SSH write shadow.cfg")
if s.FailOn == "SSH write shadow.cfg" {
fmt.Fprintln(errw, "injected failure")
return 1
}
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
p := strings.SplitN(sc.Text(), "\t", 3)
switch {
case len(p) == 3 && p[0] == "set":
s.Shadow[p[1]] = p[2]
case len(p) >= 2 && p[0] == "del":
delete(s.Shadow, p[1])
default:
fmt.Fprintln(errw, "invalid operation")
return 1
}
}
fmt.Fprintln(out, "ok")
return 0
}
fmt.Fprintf(errw, "fake: unknown command %q\n", cmd)
return 127
}
+138 -109
View File
@@ -7,13 +7,13 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
"pve-confsync/internal/adapter"
"pve-confsync/internal/config"
"pve-confsync/internal/engine"
"pve-confsync/internal/model"
@@ -33,7 +33,13 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
if s.state == nil || r.URL.Query().Get("refresh") == "1" {
cfg, err := s.store.Config(r.Context())
if errors.Is(err, store.ErrNoReference) {
writeJSON(w, http.StatusOK, map[string]any{"setup": true})
// nothing to compare yet: no cluster at all, or no primary chosen
cls, _ := s.store.Clusters(r.Context())
setup := "clusters"
if len(cls) > 0 {
setup = "primary"
}
writeJSON(w, http.StatusOK, map[string]any{"setup": setup})
return
}
if err != nil {
@@ -78,11 +84,12 @@ func (s *Server) remember(st *service.State, all bool) {
// ---- dry-run and apply (phase 2) ----
type selection struct {
Scope string `json:"scope"` // global, client or object
Scope string `json:"scope"` // global (several targets), client (one target) or object
Targets []string `json:"targets"` // global scope: the targets (empty: all of them)
Client string `json:"client"` // client and object scopes
Objects []string `json:"objects"` // object scope: "kind/id"
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
Confirm string `json:"confirm"` // apply with SDN changes: client name (reference name for global)
Confirm string `json:"confirm"` // apply with SDN changes: target name (primary name for global)
}
func (sel *selection) check() error {
@@ -90,11 +97,12 @@ func (sel *selection) check() error {
case "global":
sel.Client, sel.Objects = "", nil
case "client":
sel.Objects = nil
sel.Objects, sel.Targets = nil, nil
if sel.Client == "" {
return errors.New("client required")
}
case "object":
sel.Targets = nil
if sel.Client == "" || len(sel.Objects) == 0 {
return errors.New("client and objects required")
}
@@ -106,26 +114,29 @@ func (sel *selection) check() error {
func (sel selection) clients() []string {
if sel.Client == "" {
return nil
return sel.Targets
}
return []string{sel.Client}
}
// all tells whether the selection reads every target (the cached comparison is replaced).
func (sel selection) all() bool { return sel.Scope == "global" && len(sel.Targets) == 0 }
// plan reads the clusters and returns the plans of the selection.
func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) {
cfg, err := s.store.Config(ctx)
if err != nil {
return nil, nil, nil, err
}
if sel.Client != "" {
if _, ok := cfg.Client(sel.Client); !ok {
return nil, nil, nil, fmt.Errorf("unknown client %q", sel.Client)
for _, c := range sel.clients() {
if _, ok := cfg.Client(c); !ok {
return nil, nil, nil, fmt.Errorf("unknown target %q", c)
}
}
rctx, cancel := context.WithTimeout(ctx, readTimeout)
defer cancel()
st := service.Analyze(rctx, cfg, sel.clients())
s.remember(st, sel.Scope == "global")
s.remember(st, sel.all())
return cfg, st, st.Plans(sel.Objects), nil
}
@@ -185,7 +196,10 @@ func target(sel selection) string {
case "object":
return sel.Client + ": " + strings.Join(sel.Objects, ", ")
}
return "global"
if len(sel.Targets) > 0 {
return "cibles " + strings.Join(sel.Targets, ", ")
}
return "toutes les cibles"
}
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
@@ -203,11 +217,11 @@ func dryRunSummary(plans []service.ClientPlan) (string, string) {
}
switch {
case blocked > 0:
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d client(s), %d client(s) en erreur", changes, clients, blocked)
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d cible(s), %d cible(s) en erreur", changes, clients, blocked)
case changes == 0:
return store.RunNoop, "aucun écart"
}
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d client(s)", changes, clients)
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d cible(s)", changes, clients)
}
// handleApply recomputes the plans, checks that they are the ones reviewed in the
@@ -337,10 +351,10 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
}
}
status := store.RunOK
summary := fmt.Sprintf("%d client(s) synchronisé(s) et vérifié(s)", done)
summary := fmt.Sprintf("%d cible(s) synchronisée(s) et vérifiée(s)", done)
if failed > 0 {
status = store.RunError
summary = fmt.Sprintf("%d client(s) en échec, %d synchronisé(s)", failed, done)
summary = fmt.Sprintf("%d cible(s) en échec, %d synchronisée(s)", failed, done)
}
save(status, summary)
s.invalidate()
@@ -387,8 +401,8 @@ func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) {
}
// connection details are for the administrators
if identity(r).Role != store.RoleAdmin {
for i := range cls {
cls[i] = store.Cluster{Name: cls[i].Name, Reference: cls[i].Reference, URL: cls[i].URL, Mapping: cls[i].Mapping, Policies: cls[i].Policies}
for i, c := range cls {
cls[i] = store.Cluster{Name: c.Name, Role: c.Role, URL: c.URL, Mapping: c.Mapping, Policies: c.Policies, LastCheck: c.LastCheck}
}
}
writeJSON(w, http.StatusOK, map[string]any{"clusters": cls})
@@ -409,9 +423,9 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
return
}
err := s.store.SaveCluster(r.Context(), old, c)
detail := fmt.Sprintf("url=%s token=%s reference=%t", c.URL, c.TokenID, c.Reference)
if c.Secret != "" {
detail += " secret=modifié"
detail := fmt.Sprintf("url=%s rôle=%s tls=%s ssh=%s:%d", c.URL, c.Role, c.TLS, c.SSHHost, c.SSHPort)
if c.Password != "" {
detail += " mot de passe=modifié"
}
if old != "" && old != c.Name {
detail += " renommé depuis " + old
@@ -425,19 +439,25 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// validateCluster checks the mapping and policies of a cluster with the rules of
// the YAML configuration.
// validateCluster checks the address and the mapping and policies of a cluster.
func validateCluster(c store.Cluster) error {
if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") {
return errors.New("url: https:// expected")
u, err := url.Parse(strings.TrimSpace(c.URL))
if err != nil || u.Scheme != "https" || u.Host == "" {
return errors.New("url: https://host:8006 expected")
}
if c.User != "" && c.User != "root@pam" {
return errors.New("user: root@pam expected (the same account is used over SSH)")
}
if c.Timeout != "" {
if _, err := time.ParseDuration(c.Timeout); err != nil {
return fmt.Errorf("timeout: %w", err)
}
}
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref", Secret: "x"},
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name, Secret: "x"}, Mapping: c.Mapping, Policies: c.Policies}}}
if c.SSHPort < 0 || c.SSHPort > 65535 {
return errors.New("ssh port: 1-65535 expected")
}
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref"},
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name}, Mapping: c.Mapping, Policies: c.Policies}}}
return cfg.Validate()
}
@@ -460,51 +480,116 @@ func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleCheckCluster tests the access, the version and the token privileges.
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
cfg, err := s.store.Config(r.Context())
if err != nil && !errors.Is(err, store.ErrNoReference) {
writeError(w, http.StatusInternalServerError, err.Error(), "config")
// handleRole makes a cluster the primary, a target or disabled.
func (s *Server) handleRole(w http.ResponseWriter, r *http.Request) {
var req struct {
Role string `json:"role"`
}
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
var cl config.Cluster
write := true
name := r.PathValue("name")
err := s.store.SetRole(r.Context(), name, req.Role)
s.audit(r, Identity{}, "cluster-role", name, err == nil, errText(err, req.Role))
switch {
case cfg != nil && cfg.Reference.Name == name:
cl, write = cfg.Reference, false
case cfg != nil:
c, ok := cfg.Client(name)
if !ok {
writeError(w, http.StatusNotFound, "unknown cluster", "notfound")
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error(), "notfound")
return
case err != nil:
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleCheckCluster tests the API (root@pam, PVE 9.x) and SSH. On the first
// connection the TLS certificate (mode "fp") and the SSH host key are recorded
// (trust on first use); trust=true accepts a new SSH host key.
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
var req struct {
Trust bool `json:"trust"`
}
if r.ContentLength > 0 {
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
cl = c.Cluster
default:
writeError(w, http.StatusBadRequest, "define a reference cluster first", "config")
}
name := r.PathValue("name")
c, cc, err := s.store.Cluster(r.Context(), name)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "unknown cluster", "notfound")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
res := service.CheckCluster(ctx, cl, write, cfg.HasKind(model.KindDatacenter))
s.audit(r, Identity{}, "cluster-check", name, res.Error == "", res.Error)
res := service.CheckCluster(ctx, cc, c.TLS == store.TLSPin, req.Trust)
chk := &store.Check{At: time.Now().UTC(), OK: res.OK, Error: res.Error, Warnings: res.Warnings,
Version: res.Version, API: res.API, SSH: res.SSH}
for _, n := range res.Nodes {
chk.Nodes = append(chk.Nodes, n.Name)
}
// a TLS certificate is pinned only once the API answered with it
fp := ""
if res.API {
fp = res.TLSFingerprint
}
if err := s.store.Pin(r.Context(), name, fp, res.SSHHostKey, chk); err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
detail := res.Error
if fp != "" {
detail += " certificat TLS enregistré " + fp
}
if res.SSHHostKey != "" {
detail += " clé SSH enregistrée " + res.SSHFingerprint
}
s.audit(r, Identity{}, "cluster-check", name, res.OK, strings.TrimSpace(detail))
if fp != "" || res.SSHHostKey != "" {
s.invalidate()
}
writeJSON(w, http.StatusOK, res)
}
// handleForget clears the pinned certificate and SSH host key of a cluster
// (node reinstalled, new certificate): they are recorded again on the next test.
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
err := s.store.Forget(r.Context(), name)
s.audit(r, Identity{}, "cluster-forget", name, err == nil, errText(err, "empreinte TLS et clé SSH oubliées"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error(), "notfound")
return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
st, err := s.store.Settings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "defaults": defaultPolicies()})
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "deps": adapter.KindDeps, "defaults": defaultPolicies()})
}
// defaultPolicies describes the built-in policies, shown in the settings.
func defaultPolicies() map[model.Kind]string {
return map[model.Kind]string{
model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais",
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping du client) ; le reste : sync",
model.KindGroup: "sync (commentaire ; les membres viennent des comptes)",
model.KindRole: "sync (rôles personnalisés ; les rôles intégrés ne sont pas touchés)",
model.KindUser: "sync (comptes du realm pve : nom, e-mail, groupes, expiration, activation…)",
model.KindPassword: "sync (hachage copié par SSH dans /etc/pve/priv/shadow.cfg, jamais affiché)",
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping de la cible) ; le reste : sync",
model.KindVNet: "sync",
model.KindSubnet: "sync",
}
@@ -517,11 +602,11 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
return
}
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
Reference: config.Cluster{Name: "\x00ref", Secret: "x"}}
if len(cfg.Kinds) == 0 {
cfg.Kinds = model.Kinds
}
Reference: config.Cluster{Name: "\x00ref"}}
err := cfg.Validate()
if err == nil && len(st.Kinds) == 0 {
err = errors.New("select at least one kind")
}
if err == nil {
err = s.store.SaveSettings(r.Context(), st)
}
@@ -535,62 +620,6 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleExport returns the configuration as a CLI YAML file (no secret).
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
cfg, err := s.store.ExportConfig(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
b, err := yaml.Marshal(cfg)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "yaml")
return
}
s.audit(r, Identity{}, "config-export", "", true, "")
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="confsync.yaml"`)
_, _ = fmt.Fprintf(w, "# pve-confsync : configuration exportée le %s par %s\n# Secrets des tokens non exportés : renseigner les variables token_secret_env (ou token_secret_file).\n\n",
time.Now().Format("2006-01-02 15:04"), identity(r).User)
_, _ = w.Write(b)
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
var req struct {
YAML string `json:"yaml"`
Replace bool `json:"replace"`
}
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
var cfg config.Config
dec := yaml.NewDecoder(strings.NewReader(req.YAML))
dec.KnownFields(true)
err := dec.Decode(&cfg)
if err == nil {
if len(cfg.Kinds) == 0 {
cfg.Kinds = append(cfg.Kinds, model.Kinds...)
}
err = cfg.Validate()
}
var warns []string
if err == nil {
warns, err = s.store.ImportConfig(r.Context(), &cfg, req.Replace)
}
s.audit(r, Identity{}, "config-import", fmt.Sprintf("%d cluster(s), remplacement=%t", len(cfg.Clients)+1, req.Replace),
err == nil, errText(err, strings.Join(warns, " ; ")))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
s.invalidate()
if warns == nil {
warns = []string{}
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "warnings": warns})
}
// ---- users and audit ----
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
+52 -2
View File
@@ -183,7 +183,7 @@ func sessionKey(dir string) []byte {
// public paths: reachable without a session.
func public(p string) bool {
switch p {
case "/healthz", loginPage, "/style.css", "/boot.js", "/login.js", "/favicon.svg", "/auth/config", "/auth/login", "/auth/logout", "/auth/oidc":
case "/healthz", loginPage, "/style.css", "/boot.js", "/login.js", "/favicon.svg", "/auth/config", "/auth/login", "/auth/logout", "/auth/oidc", "/auth/setup":
return true
}
return false
@@ -197,6 +197,9 @@ func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.URL.Path == "/auth/logout":
a.handleLogout(w, r)
return
case r.URL.Path == "/auth/setup":
a.handleSetup(w, r)
return
case r.URL.Path == "/auth/oidc" && a.cfg.OIDCEnabled():
a.startLogin(w, r)
return
@@ -204,7 +207,8 @@ func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.handleCallback(w, r)
return
case r.URL.Path == "/auth/config":
writeJSON(w, http.StatusOK, map[string]any{"local": a.cfg.Local, "oidc": a.cfg.OIDCEnabled(), "demo": a.cfg.Demo})
writeJSON(w, http.StatusOK, map[string]any{"local": a.cfg.Local, "oidc": a.cfg.OIDCEnabled(), "demo": a.cfg.Demo,
"setup": a.needSetup(r.Context())})
return
}
id, ok := a.sessionIdentity(r)
@@ -299,6 +303,52 @@ func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, ret, http.StatusSeeOther)
}
// needSetup: local accounts enabled and none yet, the login page asks for the
// first administrator.
func (a *Auth) needSetup(ctx context.Context) bool {
if !a.cfg.Local {
return false
}
n, err := a.store.CountUsers(ctx)
return err == nil && n == 0
}
// handleSetup creates the first administrator on an empty database, then opens
// its session. Refused as soon as an account exists.
func (a *Auth) handleSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !a.needSetup(r.Context()) {
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, pass := strings.TrimSpace(r.PostFormValue("user")), r.PostFormValue("pass")
fail := func(code string) {
http.Redirect(w, r, loginPage+"?"+url.Values{"setup": {code}}.Encode(), http.StatusSeeOther)
}
if pass != r.PostFormValue("pass2") {
fail("mismatch")
return
}
if err := a.store.CreateFirstAdmin(r.Context(), user, pass); err != nil {
log.Printf("auth: first administrator: %v", err)
if errors.Is(err, store.ErrSetupDone) {
http.Redirect(w, r, loginPage, http.StatusSeeOther)
return
}
fail("invalid")
return
}
u, err := a.store.User(r.Context(), user)
if err != nil {
fail("invalid")
return
}
id := Identity{User: u.Name, Role: u.Role, Mode: "local"}
log.Printf("auth: first administrator %q created from %s", u.Name, clientIP(r))
a.audit(r, id, "setup", u.Name, true, "premier administrateur créé")
a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch})
http.Redirect(w, r, "/#clusters", http.StatusSeeOther)
}
// handleLogout ends the session; with OIDC the provider's session ends on its logout page if it has one.
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
id, ok := a.sessionIdentity(r)
+7 -6
View File
@@ -1,7 +1,8 @@
// Package server is the web UI of pve-confsync (phases 1 and 2): cluster list,
// visual diff per client, dry-run and synchronization per object, per client or
// global, execution journal and audit trail. The pages (web/) are embedded in
// the binary; the API is JSON under /api/.
// Package server is the web UI of pve-confsync, the only way to use it: first
// administrator, clusters (root credentials, primary and targets), replicated
// kinds, visual diff per target, dry-run and synchronization per object, per
// target or towards several targets, execution journal and audit trail. The
// pages (web/) are embedded in the binary; the API is JSON under /api/.
package server
import (
@@ -77,10 +78,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("PUT /api/clusters/{name}", s.role(admin, s.handleSaveCluster))
mux.Handle("DELETE /api/clusters/{name}", s.role(admin, s.handleDeleteCluster))
mux.Handle("POST /api/clusters/{name}/check", s.role(admin, s.handleCheckCluster))
mux.Handle("POST /api/clusters/{name}/role", s.role(admin, s.handleRole))
mux.Handle("POST /api/clusters/{name}/forget", s.role(admin, s.handleForget))
mux.Handle("GET /api/settings", s.role(viewer, s.handleSettings))
mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings))
mux.Handle("GET /api/config/export", s.role(admin, s.handleExport))
mux.Handle("POST /api/config/import", s.role(admin, s.handleImport))
mux.Handle("GET /api/users", s.role(admin, s.handleUsers))
mux.Handle("PUT /api/users/{name}", s.role(admin, s.handleSaveUser))
+109 -2
View File
@@ -10,11 +10,13 @@ import (
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"pve-confsync/internal/demo"
"pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/store"
)
@@ -117,8 +119,8 @@ func TestAuthAndRoles(t *testing.T) {
t.Fatalf("clusters: %d", code)
}
for _, c := range d["clusters"].([]any) {
if s, ok := c.(map[string]any)["secret"]; ok && s != "" {
t.Fatalf("secret returned: %v", c)
if s, ok := c.(map[string]any)["password"]; ok && s != "" {
t.Fatalf("password returned: %v", c)
}
}
}
@@ -147,6 +149,9 @@ func TestDryRunAndApply(t *testing.T) {
if code != http.StatusOK {
t.Fatalf("dry-run site-a: %d %v", code, d)
}
if b, _ := json.Marshal(d); strings.Contains(string(b), "HashOnlyForTheDemo") || !strings.Contains(string(b), "pve-password") {
t.Fatalf("password hashes must be planned but never shown: %s", b)
}
sel["digests"] = digests(d)
if code, d := op.do("POST", "/api/apply", sel, true); code != http.StatusBadRequest || d["code"] != "confirm" {
t.Fatalf("SDN apply without confirmation: %d %v", code, d)
@@ -188,4 +193,106 @@ func TestDryRunAndApply(t *testing.T) {
}
}
// TestFromScratch: empty database, first administrator from the web, clusters
// added with their root password, certificate and SSH host key pinned by the
// first test, primary chosen, replication of the accounts to one target.
func TestFromScratch(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "t.db"), bytes.Repeat([]byte{7}, 32))
if err != nil {
t.Fatal(err)
}
srv, err := New(Options{Store: st, Version: "test",
Static: fstest.MapFS{"index.html": {Data: []byte("<html>")}, "login.html": {Data: []byte("<html>")}},
Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}})
if err != nil {
t.Fatal(err)
}
hs := httptest.NewServer(srv.Handler())
ref, tgt := pvetest.New("9.1.4"), pvetest.New("9.0.10")
t.Cleanup(func() { hs.Close(); srv.Wait(); ref.Close(); tgt.Close(); st.Close() })
ref.Groups["ops"] = map[string]any{"comment": "Ops"}
ref.Users["alice@pve"] = map[string]any{"enable": 1, "groups": "ops"}
ref.Shadow["alice"] = "$5$s$aliceSecretHash"
anon := &client{t: t, url: hs.URL, c: http.DefaultClient}
if _, d := anon.do("GET", "/auth/config", nil, false); d["setup"] != true {
t.Fatalf("setup expected: %v", d)
}
jar, _ := cookiejar.New(nil)
adm := &client{t: t, url: hs.URL, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
r, err := adm.c.PostForm(hs.URL+"/auth/setup", url.Values{"user": {"cedric"}, "pass": {"a-long-password"}, "pass2": {"a-long-password"}})
if err != nil {
t.Fatal(err)
}
r.Body.Close()
if r.StatusCode != http.StatusSeeOther || r.Header.Get("Location") != "/#clusters" {
t.Fatalf("setup: %d %s", r.StatusCode, r.Header.Get("Location"))
}
if r, _ := anon.c.PostForm(hs.URL+"/auth/setup", url.Values{"user": {"intrus"}, "pass": {"a-long-password"}, "pass2": {"a-long-password"}}); r != nil {
r.Body.Close()
}
if n, _ := st.CountUsers(ctx); n != 1 {
t.Fatal("a second setup must be refused")
}
if _, d := adm.do("GET", "/api/state", nil, false); d["setup"] != "clusters" {
t.Fatalf("state on an empty database: %v", d)
}
for _, c := range []struct {
name string
s *pvetest.Server
}{{"ref", ref}, {"tgt", tgt}} {
_, port := c.s.SSHAddr()
code, d := adm.do("PUT", "/api/clusters/-", map[string]any{"name": c.name, "url": c.s.URL, "password": pvetest.Password,
"tls": "fp", "sshPort": port}, true)
if code != http.StatusOK {
t.Fatalf("save %s: %d %v", c.name, code, d)
}
code, d = adm.do("POST", "/api/clusters/"+c.name+"/check", nil, true)
if code != http.StatusOK || d["ok"] != true || d["tlsFingerprint"] == "" || d["sshFingerprint"] == "" {
t.Fatalf("check %s: %d %v", c.name, code, d)
}
if code, d = adm.do("POST", "/api/clusters/"+c.name+"/check", nil, true); d["ok"] != true || d["sshFingerprint"] != nil {
t.Fatalf("second check %s (pinned): %d %v", c.name, code, d)
}
}
if _, d := adm.do("GET", "/api/state", nil, false); d["setup"] != "primary" {
t.Fatalf("state without primary: %v", d)
}
if code, d := adm.do("POST", "/api/clusters/ref/role", map[string]any{"role": "primary"}, true); code != http.StatusOK {
t.Fatalf("role: %d %v", code, d)
}
sel := map[string]any{"scope": "global", "targets": []string{"tgt"}}
code, d := adm.do("POST", "/api/dryrun", sel, true)
if code != http.StatusOK {
t.Fatalf("dry-run: %d %v", code, d)
}
sel["digests"] = digests(d)
if code, d = adm.do("POST", "/api/apply", sel, true); code != http.StatusAccepted {
t.Fatalf("apply: %d %v", code, d)
}
id := int(d["run"].(float64))
var run map[string]any
for i := 0; i < 100; i++ {
_, run = adm.do("GET", "/api/runs/"+itoa(id), nil, false)
if run["status"] != "running" {
break
}
time.Sleep(50 * time.Millisecond)
}
if run["status"] != "ok" {
t.Fatalf("run: %v", run)
}
if tgt.Shadow["alice"] != "$5$s$aliceSecretHash" || tgt.Users["alice@pve"]["groups"] != "ops" {
t.Fatalf("target: %v %v", tgt.Shadow, tgt.Users)
}
if b, _ := json.Marshal(run); strings.Contains(string(b), "aliceSecretHash") {
t.Fatal("hash in the journal")
}
if ref.Calls != nil {
t.Fatalf("the primary must never be written: %v", ref.Calls)
}
}
func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
+67 -41
View File
@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
@@ -15,6 +16,7 @@ import (
"pve-confsync/internal/engine"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
"pve-confsync/internal/sshx"
)
// Node is the PVE version of one node.
@@ -255,6 +257,10 @@ func rows(cfg *config.Config, cl *config.Client, ref, actual []model.Object, pla
case fd.Policy != adapter.Ignore && fd.Current != nil:
fd.Target = fd.Current
}
if adapter.IsSensitive(k, f) { // password hashes: a digest only
fd.Ref, fd.Mapping = adapter.Redact(fd.Ref), adapter.Redact(fd.Mapping)
fd.Current, fd.Target = adapter.Redact(fd.Current), adapter.Redact(fd.Target)
}
row.Fields = append(row.Fields, fd)
}
out = append(out, row)
@@ -355,67 +361,87 @@ func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log fu
return err
}
// Check tests the access to a cluster: versions (9.x only) and token privileges.
// Check is the result of a connection test: API with the root credentials
// (login, PVE 9.x on every node) and SSH as root on the node.
type Check struct {
Nodes []Node `json:"nodes,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
API bool `json:"api"`
SSH bool `json:"ssh"`
Version string `json:"version,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
// set when something is seen for the first time and must be recorded
TLSFingerprint string `json:"tlsFingerprint,omitempty"`
SSHHostKey string `json:"-"`
SSHFingerprint string `json:"sshFingerprint,omitempty"`
SSHMismatch bool `json:"sshMismatch,omitempty"` // the node presents another key than the recorded one
}
// CheckCluster runs the same checks as the "check" command.
func CheckCluster(ctx context.Context, cl config.Cluster, write, dcOptions bool) Check {
// CheckCluster tests a cluster. pinTLS: the TLS mode pins the certificate and
// none is recorded yet (trust on first use). trust accepts a new SSH host key.
func CheckCluster(ctx context.Context, cl config.Cluster, pinTLS, trust bool) Check {
var res Check
c, err := cl.PVE()
if err != nil {
fail := func(err error) Check {
res.Error = err.Error()
return res
}
if pinTLS && cl.Fingerprint == "" {
fp, err := pve.PeerFingerprint(ctx, cl.URL)
if err != nil {
return fail(fmt.Errorf("TLS: %w", err))
}
cl.Fingerprint, res.TLSFingerprint = fp, fp
}
if cl.Insecure {
res.Warnings = append(res.Warnings, "certificat TLS non vérifié (labo uniquement)")
}
key := cl.SSHHostKey
cl.SSHHostKey = "" // API only for now
c, err := cl.PVE()
if err != nil {
return fail(err)
}
if err := c.Login(ctx); err != nil {
return fail(fmt.Errorf("API: %w", err))
}
vs, err := c.CheckVersion(ctx)
res.Nodes = nodes(vs)
if err != nil {
res.Error = err.Error()
return res
if len(vs) > 0 {
res.Version = vs[0].Version
}
perms, err := c.Permissions(ctx)
if err != nil {
res.Warnings = append(res.Warnings, fmt.Sprintf("token privileges unreadable (%v)", err))
return res
return fail(err)
}
res.Warnings = PrivilegeWarnings(perms, write, dcOptions)
return res
}
res.API = true
// PrivilegeWarnings flags a reference token able to write, and a client token
// missing the privileges needed by the synchronized kinds.
func PrivilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string {
has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok }
anyPath := func(priv string) bool {
for p := range perms {
if has(p, priv) {
return true
}
o := cl.SSHOptions()
if key == "" || trust {
seen, fp, err := sshx.Scan(ctx, o)
if err != nil {
return fail(fmt.Errorf("SSH: %w", err))
}
return false
key, res.SSHHostKey, res.SSHFingerprint = seen, seen, fp
}
var w []string
if !write {
for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} {
if anyPath(p) {
w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu")
}
o.HostKey = key
sh, err := sshx.New(o)
if err != nil {
return fail(fmt.Errorf("SSH: %w", err))
}
out, err := sh.Run(ctx, "pveversion", nil)
if err != nil {
if strings.Contains(err.Error(), "host key mismatch") {
_, fp, _ := sshx.Scan(ctx, o)
res.SSHMismatch, res.SSHFingerprint = true, fp
return fail(fmt.Errorf("SSH: le nœud %s présente la clé d'hôte %s au lieu de %s enregistrée", o.Host, fp, sshx.Fingerprint(key)))
}
return w
return fail(fmt.Errorf("SSH: %w", err))
}
if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") {
w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera")
if !strings.HasPrefix(strings.TrimSpace(string(out)), "pve-manager/") {
return fail(fmt.Errorf("SSH: %s is not a PVE node (pveversion: %q)", o.Host, strings.TrimSpace(string(out))))
}
if dcOptions && !has("/", "Sys.Modify") {
w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites")
}
if !has("/", "Sys.Audit") {
w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles")
}
return w
res.SSH, res.OK = true, true
return res
}
// nodes converts the versions read by the PVE client.
+176
View File
@@ -0,0 +1,176 @@
// Package sshx runs commands on a PVE node over SSH as root, for what the API
// does not cover (the password hashes of the pve realm in /etc/pve/priv/shadow.cfg).
// The host key is pinned: the first connection records it (trust on first use),
// the next ones refuse any other key.
package sshx
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Options of an SSH access.
type Options struct {
Host string // node address
Port int // 22 by default
User string // root by default
Password string // never logged
HostKey string // pinned host key, authorized_keys format; empty: refused (see Scan)
Timeout time.Duration
}
// Client runs commands on one node. Each Run opens its own connection: the
// synchronizations are rare, a connection pool would only hold sessions open.
type Client struct{ o Options }
// New checks the options.
func New(o Options) (*Client, error) {
if o.Host == "" {
return nil, errors.New("ssh: host required")
}
if o.Password == "" {
return nil, errors.New("ssh: password required")
}
if o.HostKey == "" {
return nil, errors.New("ssh: host key not recorded yet, test the cluster first")
}
if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(o.HostKey)); err != nil {
return nil, fmt.Errorf("ssh: recorded host key: %w", err)
}
if o.Port == 0 {
o.Port = 22
}
if o.User == "" {
o.User = "root"
}
if o.Timeout == 0 {
o.Timeout = 15 * time.Second
}
return &Client{o}, nil
}
func (c *Client) addr() string { return net.JoinHostPort(c.o.Host, strconv.Itoa(c.o.Port)) }
// Run executes cmd and returns its standard output. in is written on the
// standard input (secrets are never put on the command line).
func (c *Client) Run(ctx context.Context, cmd string, in []byte) ([]byte, error) {
want, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(c.o.HostKey))
cfg := &ssh.ClientConfig{
User: c.o.User,
Auth: auth(c.o.Password),
HostKeyCallback: ssh.FixedHostKey(want),
Timeout: c.o.Timeout,
}
conn, err := dial(ctx, c.addr(), cfg)
if err != nil {
return nil, err
}
defer conn.Close()
sess, err := conn.NewSession()
if err != nil {
return nil, fmt.Errorf("ssh %s: %w", c.addr(), err)
}
defer sess.Close()
var out, stderr bytes.Buffer
sess.Stdout, sess.Stderr = &out, &stderr
if in != nil {
sess.Stdin = bytes.NewReader(in)
}
done := make(chan error, 1)
go func() { done <- sess.Run(cmd) }()
select {
case err = <-done:
case <-ctx.Done():
conn.Close()
return nil, ctx.Err()
}
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return out.Bytes(), fmt.Errorf("ssh %s: %s", c.addr(), msg)
}
return out.Bytes(), nil
}
// auth: password, and keyboard-interactive answering the password (PAM).
func auth(password string) []ssh.AuthMethod {
return []ssh.AuthMethod{
ssh.Password(password),
ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) {
a := make([]string, len(qs))
for i := range a {
a[i] = password
}
return a, nil
}),
}
}
func dial(ctx context.Context, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
d := net.Dialer{Timeout: cfg.Timeout}
nc, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("ssh %s: %w", addr, err)
}
cc, chans, reqs, err := ssh.NewClientConn(nc, addr, cfg)
if err != nil {
nc.Close()
return nil, fmt.Errorf("ssh %s: %w", addr, err)
}
return ssh.NewClient(cc, chans, reqs), nil
}
// Scan connects without a pinned key and returns the key the node presents
// (authorized_keys format) and its SHA256 fingerprint, after checking that the
// password is accepted. The caller records the key (trust on first use).
func Scan(ctx context.Context, o Options) (key, fingerprint string, err error) {
if o.Port == 0 {
o.Port = 22
}
if o.User == "" {
o.User = "root"
}
if o.Timeout == 0 {
o.Timeout = 15 * time.Second
}
var seen ssh.PublicKey
cfg := &ssh.ClientConfig{
User: o.User,
Auth: auth(o.Password),
HostKeyCallback: func(_ string, _ net.Addr, k ssh.PublicKey) error {
seen = k
return nil
},
Timeout: o.Timeout,
}
addr := net.JoinHostPort(o.Host, strconv.Itoa(o.Port))
conn, err := dial(ctx, addr, cfg)
if seen != nil {
key = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(seen)))
fingerprint = ssh.FingerprintSHA256(seen)
}
if err != nil {
return key, fingerprint, err
}
conn.Close()
return key, fingerprint, nil
}
// Fingerprint returns the SHA256 fingerprint of a recorded key ("" if invalid).
func Fingerprint(key string) string {
k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
return ""
}
return ssh.FingerprintSHA256(k)
}
+3 -10
View File
@@ -18,7 +18,7 @@ import (
"strings"
)
// Box encrypts the token secrets at rest (AES-256-GCM, random nonce, version byte).
// Box encrypts the cluster passwords at rest (AES-256-GCM, random nonce, version byte).
type Box struct{ aead cipher.AEAD }
const boxVersion = 1
@@ -65,7 +65,7 @@ func (b *Box) Open(blob []byte) ([]byte, error) {
return plain, nil
}
// LoadKey returns the encryption key of the token secrets, in this order:
// LoadKey returns the encryption key of the cluster passwords, in this order:
// CONFSYNC_SECRET_KEY (64 hex characters or 44 base64 characters),
// CONFSYNC_SECRET_KEY_FILE (same content, docker secret), or <dataDir>/secret.key,
// created on first start. The last case keeps the key next to the database: fine for
@@ -96,7 +96,7 @@ func LoadKey(dataDir string) ([]byte, string, error) {
if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil {
return nil, "", fmt.Errorf("cannot save the encryption key: %w", err)
}
log.Printf("encryption key created in %s: keep a copy, the token secrets cannot be read without it", path)
log.Printf("encryption key created in %s: keep a copy, the cluster passwords cannot be read without it", path)
return k, path, nil
}
@@ -149,10 +149,3 @@ func CheckPassword(hash, pass string) bool {
got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// RandomPassword returns a password for the first admin account.
func RandomPassword() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+301 -176
View File
@@ -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")
+106 -14
View File
@@ -3,6 +3,7 @@ package store
import (
"bytes"
"context"
"database/sql"
"path/filepath"
"strings"
"testing"
@@ -10,30 +11,30 @@ import (
func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, 32) }
func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
func TestPasswordsEncryptedAndKeyChecked(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "t.db")
st, err := Open(path, testKey(1))
if err != nil {
t.Fatal(err)
}
if err := st.SaveCluster(ctx, "", Cluster{Name: "ref", Reference: true, URL: "https://ref:8006", TokenID: "a@pve!ro", Secret: "s3cr3t-uuid"}); err != nil {
if err := st.SaveCluster(ctx, "", Cluster{Name: "ref", Role: ClusterPrimary, URL: "https://ref:8006", Password: "s3cr3t-root"}); err != nil {
t.Fatal(err)
}
var blob []byte
if err := st.db.QueryRowContext(ctx, `SELECT secret FROM clusters WHERE name = 'ref'`).Scan(&blob); err != nil {
if err := st.db.QueryRowContext(ctx, `SELECT password FROM clusters WHERE name = 'ref'`).Scan(&blob); err != nil {
t.Fatal(err)
}
if bytes.Contains(blob, []byte("s3cr3t")) {
t.Fatal("secret stored in clear")
t.Fatal("password stored in clear")
}
cls, _ := st.Clusters(ctx)
if len(cls) != 1 || cls[0].Secret != "" || !cls[0].HasSecret {
t.Fatalf("listing must hide the secret: %+v", cls)
if len(cls) != 1 || cls[0].Password != "" || !cls[0].HasPassword || cls[0].User != "root@pam" || cls[0].TLS != TLSPin {
t.Fatalf("listing must hide the password: %+v", cls)
}
// an empty secret on update keeps the stored one
// an empty password on update keeps the stored one
c := cls[0]
c.URL = "https://ref2:8006"
c.Timeout = "20s"
if err := st.SaveCluster(ctx, "ref", c); err != nil {
t.Fatal(err)
}
@@ -41,12 +42,8 @@ func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if s, _ := cfg.Reference.ResolveSecret(); s != "s3cr3t-uuid" {
t.Fatalf("secret after update: %q", s)
}
exp, _ := st.ExportConfig(ctx)
if exp.Reference.Secret != "" || exp.Reference.TokenSecretEnv != "PVE_REF_SECRET" {
t.Fatalf("export: %+v", exp.Reference)
if cfg.Reference.Password != "s3cr3t-root" || cfg.Reference.User != "root@pam" {
t.Fatalf("password after update: %+v", cfg.Reference)
}
st.Close()
if _, err := Open(path, testKey(2)); err == nil {
@@ -54,6 +51,74 @@ func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
}
}
func TestRolesAndPins(t *testing.T) {
ctx := context.Background()
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.Config(ctx); err != ErrNoReference {
t.Fatalf("empty database: %v", err)
}
for _, n := range []string{"a", "b", "c"} {
if err := st.SaveCluster(ctx, "", Cluster{Name: n, URL: "https://" + n + ":8006", Password: "pw"}); err != nil {
t.Fatal(err)
}
}
if err := st.SetRole(ctx, "a", ClusterPrimary); err != nil {
t.Fatal(err)
}
if err := st.SetRole(ctx, "c", ClusterDisabled); err != nil {
t.Fatal(err)
}
if err := st.SetRole(ctx, "b", ClusterPrimary); err != nil { // a becomes a target
t.Fatal(err)
}
cfg, err := st.Config(ctx)
if err != nil {
t.Fatal(err)
}
if cfg.Reference.Name != "b" || len(cfg.Clients) != 1 || cfg.Clients[0].Name != "a" {
t.Fatalf("primary %s, targets %+v", cfg.Reference.Name, cfg.Clients)
}
if err := st.Pin(ctx, "a", "AA:BB", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", &Check{OK: true}); err != nil {
t.Fatal(err)
}
_, cc, _ := st.Cluster(ctx, "a")
if cc.Fingerprint != "AA:BB" || cc.SSHHostKey == "" {
t.Fatalf("pins = %+v", cc)
}
// another address: the pins are forgotten
c, _, _ := st.Cluster(ctx, "a")
c.URL = "https://a2:8006"
if err := st.SaveCluster(ctx, "a", c); err != nil {
t.Fatal(err)
}
_, cc, _ = st.Cluster(ctx, "a")
if cc.Fingerprint != "" || cc.SSHHostKey != "" {
t.Fatalf("pins kept after an address change: %+v", cc)
}
}
func TestFirstAdmin(t *testing.T) {
ctx := context.Background()
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateFirstAdmin(ctx, "admin", "too-short"); err == nil {
t.Fatal("short password accepted")
}
if err := st.CreateFirstAdmin(ctx, "admin", "a-long-password"); err != nil {
t.Fatal(err)
}
if err := st.CreateFirstAdmin(ctx, "other", "a-long-password"); err != ErrSetupDone {
t.Fatalf("second setup: %v", err)
}
}
func TestUsers(t *testing.T) {
ctx := context.Background()
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
@@ -99,3 +164,30 @@ func TestPasswordHash(t *testing.T) {
t.Fatal("hash check")
}
}
func TestMigrateFromTokens(t *testing.T) {
path := filepath.Join(t.TempDir(), "t.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE clusters (name TEXT PRIMARY KEY, reference INTEGER, url TEXT, token_id TEXT, secret BLOB, updated_at INTEGER);
INSERT INTO clusters VALUES ('old', 1, 'https://x:8006', 'a@pve!t', x'01', 0);
CREATE TABLE users (name TEXT PRIMARY KEY, role TEXT NOT NULL, password TEXT NOT NULL, epoch INTEGER NOT NULL, created_at INTEGER NOT NULL, last_login INTEGER NOT NULL DEFAULT 0);
INSERT INTO users VALUES ('admin', 'admin', 'h', 1, 1, 0)`); err != nil {
t.Fatal(err)
}
db.Close()
st, err := Open(path, testKey(1))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cls, err := st.Clusters(context.Background())
if err != nil || len(cls) != 0 {
t.Fatalf("clusters after migration: %v %v", cls, err)
}
if n, _ := st.CountUsers(context.Background()); n != 1 {
t.Fatal("users must be kept")
}
}
+30
View File
@@ -180,3 +180,33 @@ func (s *Store) keepAnAdmin(ctx context.Context, name, newRole string) error {
}
return nil
}
// ErrSetupDone: the first administrator already exists.
var ErrSetupDone = errors.New("the first administrator already exists")
// CreateFirstAdmin creates the first administrator from the web page shown on an
// empty database. The insert only happens while the table is empty: two
// concurrent requests cannot both succeed.
func (s *Store) CreateFirstAdmin(ctx context.Context, name, pass string) error {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return errors.New("invalid user name")
}
if len(pass) < 12 {
return errors.New("password: 12 characters minimum")
}
hash, err := HashPassword(pass)
if err != nil {
return err
}
now := time.Now()
res, err := s.db.ExecContext(ctx, `INSERT INTO users (name, role, password, epoch, created_at)
SELECT ?, 'admin', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM users)`, name, hash, now.UnixNano(), now.Unix())
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrSetupDone
}
return nil
}