Files
pve-confsync/internal/pve/pvetest/fake.go
T
2026-10-23 17:43:20 +02:00

376 lines
11 KiB
Go

// Package pvetest is an in-memory fake of the subset of the PVE 9.x API used by
// 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 (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"sync"
)
// SDN holds one SDN configuration (pending or running).
type SDN struct {
Zones map[string]map[string]any
VNets map[string]map[string]any
Subnets map[string]map[string]any // id "zone-ip-mask" -> fields (with vnet)
}
func newSDN() SDN {
return SDN{map[string]map[string]any{}, map[string]map[string]any{}, map[string]map[string]any{}}
}
func (s SDN) clone() SDN {
c := newSDN()
cp := func(dst, src map[string]map[string]any) {
for k, v := range src {
m := map[string]any{}
for f, x := range v {
m[f] = x
}
dst[k] = m
}
}
cp(c.Zones, s.Zones)
cp(c.VNets, s.VNets)
cp(c.Subnets, s.Subnets)
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
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
}
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, 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()
defer s.mu.Unlock()
s.Running = s.Pending.clone()
}
func reply(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
}
func fail(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]any{"data": nil, "message": msg})
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
_ = 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
}
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 {
s.Calls = append(s.Calls, key)
}
if s.FailOn != "" && s.FailOn == key {
fail(w, 500, "injected failure")
return
}
// writes on SDN objects must carry the token while the lock is held
if r.Method != http.MethodGet && strings.HasPrefix(p, "cluster/sdn/") && p != "cluster/sdn/lock" && p != "cluster/sdn/rollback" {
if s.Lock != "" && r.Form.Get("lock-token") != s.Lock {
fail(w, 500, "SDN config is locked")
return
}
}
running := r.Form.Get("running") == "1"
cfg := &s.Pending
if running {
cfg = &s.Running
}
switch {
case key == "GET version" || (r.Method == "GET" && len(seg) == 3 && seg[0] == "nodes" && seg[2] == "version"):
reply(w, map[string]any{"version": s.Version, "release": release(s.Version), "repoid": "abcdef"})
case key == "GET cluster/status":
reply(w, []any{
map[string]any{"type": "cluster", "name": "fake", "quorate": 1},
map[string]any{"type": "node", "name": "n1", "online": 1},
})
case r.Method == "GET" && len(seg) == 4 && seg[0] == "nodes" && seg[2] == "sdn" && seg[3] == "zones":
var out []any
for id := range s.Running.Zones {
out = append(out, map[string]any{"zone": id, "status": "available"})
}
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 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":
for k, v := range r.PostForm {
if k == "delete" {
for _, d := range strings.Split(v[0], ",") {
delete(s.Options, d)
}
continue
}
s.Options[k] = v[0]
}
reply(w, nil)
case key == "POST cluster/sdn/lock":
if r.Form.Get("allow-pending") != "1" && s.hasPending() {
fail(w, 500, "configuration has pending changes")
return
}
if s.Lock != "" {
fail(w, 500, "could not acquire lock for SDN config")
return
}
s.locks++
s.Lock = fmt.Sprintf("token-%d", s.locks)
reply(w, s.Lock)
case key == "POST cluster/sdn/rollback":
if r.Form.Get("lock-token") != s.Lock {
fail(w, 500, "bad token")
return
}
s.Pending = s.Running.clone()
if r.Form.Get("release-lock") != "0" {
s.Lock = ""
}
reply(w, nil)
case key == "PUT cluster/sdn":
s.Running = s.Pending.clone()
if r.Form.Get("release-lock") != "0" {
s.Lock = ""
}
reply(w, "UPID:n1:00001234:00005678:65000000:reloadnetworkall::root@pam!confsync:")
case p == "cluster/sdn/zones" && r.Method == "GET":
reply(w, list(cfg.Zones, "zone"))
case p == "cluster/sdn/zones" && r.Method == "POST":
create(w, r, cfg.Zones, "zone", "lock-token")
case len(seg) == 4 && seg[2] == "zones":
item(w, r, cfg.Zones, seg[3])
case p == "cluster/sdn/vnets" && r.Method == "GET":
reply(w, list(cfg.VNets, "vnet"))
case p == "cluster/sdn/vnets" && r.Method == "POST":
if z := r.PostForm.Get("zone"); s.Pending.Zones[z] == nil {
fail(w, 400, "zone "+z+" does not exist")
return
}
create(w, r, cfg.VNets, "vnet", "lock-token")
case len(seg) == 4 && seg[2] == "vnets":
item(w, r, cfg.VNets, seg[3])
case len(seg) == 5 && seg[2] == "vnets" && seg[4] == "subnets" && r.Method == "GET":
m := map[string]map[string]any{}
for id, f := range cfg.Subnets {
if f["vnet"] == seg[3] {
m[id] = f
}
}
reply(w, list(m, "subnet"))
case len(seg) == 5 && seg[2] == "vnets" && seg[4] == "subnets" && r.Method == "POST":
v := s.Pending.VNets[seg[3]]
if v == nil {
fail(w, 400, "vnet does not exist")
return
}
cidr := r.PostForm.Get("subnet")
id := fmt.Sprint(v["zone"]) + "-" + strings.Replace(cidr, "/", "-", 1)
if s.Pending.Subnets[id] != nil {
fail(w, 400, "already defined")
return
}
f := fields(r.PostForm, "subnet", "type", "lock-token")
f["vnet"] = seg[3]
s.Pending.Subnets[id] = f
reply(w, nil)
case len(seg) == 6 && seg[2] == "vnets" && seg[4] == "subnets":
item(w, r, cfg.Subnets, seg[5])
default:
fail(w, 501, "not implemented in fake: "+key)
}
}
func release(v string) string {
p := strings.SplitN(v, ".", 3)
if len(p) < 2 {
return v
}
return p[0] + "." + p[1]
}
func (s *Server) hasPending() bool {
a, _ := json.Marshal(s.Pending)
b, _ := json.Marshal(s.Running)
return string(a) != string(b)
}
// parsedOptions mimics parse_datacenter_config: some options come back as objects/lists.
func (s *Server) parsedOptions() map[string]any {
out := map[string]any{"description": "", "mac_prefix": "BC:24:11", "allowed-tags": []string{}}
for k, v := range s.Options {
switch k {
case "registered-tags":
out[k] = strings.Split(v, ";")
case "tag-style", "next-id", "user-tag-access", "migration", "ha", "crs":
m := map[string]any{}
for _, kv := range strings.Split(v, ",") {
p := strings.SplitN(kv, "=", 2)
if len(p) != 2 {
continue
}
if k == "user-tag-access" && p[0] == "user-allow-list" {
m[p[0]] = strings.Split(p[1], ";")
continue
}
if k == "next-id" {
var n json.Number = json.Number(p[1])
m[p[0]] = n
continue
}
m[p[0]] = p[1]
}
out[k] = m
default:
out[k] = v
}
}
return out
}
func list(m map[string]map[string]any, idKey string) []any {
ids := make([]string, 0, len(m))
for id := range m {
ids = append(ids, id)
}
sort.Strings(ids)
out := []any{}
for _, id := range ids {
o := map[string]any{idKey: id, "digest": "0123"}
for k, v := range m[id] {
o[k] = v
}
out = append(out, o)
}
return out
}
func fields(f url.Values, skip ...string) map[string]any {
out := map[string]any{}
sk := map[string]bool{}
for _, s := range skip {
sk[s] = true
}
for k, v := range f {
if sk[k] {
continue
}
if len(v) > 1 || k == "dhcp-range" { // array in the PVE schema
l := make([]any, len(v))
for i := range v {
l[i] = v[i]
}
out[k] = l
} else {
out[k] = v[0]
}
}
return out
}
func create(w http.ResponseWriter, r *http.Request, m map[string]map[string]any, idKey string, skip ...string) {
id := r.PostForm.Get(idKey)
if m[id] != nil {
fail(w, 400, idKey+" "+id+" already defined")
return
}
m[id] = fields(r.PostForm, append(skip, idKey)...)
reply(w, nil)
}
func item(w http.ResponseWriter, r *http.Request, m map[string]map[string]any, id string) {
o := m[id]
if o == nil {
fail(w, 404, id+" does not exist")
return
}
switch r.Method {
case http.MethodPut:
for k, v := range fields(r.PostForm, "lock-token", "delete", "digest") {
o[k] = v
}
if d := r.PostForm.Get("delete"); d != "" {
for _, k := range strings.Split(d, ",") {
delete(o, k)
}
}
reply(w, nil)
case http.MethodDelete:
delete(m, id)
reply(w, nil)
default:
reply(w, o)
}
}