Full web : fichiers restants

This commit is contained in:
cedric committed 2026-10-23 17:43:20 +02:00
1 parent ec3b7c9a01
commit 4826fa5b82
26 files changed
+2537 -793

No files matched your search

+138 -109
View File
@@ -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) {