Full web : fichiers restants
This commit is contained in:
1 parent
ec3b7c9a01
commit
4826fa5b82
26 files changed
+2537
-793
No files matched your search
+36
-12
@@ -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}}}),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user