UI : vocabulaire unifié, comptes + mots de passe, choix par élément, clé AES auto
This commit is contained in:
1 parent
ee8343c69f
commit
2ca2f87307
19 files changed
+377
-143
No files matched your search
+2
-3
@@ -6,7 +6,7 @@
|
||||
|
||||
# ---------- Commun ----------
|
||||
TZ=Europe/Paris
|
||||
# Port publié par docker-compose.demo.yml
|
||||
# Port publié sur l'hôte (docker-compose.yml et docker-compose.demo.yml)
|
||||
# CONFSYNC_PORT=8080
|
||||
|
||||
# ---------- Interface web ----------
|
||||
@@ -18,8 +18,7 @@ TZ=Europe/Paris
|
||||
# Clé AES-256 des mots de passe root en base (64 caractères hex ou base64 de 32 octets) :
|
||||
# openssl rand -hex 32
|
||||
# Ordre de lecture : CONFSYNC_SECRET_KEY, puis CONFSYNC_SECRET_KEY_FILE, sinon DATA_DIR/secret.key
|
||||
# (créée au 1er démarrage). Le compose fournit déjà CONFSYNC_SECRET_KEY_FILE ; une valeur
|
||||
# CONFSYNC_SECRET_KEY ici serait prioritaire. Sans cette clé, les mots de passe enregistrés sont perdus.
|
||||
# (créée automatiquement au 1er démarrage). Une valeur CONFSYNC_SECRET_KEY ici est prioritaire. Sans cette clé, les mots de passe enregistrés sont perdus.
|
||||
# CONFSYNC_SECRET_KEY=
|
||||
# CONFSYNC_SECRET_KEY_FILE=/run/secrets/confsync_key
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ Testé contre un faux serveur PVE (API + SSH) ; **pas encore testé sur un vrai
|
||||
| `sdn-vnet` | API `/cluster/sdn/vnets` | `sync` |
|
||||
| `sdn-subnet` | API `/cluster/sdn/vnets/{vnet}/subnets` | `sync` |
|
||||
|
||||
Dépendances : un mot de passe exige son compte, un compte exige ses groupes ; le menu
|
||||
« Éléments répliqués » les coche ensemble. Un parent absent de la cible et non répliqué
|
||||
Les comptes du realm pve et leurs mots de passe forment un seul élément à répliquer ;
|
||||
ils exigent leurs groupes, que le menu « Éléments répliqués » coche avec eux. Un parent absent de la cible et non répliqué
|
||||
est une erreur bloquante.
|
||||
|
||||
Hors périmètre : EVPN (et les zones simple, QinQ, faucet, avec leurs VNets, ignorées
|
||||
@@ -66,15 +66,10 @@ docker compose -f docker-compose.demo.yml up -d --build # même démo en conte
|
||||
docker compose up -d # production : données dans le volume /data
|
||||
```
|
||||
|
||||
Avant le premier `docker compose up`, créer la clé AES-256 des mots de passe en base
|
||||
(secret docker, à sauvegarder : sans elle, les mots de passe enregistrés sont perdus) :
|
||||
|
||||
```sh
|
||||
mkdir -p secrets
|
||||
openssl rand -hex 32 > secrets/confsync_key # 64 caractères hex (32 octets)
|
||||
chmod 600 secrets/confsync_key
|
||||
sudo chown 10001 secrets/confsync_key # lisible par l'utilisateur du conteneur
|
||||
```
|
||||
La clé AES-256 des mots de passe en base est créée au premier démarrage dans
|
||||
`/data/secret.key` (à sauvegarder : sans elle, les mots de passe enregistrés sont perdus).
|
||||
En production, on peut la garder hors du volume en secret docker (`CONFSYNC_SECRET_KEY_FILE`,
|
||||
voir les commentaires de `docker-compose.yml`). Port publié : `CONFSYNC_PORT` (`.env`, 8080 par défaut).
|
||||
|
||||
Le mode démo démarre un primaire PVE 9.1, deux cibles avec des écarts (dont un mapping
|
||||
manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`,
|
||||
@@ -97,6 +92,8 @@ devient administrateur** : ne pas l'exposer avant. Alternative : `CONFSYNC_ADMIN
|
||||
chaque champ la politique, la valeur du primaire, le mapping, la valeur actuelle et
|
||||
la valeur après réplication. Les hachés sont masqués (`<masqué xxxxxx>`).
|
||||
- **Dry-run / Répliquer** par objet (avec ses parents à créer), par cible ou global.
|
||||
Dans le dry-run, chaque élément peut être exclu de l'exécution (« Ne pas répliquer ») ;
|
||||
le dry-run est alors relancé sans lui, sauf ce dont les autres ont besoin.
|
||||
La réplication passe toujours par un dry-run : le serveur recalcule le plan et refuse
|
||||
s'il a changé depuis (empreinte du plan). Avec des changements SDN, il faut taper le
|
||||
nom de la cible (celui du primaire en global). Une seule réplication à la fois.
|
||||
|
||||
+12
-10
@@ -1,5 +1,5 @@
|
||||
# Interface web :
|
||||
# docker compose up -d # http://<hôte>:8080
|
||||
# docker compose up -d # http://<hôte>:${CONFSYNC_PORT:-8080}
|
||||
# Base vide : la page de connexion crée le premier administrateur (ne pas exposer
|
||||
# l'instance avant), sauf si CONFSYNC_ADMIN_PASSWORD(_FILE) est fourni.
|
||||
# Clusters, mots de passe root (chiffrés en base) et réglages : tout se fait dans l'UI.
|
||||
@@ -10,10 +10,12 @@ services:
|
||||
container_name: pve-confsync
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure)
|
||||
- "${CONFSYNC_PORT:-8080}:8080" # port publié (.env) ; ou derrière Traefik (HTTPS : cookies Secure)
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Paris}
|
||||
CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key # clé AES des mots de passe en base, hors volume /data
|
||||
# Clé AES des mots de passe en base : créée au premier démarrage dans /data/secret.key.
|
||||
# Pour la garder hors du volume /data (recommandé en production) : secret docker ci-dessous.
|
||||
# CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key
|
||||
# CONFSYNC_ADMIN_PASSWORD_FILE: /run/secrets/confsync_admin
|
||||
# OIDC_ISSUER: https://auth.example.org/application/o/confsync/
|
||||
# OIDC_CLIENT_ID: confsync
|
||||
@@ -25,15 +27,15 @@ services:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- confsync-data:/data # SQLite, session.key
|
||||
secrets:
|
||||
- confsync_key
|
||||
- confsync-data:/data # SQLite, secret.key, session.key
|
||||
# secrets:
|
||||
# - confsync_key
|
||||
|
||||
# Fichier chmod 600, propriétaire 10001, monté dans /run/secrets/confsync_key.
|
||||
# Optionnel : clé fournie en secret docker (fichier chmod 600, propriétaire 10001).
|
||||
# mkdir -p secrets && openssl rand -hex 32 > secrets/confsync_key
|
||||
secrets:
|
||||
confsync_key:
|
||||
file: ./secrets/confsync_key
|
||||
# secrets:
|
||||
# confsync_key:
|
||||
# file: ./secrets/confsync_key
|
||||
|
||||
volumes:
|
||||
confsync-data:
|
||||
@@ -72,6 +72,36 @@ var KindDeps = map[model.Kind][]model.Kind{
|
||||
model.KindUser: {model.KindGroup},
|
||||
}
|
||||
|
||||
// KindBundles are kinds chosen as one item: the accounts of the pve realm go
|
||||
// with their passwords (the API holds the accounts, shadow.cfg the passwords).
|
||||
var KindBundles = [][]model.Kind{{model.KindUser, model.KindPassword}}
|
||||
|
||||
// Bundle adds to a list of kinds the other kinds of their bundles, in the
|
||||
// order of model.Kinds.
|
||||
func Bundle(kinds []model.Kind) []model.Kind {
|
||||
in := map[model.Kind]bool{}
|
||||
for _, k := range kinds {
|
||||
in[k] = true
|
||||
}
|
||||
for _, b := range KindBundles {
|
||||
for _, k := range b {
|
||||
if in[k] {
|
||||
for _, x := range b {
|
||||
in[x] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out := []model.Kind{}
|
||||
for _, k := range model.Kinds {
|
||||
if in[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Sensitive is implemented by adapters whose fields must never be shown nor
|
||||
// journaled (password hashes): they are replaced by Redact.
|
||||
type Sensitive interface {
|
||||
@@ -102,7 +132,7 @@ func Redact(v any) any {
|
||||
return v
|
||||
}
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return "<masqué " + hex.EncodeToString(sum[:3]) + ">"
|
||||
return "<hidden " + hex.EncodeToString(sum[:3]) + ">"
|
||||
}
|
||||
|
||||
// Parents returns the keys of the objects o needs on the target.
|
||||
|
||||
@@ -56,7 +56,7 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
|
||||
ref.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
|
||||
ref.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
|
||||
ref.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
|
||||
ref.Users["carol@pam"] = map[string]any{"enable": 1, "comment": "compte Linux, non répliqué"}
|
||||
ref.Users["carol@pam"] = map[string]any{"enable": 1, "comment": "Linux account, not replicated"}
|
||||
ref.Shadow["alice"] = "$5$Kd1Xa9$Q8yJdemoAliceHashOnlyForTheDemo0123456789AB"
|
||||
ref.Shadow["bob"] = "$5$Pq7Zr2$W3vLdemoBobHashOnlyForTheDemo0123456789ABCDE"
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func ApplyWithLog(ctx context.Context, c *pve.Client, plan Plan, notify func(Ste
|
||||
err := c.Post(ctx, "cluster/sdn/lock", nil, &token)
|
||||
res.log("sdn lock", err)
|
||||
if err != nil {
|
||||
res.Err = fmt.Errorf("SDN lock (pending changes on the client?): %w", err)
|
||||
res.Err = fmt.Errorf("SDN lock (pending changes on the target?): %w", err)
|
||||
return res
|
||||
}
|
||||
rollback := func(cause error) {
|
||||
|
||||
@@ -158,13 +158,13 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
|
||||
o, exists := cur[r.ID]
|
||||
if !exists {
|
||||
if a.Singleton() {
|
||||
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: not found on client", r.Key()))
|
||||
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: not found on the target", r.Key()))
|
||||
continue
|
||||
}
|
||||
for _, f := range a.Required(model.Object{Kind: k, ID: r.ID, Fields: desired}) {
|
||||
if _, ok := desired[f]; !ok {
|
||||
plan.Errors = append(plan.Errors,
|
||||
fmt.Sprintf("%s: field %q required for creation, add it to the mapping of client %s", r.Key(), f, cl.Name))
|
||||
fmt.Sprintf("%s: field %q required for creation, add it to the mapping of target %s", r.Key(), f, cl.Name))
|
||||
}
|
||||
}
|
||||
plan.Changes = append(plan.Changes, Change{Kind: k, ID: r.ID, Op: OpCreate, Set: desired,
|
||||
|
||||
@@ -151,3 +151,37 @@ func (p Plan) Digest() string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
// Skip removes from a plan the changes of the kinds left out of a replication
|
||||
// ("not synchronized" in the dry-run), then keeps what the remaining changes
|
||||
// need as Filter does: a group to create for an account stays in the plan.
|
||||
// keys narrows the plan to some objects first (empty: every object).
|
||||
func Skip(p Plan, keys []string, skip []model.Kind) Plan {
|
||||
if len(skip) == 0 {
|
||||
return Filter(p, keys)
|
||||
}
|
||||
out := map[model.Kind]bool{}
|
||||
for _, k := range skip {
|
||||
out[k] = true
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
for _, c := range p.Changes {
|
||||
keys = append(keys, c.Key())
|
||||
}
|
||||
for _, e := range p.Errors {
|
||||
if k, ok := ErrorKey(e); ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
var kept []string
|
||||
for _, k := range keys {
|
||||
if kind, _, _ := strings.Cut(k, "/"); !out[model.Kind(kind)] {
|
||||
kept = append(kept, k)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return Plan{Client: p.Client, Warnings: p.Warnings}
|
||||
}
|
||||
return Filter(p, kept)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"pve-confsync/internal/model"
|
||||
)
|
||||
|
||||
// Items left out of a run ("do not replicate") leave the plan, except what the
|
||||
// remaining changes need: the group to create for a new account.
|
||||
func TestSkipKinds(t *testing.T) {
|
||||
p := Plan{Client: "c", Changes: []Change{
|
||||
{Kind: model.KindGroup, ID: "ops", Op: OpCreate, Set: map[string]any{"comment": "Ops"}},
|
||||
{Kind: model.KindGroup, ID: "dev", Op: OpUpdate, Set: map[string]any{"comment": "Dev"}},
|
||||
{Kind: model.KindUser, ID: "bob@pve", Op: OpCreate, Set: map[string]any{"groups": "ops"}},
|
||||
{Kind: model.KindPassword, ID: "bob@pve", Op: OpCreate, Set: map[string]any{"hash": "$5$x"}},
|
||||
{Kind: model.KindVNet, ID: "v1", Op: OpUpdate, Set: map[string]any{"alias": "a"}},
|
||||
}, Errors: []string{"sdn-zone/z1: field \"bridge\" required for creation, add it to the mapping of target c"}}
|
||||
keys := func(p Plan) []string {
|
||||
var out []string
|
||||
for _, c := range p.Changes {
|
||||
out = append(out, c.Key())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
got := Skip(p, nil, []model.Kind{model.KindGroup, model.KindZone, model.KindVNet})
|
||||
if want := []string{"pve-group/ops", "pve-user/bob@pve", "pve-password/bob@pve"}; !reflect.DeepEqual(keys(got), want) {
|
||||
t.Fatalf("changes: %v, want %v", keys(got), want)
|
||||
}
|
||||
if len(got.Errors) != 0 {
|
||||
t.Fatalf("the error of a skipped kind must not block: %v", got.Errors)
|
||||
}
|
||||
if got := Skip(p, nil, model.Kinds); len(got.Changes) != 0 || len(got.Errors) != 0 {
|
||||
t.Fatalf("everything skipped: %+v", got)
|
||||
}
|
||||
if got := Skip(p, nil, nil); len(got.Changes) != len(p.Changes) || len(got.Errors) != 1 {
|
||||
t.Fatalf("nothing skipped: %+v", got)
|
||||
}
|
||||
}
|
||||
+45
-45
@@ -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))
|
||||
}
|
||||
return "toutes les cibles"
|
||||
t += " (not replicated: " + strings.Join(ks, ", ") + ")"
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestCheckSSHHostKeyMismatch(t *testing.T) {
|
||||
SSHHostKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(other)))}
|
||||
|
||||
res := CheckCluster(context.Background(), cl, false, false)
|
||||
if res.OK || !res.SSHMismatch || !res.API || res.SSHHostKey != "" || !strings.Contains(res.Error, "clé d'hôte") {
|
||||
if res.OK || !res.SSHMismatch || !res.API || res.SSHHostKey != "" || !strings.Contains(res.Error, "host key") {
|
||||
t.Fatalf("mismatch expected: %+v", res)
|
||||
}
|
||||
res = CheckCluster(context.Background(), cl, false, true)
|
||||
|
||||
@@ -315,7 +315,7 @@ type ClientPlan struct {
|
||||
|
||||
// Plans returns the plans of the given clients restricted to the given objects
|
||||
// (empty: everything), as a dry-run shows them and an apply checks them.
|
||||
func (st *State) Plans(keys []string) []ClientPlan {
|
||||
func (st *State) Plans(keys []string, skip []model.Kind) []ClientPlan {
|
||||
var out []ClientPlan
|
||||
for _, c := range st.Clients {
|
||||
cp := ClientPlan{Client: c.Name}
|
||||
@@ -325,7 +325,7 @@ func (st *State) Plans(keys []string) []ClientPlan {
|
||||
case c.Error != "":
|
||||
cp.Error = c.Error
|
||||
default:
|
||||
cp.Plan = engine.Filter(c.Plan, keys)
|
||||
cp.Plan = engine.Skip(c.Plan, keys, skip)
|
||||
cp.Digest = cp.Plan.Digest()
|
||||
cp.SDN = cp.Plan.HasSDN()
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (st *State) Plans(keys []string) []ClientPlan {
|
||||
|
||||
// Apply writes one plan on a client, then checks that the selected objects are
|
||||
// in line with the reference (same reference objects as the plan).
|
||||
func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log func(engine.Step)) error {
|
||||
func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, skip []model.Kind, log func(engine.Step)) error {
|
||||
cl, ok := st.cfg.Client(cp.Client)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown client %q", cp.Client)
|
||||
@@ -351,7 +351,7 @@ func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log fu
|
||||
}
|
||||
start := time.Now()
|
||||
err = engine.Verify(ctx, c, func(actual []model.Object) engine.Plan {
|
||||
return engine.Filter(engine.MakePlan(st.cfg, cl, st.ref, actual), keys)
|
||||
return engine.Skip(engine.MakePlan(st.cfg, cl, st.ref, actual), keys, skip)
|
||||
}, st.cfg.Kinds)
|
||||
s := engine.Step{At: start.UTC(), Action: "verify"}
|
||||
if err != nil {
|
||||
@@ -394,7 +394,7 @@ func CheckCluster(ctx context.Context, cl config.Cluster, pinTLS, trust bool) Ch
|
||||
cl.Fingerprint, res.TLSFingerprint = fp, fp
|
||||
}
|
||||
if cl.Insecure {
|
||||
res.Warnings = append(res.Warnings, "certificat TLS non vérifié (labo uniquement)")
|
||||
res.Warnings = append(res.Warnings, "TLS certificate not verified (lab only)")
|
||||
}
|
||||
key := cl.SSHHostKey
|
||||
cl.SSHHostKey = "" // API only for now
|
||||
@@ -433,7 +433,7 @@ func CheckCluster(ctx context.Context, cl config.Cluster, pinTLS, trust bool) Ch
|
||||
if strings.Contains(err.Error(), "host key mismatch") {
|
||||
_, fp, _ := sshx.Scan(ctx, o)
|
||||
res.SSHMismatch, res.SSHFingerprint = true, fp
|
||||
return fail(fmt.Errorf("SSH: le nœud %s présente la clé d'hôte %s au lieu de %s enregistrée", o.Host, fp, sshx.Fingerprint(key)))
|
||||
return fail(fmt.Errorf("SSH: node %s presents host key %s instead of the recorded %s", o.Host, fp, sshx.Fingerprint(key)))
|
||||
}
|
||||
return fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
|
||||
+170
-55
@@ -15,7 +15,7 @@
|
||||
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Répliquer',
|
||||
kindMenu: 'Éléments répliqués', targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.',
|
||||
kindMenuRO: 'Seuls les administrateurs changent les éléments répliqués.',
|
||||
kindMenuHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte : ils sont cochés ensemble.',
|
||||
kindMenuHelp: 'Les comptes du realm pve sont répliqués avec leurs mots de passe et ont besoin de leurs groupes : ils sont cochés ensemble.',
|
||||
kindGroups: { dc: 'Datacenter', access: 'Comptes et droits (realm pve)', sdn: 'SDN' },
|
||||
setupClusters: 'Base vide : ajoutez vos clusters (URL et mot de passe root), puis choisissez le primaire.', setupClustersBtn: 'Ajouter des clusters',
|
||||
setupPrimary: 'Aucun cluster primaire : choisissez la source de la réplication dans la vue Clusters.', setupPrimaryBtn: 'Choisir le primaire',
|
||||
@@ -25,25 +25,25 @@
|
||||
colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)',
|
||||
colRole: 'Rôle', colPassword: 'Nouveau mot de passe', colLastLogin: 'Dernière connexion',
|
||||
more: 'Plus', toTop: 'Haut de page', close: 'Fermer', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer',
|
||||
applyBtn: 'Synchroniser', addRow: '+ Ajouter',
|
||||
applyBtn: 'Répliquer', addRow: '+ Ajouter',
|
||||
kinds: {
|
||||
'datacenter-options': 'Options datacenter', 'pve-group': 'Groupes', 'pve-role': 'Rôles personnalisés',
|
||||
'pve-user': 'Comptes du realm pve', 'pve-password': 'Mots de passe (SSH)',
|
||||
'pve-user': 'Comptes du realm pve et mots de passe', 'pve-password': 'Mots de passe du realm pve',
|
||||
'sdn-zone': 'Zones SDN (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
||||
},
|
||||
kindShort: {
|
||||
'datacenter-options': 'options', 'pve-group': 'groupe', 'pve-role': 'rôle', 'pve-user': 'compte', 'pve-password': 'mot de passe',
|
||||
'datacenter-options': 'options', 'pve-group': 'groupe', 'pve-role': 'rôle', 'pve-user': 'compte', 'pve-password': 'mdp',
|
||||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||||
},
|
||||
allKinds: 'Tous les types',
|
||||
st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors primaire', error: 'Erreur' },
|
||||
filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs',
|
||||
reference: 'PRIMAIRE', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Synchroniser la cible',
|
||||
dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet',
|
||||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Répliquer vers la cible',
|
||||
dryObj: 'Dry-run de cet objet', syncObj: 'Répliquer cet objet',
|
||||
refError: 'Primaire illisible', clientError: 'Cible illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||||
fieldsN: '{n} champ(s)', objectsN: '{n} objet(s)', noFields: 'aucun champ',
|
||||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après synchro',
|
||||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après réplication',
|
||||
emptyOk: 'Aucun écart pour cette cible.', emptyOkHint: 'La cible est conforme au primaire pour les éléments répliqués.',
|
||||
emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.',
|
||||
noClient: 'Aucune cible.', noClientHint: 'Ajoutez des clusters cibles dans la vue Clusters.',
|
||||
@@ -53,28 +53,28 @@
|
||||
planSaved: 'Dry-run enregistré dans le journal (n° {id}).', changesN: '{n} changement(s)', sdnBadge: 'SDN',
|
||||
sdnConfirm: "Les changements SDN sont appliqués sous verrou puis validés : le réseau de tous les nœuds de {c} est rechargé. Tapez « {w} » pour confirmer.",
|
||||
sdnConfirmGlobal: "Des changements SDN rechargent le réseau des nœuds de plusieurs cibles. Tapez le nom du cluster primaire « {w} » pour confirmer.",
|
||||
applyNow: 'Appliquer', applying: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
||||
applyNow: 'Appliquer', applying: 'Réplication en cours…', applyDone: 'Réplication terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
||||
relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était',
|
||||
res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' },
|
||||
mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
||||
allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs',
|
||||
mode: { 'dry-run': 'dry-run', apply: 'réplication' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
||||
allModes: 'Tous les modes', onlyApply: 'Réplications', onlyDry: 'Dry-runs',
|
||||
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
||||
action: {
|
||||
setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro',
|
||||
setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'réplication lancée', apply: 'réplication',
|
||||
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
|
||||
'cluster-role': 'rôle du cluster', 'cluster-forget': 'empreintes oubliées', 'settings-save': 'paramètres',
|
||||
'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe',
|
||||
},
|
||||
tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||||
tabSync: 'Réplication', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||||
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
|
||||
addCluster: '+ Ajouter un cluster', addBulk: '+ Ajouter une liste', testAll: 'Tout tester',
|
||||
clustersHelp: "Chaque cluster est joint avec root@pam : l'API pour la configuration, SSH (root, même mot de passe) pour ce que l'API ne couvre pas (mots de passe du realm pve). Le certificat TLS et la clé d'hôte SSH sont enregistrés au premier test, puis vérifiés. Le primaire n'est jamais modifié. Les mots de passe sont chiffrés en base (AES-256-GCM) et jamais réaffichés.",
|
||||
test: 'Tester', edit: 'Modifier', testing: 'Test en cours…', testOk: 'Accès OK', role: { admin: 'admin', operator: 'opérateur', viewer: 'lecture seule' },
|
||||
crole: { primary: 'primaire', target: 'cible', disabled: 'inactif' }, makePrimary: 'Définir comme primaire',
|
||||
crole: { primary: 'primaire', target: 'cible', disabled: 'désactivé' }, makePrimary: 'Définir comme primaire',
|
||||
neverTested: 'jamais testé', testedAt: 'testé le {t}', pinned: 'enregistré : {l}', trustKey: 'Faire confiance à la nouvelle clé',
|
||||
confirmTrust: "La clé d'hôte SSH de {c} a changé ({fp}). Ne l'acceptez que si le nœud a été réinstallé ou remplacé. Faire confiance à cette clé ?",
|
||||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle',
|
||||
rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Inactif',
|
||||
rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Désactivé',
|
||||
cUser: 'Utilisateur', cPassword: 'Mot de passe root', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||||
cSSHHost: 'Nœud SSH', cSSHPort: 'Port SSH', cSSHKey: "Clé d'hôte SSH", sshKeyNone: 'enregistrée au premier test',
|
||||
fpPh: 'enregistrée au premier test', sshHostPh: "hôte de l'URL",
|
||||
@@ -86,20 +86,31 @@
|
||||
confirmDelete: 'Supprimer le cluster {c} ? Son mapping est perdu.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
||||
bulkTitle: 'Ajouter une liste de clusters', bulkHelp: "Un cluster par ligne : « nom URL », ou l'URL seule (le nom est alors l'hôte). Tous sont ajoutés comme cibles avec le même mot de passe root, puis testés ; choisissez ensuite le primaire.",
|
||||
bulkPassword: 'Mot de passe root', bulkGo: 'Ajouter et tester', bulkBadLine: 'ligne illisible : {l}',
|
||||
kindsTitle: 'Éléments répliqués', kindsHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte. Hors périmètre : EVPN, firewall, storage, ACL, jobs, tokens API, comptes pam et des autres realms.',
|
||||
kindsTitle: 'Éléments répliqués', kindsHelp: 'Les comptes du realm pve vont avec leurs mots de passe et ont besoin de leurs groupes. Hors périmètre : EVPN, firewall, storage, ACL, jobs, tokens API, comptes pam et des autres realms.',
|
||||
pruneTitle: 'Suppression', pruneLabel: 'Supprimer sur les cibles les objets absents du primaire',
|
||||
pruneHelp: 'Désactivé par défaut (mode additif + mise à jour). Activé, les dry-runs listent aussi les suppressions.',
|
||||
selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?, *@pve). Vide : tout.",
|
||||
polTitle: 'Politiques par champ', polHelp: 'sync : copie du primaire ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping de la cible. Défauts :',
|
||||
unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}',
|
||||
usersHelp: "Comptes locaux de pve-confsync (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
||||
usersHelp: "Comptes locaux de pve-confsync (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et réplication), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
||||
addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire',
|
||||
accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation',
|
||||
pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.',
|
||||
accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC',
|
||||
language: 'Langue', themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
|
||||
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
|
||||
noRights: 'Votre rôle ne permet pas de synchroniser.', errorPrefix: 'Erreur : ',
|
||||
noRights: 'Votre rôle ne permet pas de répliquer.', errorPrefix: 'Erreur : ',
|
||||
planKinds: 'Éléments de cette exécution', kindOn: 'Répliquer', kindOff: 'Ne pas répliquer',
|
||||
planKindsHelp: "Un élément exclu n'est pas écrit, sauf ce dont les autres ont besoin (un groupe à créer pour un compte). Changer un choix relance le dry-run.",
|
||||
redacted: 'masqué',
|
||||
polDef: {
|
||||
'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais',
|
||||
'pve-group': 'sync (commentaire ; les membres viennent des comptes)',
|
||||
'pve-role': 'sync (rôles personnalisés ; les rôles intégrés ne sont pas touchés)',
|
||||
'pve-user': 'sync (nom, e-mail, groupes, expiration, activation… ; mot de passe : hachage copié par SSH dans /etc/pve/priv/shadow.cfg, jamais affiché)',
|
||||
'sdn-zone': 'nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping de la cible) ; le reste : sync',
|
||||
'sdn-vnet': 'sync', 'sdn-subnet': 'sync',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
||||
@@ -108,7 +119,7 @@
|
||||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Replicate',
|
||||
kindMenu: 'Replicated items', targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.',
|
||||
kindMenuRO: 'Only administrators change the replicated items.',
|
||||
kindMenuHelp: 'An account needs its groups, a password its account: they are checked together.',
|
||||
kindMenuHelp: 'The accounts of the pve realm are replicated with their passwords and need their groups: they are checked together.',
|
||||
kindGroups: { dc: 'Datacenter', access: 'Accounts and permissions (pve realm)', sdn: 'SDN' },
|
||||
setupClusters: 'Empty database: add your clusters (URL and root password), then choose the primary.', setupClustersBtn: 'Add clusters',
|
||||
setupPrimary: 'No primary cluster: choose the source of the replication in the Clusters view.', setupPrimaryBtn: 'Choose the primary',
|
||||
@@ -118,25 +129,25 @@
|
||||
colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)',
|
||||
colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login',
|
||||
more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete',
|
||||
applyBtn: 'Synchronize', addRow: '+ Add',
|
||||
applyBtn: 'Replicate', addRow: '+ Add',
|
||||
kinds: {
|
||||
'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
|
||||
'pve-user': 'pve realm accounts', 'pve-password': 'Passwords (SSH)',
|
||||
'pve-user': 'Accounts of the pve realm and passwords', 'pve-password': 'Passwords of the pve realm',
|
||||
'sdn-zone': 'SDN zones (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
||||
},
|
||||
kindShort: {
|
||||
'datacenter-options': 'options', 'pve-group': 'group', 'pve-role': 'role', 'pve-user': 'account', 'pve-password': 'password',
|
||||
'datacenter-options': 'options', 'pve-group': 'group', 'pve-role': 'role', 'pve-user': 'account', 'pve-password': 'passwd',
|
||||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||||
},
|
||||
allKinds: 'All kinds',
|
||||
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not on primary', error: 'Error' },
|
||||
filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors',
|
||||
reference: 'PRIMARY', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Synchronize target',
|
||||
dryObj: 'Dry-run this object', syncObj: 'Synchronize this object',
|
||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Replicate to target',
|
||||
dryObj: 'Dry-run this object', syncObj: 'Replicate this object',
|
||||
refError: 'Primary unreadable', clientError: 'Target unreadable', planError: 'Blocking', warning: 'Warning',
|
||||
fieldsN: '{n} field(s)', objectsN: '{n} object(s)', noFields: 'no field',
|
||||
thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After sync',
|
||||
thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After replication',
|
||||
emptyOk: 'No difference for this target.', emptyOkHint: 'The target is in line with the primary for the replicated items.',
|
||||
emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.',
|
||||
noClient: 'No target.', noClientHint: 'Add target clusters in the Clusters view.',
|
||||
@@ -146,19 +157,19 @@
|
||||
planSaved: 'Dry-run saved in the journal (#{id}).', changesN: '{n} change(s)', sdnBadge: 'SDN',
|
||||
sdnConfirm: 'SDN changes are written under lock then committed: the network of every node of {c} is reloaded. Type "{w}" to confirm.',
|
||||
sdnConfirmGlobal: 'SDN changes reload the network of the nodes of several targets. Type the name of the primary cluster "{w}" to confirm.',
|
||||
applyNow: 'Apply', applying: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.',
|
||||
applyNow: 'Apply', applying: 'Replication in progress…', applyDone: 'Replication finished', stale: 'The state changed since the dry-run: run it again.',
|
||||
relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was',
|
||||
res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' },
|
||||
mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
||||
allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs',
|
||||
mode: { 'dry-run': 'dry-run', apply: 'replication' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
||||
allModes: 'All modes', onlyApply: 'Replications', onlyDry: 'Dry-runs',
|
||||
noRuns: 'No run yet.', noAudit: 'No event.',
|
||||
action: {
|
||||
setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync',
|
||||
setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'replication started', apply: 'replication',
|
||||
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
|
||||
'cluster-role': 'cluster role', 'cluster-forget': 'fingerprints forgotten', 'settings-save': 'settings',
|
||||
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
|
||||
},
|
||||
tabSync: 'Synchronization', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||||
tabSync: 'Replication', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||||
roNote: 'Read only: only administrators change these settings.',
|
||||
addCluster: '+ Add a cluster', addBulk: '+ Add a list', testAll: 'Test all',
|
||||
clustersHelp: 'Each cluster is reached as root@pam: the API for the configuration, SSH (root, same password) for what the API does not cover (passwords of the pve realm). The TLS certificate and the SSH host key are recorded on the first test, then checked. The primary is never written. Passwords are encrypted in the database (AES-256-GCM) and never shown again.',
|
||||
@@ -179,24 +190,39 @@
|
||||
confirmDelete: 'Delete cluster {c}? Its mapping is lost.', confirmDeleteUser: 'Delete user {u}?',
|
||||
bulkTitle: 'Add a list of clusters', bulkHelp: 'One cluster per line: "name URL", or the URL alone (the name is then the host). All are added as targets with the same root password, then tested; choose the primary afterwards.',
|
||||
bulkPassword: 'Root password', bulkGo: 'Add and test', bulkBadLine: 'unreadable line: {l}',
|
||||
kindsTitle: 'Replicated items', kindsHelp: 'An account needs its groups, a password its account. Out of scope: EVPN, firewall, storage, ACL, jobs, API tokens, accounts of pam and the other realms.',
|
||||
kindsTitle: 'Replicated items', kindsHelp: 'The accounts of the pve realm go with their passwords and need their groups. Out of scope: EVPN, firewall, storage, ACL, jobs, API tokens, accounts of pam and the other realms.',
|
||||
pruneTitle: 'Deletion', pruneLabel: 'Delete on the targets the objects absent from the primary',
|
||||
pruneHelp: 'Off by default (add + update). On, dry-runs also list the deletions.',
|
||||
selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?, *@pve). Empty: everything.',
|
||||
polTitle: 'Field policies', polHelp: 'sync: copied from the primary; ignore: never read nor written; override: value from the target mapping. Defaults:',
|
||||
unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}',
|
||||
usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.',
|
||||
usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and replication), read only. OIDC users get their role from their groups.',
|
||||
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
|
||||
accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm',
|
||||
pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.',
|
||||
accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC',
|
||||
language: 'Language', themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
|
||||
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
|
||||
noRights: 'Your role cannot synchronize.', errorPrefix: 'Error: ',
|
||||
noRights: 'Your role cannot replicate.', errorPrefix: 'Error: ',
|
||||
planKinds: 'Items of this run', kindOn: 'Replicate', kindOff: 'Do not replicate',
|
||||
planKindsHelp: 'An excluded item is not written, except what the others need (a group to create for an account). Changing a choice runs the dry-run again.',
|
||||
redacted: 'hidden',
|
||||
polDef: {
|
||||
'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id: sync; the rest: ignore; webauthn, u2f, http_proxy: never',
|
||||
'pve-group': 'sync (comment; the members come from the accounts)',
|
||||
'pve-role': 'sync (custom roles; the built-in roles are not touched)',
|
||||
'pve-user': 'sync (name, e-mail, groups, expiry, enabled…; password: hash copied over SSH into /etc/pve/priv/shadow.cfg, never shown)',
|
||||
'sdn-zone': 'nodes, bridge, peers, fabric, dns, reversedns, dnszone: override (target mapping); the rest: sync',
|
||||
'sdn-vnet': 'sync', 'sdn-subnet': 'sync',
|
||||
},
|
||||
},
|
||||
};
|
||||
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||||
const KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user', 'pve-password']], ['sdn', ['sdn-zone', 'sdn-vnet', 'sdn-subnet']]];
|
||||
const KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user']], ['sdn', ['sdn-zone', 'sdn-vnet', 'sdn-subnet']]];
|
||||
// kinds chosen as one item: the accounts of the pve realm with their passwords
|
||||
const BUNDLES = { 'pve-user': ['pve-password'] };
|
||||
const ITEMS = KINDS.filter((k) => !Object.values(BUNDLES).some((b) => b.includes(k)));
|
||||
const bundled = (k) => [k, ...(BUNDLES[k] || [])];
|
||||
const POLICIES = ['sync', 'ignore', 'override'];
|
||||
const ROLES = ['admin', 'operator', 'viewer'];
|
||||
|
||||
@@ -277,7 +303,64 @@
|
||||
function fmtVal(v) {
|
||||
if (v == null) return '';
|
||||
if (Array.isArray(v)) return '[' + v.join(', ') + ']';
|
||||
return String(v);
|
||||
return String(v).replace(/^<hidden (\w+)>$/, (m, x) => '<' + t('redacted') + ' ' + x + '>');
|
||||
}
|
||||
|
||||
// tm translates the messages written by the server (always in English) into
|
||||
// the language of the page; unknown parts stay as they are.
|
||||
const MSG_FR = [
|
||||
[/^no difference$/, 'aucun écart'],
|
||||
[/^(\d+) change\(s\) on (\d+) target\(s\), (\d+) target\(s\) in error$/, '$1 changement(s) sur $2 cible(s), $3 cible(s) en erreur'],
|
||||
[/^(\d+) change\(s\) on (\d+) target\(s\)$/, '$1 changement(s) sur $2 cible(s)'],
|
||||
[/^(\d+) change\(s\), verified$/, '$1 changement(s), vérifié'],
|
||||
[/^(\d+) target\(s\) replicated and verified$/, '$1 cible(s) répliquée(s) et vérifiée(s)'],
|
||||
[/^(\d+) target\(s\) failed, (\d+) replicated$/, '$1 cible(s) en échec, $2 répliquée(s)'],
|
||||
[/^a replication is already running$/, 'une réplication est déjà en cours'],
|
||||
[/: the state changed since the dry-run, run it again$/, " : l'état a changé depuis le dry-run, relancez-le"],
|
||||
[/^SDN changes: confirmation required \((.*)\)$/, 'changements SDN : confirmation requise ($1)'],
|
||||
[/^url: https:\/\/host:8006 expected$/, 'URL : https://hôte:8006 attendue'],
|
||||
[/^user: root@pam expected.*$/, 'utilisateur : root@pam attendu (le même compte sert en SSH)'],
|
||||
[/^ssh port: 1-65535 expected$/, 'port SSH : 1 à 65535'],
|
||||
[/^select at least one kind$/, 'cochez au moins un élément'],
|
||||
[/^name and url are required$/, 'nom et URL obligatoires'],
|
||||
[/^the root password is required$/, 'le mot de passe root est obligatoire'],
|
||||
[/^the CA certificate \(PEM\) is required$/, "le certificat d'autorité (PEM) est obligatoire"],
|
||||
[/^cluster "(.*)" already exists$/, 'le cluster « $1 » existe déjà'],
|
||||
[/^unknown cluster$/, 'cluster inconnu'],
|
||||
[/^no primary cluster defined$/, 'aucun cluster primaire défini'],
|
||||
[/^wrong current password$/, 'mot de passe actuel incorrect'],
|
||||
[/^password managed by the identity provider$/, "mot de passe géré par le fournisseur d'identité"],
|
||||
[/^invalid user name$/, "nom d'utilisateur invalide"],
|
||||
[/^password: 8 characters minimum$/, 'mot de passe : 8 caractères minimum'],
|
||||
[/^a password is required for a new user$/, 'un mot de passe est obligatoire pour un nouvel utilisateur'],
|
||||
[/^this is the last local administrator$/, "c'est le dernier administrateur local"],
|
||||
[/^role (\w+) required$/, 'rôle $1 requis'],
|
||||
[/^TLS certificate not verified \(lab only\)$/, 'certificat TLS non vérifié (labo uniquement)'],
|
||||
[/SSH: node (\S+) presents host key (\S+) instead of the recorded (\S+)/, "SSH : le nœud $1 présente la clé d'hôte $2 au lieu de $3 enregistrée"],
|
||||
[/is not a PVE node/, "n'est pas un nœud PVE"],
|
||||
[/node (\S+) runs PVE "([^"]*)", only (\d+)\.x is supported/, 'le nœud $1 est en PVE « $2 », seul $3.x est pris en charge'],
|
||||
[/authentication of (\S+) failed/, "échec de l'authentification de $1"],
|
||||
[/(\S+) has a second factor \(TFA\), not supported/, '$1 a un second facteur (TFA), non pris en charge'],
|
||||
[/certificate fingerprint mismatch \(got ([^)]*)\)/, 'empreinte du certificat différente (reçue : $1)'],
|
||||
[/host key not recorded yet, test the cluster first/, "clé d'hôte pas encore enregistrée, testez d'abord le cluster"],
|
||||
[/: not found on the target$/, ' : absent de la cible'],
|
||||
[/: field "([^"]*)" required for creation, add it to the mapping of target (.*)$/, ' : champ « $1 » obligatoire pour la création, à ajouter au mapping de la cible $2'],
|
||||
[/: field "([^"]*)" differs \((.*)\) and cannot be changed, re-creation needed$/, ' : champ « $1 » différent ($2) et non modifiable, recréation nécessaire'],
|
||||
[/: (\S+) absent on target (\S+) and not replicated$/, ' : $1 absent de la cible $2 et non répliqué'],
|
||||
[/^zone (\S+): type "([^"]*)" not handled in v1, skipped$/, 'zone $1 : type « $2 » non pris en charge, ignorée'],
|
||||
[/^vnet (\S+): zone "([^"]*)" not handled, skipped$/, 'vnet $1 : zone « $2 » non prise en charge, ignoré'],
|
||||
[/plan has (\d+) error\(s\), nothing applied/, 'le plan a $1 erreur(s), rien appliqué'],
|
||||
[/still different after apply: /, 'toujours différent après réplication : '],
|
||||
[/SDN lock \(pending changes on the target\?\)/, 'verrou SDN (changements en attente sur la cible ?)'],
|
||||
[/SDN configuration committed but reload failed/, 'configuration SDN validée mais rechargement en échec'],
|
||||
[/root@pam cannot be deleted/, 'root@pam ne peut pas être supprimé'],
|
||||
[/\bconnection refused\b/, 'connexion refusée'],
|
||||
];
|
||||
function tm(msg) {
|
||||
if (lang !== 'fr' || !msg) return msg;
|
||||
let out = String(msg);
|
||||
for (const [re, to] of MSG_FR) out = out.replace(re, to);
|
||||
return out;
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
@@ -317,7 +400,7 @@
|
||||
}
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
const e = new Error(data.error || r.statusText);
|
||||
const e = new Error(tm(data.error || r.statusText));
|
||||
e.status = r.status;
|
||||
e.code = data.code;
|
||||
e.data = data;
|
||||
@@ -497,7 +580,7 @@
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }), h('span', { class: 'c-name', text: r.name }),
|
||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||
h('div', { class: 'c-url', text: r.url }),
|
||||
r.error ? h('div', { class: 'c-err', text: r.error, title: r.error })
|
||||
r.error ? h('div', { class: 'c-err', text: tm(r.error), title: r.error })
|
||||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
|
||||
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
|
||||
for (const c of st.clients) {
|
||||
@@ -520,7 +603,7 @@
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||
h('div', { class: 'c-url', text: c.url }),
|
||||
counts,
|
||||
c.error ? h('div', { class: 'c-err', text: c.error, title: c.error }) : null));
|
||||
c.error ? h('div', { class: 'c-err', text: tm(c.error), title: c.error }) : null));
|
||||
}
|
||||
renderClient();
|
||||
}
|
||||
@@ -586,7 +669,7 @@
|
||||
$('#objEmptyHint').textContent = hint;
|
||||
}
|
||||
function note(kind, label, text) {
|
||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text }));
|
||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text: tm(text) }));
|
||||
}
|
||||
|
||||
function match(r) {
|
||||
@@ -606,7 +689,7 @@
|
||||
|
||||
function summary(r) {
|
||||
const s = h('span', { class: 'sum' });
|
||||
if (r.status === 'error') { s.textContent = (r.errors || [])[0] || ''; s.title = s.textContent; return s; }
|
||||
if (r.status === 'error') { s.textContent = tm((r.errors || [])[0] || ''); s.title = s.textContent; return s; }
|
||||
const ch = (r.fields || []).filter((f) => f.change);
|
||||
if (!ch.length || r.status === 'delete') {
|
||||
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
||||
@@ -648,7 +731,7 @@
|
||||
|
||||
function details(r) {
|
||||
const d = h('div', { class: 'details' });
|
||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.join('\n') }));
|
||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.map(tm).join('\n') }));
|
||||
const tb = h('tbody');
|
||||
for (const f of r.fields || []) {
|
||||
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
||||
@@ -699,6 +782,7 @@
|
||||
try {
|
||||
const d = await api('POST', 'api/dryrun', sel);
|
||||
plan.plans = d.plans;
|
||||
renderPlanKinds(sel, d.plans, intentSync);
|
||||
renderPlans(d.plans);
|
||||
$('#planNote').textContent = t('planSaved', { id: d.run });
|
||||
const total = d.plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).length, 0);
|
||||
@@ -717,6 +801,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
// renderPlanKinds shows, above the plan, one "replicate / do not replicate"
|
||||
// choice per replicated item: a change runs the dry-run again without the
|
||||
// items left out, so the reviewed plan is the one applied.
|
||||
function renderPlanKinds(sel, plans, intentSync) {
|
||||
const box = $('#planKinds');
|
||||
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
||||
const items = ITEMS.filter((k) => kinds.includes(k));
|
||||
box.hidden = sel.scope === 'object' || items.length < 2;
|
||||
if (box.hidden) return;
|
||||
const skip = new Set(sel.skip || []);
|
||||
const count = (k) => plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).filter((c) => bundled(k).includes(c.kind)).length, 0);
|
||||
const rows = items.map((k) => {
|
||||
const off = skip.has(k);
|
||||
const set = (v) => {
|
||||
const next = new Set(skip);
|
||||
bundled(k).forEach((x) => (v ? next.delete(x) : next.add(x)));
|
||||
openPlan(Object.assign({}, sel, { skip: KINDS.filter((x) => next.has(x)) }), intentSync);
|
||||
};
|
||||
const n = off ? '—' : String(count(k));
|
||||
return h('div', { class: 'pk-row' + (off ? ' off' : '') },
|
||||
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
||||
h('span', { class: 'pk-name', text: t('kinds.' + k) }),
|
||||
h('span', { class: 'pk-n', text: n, title: off ? '' : t('changesN', { n }) }),
|
||||
h('div', { class: 'seg', role: 'radiogroup', 'aria-label': t('kinds.' + k) },
|
||||
h('button', { type: 'button', role: 'radio', class: off ? '' : 'on', 'aria-checked': String(!off), onclick: () => off && set(true) }, t('kindOn')),
|
||||
h('button', { type: 'button', role: 'radio', class: off ? 'on' : '', 'aria-checked': String(off), onclick: () => !off && set(false) }, t('kindOff'))));
|
||||
});
|
||||
box.replaceChildren(h('div', { class: 'pk-head' }, h('strong', { text: t('planKinds') }), h('span', { class: 'muted small', text: t('planKindsHelp') })), ...rows);
|
||||
}
|
||||
|
||||
function changeList(changes) {
|
||||
const ul = h('ul', { class: 'plan' });
|
||||
for (const ch of changes || []) {
|
||||
@@ -786,7 +900,7 @@
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||
if (!d.run) { toast(d.summary || t('planEmpty')); return; }
|
||||
if (!d.run) { toast(tm(d.summary) || t('planEmpty')); return; }
|
||||
plan.run = d.run;
|
||||
$('#planApply').hidden = true;
|
||||
$('#planTitle').textContent = t('applying');
|
||||
@@ -819,7 +933,7 @@
|
||||
if (run.status !== 'running') {
|
||||
clearInterval(plan.poll);
|
||||
$('#planTitle').textContent = t('applyDone') + ' — ' + t('res.' + run.status);
|
||||
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + run.summary;
|
||||
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + tm(run.summary);
|
||||
loadState(true);
|
||||
if (S.view === 'runs') loadRuns(true);
|
||||
}
|
||||
@@ -887,7 +1001,7 @@
|
||||
h('span', { class: 'txt', text: t('scope.' + r.scope) }),
|
||||
h('span', { class: 'txt mono', text: (r.clients || []).join(', '), title: (r.objects || []).join(', ') }),
|
||||
h('span', null, h('span', { class: 'badge ' + r.status, text: t('res.' + r.status) })),
|
||||
h('span', { class: 'txt', text: r.summary, title: r.summary }));
|
||||
h('span', { class: 'txt', text: tm(r.summary), title: tm(r.summary) }));
|
||||
if (open) {
|
||||
const det = h('div', { class: 'details' }, h('p', { class: 'muted', text: '…' }));
|
||||
row.appendChild(det);
|
||||
@@ -991,7 +1105,7 @@
|
||||
const parts = [t('testedAt', { t: fmtTime(k.at, true) })];
|
||||
if (k.version) parts.push('PVE ' + k.version);
|
||||
if ((k.nodes || []).length) parts.push(t('nodes', { n: k.nodes.length }) + ' (' + k.nodes.join(', ') + ')');
|
||||
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + k.error : '') });
|
||||
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + tm(k.error) : '') });
|
||||
}
|
||||
function renderClusters() {
|
||||
const list = $('#clusterList');
|
||||
@@ -1056,7 +1170,7 @@
|
||||
if (r.sshFingerprint && !r.sshMismatch) pins.push('SSH ' + r.sshFingerprint);
|
||||
if (!r.ok) {
|
||||
el.className = 'chk ko';
|
||||
el.replaceChildren('✗ ' + r.error);
|
||||
el.replaceChildren('✗ ' + tm(r.error));
|
||||
if (r.sshMismatch) {
|
||||
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
||||
onclick: () => { if (confirm(t('confirmTrust', { c: name, fp: r.sshFingerprint }))) checkCluster(name, el, true); } }));
|
||||
@@ -1065,7 +1179,7 @@
|
||||
}
|
||||
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
|
||||
el.textContent = '✓ ' + t('testOk') + (pins.length ? ' — ' + t('pinned', { l: pins.join(' · ') }) : '') +
|
||||
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : '');
|
||||
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.map(tm).join(' ; ') : '');
|
||||
return true;
|
||||
}
|
||||
async function testAll() {
|
||||
@@ -1306,7 +1420,7 @@
|
||||
password: $('#bulkPassword').value, hasPassword: false, tls: $('#bulkTLS').value, fingerprint: '', caPem: '', timeout: '',
|
||||
sshHost: '', sshPort: 22, sshHostKey: '', sshFingerprint: '', mapping: {}, policies: {}, updatedAt: new Date().toISOString() });
|
||||
const r = await api('POST', 'api/clusters/' + encodeURIComponent(it.name) + '/check');
|
||||
line(r.ok ? 'ok' : 'ko', (r.ok ? '✓ ' : '✗ ') + it.name + ' — ' + (r.ok ? 'PVE ' + r.version + (r.sshFingerprint ? ' · SSH ' + r.sshFingerprint : '') : r.error));
|
||||
line(r.ok ? 'ok' : 'ko', (r.ok ? '✓ ' : '✗ ') + it.name + ' — ' + (r.ok ? 'PVE ' + r.version + (r.sshFingerprint ? ' · SSH ' + r.sshFingerprint : '') : tm(r.error)));
|
||||
} catch (e) {
|
||||
if (e.message === 'auth') return;
|
||||
line('ko', '✗ ' + it.name + ' — ' + e.message);
|
||||
@@ -1322,7 +1436,6 @@
|
||||
try {
|
||||
const d = await api('GET', 'api/settings');
|
||||
set.settings = d.settings;
|
||||
set.defaults = d.defaults || {};
|
||||
S.settings = d.settings;
|
||||
S.deps = d.deps || {};
|
||||
} catch (e) {
|
||||
@@ -1335,7 +1448,7 @@
|
||||
function renderSettings() {
|
||||
const st = set.settings;
|
||||
const chips = $('#kindChips');
|
||||
chips.replaceChildren(...KINDS.map((k) => h('button', {
|
||||
chips.replaceChildren(...ITEMS.map((k) => h('button', {
|
||||
type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)),
|
||||
onclick: (ev) => {
|
||||
const on = withDeps($$('#kindChips .chip.on').map((c) => c.dataset.kind), k, !ev.currentTarget.classList.contains('on'));
|
||||
@@ -1345,14 +1458,14 @@
|
||||
}, t('kinds.' + k))));
|
||||
$('#pruneSw').checked = !!st.prune;
|
||||
const sel = $('#selRows');
|
||||
sel.replaceChildren(...KINDS.map((k) => {
|
||||
sel.replaceChildren(...ITEMS.map((k) => {
|
||||
const o = (st.objects || {})[k] || {};
|
||||
return h('div', { class: 'erow sel', dataset: { kind: k } },
|
||||
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
||||
h('input', { class: 'inp mono', value: (o.include || []).join(', '), placeholder: '*', 'data-k': 'include', oninput: () => setDirty(true) }),
|
||||
h('input', { class: 'inp mono', value: (o.exclude || []).join(', '), placeholder: 'lab*', 'data-k': 'exclude', oninput: () => setDirty(true) }));
|
||||
}));
|
||||
$('#polDefaults').replaceChildren(...KINDS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), set.defaults[k] || '')));
|
||||
$('#polDefaults').replaceChildren(...ITEMS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), t('polDef.' + k))));
|
||||
const pr = $('#polRows');
|
||||
pr.replaceChildren();
|
||||
for (const [k, fields] of Object.entries(st.policies || {})) {
|
||||
@@ -1374,8 +1487,10 @@
|
||||
const exc = splitP($('[data-k="exclude"]', row).value);
|
||||
if (inc.length || exc.length) objects[row.dataset.kind] = { include: inc, exclude: exc };
|
||||
}
|
||||
// a password follows the selection of its account
|
||||
for (const [k, b] of Object.entries(BUNDLES)) for (const x of b) { if (objects[k]) objects[x] = objects[k]; }
|
||||
const body = {
|
||||
kinds: $$('#kindChips .chip.on').map((c) => c.dataset.kind),
|
||||
kinds: KINDS.filter((k) => $$('#kindChips .chip.on').some((c) => bundled(c.dataset.kind).includes(k))),
|
||||
objects,
|
||||
policies: readPolicies($('#polRows')),
|
||||
prune: $('#pruneSw').checked,
|
||||
@@ -1395,15 +1510,15 @@
|
||||
// withDeps checks or unchecks a kind with the kinds it needs (or that need it).
|
||||
function withDeps(kinds, k, on) {
|
||||
const out = new Set(kinds);
|
||||
const need = (x) => { out.add(x); (S.deps[x] || []).forEach(need); };
|
||||
const need = (x) => { out.add(x); bundled(x).forEach((b) => out.add(b)); (S.deps[x] || []).forEach(need); };
|
||||
const drop = (x) => { out.delete(x); Object.entries(S.deps).forEach(([y, ds]) => { if (ds.includes(x) && out.has(y)) drop(y); }); };
|
||||
if (on) need(k); else drop(k);
|
||||
if (on) need(k); else bundled(k).forEach(drop);
|
||||
return KINDS.filter((x) => out.has(x));
|
||||
}
|
||||
function renderKindMenu() {
|
||||
const pop = $('#kindMenuPop');
|
||||
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
||||
$('#kindMenuN').textContent = kinds.length + '/' + KINDS.length;
|
||||
$('#kindMenuN').textContent = ITEMS.filter((k) => kinds.includes(k)).length + '/' + ITEMS.length;
|
||||
const admin = can.admin();
|
||||
pop.replaceChildren();
|
||||
for (const [g, ks] of KIND_GROUPS) {
|
||||
|
||||
+4
-3
@@ -96,7 +96,7 @@
|
||||
</button>
|
||||
<button id="syncAllBtn" class="btn tool sync" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||
<span data-i18n="syncAll">Tout synchroniser</span>
|
||||
<span data-i18n="syncAll">Répliquer</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -191,6 +191,7 @@
|
||||
<h2 id="planTitle"></h2>
|
||||
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
||||
</header>
|
||||
<div id="planKinds" class="plan-kinds" hidden></div>
|
||||
<div id="planBody" class="dlg-body"></div>
|
||||
<footer>
|
||||
<span id="planNote" class="muted small"></span>
|
||||
@@ -198,7 +199,7 @@
|
||||
<button id="planClose" class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
|
||||
<button id="planApply" class="btn primary" type="button" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||
<span data-i18n="applyBtn">Synchroniser</span>
|
||||
<span data-i18n="applyBtn">Répliquer</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -315,7 +316,7 @@
|
||||
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
||||
<button type="button" role="tab" data-tab="sync">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||
<span data-i18n="tabSync">Synchronisation</span>
|
||||
<span data-i18n="tabSync">Réplication</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="users" data-admin>
|
||||
<!-- Lucide "users" (ISC license) -->
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
|
||||
<span>pve-confsync</span>
|
||||
</div>
|
||||
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
|
||||
<p class="muted" data-i18n="intro">Réplication de configuration Proxmox VE</p>
|
||||
|
||||
<form id="setupForm" class="setup-form" method="post" action="auth/setup" hidden>
|
||||
<h2 data-i18n="setupTitle">Premier démarrage</h2>
|
||||
|
||||
+2
-2
@@ -1,14 +1,14 @@
|
||||
// Login page: language, theme, local form and/or OIDC button (from /auth/config).
|
||||
(function () {
|
||||
var I18N = {
|
||||
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||
fr: { intro: 'Réplication de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
|
||||
error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre',
|
||||
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.',
|
||||
setupTitle: 'Premier démarrage', setupIntro: 'La base est vide : créez le compte administrateur de pve-confsync (12 caractères minimum).',
|
||||
pass2: 'Confirmation', setupBtn: 'Créer le compte et continuer',
|
||||
mismatch: 'Les deux mots de passe diffèrent.', invalid: 'Nom ou mot de passe refusé (12 caractères minimum).' },
|
||||
en: { intro: 'Proxmox VE configuration synchronization', user: 'User', pass: 'Password',
|
||||
en: { intro: 'Proxmox VE configuration replication', user: 'User', pass: 'Password',
|
||||
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
|
||||
error: 'Wrong user or password.', theme: 'Light / dark theme',
|
||||
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.',
|
||||
|
||||
@@ -741,3 +741,12 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
|
||||
.setup-form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||
.setup-form h2 { margin: 0 0 4px; font-size: 15px; text-align: center; }
|
||||
.setup-form p { margin: 0 0 6px; font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* dry-run: replicate / do not replicate, per item */
|
||||
.plan-kinds { display: grid; gap: 6px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--panel-2); }
|
||||
.plan-kinds .pk-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-bottom: 2px; }
|
||||
.plan-kinds .pk-row { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr) 2.5rem auto; align-items: center; gap: 10px; }
|
||||
.plan-kinds .pk-row.off .pk-name, .plan-kinds .pk-row.off .kind { opacity: .55; }
|
||||
.plan-kinds .pk-n { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.plan-kinds .seg button { padding: 3px 10px; font-size: 12px; }
|
||||
@media (max-width: 560px) { .plan-kinds .pk-row { grid-template-columns: minmax(0, 1fr) auto; } .plan-kinds .pk-row .kind, .plan-kinds .pk-n { display: none; } }
|
||||
Reference in new issue
Block a user