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