server : API (état, dry-run, apply avec empreinte et confirmation SDN, journal, paramètres), page de connexion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-13 16:03:56 +02:00
1 parent 3aae9ce925
commit 010034b988
7 files changed
+1014

No files matched your search

+689
View File
@@ -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})
}