Synchronisation de configuration Proxmox VE
+ + Se connecter avec le SSO +diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..d32b066 --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,689 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" + + "pve-confsync/internal/config" + "pve-confsync/internal/engine" + "pve-confsync/internal/model" + "pve-confsync/internal/service" + "pve-confsync/internal/store" +) + +// readTimeout bounds a comparison (reading every cluster). +const readTimeout = 2 * time.Minute + +// ---- state (phase 1: clusters and visual diff) ---- + +// handleState returns the last comparison; refresh=1 reads the clusters again. +func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + 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}) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "config") + return + } + ctx, cancel := context.WithTimeout(r.Context(), readTimeout) + defer cancel() + s.state, s.stateAt = service.Analyze(ctx, cfg, nil), time.Now() + } + writeJSON(w, http.StatusOK, map[string]any{"state": s.state}) +} + +// invalidate forgets the cached comparison (configuration changed, apply done). +func (s *Server) invalidate() { + s.stateMu.Lock() + s.state = nil + s.stateMu.Unlock() +} + +// remember merges the clients of a partial comparison into the cached one, so +// that the screen shows what a dry-run just read. +func (s *Server) remember(st *service.State, all bool) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if all || s.state == nil { + if all { + s.state, s.stateAt = st, time.Now() + } + return + } + s.state.Reference = st.Reference + for _, c := range st.Clients { + for i := range s.state.Clients { + if s.state.Clients[i].Name == c.Name { + s.state.Clients[i] = c + } + } + } +} + +// ---- dry-run and apply (phase 2) ---- + +type selection struct { + Scope string `json:"scope"` // global, client or object + 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) +} + +func (sel *selection) check() error { + switch sel.Scope { + case "global": + sel.Client, sel.Objects = "", nil + case "client": + sel.Objects = nil + if sel.Client == "" { + return errors.New("client required") + } + case "object": + if sel.Client == "" || len(sel.Objects) == 0 { + return errors.New("client and objects required") + } + default: + return fmt.Errorf("unknown scope %q", sel.Scope) + } + return nil +} + +func (sel selection) clients() []string { + if sel.Client == "" { + return nil + } + return []string{sel.Client} +} + +// 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) + } + } + rctx, cancel := context.WithTimeout(ctx, readTimeout) + defer cancel() + st := service.Analyze(rctx, cfg, sel.clients()) + s.remember(st, sel.Scope == "global") + return cfg, st, st.Plans(sel.Objects), nil +} + +type runDetail struct { + Plans []service.ClientPlan `json:"plans"` + Results map[string]*clientResult `json:"results,omitempty"` +} + +type clientResult struct { + Status string `json:"status"` // running, ok, error, noop, skipped + Error string `json:"error,omitempty"` + Steps []engine.Step `json:"steps"` +} + +func (s *Server) handleDryRun(w http.ResponseWriter, r *http.Request) { + var sel selection + if err := readJSON(r, &sel); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + if err := sel.check(); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + id := identity(r) + _, _, plans, err := s.plan(r.Context(), sel) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "config") + return + } + status, summary := dryRunSummary(plans) + d, _ := json.Marshal(runDetail{Plans: plans}) + run := store.Run{User: id.User, Mode: "dry-run", Scope: sel.Scope, Clients: planClients(plans), Objects: sel.Objects, + Status: status, Summary: summary, Detail: d} + runID, err := s.store.StartRun(r.Context(), run) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + _ = s.store.UpdateRun(r.Context(), runID, status, summary, d) + s.audit(r, id, "dry-run", target(sel), true, summary) + writeJSON(w, http.StatusOK, map[string]any{"run": runID, "plans": plans, "summary": summary}) +} + +func planClients(plans []service.ClientPlan) []string { + out := make([]string, 0, len(plans)) + for _, p := range plans { + out = append(out, p.Client) + } + return out +} + +func target(sel selection) string { + switch sel.Scope { + case "client": + return sel.Client + case "object": + return sel.Client + ": " + strings.Join(sel.Objects, ", ") + } + return "global" +} + +// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors. +func dryRunSummary(plans []service.ClientPlan) (string, string) { + changes, clients, blocked := 0, 0, 0 + for _, p := range plans { + if p.Error != "" || len(p.Plan.Errors) > 0 { + blocked++ + continue + } + if n := len(p.Plan.Changes); n > 0 { + changes += n + clients++ + } + } + switch { + case blocked > 0: + return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d client(s), %d client(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) +} + +// handleApply recomputes the plans, checks that they are the ones reviewed in the +// dry-run (digests) and the confirmation of the SDN changes, then applies them in +// the background, client after client. Progress goes to the journal. +func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { + var sel selection + if err := readJSON(r, &sel); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + if err := sel.check(); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + id := identity(r) + if !s.applyMu.TryLock() { + writeError(w, http.StatusConflict, "une synchronisation est déjà en cours", "busy") + return + } + started := false + defer func() { + if !started { + s.applyMu.Unlock() + } + }() + cfg, st, plans, err := s.plan(r.Context(), sel) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "config") + return + } + var todo []service.ClientPlan + sdn := false + for _, p := range plans { + switch { + case p.Error != "": + s.audit(r, id, "apply", target(sel), false, p.Client+": "+p.Error) + writeError(w, http.StatusConflict, p.Client+": "+p.Error, "read") + return + case len(p.Plan.Errors) > 0: + s.audit(r, id, "apply", target(sel), false, p.Client+": plan en erreur") + writeError(w, http.StatusConflict, p.Client+": "+strings.Join(p.Plan.Errors, "; "), "plan") + return + case sel.Digests[p.Client] != p.Digest: + s.audit(r, id, "apply", target(sel), false, p.Client+": l'état a changé depuis le dry-run") + writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": l'état a changé depuis le dry-run, relancer le dry-run", + "code": "stale", "plans": plans}) + return + case p.Plan.Empty(): + continue + } + sdn = sdn || p.SDN + todo = append(todo, p) + } + if len(todo) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "aucun écart"}) + return + } + if sdn { + want := sel.Client + if sel.Scope == "global" { + want = cfg.Reference.Name + } + if sel.Confirm != want { + writeError(w, http.StatusBadRequest, "changements SDN : confirmation requise ("+want+")", "confirm") + return + } + } + det := &runDetail{Plans: todo, Results: map[string]*clientResult{}} + for _, p := range todo { + det.Results[p.Client] = &clientResult{Status: "pending", Steps: []engine.Step{}} + } + d, _ := json.Marshal(det) + runID, err := s.store.StartRun(r.Context(), store.Run{User: id.User, Mode: "apply", Scope: sel.Scope, + Clients: planClients(todo), Objects: sel.Objects, Status: store.RunRunning, Summary: "en cours", Detail: d}) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + s.audit(r, id, "apply-start", target(sel), true, fmt.Sprintf("journal #%d", runID)) + started = true + s.jobs.Add(1) + // the request ends now: the job keeps a copy that outlives it (audit IP) + rr := r.Clone(context.WithoutCancel(r.Context())) + go func() { + defer s.jobs.Done() + defer s.applyMu.Unlock() + s.runApply(rr, id, runID, sel, st, det) + }() + writeJSON(w, http.StatusAccepted, map[string]any{"run": runID}) +} + +// runApply applies the plans one client after the other; a failed client does +// not stop the next ones (each has its own SDN lock and rollback). +func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selection, st *service.State, det *runDetail) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + var mu sync.Mutex // det is read by save while a step is appended + save := func(status, summary string) { + mu.Lock() + d, _ := json.Marshal(det) + mu.Unlock() + if err := s.store.UpdateRun(ctx, runID, status, summary, d); err != nil { + log.Printf("journal #%d: %v", runID, err) + } + } + failed, done := 0, 0 + for _, p := range det.Plans { + res := det.Results[p.Client] + res.Status = store.RunRunning + save(store.RunRunning, fmt.Sprintf("%s en cours", p.Client)) + err := st.Apply(ctx, p, sel.Objects, func(step engine.Step) { + mu.Lock() + res.Steps = append(res.Steps, step) + mu.Unlock() + save(store.RunRunning, fmt.Sprintf("%s : %s", p.Client, step.Action)) + }) + if err != nil { + failed++ + res.Status, res.Error = store.RunError, err.Error() + log.Printf("apply %s (journal #%d): %v", p.Client, runID, err) + s.audit(r, id, "apply", p.Client, false, err.Error()) + } else { + done++ + res.Status = store.RunOK + s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d changement(s), vérifié", len(p.Plan.Changes))) + } + } + status := store.RunOK + summary := fmt.Sprintf("%d client(s) synchronisé(s) et vérifié(s)", done) + if failed > 0 { + status = store.RunError + summary = fmt.Sprintf("%d client(s) en échec, %d synchronisé(s)", failed, done) + } + save(status, summary) + s.invalidate() +} + +func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + runs, err := s.store.Runs(r.Context(), limit, before) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if runs == nil { + runs = []store.Run{} + } + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) +} + +func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + run, err := s.store.Run(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "unknown run", "notfound") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + writeJSON(w, http.StatusOK, run) +} + +// ---- clusters and settings (admin) ---- + +func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) { + cls, err := s.store.Clusters(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if cls == nil { + cls = []store.Cluster{} + } + // 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} + } + } + writeJSON(w, http.StatusOK, map[string]any{"clusters": cls}) +} + +func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) { + var c store.Cluster + if err := readJSON(r, &c); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + old := r.PathValue("name") + if old == "-" { // creation + old = "" + } + if err := validateCluster(c); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + 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é" + } + if old != "" && old != c.Name { + detail += " renommé depuis " + old + } + s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + s.invalidate() + 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. +func validateCluster(c store.Cluster) error { + if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") { + return errors.New("url: https:// expected") + } + 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}}} + return cfg.Validate() +} + +func errText(err error, ok string) string { + if err != nil { + return err.Error() + } + return ok +} + +func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + err := s.store.DeleteCluster(r.Context(), name) + s.audit(r, Identity{}, "cluster-delete", name, err == nil, errText(err, "")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error(), "notfound") + return + } + s.invalidate() + 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") + return + } + var cl config.Cluster + write := true + 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") + return + } + cl = c.Cluster + default: + writeError(w, http.StatusBadRequest, "define a reference cluster first", "config") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*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) + writeJSON(w, http.StatusOK, res) +} + +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()}) +} + +// 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.KindVNet: "sync", + model.KindSubnet: "sync", + } +} + +func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) { + var st store.Settings + if err := readJSON(r, &st); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "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 + } + err := cfg.Validate() + if err == nil { + err = s.store.SaveSettings(r.Context(), st) + } + b, _ := json.Marshal(st) + s.audit(r, Identity{}, "settings-save", "synchronisation", err == nil, errText(err, string(b))) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + s.invalidate() + 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) { + us, err := s.store.Users(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if us == nil { + us = []store.User{} + } + writeJSON(w, http.StatusOK, map[string]any{"users": us}) +} + +func (s *Server) handleSaveUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Role string `json:"role"` + Password string `json:"password"` + } + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + name := r.PathValue("name") + err := s.store.SaveUser(r.Context(), name, req.Role, req.Password) + detail := "rôle " + req.Role + if req.Password != "" { + detail += ", mot de passe défini" + } + s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + err := s.store.DeleteUser(r.Context(), name) + s.audit(r, Identity{}, "user-delete", name, err == nil, errText(err, "")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handlePassword lets a local user change their own password. +func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { + id := identity(r) + var req struct { + Old string `json:"old"` + New string `json:"new"` + } + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + if id.Mode != "local" { + writeError(w, http.StatusBadRequest, "password managed by the identity provider", "oidc") + return + } + u, ok := s.store.Authenticate(r.Context(), id.User, req.Old) + if !ok { + time.Sleep(loginFailDelay) + s.audit(r, id, "password-change", id.User, false, "ancien mot de passe incorrect") + writeError(w, http.StatusForbidden, "wrong current password", "password") + return + } + err := s.store.SaveUser(r.Context(), id.User, u.Role, req.New) + s.audit(r, id, "password-change", id.User, err == nil, errText(err, "")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + // the new epoch ends the other sessions; this one gets a new cookie + if nu, err := s.store.User(r.Context(), id.User); err == nil { + s.auth.setSession(w, r, session{User: nu.Name, Role: nu.Role, Mode: "local", Epoch: nu.Epoch}) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + ev, err := s.store.Events(r.Context(), limit, before) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if ev == nil { + ev = []store.Event{} + } + writeJSON(w, http.StatusOK, map[string]any{"events": ev}) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..732722d --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,191 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "pve-confsync/internal/demo" + "pve-confsync/internal/store" +) + +type client struct { + t *testing.T + url string + c *http.Client +} + +func newServer(t *testing.T) string { + t.Helper() + 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) + } + clusters, err := demo.Start(ctx, st) + if err != nil { + t.Fatal(err) + } + srv, err := New(Options{Store: st, Version: "test", Demo: true, + Static: fstest.MapFS{"index.html": {Data: []byte("")}, "login.html": {Data: []byte("")}}, + Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}}) + if err != nil { + t.Fatal(err) + } + hs := httptest.NewServer(srv.Handler()) + t.Cleanup(func() { hs.Close(); srv.Wait(); clusters.Close(); st.Close() }) + return hs.URL +} + +func login(t *testing.T, base, user string) *client { + jar, _ := cookiejar.New(nil) + c := &client{t: t, url: base, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} + r, err := c.c.PostForm(base+"/auth/login", url.Values{"user": {user}, "pass": {demo.Password}}) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if loc := r.Header.Get("Location"); r.StatusCode != http.StatusSeeOther || loc != "/" { + t.Fatalf("login %s: %d %s", user, r.StatusCode, loc) + } + return c +} + +func (c *client) do(method, path string, body any, csrf bool) (int, map[string]any) { + c.t.Helper() + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, c.url+path, rd) + req.Header.Set("Content-Type", "application/json") + if csrf { + req.Header.Set("X-Confsync", "1") + } + r, err := c.c.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer r.Body.Close() + var out map[string]any + _ = json.NewDecoder(r.Body).Decode(&out) + return r.StatusCode, out +} + +func digests(d map[string]any) map[string]string { + out := map[string]string{} + for _, p := range d["plans"].([]any) { + p := p.(map[string]any) + out[p["client"].(string)] = p["digest"].(string) + } + return out +} + +func TestAuthAndRoles(t *testing.T) { + base := newServer(t) + anon := &client{t: t, url: base, c: http.DefaultClient} + if code, _ := anon.do("GET", "/api/state", nil, false); code != http.StatusUnauthorized { + t.Fatalf("anonymous state: %d", code) + } + viewer := login(t, base, "lecteur") + if code, _ := viewer.do("GET", "/api/state", nil, false); code != http.StatusOK { + t.Fatalf("viewer state: %d", code) + } + if code, _ := viewer.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, true); code != http.StatusForbidden { + t.Fatalf("viewer dry-run: %d", code) + } + op := login(t, base, "operateur") + if code, _ := op.do("GET", "/api/audit", nil, false); code != http.StatusForbidden { + t.Fatalf("operator audit: %d", code) + } + if code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, false); code != http.StatusForbidden || d["code"] != "csrf" { + t.Fatalf("dry-run without header: %d %v", code, d) + } + code, d := op.do("GET", "/api/clusters", nil, false) + if code != http.StatusOK { + 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) + } + } +} + +func TestDryRunAndApply(t *testing.T) { + base := newServer(t) + op := login(t, base, "operateur") + + // ceph needs the zone stor, which site-b cannot create (no bridge mapping) + code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}}, true) + if code != http.StatusOK { + t.Fatalf("dry-run site-b: %d %v", code, d) + } + p := d["plans"].([]any)[0].(map[string]any)["plan"].(map[string]any) + if errs, _ := p["errors"].([]any); len(errs) == 0 { + t.Fatalf("ceph without its zone must be blocked: %v", p) + } + code, d = op.do("POST", "/api/apply", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}, + "digests": digests(d), "confirm": "site-b"}, true) + if code != http.StatusConflict { + t.Fatalf("blocked apply: %d %v", code, d) + } + + sel := map[string]any{"scope": "client", "client": "site-a"} + code, d = op.do("POST", "/api/dryrun", sel, true) + if code != http.StatusOK { + t.Fatalf("dry-run site-a: %d %v", code, d) + } + 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) + } + stale := map[string]any{"scope": "client", "client": "site-a", "digests": map[string]string{"site-a": "0"}, "confirm": "site-a"} + if code, d := op.do("POST", "/api/apply", stale, true); code != http.StatusConflict || d["code"] != "stale" { + t.Fatalf("stale digest: %d %v", code, d) + } + sel["confirm"] = "site-a" + code, d = op.do("POST", "/api/apply", sel, true) + if 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 = op.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) + } + _, d = op.do("GET", "/api/state?refresh=1", nil, false) + for _, c := range d["state"].(map[string]any)["clients"].([]any) { + c := c.(map[string]any) + if c["name"] == "site-a" { + if ch, _ := c["plan"].(map[string]any)["changes"].([]any); len(ch) != 0 { + t.Fatalf("site-a still differs after the apply: %v", ch) + } + } + } + admin := login(t, base, "admin") + _, d = admin.do("GET", "/api/audit", nil, false) + if len(d["events"].([]any)) == 0 { + t.Fatal("empty audit trail") + } +} + +func itoa(i int) string { b, _ := json.Marshal(i); return string(b) } diff --git a/web/boot.js b/web/boot.js new file mode 100644 index 0000000..0b164a3 --- /dev/null +++ b/web/boot.js @@ -0,0 +1,10 @@ +// Saved theme, language and density applied before the first paint (no flash). +// External file: the Content-Security-Policy forbids inline scripts. +try { + var t = localStorage.getItem('confsync.theme'); + if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; + var l = localStorage.getItem('confsync.lang'); + if (l) document.documentElement.lang = l; + var d = localStorage.getItem('confsync.density'); + if (d === 'compact') document.documentElement.dataset.density = d; +} catch (e) {} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..c78bc7f --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +// Package web holds the pages of the web UI, embedded in the binary. +package web + +import "embed" + +// FS is index.html, login.html, style.css, boot.js, app.js and favicon.svg. +// +//go:embed *.html *.css *.js *.svg +var FS embed.FS diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..197ca9f --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..592cd82 --- /dev/null +++ b/web/login.html @@ -0,0 +1,47 @@ + + +
+ + +Synchronisation de configuration Proxmox VE
+ + Se connecter avec le SSO + elements, never HTML from a string
+ d.demo.split(/(\{[aolp]\})/).forEach(function (part) {
+ var v = { '{a}': 'admin', '{o}': 'operateur', '{l}': 'lecteur', '{p}': 'confsync-demo' }[part];
+ h.appendChild(v ? code(v) : document.createTextNode(part));
+ });
+ }
+ }
+ document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) {
+ b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); });
+ });
+ el('themeBtn').addEventListener('click', function () {
+ var root = document.documentElement;
+ var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
+ root.dataset.theme = dark ? 'light' : 'dark';
+ set('theme', root.dataset.theme);
+ });
+
+ var q = new URLSearchParams(location.search);
+ var ret = q.get('r') || '/';
+ el('ret').value = ret;
+ el('err').hidden = q.get('e') !== '1';
+ el('ssoBtn').href = 'auth/oidc?r=' + encodeURIComponent(ret);
+
+ function show() {
+ el('localForm').hidden = !cfg.local;
+ el('ssoBtn').hidden = !cfg.oidc;
+ el('sep').hidden = !(cfg.local && cfg.oidc);
+ el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary';
+ el('demoHint').hidden = !cfg.demo;
+ applyLang();
+ }
+ show();
+ fetch('auth/config', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.json(); })
+ .then(function (c) { cfg = c; show(); })
+ .catch(function () {});
+})();