UI : vocabulaire unifié, comptes + mots de passe, choix par élément, clé AES auto

This commit is contained in:
claude Bot committed 2026-10-23 18:47:04 +02:00
1 parent ee8343c69f
commit 2ca2f87307
19 files changed
+377 -143

No files matched your search

+45 -45
View File
@@ -88,11 +88,15 @@ type selection struct {
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"
Skip []model.Kind `json:"skip"` // kinds left out of this run ("not synchronized")
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
Confirm string `json:"confirm"` // apply with SDN changes: target name (primary name for global)
}
func (sel *selection) check() error {
if len(sel.Skip) > 0 {
sel.Skip = adapter.Bundle(sel.Skip)
}
switch sel.Scope {
case "global":
sel.Client, sel.Objects = "", nil
@@ -137,7 +141,7 @@ func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *serv
defer cancel()
st := service.Analyze(rctx, cfg, sel.clients())
s.remember(st, sel.all())
return cfg, st, st.Plans(sel.Objects), nil
return cfg, st, st.Plans(sel.Objects, sel.Skip), nil
}
type runDetail struct {
@@ -190,16 +194,25 @@ func planClients(plans []service.ClientPlan) []string {
}
func target(sel selection) string {
switch sel.Scope {
case "client":
return sel.Client
case "object":
return sel.Client + ": " + strings.Join(sel.Objects, ", ")
var t string
switch {
case sel.Scope == "client":
t = sel.Client
case sel.Scope == "object":
t = sel.Client + ": " + strings.Join(sel.Objects, ", ")
case len(sel.Targets) > 0:
t = "targets " + strings.Join(sel.Targets, ", ")
default:
t = "all targets"
}
if len(sel.Targets) > 0 {
return "cibles " + strings.Join(sel.Targets, ", ")
if len(sel.Skip) > 0 {
var ks []string
for _, k := range sel.Skip {
ks = append(ks, string(k))
}
t += " (not replicated: " + strings.Join(ks, ", ") + ")"
}
return "toutes les cibles"
return t
}
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
@@ -217,11 +230,11 @@ func dryRunSummary(plans []service.ClientPlan) (string, string) {
}
switch {
case blocked > 0:
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d cible(s), %d cible(s) en erreur", changes, clients, blocked)
return store.RunBlocked, fmt.Sprintf("%d change(s) on %d target(s), %d target(s) in error", changes, clients, blocked)
case changes == 0:
return store.RunNoop, "aucun écart"
return store.RunNoop, "no difference"
}
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d cible(s)", changes, clients)
return store.RunOK, fmt.Sprintf("%d change(s) on %d target(s)", changes, clients)
}
// handleApply recomputes the plans, checks that they are the ones reviewed in the
@@ -239,7 +252,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
}
id := identity(r)
if !s.applyMu.TryLock() {
writeError(w, http.StatusConflict, "une synchronisation est déjà en cours", "busy")
writeError(w, http.StatusConflict, "a replication is already running", "busy")
return
}
started := false
@@ -266,8 +279,8 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
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",
s.audit(r, id, "apply", target(sel), false, p.Client+": the state changed since the dry-run")
writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": the state changed since the dry-run, run it again",
"code": "stale", "plans": plans})
return
case p.Plan.Empty():
@@ -277,7 +290,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
todo = append(todo, p)
}
if len(todo) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "aucun écart"})
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "no difference"})
return
}
if sdn {
@@ -286,7 +299,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
want = cfg.Reference.Name
}
if sel.Confirm != want {
writeError(w, http.StatusBadRequest, "changements SDN : confirmation requise ("+want+")", "confirm")
writeError(w, http.StatusBadRequest, "SDN changes: confirmation required ("+want+")", "confirm")
return
}
}
@@ -333,7 +346,7 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
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) {
err := st.Apply(ctx, p, sel.Objects, sel.Skip, func(step engine.Step) {
mu.Lock()
res.Steps = append(res.Steps, step)
mu.Unlock()
@@ -347,14 +360,14 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
} 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)))
s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d change(s), verified", len(p.Plan.Changes)))
}
}
status := store.RunOK
summary := fmt.Sprintf("%d cible(s) synchronisée(s) et vérifiée(s)", done)
summary := fmt.Sprintf("%d target(s) replicated and verified", done)
if failed > 0 {
status = store.RunError
summary = fmt.Sprintf("%d cible(s) en échec, %d synchronisée(s)", failed, done)
summary = fmt.Sprintf("%d target(s) failed, %d replicated", failed, done)
}
save(status, summary)
s.invalidate()
@@ -423,12 +436,12 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
return
}
err := s.store.SaveCluster(r.Context(), old, c)
detail := fmt.Sprintf("url=%s rôle=%s tls=%s ssh=%s:%d", c.URL, c.Role, c.TLS, c.SSHHost, c.SSHPort)
detail := fmt.Sprintf("url=%s role=%s tls=%s ssh=%s:%d", c.URL, c.Role, c.TLS, c.SSHHost, c.SSHPort)
if c.Password != "" {
detail += " mot de passe=modifié"
detail += " password=changed"
}
if old != "" && old != c.Name {
detail += " renommé depuis " + old
detail += " renamed from " + old
}
s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail))
if err != nil {
@@ -546,10 +559,10 @@ func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
}
detail := res.Error
if fp != "" {
detail += " certificat TLS enregistré " + fp
detail += " TLS certificate recorded " + fp
}
if res.SSHHostKey != "" {
detail += " clé SSH enregistrée " + res.SSHFingerprint
detail += " SSH key recorded " + res.SSHFingerprint
}
s.audit(r, Identity{}, "cluster-check", name, res.OK, strings.TrimSpace(detail))
if fp != "" || res.SSHHostKey != "" {
@@ -563,7 +576,7 @@ func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
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"))
s.audit(r, Identity{}, "cluster-forget", name, err == nil, errText(err, "TLS fingerprint and SSH key forgotten"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error(), "notfound")
return
@@ -578,21 +591,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
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.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",
}
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "deps": adapter.KindDeps, "bundles": adapter.KindBundles})
}
func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
@@ -601,6 +600,7 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
st.Kinds = adapter.Bundle(st.Kinds)
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
Reference: config.Cluster{Name: "\x00ref"}}
err := cfg.Validate()
@@ -611,7 +611,7 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
err = s.store.SaveSettings(r.Context(), st)
}
b, _ := json.Marshal(st)
s.audit(r, Identity{}, "settings-save", "synchronisation", err == nil, errText(err, string(b)))
s.audit(r, Identity{}, "settings-save", "replication", err == nil, errText(err, string(b)))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
@@ -645,9 +645,9 @@ func (s *Server) handleSaveUser(w http.ResponseWriter, r *http.Request) {
}
name := r.PathValue("name")
err := s.store.SaveUser(r.Context(), name, req.Role, req.Password)
detail := "rôle " + req.Role
detail := "role " + req.Role
if req.Password != "" {
detail += ", mot de passe défini"
detail += ", password set"
}
s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail))
if err != nil {
+1 -1
View File
@@ -344,7 +344,7 @@ func (a *Auth) handleSetup(w http.ResponseWriter, r *http.Request) {
}
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.audit(r, id, "setup", u.Name, true, "first administrator created")
a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch})
http.Redirect(w, r, "/#clusters", http.StatusSeeOther)
}
+6
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
@@ -276,6 +277,11 @@ func TestFromScratch(t *testing.T) {
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)
}
// accounts left out of the run ("do not replicate"): their passwords go with them
skipped := map[string]any{"scope": "global", "targets": []string{"tgt"}, "skip": []string{"pve-user", "pve-group", "pve-role"}}
if code, d := adm.do("POST", "/api/dryrun", skipped, true); code != http.StatusOK || strings.Contains(fmt.Sprint(d["plans"]), "pve-") {
t.Fatalf("dry-run without the accounts: %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 {