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}) }