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