diff --git a/Dockerfile b/Dockerfile index 08cb76b..e6e1d23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd COPY internal ./internal +COPY web ./web ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \ -o /out/pve-confsync ./cmd/pve-confsync @@ -20,6 +21,9 @@ COPY --from=build /out/pve-confsync /usr/local/bin/pve-confsync USER confsync WORKDIR /data VOLUME /data -ENV CONFSYNC_CONFIG=/config/confsync.yaml +ENV CONFSYNC_CONFIG=/config/confsync.yaml DATA_DIR=/data LISTEN=:8080 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 ENTRYPOINT ["/usr/local/bin/pve-confsync"] -CMD ["check"] +# interface web ; la CLI reste disponible : docker compose run --rm confsync diff +CMD ["serve"] diff --git a/README.md b/README.md index 6d914a6..11b041d 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Synchronisation d'une partie de la configuration d'un cluster Proxmox VE 9.x « référence » vers N clusters « clients », via l'API PVE et des tokens API. Sens unique référence → clients. Pas de VM, pas de disques. -**État : phase 0 (CLI).** Export, diff et apply en YAML pour les options datacenter -et le SDN (zones VLAN/VXLAN, VNets, subnets). Testé contre un faux serveur PVE ; -**pas encore testé sur les clusters de labo.** +**État : phases 0 à 2.** CLI (export, diff et apply en YAML) et interface web +(écarts par client, dry-run et synchronisation par objet, par client ou global, journal, +audit) pour les options datacenter et le SDN (zones VLAN/VXLAN, VNets, subnets). +Testé contre un faux serveur PVE ; **pas encore testé sur les clusters de labo.** ## Périmètre @@ -71,12 +72,59 @@ docker compose run --rm confsync check docker compose run --rm confsync apply -confirm ``` +## Interface web + +```sh +go run ./cmd/pve-confsync serve -demo # démo : faux clusters PVE, http://localhost:8080 +docker compose up -d confsync-web # production : données dans le volume /data +``` + +Le mode démo démarre une référence PVE 9.1, deux clients avec des écarts (dont un mapping +manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`, +mot de passe `confsync-demo`. + +- **Écarts** : une carte par cluster (version, compteurs à créer / modifier / erreurs / + hors référence), puis les objets du client choisi ; le détail d'un objet montre pour + chaque champ la politique, la valeur de référence, le mapping, la valeur actuelle et + la valeur après synchro. +- **Dry-run / Synchroniser** par objet (avec ses dépendances : zone et vnet parents à + créer), par client ou global. La synchronisation 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 du client (celui de la référence en global). + Une seule synchronisation à la fois, suivie étape par étape. +- **Journal** des dry-runs et synchronisations (plans, étapes, résultats) et **Audit** + (connexions, actions, modifications des paramètres ; admin). +- **Paramètres** : clusters (test d'accès et des droits, TLS, mapping et politiques par + client), types synchronisés, sélection, politiques, suppression (désactivée par défaut), + utilisateurs locaux, import / export YAML (format de la CLI), compte, apparence. + +Rôles : `admin` (tout), `operator` (dry-run et synchronisation), `viewer` (lecture). +Les comptes locaux restent disponibles même avec OIDC. + +| Variable | Défaut | Rôle | +|---|---|---| +| `LISTEN` | `:8080` | adresse d'écoute | +| `DATA_DIR` | `/data` | base SQLite, `secret.key`, `session.key` | +| `CONFSYNC_SECRET_KEY` / `_FILE` | `DATA_DIR/secret.key` (créée) | clé AES-256 (hex ou base64) des secrets en base | +| `CONFSYNC_ADMIN_USER`, `CONFSYNC_ADMIN_PASSWORD` / `_FILE` | `admin`, aléatoire (journalisé une fois) | premier administrateur, base vide | +| `CONFSYNC_IMPORT` | | YAML de la CLI importé si la base n'a aucun cluster | +| `AUTH_LOCAL` | `true` | comptes locaux | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` / `_FILE`, `OIDC_REDIRECT_URL` | | OIDC (code + PKCE) ; `OIDC_REDIRECT_URL` complète, par ex. `https://confsync.example.org/auth/callback` | +| `OIDC_SCOPES`, `OIDC_GROUPS_CLAIM` | `openid profile email`, `groups` | ajouter le scope qui fournit les groupes chez l'IdP | +| `OIDC_ADMIN_GROUP`, `OIDC_OPERATOR_GROUP`, `OIDC_DEFAULT_ROLE` | `viewer` | rôle d'après les groupes | +| `SESSION_TTL` | `12h` | durée des sessions | +| `CONFSYNC_DEMO` | `false` | mode démo | + ## Sécurité - Secrets des tokens : jamais dans le YAML (`token_secret` en clair est refusé) ; lus depuis une variable d'environnement (`token_secret_env`) ou un fichier (`token_secret_file`, - secret docker). Ils ne sont ni affichés ni journalisés. Le chiffrement au repos arrive avec - le stockage SQLite (phase 1). + secret docker). Ils ne sont ni affichés ni journalisés. Dans l'interface, ils sont chiffrés en base + (AES-256-GCM, clé hors base) et jamais renvoyés au navigateur ; l'export YAML les remplace + par `token_secret_env`. +- Interface : mots de passe PBKDF2-SHA256 (600 000 itérations), cookie de session signé + (HttpOnly, SameSite=Lax, Secure derrière HTTPS) invalidé au changement de mot de passe, + en-tête `X-Confsync` exigé sur les écritures (CSRF), CSP sans script ni style en ligne. - TLS : `fingerprint` (pinning SHA-256 du certificat, adapté aux certificats auto-signés PVE), `ca_file`, ou `insecure: true` réservé au labo. - Tokens (privilege separation activée, l'ACL doit être posée sur l'utilisateur et le token) : @@ -99,7 +147,12 @@ un droit. ## Arborescence ``` -cmd/pve-confsync/ CLI (check, export, diff, apply) +cmd/pve-confsync/ CLI (check, export, diff, apply, serve) +internal/service/ analyse pour l'UI : lecture parallèle, plans, lignes de diff par champ +internal/store/ SQLite : clusters (secrets chiffrés), paramètres, utilisateurs, journal, audit +internal/server/ HTTP : auth locale + OIDC, rôles, API, jobs de synchronisation +internal/demo/ faux clusters du mode démo +web/ UI embarquée (index.html, app.js, style.css, login.html) internal/pve/ client API : token, TLS, form-encoding, version, tâches internal/pve/pvetest/ faux serveur PVE 9.x pour les tests internal/model/ objets génériques, snapshot YAML, valeurs canoniques @@ -111,18 +164,19 @@ docs/api-pve.md routes et schémas vérifiés dans les sources PVE Tests : `go test ./...` (bout en bout contre le faux serveur : synchro complète, mapping manquant, rollback sur erreur, refus de PVE 8, prune et ordre de suppression, forme -canonique des options datacenter, CLI dry-run/apply/export). +canonique des options datacenter, CLI dry-run/apply/export ; sélection d'objets et dépendances ; chiffrement, utilisateurs ; +serveur de bout en bout : rôles, CSRF, dry-run, empreinte périmée, confirmation SDN, apply). ## Phasage -0. **CLI** export / diff / apply YAML (options datacenter, tags, SDN). ← ici +0. **CLI** export / diff / apply YAML (options datacenter, tags, SDN). 1. UI lecture seule : clusters, diff visuel par client. Ergonomie reprise de Logstream (`web/index.html`, `style.css`, `app.js`, `login.html`, auth locale + OIDC, audit) ; SQLite (clusters, profils, mappings, historique, audit), secrets chiffrés. -2. UI d'action : Dry-run / Synchroniser (par objet, par client, global), journal des exécutions. +2. UI d'action : Dry-run / Synchroniser (par objet, par client, global), journal des exécutions. ← ici 3. Planification, détection de dérive en lecture seule, notifications. -## Reste à faire avant la phase 1 +## Reste à faire - Valider sur les deux clusters de labo : forme réelle des réponses (`nodes`, `dhcp-range`, booléens), comportement de `PUT /cluster/options` avec `delete`. diff --git a/cmd/pve-confsync/main.go b/cmd/pve-confsync/main.go index 6bf87b5..14eb719 100644 --- a/cmd/pve-confsync/main.go +++ b/cmd/pve-confsync/main.go @@ -1,6 +1,7 @@ // pve-confsync synchronizes part of the configuration of a reference Proxmox VE // cluster (datacenter options, SDN VLAN/VXLAN zones, vnets, subnets) to client -// clusters through the PVE API. Phase 0: command line only. +// clusters through the PVE API: command line (check, export, diff, apply) and +// web UI (serve). package main import ( @@ -24,6 +25,7 @@ import ( "pve-confsync/internal/engine" "pve-confsync/internal/model" "pve-confsync/internal/pve" + "pve-confsync/internal/service" ) var version = "dev" @@ -37,6 +39,7 @@ Commandes : export exporte l'état normalisé d'un cluster en YAML (défaut : la référence) diff affiche les écarts référence -> clients (code retour 2 s'il y en a) apply dry-run par défaut ; applique avec -confirm + serve interface web (clusters, diff, dry-run, synchronisation, journal) version affiche la version Lancer "pve-confsync -h" pour les options d'une commande. @@ -69,6 +72,8 @@ func main() { code, err = cmdDiff(ctx, *cfgFile, args[1:]) case "apply": err = cmdApply(ctx, *cfgFile, args[1:]) + case "serve": + err = cmdServe(ctx, args[1:]) default: global.Usage() os.Exit(1) @@ -116,7 +121,7 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error { fmt.Printf(" avertissement : droits du token non lisibles (%v)\n", err) return } - for _, w := range privilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) { + for _, w := range service.PrivilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) { fmt.Println(" avertissement :", w) } } @@ -127,39 +132,6 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error { return errors.Join(errs...) } -// privilegeWarnings flags a reference token able to write, and a client token -// missing the privileges needed by the synchronized kinds. -func privilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string { - has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok } - any := func(priv string) bool { - for p := range perms { - if has(p, priv) { - return true - } - } - return false - } - var w []string - if !write { - for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} { - if any(p) { - w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu") - } - } - return w - } - if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") { - w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera") - } - if dcOptions && !has("/", "Sys.Modify") { - w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites") - } - if !has("/", "Sys.Audit") { - w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles") - } - return w -} - // ---- export ---- func cmdExport(ctx context.Context, cfgFile string, args []string) error { diff --git a/cmd/pve-confsync/serve.go b/cmd/pve-confsync/serve.go new file mode 100644 index 0000000..cab609b --- /dev/null +++ b/cmd/pve-confsync/serve.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "pve-confsync/internal/config" + "pve-confsync/internal/demo" + "pve-confsync/internal/server" + "pve-confsync/internal/store" + "pve-confsync/web" +) + +// cmdServe starts the web UI (phases 1 and 2). Settings come from flags or from +// the environment, like Logstream (see README). +func cmdServe(ctx context.Context, args []string) error { + fl := flag.NewFlagSet("serve", flag.ExitOnError) + listen := fl.String("listen", envOr("LISTEN", ":8080"), "adresse d'écoute HTTP") + dataDir := fl.String("data", envOr("DATA_DIR", "/data"), "répertoire des données (base SQLite, clés)") + demoMode := fl.Bool("demo", envBool("CONFSYNC_DEMO", false), "mode démo : faux clusters PVE en mémoire, base temporaire") + webDir := fl.String("web", os.Getenv("WEB_DIR"), "servir les pages depuis ce répertoire au lieu de celles embarquées (développement)") + _ = fl.Parse(args) + + if *demoMode && os.Getenv("DATA_DIR") == "" && !flagSet(fl, "data") { + dir, err := os.MkdirTemp("", "confsync-demo-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + *dataDir = dir + } + if err := os.MkdirAll(*dataDir, 0o700); err != nil { + return err + } + key, keySrc, err := store.LoadKey(*dataDir) + if err != nil { + return err + } + st, err := store.Open(filepath.Join(*dataDir, "confsync.db"), key) + if err != nil { + return err + } + defer st.Close() + log.Printf("database %s, encryption key from %s", filepath.Join(*dataDir, "confsync.db"), keySrc) + + if *demoMode { + clusters, err := demo.Start(ctx, st) + if err != nil { + return fmt.Errorf("demo: %w", err) + } + defer clusters.Close() + log.Printf("DEMO mode: fake PVE clusters started; accounts admin, operateur, lecteur, password %q", demo.Password) + } else if err := bootstrap(ctx, st); err != nil { + return err + } + + ttl, err := time.ParseDuration(envOr("SESSION_TTL", "12h")) + if err != nil { + return fmt.Errorf("SESSION_TTL: %w", err) + } + var static fs.FS = web.FS + if *webDir != "" { + static = os.DirFS(*webDir) + } + srv, err := server.New(server.Options{ + Store: st, + Static: static, + Version: version, + Demo: *demoMode, + Auth: server.AuthConfig{ + Local: envBool("AUTH_LOCAL", true), + Issuer: os.Getenv("OIDC_ISSUER"), + ClientID: os.Getenv("OIDC_CLIENT_ID"), + ClientSecret: secretEnv("OIDC_CLIENT_SECRET"), + RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + Scopes: os.Getenv("OIDC_SCOPES"), + GroupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"), + AdminGroup: os.Getenv("OIDC_ADMIN_GROUP"), + OperatorGroup: os.Getenv("OIDC_OPERATOR_GROUP"), + DefaultRole: os.Getenv("OIDC_DEFAULT_ROLE"), + SessionTTL: ttl, + DataDir: *dataDir, + Demo: *demoMode, + }, + }) + if err != nil { + return err + } + hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} + errc := make(chan error, 1) + go func() { errc <- hs.ListenAndServe() }() + log.Printf("pve-confsync %s: web UI on %s", version, *listen) + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + log.Printf("stopping: waiting for the synchronizations in progress") + sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = hs.Shutdown(sctx) + srv.Wait() + return nil +} + +// bootstrap creates the first administrator and imports a YAML configuration +// on an empty database. +func bootstrap(ctx context.Context, st *store.Store) error { + n, err := st.CountUsers(ctx) + if err != nil { + return err + } + if n == 0 { + user := envOr("CONFSYNC_ADMIN_USER", "admin") + pass := secretEnv("CONFSYNC_ADMIN_PASSWORD") + generated := pass == "" + if generated { + pass = store.RandomPassword() + } + if err := st.SaveUser(ctx, user, store.RoleAdmin, pass); err != nil { + return fmt.Errorf("first administrator: %w", err) + } + if generated { + log.Printf("first administrator %q created, password: %s (change it in Settings > Account)", user, pass) + } else { + log.Printf("first administrator %q created", user) + } + } + if file := os.Getenv("CONFSYNC_IMPORT"); file != "" { + cls, err := st.Clusters(ctx) + if err != nil { + return err + } + if len(cls) > 0 { + return nil // imported once: the database is the reference from now on + } + cfg, err := config.Load(file) + if err != nil { + return fmt.Errorf("CONFSYNC_IMPORT: %w", err) + } + warns, err := st.ImportConfig(ctx, cfg, false) + if err != nil { + return fmt.Errorf("CONFSYNC_IMPORT: %w", err) + } + for _, w := range warns { + log.Printf("import: %s", w) + } + log.Printf("configuration imported from %s", file) + } + return nil +} + +// secretEnv reads NAME, or the file named by NAME_FILE (docker secret). +func secretEnv(name string) string { + if v := os.Getenv(name); v != "" { + return v + } + if f := os.Getenv(name + "_FILE"); f != "" { + b, err := os.ReadFile(f) + if err != nil { + log.Printf("%s_FILE: %v", name, err) + return "" + } + return strings.TrimSpace(string(b)) + } + return "" +} + +func envBool(name string, def bool) bool { + v := os.Getenv(name) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + log.Printf("%s=%q: true or false expected, using %t", name, v, def) + return def + } + return b +} + +func flagSet(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} diff --git a/docker-compose.yml b/docker-compose.yml index 71e39b4..e7848ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,64 @@ -# Phase 0 : outil en ligne de commande, lancé à la demande : +# Interface web (phases 1-2) : +# docker compose up -d confsync-web # http://:8080 ; mot de passe admin dans les logs au 1er démarrage +# CLI de la phase 0, lancée à la demande avec confsync.yaml : # docker compose run --rm confsync check # docker compose run --rm confsync diff # docker compose run --rm confsync export -o /data/ref.yaml # docker compose run --rm confsync apply # dry-run # docker compose run --rm confsync apply -confirm # application (confirmation SDN interactive) services: - confsync: + confsync-web: build: . image: pve-confsync:dev - container_name: pve-confsync + container_name: pve-confsync-web + command: ["serve"] + restart: unless-stopped + ports: + - "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure) environment: TZ: ${TZ:-Europe/Paris} - PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env) + CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key # clé des secrets en base, hors volume /data + # CONFSYNC_ADMIN_PASSWORD_FILE: /run/secrets/confsync_admin + # CONFSYNC_IMPORT: /config/confsync.yaml # import initial de la config CLI (base vide) + # OIDC_ISSUER: https://auth.example.org/application/o/confsync/ + # OIDC_CLIENT_ID: confsync + # OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client + # OIDC_REDIRECT_URL: https://confsync.example.org/auth/callback + # OIDC_ADMIN_GROUP: confsync-admins + # OIDC_OPERATOR_GROUP: confsync-operators + env_file: + - path: .env + required: false + volumes: + - confsync-data:/data # SQLite, session.key + secrets: + - confsync_key + + confsync: + image: pve-confsync:dev + container_name: pve-confsync + profiles: ["cli"] # pas démarré par "up" ; "run" l'active + command: ["check"] + environment: + TZ: ${TZ:-Europe/Paris} + PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env) env_file: - path: .env required: false volumes: - ./confsync.yaml:/config/confsync.yaml:ro - - confsync-data:/data # exports YAML, plus tard SQLite (phase 1) + - confsync-cli:/data # exports YAML secrets: - pve_ref - pve_site_a stdin_open: true tty: true -# Secrets des tokens API sous forme de fichiers (chmod 600), montés dans /run/secrets/ +# Fichiers chmod 600, propriétaire 10001, montés dans /run/secrets/. +# confsync_key : openssl rand -hex 32 > secrets/confsync_key secrets: + confsync_key: + file: ./secrets/confsync_key pve_ref: file: ./secrets/pve_ref pve_site_a: @@ -33,3 +66,4 @@ secrets: volumes: confsync-data: + confsync-cli: diff --git a/go.mod b/go.mod index 61040e3..507796d 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,21 @@ module pve-confsync -go 1.24 +go 1.24.0 -require gopkg.in/yaml.v3 v3.0.1 +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.40.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..d2d5d13 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ= +modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go index 3baa10c..a92b3c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,10 @@ type Cluster struct { Fingerprint string `yaml:"fingerprint,omitempty"` Insecure bool `yaml:"insecure,omitempty"` Timeout string `yaml:"timeout,omitempty"` + + // Set by the web UI from its database, never read from nor written to YAML. + Secret string `yaml:"-"` // token secret, decrypted + CAPEM string `yaml:"-"` // CA bundle, PEM } // Client is a target cluster with its mapping (kind -> id -> field -> value) @@ -74,6 +78,9 @@ func Load(file string) (*Config, error) { return &c, c.validate() } +// Validate checks a configuration built elsewhere (web UI database, YAML import). +func (c *Config) Validate() error { return c.validate() } + func (c *Config) validate() error { var errs []error seen := map[string]bool{} @@ -86,7 +93,7 @@ func (c *Config) validate() error { errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name)) } seen[cl.Name] = true - if cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" { + if cl.Secret == "" && cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" { errs = append(errs, fmt.Errorf("cluster %s: token_secret_env or token_secret_file is required", cl.Name)) } } @@ -158,11 +165,18 @@ func (cl Cluster) PVE() (*pve.Client, error) { } return pve.New(pve.Options{ Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret, - CAFile: cl.CAFile, Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout, + CAFile: cl.CAFile, CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout, }) } +// ResolveSecret reads the token secret from the environment variable or file of +// the YAML configuration (used when a YAML file is imported in the web UI). +func (cl Cluster) ResolveSecret() (string, error) { return cl.secret() } + func (cl Cluster) secret() (string, error) { + if cl.Secret != "" { + return cl.Secret, nil + } if cl.TokenSecretEnv != "" { if v := os.Getenv(cl.TokenSecretEnv); v != "" { return v, nil diff --git a/internal/demo/demo.go b/internal/demo/demo.go new file mode 100644 index 0000000..3b7419e --- /dev/null +++ b/internal/demo/demo.go @@ -0,0 +1,129 @@ +// Package demo starts fake PVE clusters (pvetest) and fills a database with +// them, to try the web UI without a Proxmox cluster: a reference, two clients +// with differences (missing objects, different fields, a missing mapping) and +// a PVE 8 cluster that is refused. +package demo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + + "pve-confsync/internal/adapter" + "pve-confsync/internal/model" + "pve-confsync/internal/pve/pvetest" + "pve-confsync/internal/store" +) + +// Password of the demo accounts admin, operateur and lecteur. +const Password = "confsync-demo" + +// Clusters are the fake clusters; Close stops them. +type Clusters struct{ servers []*pvetest.Server } + +// Close stops the fake clusters. +func (c *Clusters) Close() { + for _, s := range c.servers { + s.Close() + } +} + +// Start starts the fake clusters and records them (and the demo accounts) in st. +func Start(ctx context.Context, st *store.Store) (*Clusters, error) { + ref := pvetest.New("9.1.4") + ref.Options = map[string]string{ + "registered-tags": "backup;dev;prod", + "tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full", + "next-id": "lower=100,upper=999999", + "user-tag-access": "user-allow=list,user-allow-list=team-a;team-b", + "keyboard": "fr", + } + ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"} + ref.Pending.Zones["stor"] = map[string]any{"type": "vlan", "bridge": "vmbr2", "mtu": 9000, "ipam": "pve"} + ref.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "10.0.0.1,10.0.0.2", "mtu": 1450, "ipam": "pve"} + ref.Pending.Zones["evpnz"] = map[string]any{"type": "evpn", "controller": "evpnctl", "vrf-vxlan": 4000} + ref.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"} + ref.Pending.VNets["v200"] = map[string]any{"zone": "vlanz", "tag": 200, "alias": "dev"} + ref.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"} + ref.Pending.VNets["vx1"] = map[string]any{"zone": "vxz", "tag": 10001, "alias": "overlay"} + ref.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1", + "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} + ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"} + ref.Commit() + + // site-a: tags and an MTU behind, the VXLAN zone and the dev network missing + a := pvetest.New("9.0.10") + a.Options = map[string]string{ + "registered-tags": "dev;prod", + "tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full", + "next-id": "lower=2000,upper=2999", + "keyboard": "en-us", + } + a.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr1", "mtu": 9000, "ipam": "pve"} + a.Pending.Zones["stor"] = map[string]any{"type": "vlan", "bridge": "vmbr2", "mtu": 9000, "ipam": "pve", "nodes": "pve-a1,pve-a2"} + a.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "production"} + a.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"} + a.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"} + a.Commit() + + // site-b: nearly in line, but no mapping for the bridge of the "stor" zone + // (blocking error) and a local zone absent from the reference (kept, no prune) + b := pvetest.New("9.1.2") + b.Options = map[string]string{ + "registered-tags": "backup;dev;prod", + "tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full", + "next-id": "lower=100,upper=999999", + "user-tag-access": "user-allow=list,user-allow-list=team-a;team-b", + } + b.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"} + b.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "172.16.0.1,172.16.0.2", "mtu": 1450, "ipam": "pve"} + b.Pending.Zones["labz"] = map[string]any{"type": "vlan", "bridge": "vmbr9", "ipam": "pve"} + b.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"} + b.Pending.VNets["v200"] = map[string]any{"zone": "vlanz", "tag": 200, "alias": "dev"} + b.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1", + "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} + b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"} + b.Commit() + + // legacy: PVE 8, refused by the version check + old := pvetest.New("8.4.1") + + c := &Clusters{servers: []*pvetest.Server{ref, a, b, old}} + save := func(s *pvetest.Server, name, token string, refer bool, pos int, + mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error { + sum := sha256.Sum256(s.Certificate().Raw) + return st.SaveCluster(ctx, "", store.Cluster{Name: name, Reference: refer, Position: pos, URL: s.URL, + TokenID: token, Secret: "demo-" + name, Fingerprint: hex.EncodeToString(sum[:]), + Mapping: mapping, Policies: pol}) + } + steps := []error{ + save(ref, "ref-rennes", "confsync@pve!ro", true, 0, nil, nil), + save(a, "site-a", "confsync@pve!rw", false, 1, map[model.Kind]map[string]map[string]any{ + model.KindDatacenter: {"datacenter": {"next-id": "lower=2000,upper=2999"}}, + model.KindZone: { + "vlanz": {"bridge": "vmbr1"}, + "stor": {"bridge": "vmbr2", "nodes": "pve-a1,pve-a2"}, + "vxz": {"peers": "192.168.10.1,192.168.10.2"}, + }, + }, nil), + save(b, "site-b", "confsync@pve!rw", false, 2, map[model.Kind]map[string]map[string]any{ + model.KindZone: { + "vlanz": {"bridge": "vmbr0"}, + "vxz": {"peers": "172.16.0.1,172.16.0.2"}, + }, + }, map[model.Kind]map[string]adapter.Policy{model.KindZone: {"mtu": adapter.Ignore}}), + save(old, "legacy-pve8", "confsync@pve!rw", false, 3, nil, nil), + st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds, + Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}), + } + for _, u := range []struct{ name, role string }{{"admin", store.RoleAdmin}, {"operateur", store.RoleOperator}, {"lecteur", store.RoleViewer}} { + steps = append(steps, st.SaveUser(ctx, u.name, u.role, Password)) + } + for _, err := range steps { + if err != nil { + c.Close() + return nil, err + } + } + return c, nil +} diff --git a/internal/engine/apply.go b/internal/engine/apply.go index c5630de..38d54ff 100644 --- a/internal/engine/apply.go +++ b/internal/engine/apply.go @@ -24,6 +24,8 @@ type Result struct { Client string `json:"client"` Steps []Step `json:"steps"` Err error `json:"-"` + + notify func(Step) } func (r *Result) log(action string, err error) { @@ -32,6 +34,9 @@ func (r *Result) log(action string, err error) { s.Error = err.Error() } r.Steps = append(r.Steps, s) + if r.notify != nil { + r.notify(s) + } } // TaskPoll is the poll interval of the SDN reload task. @@ -42,9 +47,15 @@ var TaskPoll = 2 * time.Second // committed with PUT /cluster/sdn. Any failure before the commit rolls the // pending SDN changes back (POST /cluster/sdn/rollback). func Apply(ctx context.Context, c *pve.Client, plan Plan) Result { - res := Result{Client: plan.Client} + return ApplyWithLog(ctx, c, plan, nil) +} + +// ApplyWithLog is Apply calling notify after each step (live journal of the web UI). +func ApplyWithLog(ctx context.Context, c *pve.Client, plan Plan, notify func(Step)) Result { + res := Result{Client: plan.Client, notify: notify} if len(plan.Errors) > 0 { res.Err = fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors)) + res.log("plan check", res.Err) return res } if _, err := c.CheckVersion(ctx); err != nil { diff --git a/internal/engine/select.go b/internal/engine/select.go new file mode 100644 index 0000000..912db30 --- /dev/null +++ b/internal/engine/select.go @@ -0,0 +1,144 @@ +package engine + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "strings" + + "pve-confsync/internal/model" +) + +// Key is the identity of a planned change ("kind/id"), as model.Object.Key. +func (c Change) Key() string { return string(c.Kind) + "/" + c.ID } + +// Filter keeps the changes of the given objects, plus the ones they depend on: +// creating a vnet needs its zone when the zone is created too, a subnet needs its +// vnet; deleting a zone needs the deletion of its vnets and their subnets first. +// The execution order of the plan is kept. An empty key list keeps everything. +func Filter(p Plan, keys []string) Plan { + if len(keys) == 0 { + return p + } + byKey := map[string]Change{} + for _, c := range p.Changes { + byKey[c.Key()] = c + } + want := map[string]bool{} + for _, k := range keys { + if _, ok := byKey[k]; ok { + want[k] = true + } + } + for changed := true; changed; { + changed = false + add := func(k string) { + if _, ok := byKey[k]; ok && !want[k] { + want[k] = true + changed = true + } + } + for k := range want { + c := byKey[k] + switch c.Op { + case OpCreate, OpUpdate: + // parents written in the same plan must exist first + if z, ok := c.Set["zone"].(string); ok && byKey[string(model.KindZone)+"/"+z].Op == OpCreate { + add(string(model.KindZone) + "/" + z) + } + if v, ok := c.Set["vnet"].(string); ok && byKey[string(model.KindVNet)+"/"+v].Op == OpCreate { + add(string(model.KindVNet) + "/" + v) + } + case OpDelete: + // children deleted in the same plan must go first + for _, d := range p.Changes { + if d.Op != OpDelete { + continue + } + switch { + case c.Kind == model.KindZone && d.Kind == model.KindVNet && d.Old["zone"] == c.ID, + c.Kind == model.KindVNet && d.Kind == model.KindSubnet && d.Old["vnet"] == c.ID: + add(d.Key()) + } + } + } + } + } + // an error on the parent of a selected object blocks it too (a vnet whose + // zone cannot be created) + blocking := map[string]bool{} + for k := range want { + c := byKey[k] + if c.Op == OpDelete { + continue + } + if z, ok := c.Set["zone"].(string); ok { + blocking[string(model.KindZone)+"/"+z] = true + } + if v, ok := c.Set["vnet"].(string); ok { + blocking[string(model.KindVNet)+"/"+v] = true + } + } + out := Plan{Client: p.Client, Warnings: p.Warnings} + // errors about other objects ("kind/id: ...") do not block this selection + for _, e := range p.Errors { + if k, ok := ErrorKey(e); !ok || want[k] || blocking[k] || contains(keys, k) { + out.Errors = append(out.Errors, e) + } + } + for _, c := range p.Changes { + if want[c.Key()] { + out.Changes = append(out.Changes, c) + } + } + return out +} + +// ErrorKey returns the object an error of MakePlan is about ("kind/id: ..."; +// ": " and not ":" because IPv6 subnet ids hold colons). +func ErrorKey(e string) (string, bool) { + for _, k := range model.Kinds { + if strings.HasPrefix(e, string(k)+"/") { + if i := strings.Index(e, ": "); i > 0 { + return e[:i], true + } + } + } + return "", false +} + +func contains(l []string, s string) bool { + for _, x := range l { + if x == s { + return true + } + } + return false +} + +// Digest identifies the content of a plan: the web UI shows a dry-run, then applies +// only if the plan computed again just before the apply has the same digest, so +// that what is written is exactly what the user reviewed. +func (p Plan) Digest() string { + type ch struct { + Op Op `json:"op"` + Key string `json:"key"` + Set map[string]any `json:"set,omitempty"` + Unset []string `json:"unset,omitempty"` + } + l := make([]ch, 0, len(p.Changes)) + for _, c := range p.Changes { + l = append(l, ch{c.Op, c.Key(), c.Set, c.Unset}) + } + errs := append([]string(nil), p.Errors...) + sort.Strings(errs) + // map keys are sorted by encoding/json: the digest is stable + b, _ := json.Marshal(struct { + Client string `json:"client"` + Changes []ch `json:"changes"` + Errors []string `json:"errors"` + }{p.Client, l, errs}) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:12]) +} diff --git a/internal/engine/select_test.go b/internal/engine/select_test.go new file mode 100644 index 0000000..8df8ae7 --- /dev/null +++ b/internal/engine/select_test.go @@ -0,0 +1,92 @@ +package engine + +import ( + "testing" + + "pve-confsync/internal/model" +) + +func keys(p Plan) []string { + var out []string + for _, c := range p.Changes { + out = append(out, c.Key()) + } + return out +} + +func TestFilterDependencies(t *testing.T) { + p := Plan{Client: "c", Changes: []Change{ + {Kind: model.KindZone, ID: "z1", Op: OpCreate, Set: map[string]any{"type": "vlan"}}, + {Kind: model.KindZone, ID: "z2", Op: OpUpdate, Set: map[string]any{"mtu": 1500}}, + {Kind: model.KindVNet, ID: "v1", Op: OpCreate, Set: map[string]any{"zone": "z1"}}, + {Kind: model.KindVNet, ID: "v2", Op: OpCreate, Set: map[string]any{"zone": "z2"}}, + {Kind: model.KindSubnet, ID: "z1-10.0.0.0-24", Op: OpCreate, Set: map[string]any{"vnet": "v1"}}, + }} + got := keys(Filter(p, []string{"sdn-subnet/z1-10.0.0.0-24"})) + want := []string{"sdn-zone/z1", "sdn-vnet/v1", "sdn-subnet/z1-10.0.0.0-24"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v (plan order kept)", got, want) + } + } + // an update of the parent zone is not pulled in + if got := keys(Filter(p, []string{"sdn-vnet/v2"})); len(got) != 1 { + t.Fatalf("got %v, want only the vnet", got) + } + // unknown keys select nothing; no keys select everything + if got := Filter(p, []string{"sdn-vnet/nope"}); len(got.Changes) != 0 { + t.Fatalf("unknown key selected %v", keys(got)) + } + if got := Filter(p, nil); len(got.Changes) != len(p.Changes) { + t.Fatal("empty selection must keep the whole plan") + } +} + +func TestFilterDeleteChildrenFirst(t *testing.T) { + p := Plan{Changes: []Change{ + {Kind: model.KindSubnet, ID: "z-10.0.0.0-24", Op: OpDelete, Old: map[string]any{"vnet": "v"}}, + {Kind: model.KindVNet, ID: "v", Op: OpDelete, Old: map[string]any{"zone": "z"}}, + {Kind: model.KindVNet, ID: "other", Op: OpDelete, Old: map[string]any{"zone": "y"}}, + {Kind: model.KindZone, ID: "z", Op: OpDelete}, + }} + got := keys(Filter(p, []string{"sdn-zone/z"})) + if len(got) != 3 || got[2] != "sdn-zone/z" { + t.Fatalf("got %v", got) + } +} + +func TestFilterErrors(t *testing.T) { + p := Plan{ + Changes: []Change{ + {Kind: model.KindVNet, ID: "ceph", Op: OpCreate, Set: map[string]any{"zone": "stor"}}, + {Kind: model.KindVNet, ID: "vx1", Op: OpCreate, Set: map[string]any{"zone": "vxz"}}, + }, + Errors: []string{`sdn-zone/stor: field "bridge" required`, "sdn-subnet/z-fd00::-64: bad", "global problem"}, + } + if got := Filter(p, []string{"sdn-vnet/vx1"}); len(got.Errors) != 1 || got.Errors[0] != "global problem" { + t.Fatalf("vx1: errors %v", got.Errors) + } + // the zone of ceph cannot be created: its error blocks ceph + if got := Filter(p, []string{"sdn-vnet/ceph"}); len(got.Errors) != 2 { + t.Fatalf("ceph: errors %v", got.Errors) + } + if k, ok := ErrorKey("sdn-subnet/z-fd00::-64: bad"); !ok || k != "sdn-subnet/z-fd00::-64" { + t.Fatalf("ErrorKey IPv6: %q %v", k, ok) + } +} + +func TestDigest(t *testing.T) { + a := Plan{Client: "c", Changes: []Change{{Kind: model.KindZone, ID: "z", Op: OpUpdate, Set: map[string]any{"mtu": 1500}}}} + b := Plan{Client: "c", Changes: []Change{{Kind: model.KindZone, ID: "z", Op: OpUpdate, Set: map[string]any{"mtu": 9000}}}} + if a.Digest() == b.Digest() { + t.Fatal("different plans, same digest") + } + a2 := a + a2.Warnings = []string{"w"} + if a.Digest() != a2.Digest() { + t.Fatal("warnings must not change the digest") + } +} diff --git a/internal/pve/client.go b/internal/pve/client.go index 3125a13..085608a 100644 --- a/internal/pve/client.go +++ b/internal/pve/client.go @@ -29,6 +29,7 @@ type Options struct { TokenID string // user@realm!tokenname TokenSecret string // never logged CAFile string // PEM bundle to trust (optional) + CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning) Insecure bool // skip TLS verification (lab only) Timeout time.Duration @@ -93,14 +94,18 @@ func New(o Options) (*Client, error) { } return nil } - case o.CAFile != "": - pem, err := os.ReadFile(o.CAFile) - if err != nil { - return nil, fmt.Errorf("cluster %s: %w", o.Name, err) + case o.CAFile != "" || len(o.CAPEM) > 0: + pem, src := o.CAPEM, "the CA certificate" + if o.CAFile != "" { + var err error + if pem, err = os.ReadFile(o.CAFile); err != nil { + return nil, fmt.Errorf("cluster %s: %w", o.Name, err) + } + src = o.CAFile } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, o.CAFile) + return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src) } tlsCfg.RootCAs = pool case o.Insecure: diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..d32b066 --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,689 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" + + "pve-confsync/internal/config" + "pve-confsync/internal/engine" + "pve-confsync/internal/model" + "pve-confsync/internal/service" + "pve-confsync/internal/store" +) + +// readTimeout bounds a comparison (reading every cluster). +const readTimeout = 2 * time.Minute + +// ---- state (phase 1: clusters and visual diff) ---- + +// handleState returns the last comparison; refresh=1 reads the clusters again. +func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if s.state == nil || r.URL.Query().Get("refresh") == "1" { + cfg, err := s.store.Config(r.Context()) + if errors.Is(err, store.ErrNoReference) { + writeJSON(w, http.StatusOK, map[string]any{"setup": true}) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "config") + return + } + ctx, cancel := context.WithTimeout(r.Context(), readTimeout) + defer cancel() + s.state, s.stateAt = service.Analyze(ctx, cfg, nil), time.Now() + } + writeJSON(w, http.StatusOK, map[string]any{"state": s.state}) +} + +// invalidate forgets the cached comparison (configuration changed, apply done). +func (s *Server) invalidate() { + s.stateMu.Lock() + s.state = nil + s.stateMu.Unlock() +} + +// remember merges the clients of a partial comparison into the cached one, so +// that the screen shows what a dry-run just read. +func (s *Server) remember(st *service.State, all bool) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if all || s.state == nil { + if all { + s.state, s.stateAt = st, time.Now() + } + return + } + s.state.Reference = st.Reference + for _, c := range st.Clients { + for i := range s.state.Clients { + if s.state.Clients[i].Name == c.Name { + s.state.Clients[i] = c + } + } + } +} + +// ---- dry-run and apply (phase 2) ---- + +type selection struct { + Scope string `json:"scope"` // global, client or object + Client string `json:"client"` // client and object scopes + Objects []string `json:"objects"` // object scope: "kind/id" + Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan + Confirm string `json:"confirm"` // apply with SDN changes: client name (reference name for global) +} + +func (sel *selection) check() error { + switch sel.Scope { + case "global": + sel.Client, sel.Objects = "", nil + case "client": + sel.Objects = nil + if sel.Client == "" { + return errors.New("client required") + } + case "object": + if sel.Client == "" || len(sel.Objects) == 0 { + return errors.New("client and objects required") + } + default: + return fmt.Errorf("unknown scope %q", sel.Scope) + } + return nil +} + +func (sel selection) clients() []string { + if sel.Client == "" { + return nil + } + return []string{sel.Client} +} + +// plan reads the clusters and returns the plans of the selection. +func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) { + cfg, err := s.store.Config(ctx) + if err != nil { + return nil, nil, nil, err + } + if sel.Client != "" { + if _, ok := cfg.Client(sel.Client); !ok { + return nil, nil, nil, fmt.Errorf("unknown client %q", sel.Client) + } + } + rctx, cancel := context.WithTimeout(ctx, readTimeout) + defer cancel() + st := service.Analyze(rctx, cfg, sel.clients()) + s.remember(st, sel.Scope == "global") + return cfg, st, st.Plans(sel.Objects), nil +} + +type runDetail struct { + Plans []service.ClientPlan `json:"plans"` + Results map[string]*clientResult `json:"results,omitempty"` +} + +type clientResult struct { + Status string `json:"status"` // running, ok, error, noop, skipped + Error string `json:"error,omitempty"` + Steps []engine.Step `json:"steps"` +} + +func (s *Server) handleDryRun(w http.ResponseWriter, r *http.Request) { + var sel selection + if err := readJSON(r, &sel); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + if err := sel.check(); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + id := identity(r) + _, _, plans, err := s.plan(r.Context(), sel) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "config") + return + } + status, summary := dryRunSummary(plans) + d, _ := json.Marshal(runDetail{Plans: plans}) + run := store.Run{User: id.User, Mode: "dry-run", Scope: sel.Scope, Clients: planClients(plans), Objects: sel.Objects, + Status: status, Summary: summary, Detail: d} + runID, err := s.store.StartRun(r.Context(), run) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + _ = s.store.UpdateRun(r.Context(), runID, status, summary, d) + s.audit(r, id, "dry-run", target(sel), true, summary) + writeJSON(w, http.StatusOK, map[string]any{"run": runID, "plans": plans, "summary": summary}) +} + +func planClients(plans []service.ClientPlan) []string { + out := make([]string, 0, len(plans)) + for _, p := range plans { + out = append(out, p.Client) + } + return out +} + +func target(sel selection) string { + switch sel.Scope { + case "client": + return sel.Client + case "object": + return sel.Client + ": " + strings.Join(sel.Objects, ", ") + } + return "global" +} + +// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors. +func dryRunSummary(plans []service.ClientPlan) (string, string) { + changes, clients, blocked := 0, 0, 0 + for _, p := range plans { + if p.Error != "" || len(p.Plan.Errors) > 0 { + blocked++ + continue + } + if n := len(p.Plan.Changes); n > 0 { + changes += n + clients++ + } + } + switch { + case blocked > 0: + return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d client(s), %d client(s) en erreur", changes, clients, blocked) + case changes == 0: + return store.RunNoop, "aucun écart" + } + return store.RunOK, fmt.Sprintf("%d changement(s) sur %d client(s)", changes, clients) +} + +// handleApply recomputes the plans, checks that they are the ones reviewed in the +// dry-run (digests) and the confirmation of the SDN changes, then applies them in +// the background, client after client. Progress goes to the journal. +func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { + var sel selection + if err := readJSON(r, &sel); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + if err := sel.check(); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + id := identity(r) + if !s.applyMu.TryLock() { + writeError(w, http.StatusConflict, "une synchronisation est déjà en cours", "busy") + return + } + started := false + defer func() { + if !started { + s.applyMu.Unlock() + } + }() + cfg, st, plans, err := s.plan(r.Context(), sel) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "config") + return + } + var todo []service.ClientPlan + sdn := false + for _, p := range plans { + switch { + case p.Error != "": + s.audit(r, id, "apply", target(sel), false, p.Client+": "+p.Error) + writeError(w, http.StatusConflict, p.Client+": "+p.Error, "read") + return + case len(p.Plan.Errors) > 0: + s.audit(r, id, "apply", target(sel), false, p.Client+": plan en erreur") + writeError(w, http.StatusConflict, p.Client+": "+strings.Join(p.Plan.Errors, "; "), "plan") + return + case sel.Digests[p.Client] != p.Digest: + s.audit(r, id, "apply", target(sel), false, p.Client+": l'état a changé depuis le dry-run") + writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": l'état a changé depuis le dry-run, relancer le dry-run", + "code": "stale", "plans": plans}) + return + case p.Plan.Empty(): + continue + } + sdn = sdn || p.SDN + todo = append(todo, p) + } + if len(todo) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "aucun écart"}) + return + } + if sdn { + want := sel.Client + if sel.Scope == "global" { + want = cfg.Reference.Name + } + if sel.Confirm != want { + writeError(w, http.StatusBadRequest, "changements SDN : confirmation requise ("+want+")", "confirm") + return + } + } + det := &runDetail{Plans: todo, Results: map[string]*clientResult{}} + for _, p := range todo { + det.Results[p.Client] = &clientResult{Status: "pending", Steps: []engine.Step{}} + } + d, _ := json.Marshal(det) + runID, err := s.store.StartRun(r.Context(), store.Run{User: id.User, Mode: "apply", Scope: sel.Scope, + Clients: planClients(todo), Objects: sel.Objects, Status: store.RunRunning, Summary: "en cours", Detail: d}) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + s.audit(r, id, "apply-start", target(sel), true, fmt.Sprintf("journal #%d", runID)) + started = true + s.jobs.Add(1) + // the request ends now: the job keeps a copy that outlives it (audit IP) + rr := r.Clone(context.WithoutCancel(r.Context())) + go func() { + defer s.jobs.Done() + defer s.applyMu.Unlock() + s.runApply(rr, id, runID, sel, st, det) + }() + writeJSON(w, http.StatusAccepted, map[string]any{"run": runID}) +} + +// runApply applies the plans one client after the other; a failed client does +// not stop the next ones (each has its own SDN lock and rollback). +func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selection, st *service.State, det *runDetail) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + var mu sync.Mutex // det is read by save while a step is appended + save := func(status, summary string) { + mu.Lock() + d, _ := json.Marshal(det) + mu.Unlock() + if err := s.store.UpdateRun(ctx, runID, status, summary, d); err != nil { + log.Printf("journal #%d: %v", runID, err) + } + } + failed, done := 0, 0 + for _, p := range det.Plans { + res := det.Results[p.Client] + res.Status = store.RunRunning + save(store.RunRunning, fmt.Sprintf("%s en cours", p.Client)) + err := st.Apply(ctx, p, sel.Objects, func(step engine.Step) { + mu.Lock() + res.Steps = append(res.Steps, step) + mu.Unlock() + save(store.RunRunning, fmt.Sprintf("%s : %s", p.Client, step.Action)) + }) + if err != nil { + failed++ + res.Status, res.Error = store.RunError, err.Error() + log.Printf("apply %s (journal #%d): %v", p.Client, runID, err) + s.audit(r, id, "apply", p.Client, false, err.Error()) + } else { + done++ + res.Status = store.RunOK + s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d changement(s), vérifié", len(p.Plan.Changes))) + } + } + status := store.RunOK + summary := fmt.Sprintf("%d client(s) synchronisé(s) et vérifié(s)", done) + if failed > 0 { + status = store.RunError + summary = fmt.Sprintf("%d client(s) en échec, %d synchronisé(s)", failed, done) + } + save(status, summary) + s.invalidate() +} + +func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + runs, err := s.store.Runs(r.Context(), limit, before) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if runs == nil { + runs = []store.Run{} + } + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) +} + +func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + run, err := s.store.Run(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "unknown run", "notfound") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + writeJSON(w, http.StatusOK, run) +} + +// ---- clusters and settings (admin) ---- + +func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) { + cls, err := s.store.Clusters(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if cls == nil { + cls = []store.Cluster{} + } + // connection details are for the administrators + if identity(r).Role != store.RoleAdmin { + for i := range cls { + cls[i] = store.Cluster{Name: cls[i].Name, Reference: cls[i].Reference, URL: cls[i].URL, Mapping: cls[i].Mapping, Policies: cls[i].Policies} + } + } + writeJSON(w, http.StatusOK, map[string]any{"clusters": cls}) +} + +func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) { + var c store.Cluster + if err := readJSON(r, &c); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + old := r.PathValue("name") + if old == "-" { // creation + old = "" + } + if err := validateCluster(c); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + err := s.store.SaveCluster(r.Context(), old, c) + detail := fmt.Sprintf("url=%s token=%s reference=%t", c.URL, c.TokenID, c.Reference) + if c.Secret != "" { + detail += " secret=modifié" + } + if old != "" && old != c.Name { + detail += " renommé depuis " + old + } + s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + s.invalidate() + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// validateCluster checks the mapping and policies of a cluster with the rules of +// the YAML configuration. +func validateCluster(c store.Cluster) error { + if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") { + return errors.New("url: https:// expected") + } + if c.Timeout != "" { + if _, err := time.ParseDuration(c.Timeout); err != nil { + return fmt.Errorf("timeout: %w", err) + } + } + cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref", Secret: "x"}, + Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name, Secret: "x"}, Mapping: c.Mapping, Policies: c.Policies}}} + return cfg.Validate() +} + +func errText(err error, ok string) string { + if err != nil { + return err.Error() + } + return ok +} + +func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + err := s.store.DeleteCluster(r.Context(), name) + s.audit(r, Identity{}, "cluster-delete", name, err == nil, errText(err, "")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error(), "notfound") + return + } + s.invalidate() + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleCheckCluster tests the access, the version and the token privileges. +func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + cfg, err := s.store.Config(r.Context()) + if err != nil && !errors.Is(err, store.ErrNoReference) { + writeError(w, http.StatusInternalServerError, err.Error(), "config") + return + } + var cl config.Cluster + write := true + switch { + case cfg != nil && cfg.Reference.Name == name: + cl, write = cfg.Reference, false + case cfg != nil: + c, ok := cfg.Client(name) + if !ok { + writeError(w, http.StatusNotFound, "unknown cluster", "notfound") + return + } + cl = c.Cluster + default: + writeError(w, http.StatusBadRequest, "define a reference cluster first", "config") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + res := service.CheckCluster(ctx, cl, write, cfg.HasKind(model.KindDatacenter)) + s.audit(r, Identity{}, "cluster-check", name, res.Error == "", res.Error) + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { + st, err := s.store.Settings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "defaults": defaultPolicies()}) +} + +// defaultPolicies describes the built-in policies, shown in the settings. +func defaultPolicies() map[model.Kind]string { + return map[model.Kind]string{ + model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais", + model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping du client) ; le reste : sync", + model.KindVNet: "sync", + model.KindSubnet: "sync", + } +} + +func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) { + var st store.Settings + if err := readJSON(r, &st); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune, + Reference: config.Cluster{Name: "\x00ref", Secret: "x"}} + if len(cfg.Kinds) == 0 { + cfg.Kinds = model.Kinds + } + err := cfg.Validate() + if err == nil { + err = s.store.SaveSettings(r.Context(), st) + } + b, _ := json.Marshal(st) + s.audit(r, Identity{}, "settings-save", "synchronisation", err == nil, errText(err, string(b))) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + s.invalidate() + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleExport returns the configuration as a CLI YAML file (no secret). +func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { + cfg, err := s.store.ExportConfig(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + b, err := yaml.Marshal(cfg) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "yaml") + return + } + s.audit(r, Identity{}, "config-export", "", true, "") + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="confsync.yaml"`) + _, _ = fmt.Fprintf(w, "# pve-confsync : configuration exportée le %s par %s\n# Secrets des tokens non exportés : renseigner les variables token_secret_env (ou token_secret_file).\n\n", + time.Now().Format("2006-01-02 15:04"), identity(r).User) + _, _ = w.Write(b) +} + +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + var req struct { + YAML string `json:"yaml"` + Replace bool `json:"replace"` + } + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + var cfg config.Config + dec := yaml.NewDecoder(strings.NewReader(req.YAML)) + dec.KnownFields(true) + err := dec.Decode(&cfg) + if err == nil { + if len(cfg.Kinds) == 0 { + cfg.Kinds = append(cfg.Kinds, model.Kinds...) + } + err = cfg.Validate() + } + var warns []string + if err == nil { + warns, err = s.store.ImportConfig(r.Context(), &cfg, req.Replace) + } + s.audit(r, Identity{}, "config-import", fmt.Sprintf("%d cluster(s), remplacement=%t", len(cfg.Clients)+1, req.Replace), + err == nil, errText(err, strings.Join(warns, " ; "))) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + s.invalidate() + if warns == nil { + warns = []string{} + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "warnings": warns}) +} + +// ---- users and audit ---- + +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + us, err := s.store.Users(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if us == nil { + us = []store.User{} + } + writeJSON(w, http.StatusOK, map[string]any{"users": us}) +} + +func (s *Server) handleSaveUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Role string `json:"role"` + Password string `json:"password"` + } + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + name := r.PathValue("name") + err := s.store.SaveUser(r.Context(), name, req.Role, req.Password) + detail := "rôle " + req.Role + if req.Password != "" { + detail += ", mot de passe défini" + } + s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + err := s.store.DeleteUser(r.Context(), name) + s.audit(r, Identity{}, "user-delete", name, err == nil, errText(err, "")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handlePassword lets a local user change their own password. +func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { + id := identity(r) + var req struct { + Old string `json:"old"` + New string `json:"new"` + } + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "request") + return + } + if id.Mode != "local" { + writeError(w, http.StatusBadRequest, "password managed by the identity provider", "oidc") + return + } + u, ok := s.store.Authenticate(r.Context(), id.User, req.Old) + if !ok { + time.Sleep(loginFailDelay) + s.audit(r, id, "password-change", id.User, false, "ancien mot de passe incorrect") + writeError(w, http.StatusForbidden, "wrong current password", "password") + return + } + err := s.store.SaveUser(r.Context(), id.User, u.Role, req.New) + s.audit(r, id, "password-change", id.User, err == nil, errText(err, "")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "invalid") + return + } + // the new epoch ends the other sessions; this one gets a new cookie + if nu, err := s.store.User(r.Context(), id.User); err == nil { + s.auth.setSession(w, r, session{User: nu.Name, Role: nu.Role, Mode: "local", Epoch: nu.Epoch}) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + ev, err := s.store.Events(r.Context(), limit, before) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error(), "store") + return + } + if ev == nil { + ev = []store.Event{} + } + writeJSON(w, http.StatusOK, map[string]any{"events": ev}) +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..f681832 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,785 @@ +package server + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + _ "crypto/sha512" // SHA-384/512 for RS384, ES384, RS512… + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "pve-confsync/internal/store" +) + +// Authentication of the web UI, on the model of Logstream (auth.go, auth_local.go): +// local accounts (SQLite, PBKDF2) shown on a login page, and/or an OpenID Connect +// provider (Keycloak, Authentik, Authelia…) with the authorization code flow and +// PKCE. Both can be enabled together: the local accounts stay the way in when the +// provider is down. Sessions are HMAC signed cookies. Only the standard library. + +const ( + sessionCookie = "confsync_session" + loginCookie = "confsync_login_" // + state: one cookie per OIDC login in progress + loginTTL = 10 * time.Minute + clockSkew = time.Minute + loginPage = "/login.html" +) + +var loginFailDelay = time.Second // slows down password guessing + +// AuthConfig is read from the environment (see README). +type AuthConfig struct { + Local bool // local accounts (AUTH_LOCAL, default true) + Issuer string + ClientID string + ClientSecret string + RedirectURL string + Scopes string + GroupsClaim string + AdminGroup string // OIDC_ADMIN_GROUP: members are admins + OperatorGroup string // OIDC_OPERATOR_GROUP: members are operators + DefaultRole string // OIDC_DEFAULT_ROLE: everyone else (default viewer) + SessionTTL time.Duration + DataDir string + Demo bool // demo mode: the login page lists the demo accounts +} + +// OIDCEnabled tells whether an OpenID Connect provider is configured. +func (c AuthConfig) OIDCEnabled() bool { return c.Issuer != "" } + +// Identity is the logged in user. +type Identity struct { + User string `json:"user"` + Role string `json:"role"` + Mode string `json:"mode"` // local or oidc +} + +type ctxKey struct{} + +// identity returns the user of a request (set by the middleware). +func identity(r *http.Request) Identity { + id, _ := r.Context().Value(ctxKey{}).(Identity) + return id +} + +type session struct { + User string `json:"u"` + Role string `json:"r"` + Mode string `json:"m"` + Epoch int64 `json:"p,omitempty"` // local: password epoch, a new password ends the sessions + Exp int64 `json:"e"` +} + +// Auth is the middleware protecting the UI and the API. +type Auth struct { + cfg AuthConfig + store *store.Store + key []byte + secure bool // cookies HTTPS only (OIDC_REDIRECT_URL in https) + audit func(r *http.Request, id Identity, action, target string, ok bool, detail string) + next http.Handler + + callback string + client *http.Client + mu sync.Mutex + meta *oidcMeta + keys map[string]crypto.PublicKey + keysAt time.Time +} + +// NewAuth checks the configuration and loads the session key. +func NewAuth(c AuthConfig, st *store.Store) (*Auth, error) { + if c.SessionTTL <= 0 { + c.SessionTTL = 12 * time.Hour + } + if c.DefaultRole == "" { + c.DefaultRole = store.RoleViewer + } + if !store.ValidRole(c.DefaultRole) { + return nil, fmt.Errorf("OIDC_DEFAULT_ROLE=%q: expected admin, operator or viewer", c.DefaultRole) + } + a := &Auth{cfg: c, store: st, key: sessionKey(c.DataDir), client: &http.Client{Timeout: 10 * time.Second}} + if !c.Local && !c.OIDCEnabled() { + return nil, errors.New("AUTH_LOCAL=false needs an OIDC provider (OIDC_ISSUER)") + } + if c.OIDCEnabled() { + var missing []string + for _, v := range [][2]string{{"OIDC_CLIENT_ID", c.ClientID}, {"OIDC_CLIENT_SECRET", c.ClientSecret}, {"OIDC_REDIRECT_URL", c.RedirectURL}} { + if v[1] == "" { + missing = append(missing, v[0]) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("OIDC: missing %s", strings.Join(missing, ", ")) + } + ru, err := url.Parse(c.RedirectURL) + if err != nil || ru.Host == "" || ru.Path == "" || ru.Path == "/" { + return nil, fmt.Errorf("OIDC_REDIRECT_URL=%q: expected a full URL such as https://confsync.example.org/auth/callback", c.RedirectURL) + } + a.callback, a.secure = ru.Path, ru.Scheme == "https" + if a.cfg.Scopes == "" { + a.cfg.Scopes = "openid profile email" + } + if !strings.Contains(" "+a.cfg.Scopes+" ", " openid ") { + a.cfg.Scopes = "openid " + a.cfg.Scopes + } + if a.cfg.GroupsClaim == "" { + a.cfg.GroupsClaim = "groups" + } + if c.AdminGroup == "" && c.OperatorGroup == "" { + log.Printf("oidc: no OIDC_ADMIN_GROUP / OIDC_OPERATOR_GROUP, every OIDC user gets the role %s", c.DefaultRole) + } + } + return a, nil +} + +// CheckProvider reads the provider configuration at startup so a mistake shows in the logs. +func (a *Auth) CheckProvider() { + if a.cfg.OIDCEnabled() { + if _, err := a.discover(); err != nil { + log.Printf("oidc: %v", err) + } else { + log.Printf("oidc authentication enabled (issuer %s)", a.cfg.Issuer) + } + } +} + +// sessionKey is kept in the data directory so sessions survive a restart. +func sessionKey(dir string) []byte { + path := filepath.Join(dir, "session.key") + if k, err := os.ReadFile(path); err == nil && len(k) >= 32 { + return k + } + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + log.Fatalf("session key: %v", err) + } + if err := os.WriteFile(path, k, 0o600); err != nil { + log.Printf("auth: cannot save %s (%v): sessions end when the server restarts", path, err) + } + return k +} + +// public paths: reachable without a session. +func public(p string) bool { + switch p { + case "/healthz", loginPage, "/style.css", "/boot.js", "/login.js", "/favicon.svg", "/auth/config", "/auth/login", "/auth/logout", "/auth/oidc": + return true + } + return false +} + +func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/auth/login": + a.handleLogin(w, r) + return + case r.URL.Path == "/auth/logout": + a.handleLogout(w, r) + return + case r.URL.Path == "/auth/oidc" && a.cfg.OIDCEnabled(): + a.startLogin(w, r) + return + case a.callback != "" && r.URL.Path == a.callback: + a.handleCallback(w, r) + return + case r.URL.Path == "/auth/config": + writeJSON(w, http.StatusOK, map[string]any{"local": a.cfg.Local, "oidc": a.cfg.OIDCEnabled(), "demo": a.cfg.Demo}) + return + } + id, ok := a.sessionIdentity(r) + if ok { + if r.URL.Path == loginPage { + http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound) + return + } + a.next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id))) + return + } + switch { + case public(r.URL.Path): + if r.URL.Path == loginPage { + w.Header().Set("Cache-Control", "no-store") + } + a.next.ServeHTTP(w, r) + case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me": + target := loginPage + if ret := r.URL.RequestURI(); ret != "/" { + target += "?" + url.Values{"r": {ret}}.Encode() + } + http.Redirect(w, r, target, http.StatusFound) + default: + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required", "code": "auth"}) + } +} + +// sessionIdentity checks the session cookie. Local accounts are read again from +// the database: a deleted user, a new role or a new password apply at once. +func (a *Auth) sessionIdentity(r *http.Request) (Identity, bool) { + var s session + c, err := r.Cookie(sessionCookie) + if err != nil || !verifyCookie(a.key, c.Value, &s) || time.Now().Unix() >= s.Exp { + return Identity{}, false + } + switch s.Mode { + case "local": + if !a.cfg.Local { + return Identity{}, false + } + u, err := a.store.User(r.Context(), s.User) + if err != nil || u.Epoch != s.Epoch { + return Identity{}, false + } + return Identity{User: u.Name, Role: u.Role, Mode: "local"}, true + case "oidc": + if !a.cfg.OIDCEnabled() || !store.ValidRole(s.Role) { + return Identity{}, false + } + return Identity{User: s.User, Role: s.Role, Mode: "oidc"}, true + } + return Identity{}, false +} + +func (a *Auth) setSession(w http.ResponseWriter, r *http.Request, s session) { + s.Exp = time.Now().Add(a.cfg.SessionTTL).Unix() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: signCookie(a.key, s), + Path: "/", + MaxAge: int(a.cfg.SessionTTL.Seconds()), + HttpOnly: true, + Secure: a.secure || isHTTPS(r), + SameSite: http.SameSiteLaxMode, + }) +} + +func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !a.cfg.Local { + http.Redirect(w, r, loginPage, http.StatusFound) + return + } + user, pass := strings.TrimSpace(r.PostFormValue("user")), r.PostFormValue("pass") + ret := safeReturn(r.PostFormValue("r")) + u, ok := a.store.Authenticate(r.Context(), user, pass) + if !ok { + log.Printf("auth: failed login for %q from %s", user, clientIP(r)) + a.audit(r, Identity{User: user, Mode: "local"}, "login", "", false, "mot de passe ou utilisateur incorrect") + time.Sleep(loginFailDelay) + q := url.Values{"e": {"1"}} + if ret != "/" { + q.Set("r", ret) + } + http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther) + return + } + id := Identity{User: u.Name, Role: u.Role, Mode: "local"} + log.Printf("auth: %s logged in from %s (%s)", u.Name, clientIP(r), u.Role) + a.audit(r, id, "login", "", true, "") + a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch}) + http.Redirect(w, r, ret, http.StatusSeeOther) +} + +// handleLogout ends the session; with OIDC the provider's session ends on its logout page if it has one. +func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) { + id, ok := a.sessionIdentity(r) + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.secure || isHTTPS(r)}) + if ok { + a.audit(r, id, "logout", "", true, "") + } + if ok && id.Mode == "oidc" { + if meta, err := a.discover(); err == nil && meta.EndSession != "" { + http.Redirect(w, r, addQuery(meta.EndSession, url.Values{"client_id": {a.cfg.ClientID}}), http.StatusFound) + return + } + } + http.Redirect(w, r, loginPage, http.StatusFound) +} + +// safeReturn keeps the page to open after login inside the application. +func safeReturn(ret string) string { + if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage { + return "/" + } + return ret +} + +// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy. +func isHTTPS(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func clientIP(r *http.Request) string { + if f := r.Header.Get("X-Forwarded-For"); f != "" { + return strings.TrimSpace(strings.Split(f, ",")[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// ---- OpenID Connect ---- + +type oidcMeta struct { + Issuer string `json:"issuer"` + AuthEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + EndSession string `json:"end_session_endpoint"` + TokenAuthMethods []string `json:"token_endpoint_auth_methods_supported"` +} + +type loginState struct { + Nonce string `json:"n"` + Verifier string `json:"v"` + Return string `json:"r"` + Exp int64 `json:"e"` +} + +func (a *Auth) startLogin(w http.ResponseWriter, r *http.Request) { + meta, err := a.discover() + if err != nil { + log.Printf("oidc: %v", err) + http.Error(w, "identity provider unreachable, try again later", http.StatusBadGateway) + return + } + state, nonce, verifier := randomString(), randomString(), randomString()+randomString() + http.SetCookie(w, &http.Cookie{ + Name: loginCookie + state, + Value: signCookie(a.key, loginState{Nonce: nonce, Verifier: verifier, Return: safeReturn(r.URL.Query().Get("r")), Exp: time.Now().Add(loginTTL).Unix()}), + Path: "/", + MaxAge: int(loginTTL.Seconds()), + HttpOnly: true, + Secure: a.secure, + SameSite: http.SameSiteLaxMode, // sent back on the redirect from the provider + }) + challenge := sha256.Sum256([]byte(verifier)) + q := url.Values{ + "response_type": {"code"}, + "client_id": {a.cfg.ClientID}, + "redirect_uri": {a.cfg.RedirectURL}, + "scope": {a.cfg.Scopes}, + "state": {state}, + "nonce": {nonce}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, + "code_challenge_method": {"S256"}, + } + http.Redirect(w, r, addQuery(meta.AuthEndpoint, q), http.StatusFound) +} + +func (a *Auth) handleCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if e := q.Get("error"); e != "" { + log.Printf("oidc: login refused by the provider: %s %s", e, q.Get("error_description")) + http.Error(w, "login refused by the identity provider: "+e, http.StatusForbidden) + return + } + state := q.Get("state") + var ls loginState + c, err := r.Cookie(loginCookie + state) + if state == "" || err != nil || !verifyCookie(a.key, c.Value, &ls) || time.Now().Unix() > ls.Exp { + http.Error(w, "login expired or started in another browser: open pve-confsync again", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.secure}) + + user, role, err := a.exchange(r, q.Get("code"), ls) + if err != nil { + log.Printf("oidc: login failed: %v", err) + a.audit(r, Identity{Mode: "oidc"}, "login", "", false, err.Error()) + http.Error(w, "login failed, see the pve-confsync logs", http.StatusForbidden) + return + } + id := Identity{User: user, Role: role, Mode: "oidc"} + log.Printf("oidc: %s logged in (%s)", user, role) + a.audit(r, id, "login", "", true, "") + a.setSession(w, r, session{User: user, Role: role, Mode: "oidc"}) + http.Redirect(w, r, ls.Return, http.StatusFound) +} + +// exchange trades the code for tokens and returns the user name from the verified +// ID token and the role given by its groups. +func (a *Auth) exchange(r *http.Request, code string, ls loginState) (string, string, error) { + if code == "" { + return "", "", errors.New("no code in the callback") + } + meta, err := a.discover() + if err != nil { + return "", "", err + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {a.cfg.RedirectURL}, + "code_verifier": {ls.Verifier}, + } + // client_secret_basic is the default; some providers only accept client_secret_post. + post := len(meta.TokenAuthMethods) > 0 && !contains(meta.TokenAuthMethods, "client_secret_basic") && contains(meta.TokenAuthMethods, "client_secret_post") + if post { + form.Set("client_id", a.cfg.ClientID) + form.Set("client_secret", a.cfg.ClientSecret) + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if !post { + req.SetBasicAuth(url.QueryEscape(a.cfg.ClientID), url.QueryEscape(a.cfg.ClientSecret)) + } + res, err := a.client.Do(req) + if err != nil { + return "", "", fmt.Errorf("token endpoint: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body)) + } + var tok struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" { + return "", "", errors.New("token endpoint: no id_token in the response") + } + claims, err := a.verifyIDToken(tok.IDToken, ls.Nonce) + if err != nil { + return "", "", err + } + role := a.cfg.DefaultRole + switch { + case a.cfg.AdminGroup != "" && hasGroup(claims[a.cfg.GroupsClaim], a.cfg.AdminGroup): + role = store.RoleAdmin + case a.cfg.OperatorGroup != "" && hasGroup(claims[a.cfg.GroupsClaim], a.cfg.OperatorGroup): + role = store.RoleOperator + } + for _, k := range []string{"preferred_username", "email", "name", "sub"} { + if v, _ := claims[k].(string); v != "" { + return v, role, nil + } + } + return "", "", errors.New("id_token: no sub") +} + +// hasGroup tells whether the groups claim (a list, or a single string) holds +// group; a leading "/" (Keycloak group paths) is ignored. +func hasGroup(claim any, group string) bool { + group = strings.TrimPrefix(group, "/") + var groups []string + switch v := claim.(type) { + case string: + groups = strings.Fields(strings.ReplaceAll(v, ",", " ")) + case []any: + for _, g := range v { + if s, ok := g.(string); ok { + groups = append(groups, s) + } + } + } + for _, g := range groups { + if strings.TrimPrefix(g, "/") == group { + return true + } + } + return false +} + +// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token. +func (a *Auth) verifyIDToken(raw, nonce string) (map[string]any, error) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return nil, errors.New("id_token: not a JWT") + } + var hdr struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := decodeSegment(parts[0], &hdr); err != nil { + return nil, fmt.Errorf("id_token header: %w", err) + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, errors.New("id_token: bad signature encoding") + } + key, err := a.keyFor(hdr.Kid) + if err != nil { + return nil, err + } + if err := verifySignature(hdr.Alg, key, []byte(parts[0]+"."+parts[1]), sig); err != nil { + return nil, fmt.Errorf("id_token: %w", err) + } + var claims map[string]any + if err := decodeSegment(parts[1], &claims); err != nil { + return nil, fmt.Errorf("id_token claims: %w", err) + } + if iss, _ := claims["iss"].(string); iss != a.cfg.Issuer { + return nil, fmt.Errorf("id_token: issuer %q, expected %q", iss, a.cfg.Issuer) + } + var aud []string + switch v := claims["aud"].(type) { + case string: + aud = []string{v} + case []any: + for _, x := range v { + if s, ok := x.(string); ok { + aud = append(aud, s) + } + } + } + if !contains(aud, a.cfg.ClientID) { + return nil, fmt.Errorf("id_token: audience %v does not include %q", aud, a.cfg.ClientID) + } + if azp, ok := claims["azp"].(string); ok && len(aud) > 1 && azp != a.cfg.ClientID { + return nil, fmt.Errorf("id_token: azp %q", azp) + } + now := time.Now() + exp, _ := claims["exp"].(float64) + if exp == 0 || now.After(time.Unix(int64(exp), 0).Add(clockSkew)) { + return nil, errors.New("id_token: expired (check the clocks)") + } + if iat, ok := claims["iat"].(float64); ok && time.Unix(int64(iat), 0).After(now.Add(clockSkew)) { + return nil, errors.New("id_token: issued in the future (check the clocks)") + } + if n, _ := claims["nonce"].(string); subtle.ConstantTimeCompare([]byte(n), []byte(nonce)) != 1 { + return nil, errors.New("id_token: wrong nonce") + } + return claims, nil +} + +func verifySignature(alg string, key crypto.PublicKey, signed, sig []byte) error { + if len(alg) != 5 { + return fmt.Errorf("unsupported algorithm %q", alg) + } + var h crypto.Hash + switch alg[2:] { + case "256": + h = crypto.SHA256 + case "384": + h = crypto.SHA384 + case "512": + h = crypto.SHA512 + } + if h == 0 { + return fmt.Errorf("unsupported algorithm %q", alg) + } + hh := h.New() + hh.Write(signed) + digest := hh.Sum(nil) + switch k := key.(type) { + case *rsa.PublicKey: + switch alg[:2] { + case "RS": + return rsa.VerifyPKCS1v15(k, h, digest, sig) + case "PS": + return rsa.VerifyPSS(k, h, digest, sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) + } + case *ecdsa.PublicKey: + size := (k.Curve.Params().BitSize + 7) / 8 + if alg[:2] != "ES" || len(sig) != 2*size { + break + } + r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:]) + if ecdsa.Verify(k, digest, r, s) { + return nil + } + return errors.New("bad signature") + } + return fmt.Errorf("algorithm %q does not match the key", alg) +} + +// discover reads the provider configuration once (and again after a failure). +func (a *Auth) discover() (*oidcMeta, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.meta != nil { + return a.meta, nil + } + u := strings.TrimSuffix(a.cfg.Issuer, "/") + "/.well-known/openid-configuration" + var m oidcMeta + if err := a.getJSON(u, &m); err != nil { + return nil, fmt.Errorf("discovery: %w", err) + } + if m.Issuer != a.cfg.Issuer { + return nil, fmt.Errorf("discovery: the provider says its issuer is %q, set OIDC_ISSUER to that exact value", m.Issuer) + } + if m.AuthEndpoint == "" || m.TokenEndpoint == "" || m.JWKSURI == "" { + return nil, errors.New("discovery: incomplete provider configuration") + } + a.meta = &m + return a.meta, nil +} + +// keyFor returns the signing key kid; the key set is reloaded when the provider rotates its keys. +func (a *Auth) keyFor(kid string) (crypto.PublicKey, error) { + meta, err := a.discover() + if err != nil { + return nil, err + } + a.mu.Lock() + defer a.mu.Unlock() + pick := func() crypto.PublicKey { + if k, ok := a.keys[kid]; ok { + return k + } + if kid == "" && len(a.keys) == 1 { + for _, k := range a.keys { + return k + } + } + return nil + } + if k := pick(); k != nil { + return k, nil + } + if time.Since(a.keysAt) < 10*time.Second { + return nil, fmt.Errorf("id_token: unknown key %q", kid) + } + var set struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` + } + if err := a.getJSON(meta.JWKSURI, &set); err != nil { + return nil, fmt.Errorf("jwks: %w", err) + } + keys := map[string]crypto.PublicKey{} + for _, k := range set.Keys { + if k.Use != "" && k.Use != "sig" { + continue + } + switch k.Kty { + case "RSA": + n, e := decodeBig(k.N), decodeBig(k.E) + if n != nil && e != nil && e.IsInt64() { + keys[k.Kid] = &rsa.PublicKey{N: n, E: int(e.Int64())} + } + case "EC": + var c elliptic.Curve + switch k.Crv { + case "P-256": + c = elliptic.P256() + case "P-384": + c = elliptic.P384() + case "P-521": + c = elliptic.P521() + } + x, y := decodeBig(k.X), decodeBig(k.Y) + if c != nil && x != nil && y != nil && c.IsOnCurve(x, y) { + keys[k.Kid] = &ecdsa.PublicKey{Curve: c, X: x, Y: y} + } + } + } + a.keys, a.keysAt = keys, time.Now() + if k := pick(); k != nil { + return k, nil + } + return nil, fmt.Errorf("id_token: unknown key %q", kid) +} + +func (a *Auth) getJSON(u string, v any) error { + res, err := a.client.Get(u) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s", u, res.Status) + } + return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v) +} + +// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256). +func signCookie(key []byte, v any) string { + b, _ := json.Marshal(v) + p := base64.RawURLEncoding.EncodeToString(b) + m := hmac.New(sha256.New, key) + m.Write([]byte(p)) + return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +func verifyCookie(key []byte, s string, v any) bool { + p, sig, ok := strings.Cut(s, ".") + if !ok { + return false + } + got, err := base64.RawURLEncoding.DecodeString(sig) + if err != nil { + return false + } + m := hmac.New(sha256.New, key) + m.Write([]byte(p)) + if !hmac.Equal(got, m.Sum(nil)) { + return false + } + return decodeSegment(p, v) == nil +} + +func decodeSegment(s string, v any) error { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + +func decodeBig(s string) *big.Int { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil || len(b) == 0 { + return nil + } + return new(big.Int).SetBytes(b) +} + +func randomString() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func addQuery(endpoint string, q url.Values) string { + sep := "?" + if strings.Contains(endpoint, "?") { + sep = "&" + } + return endpoint + sep + q.Encode() +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..bf5a75e --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,178 @@ +// Package server is the web UI of pve-confsync (phases 1 and 2): cluster list, +// visual diff per client, dry-run and synchronization per object, per client or +// global, execution journal and audit trail. The pages (web/) are embedded in +// the binary; the API is JSON under /api/. +package server + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "log" + "net/http" + "strings" + "sync" + "time" + + "pve-confsync/internal/service" + "pve-confsync/internal/store" +) + +// Options of the server. +type Options struct { + Store *store.Store + Auth AuthConfig + Static fs.FS // web/ (index.html, login.html, style.css, app.js…) + Version string // shown in the UI + Demo bool // demo mode: fake PVE clusters, shown in the UI +} + +// Server holds the state shared by the handlers. +type Server struct { + opts Options + store *store.Store + auth *Auth + + // last comparison, shown until the next refresh (reading N clusters takes time) + stateMu sync.Mutex + state *service.State + stateAt time.Time + + applyMu sync.Mutex // one synchronization at a time + jobs sync.WaitGroup +} + +// New builds the server. +func New(o Options) (*Server, error) { + a, err := NewAuth(o.Auth, o.Store) + if err != nil { + return nil, err + } + s := &Server{opts: o, store: o.Store, auth: a} + a.audit = s.audit + if err := o.Store.FailRunning(context.Background()); err != nil { + return nil, err + } + return s, nil +} + +// Wait waits for the synchronizations in progress (graceful stop). +func (s *Server) Wait() { s.jobs.Wait() } + +// Handler returns the HTTP handler: authentication, then API and static files. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) }) + mux.HandleFunc("GET /auth/me", s.handleMe) + + viewer, operator, admin := store.RoleViewer, store.RoleOperator, store.RoleAdmin + mux.Handle("GET /api/state", s.role(viewer, s.handleState)) + mux.Handle("POST /api/dryrun", s.role(operator, s.handleDryRun)) + mux.Handle("POST /api/apply", s.role(operator, s.handleApply)) + mux.Handle("GET /api/runs", s.role(viewer, s.handleRuns)) + mux.Handle("GET /api/runs/{id}", s.role(viewer, s.handleRun)) + + mux.Handle("GET /api/clusters", s.role(viewer, s.handleClusters)) + mux.Handle("PUT /api/clusters/{name}", s.role(admin, s.handleSaveCluster)) + mux.Handle("DELETE /api/clusters/{name}", s.role(admin, s.handleDeleteCluster)) + mux.Handle("POST /api/clusters/{name}/check", s.role(admin, s.handleCheckCluster)) + mux.Handle("GET /api/settings", s.role(viewer, s.handleSettings)) + mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings)) + mux.Handle("GET /api/config/export", s.role(admin, s.handleExport)) + mux.Handle("POST /api/config/import", s.role(admin, s.handleImport)) + + mux.Handle("GET /api/users", s.role(admin, s.handleUsers)) + mux.Handle("PUT /api/users/{name}", s.role(admin, s.handleSaveUser)) + mux.Handle("DELETE /api/users/{name}", s.role(admin, s.handleDeleteUser)) + mux.Handle("POST /api/account/password", s.role(viewer, s.handlePassword)) + mux.Handle("GET /api/audit", s.role(admin, s.handleAudit)) + + static := http.FileServerFS(s.opts.Static) + mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".html") || r.URL.Path == "/" { + w.Header().Set("Cache-Control", "no-cache") + } + static.ServeHTTP(w, r) + })) + s.auth.next = mux + return securityHeaders(s.auth) +} + +// securityHeaders: no inline script, no framing, no MIME sniffing. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +// role restricts a handler to a minimum role. Writes must carry the header +// X-Confsync (set by app.js): a page of another site cannot add it to a +// cross-site request without a CORS preflight, which is never granted (CSRF). +func (s *Server) role(min string, h http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := identity(r) + if r.Method != http.MethodGet && r.Header.Get("X-Confsync") != "1" { + writeError(w, http.StatusForbidden, "missing X-Confsync header", "csrf") + return + } + if store.RoleRank(id.Role) < store.RoleRank(min) { + writeError(w, http.StatusForbidden, "role "+min+" required", "forbidden") + return + } + h(w, r) + }) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + id := identity(r) + if id.User == "" { + writeError(w, http.StatusUnauthorized, "authentication required", "auth") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": id.User, "role": id.Role, "mode": id.Mode, + "version": s.opts.Version, "demo": s.opts.Demo, + "local": s.opts.Auth.Local, "oidc": s.opts.Auth.OIDCEnabled(), + }) +} + +// audit records an event of the audit trail (never a secret in detail). +func (s *Server) audit(r *http.Request, id Identity, action, target string, ok bool, detail string) { + if id.User == "" { + id = identity(r) + } + e := store.Event{User: id.User, Role: id.Role, IP: clientIP(r), Action: action, Target: target, OK: ok, Detail: detail} + if err := s.store.Audit(context.WithoutCancel(r.Context()), e); err != nil { + log.Printf("audit: %v", err) + } +} + +// ---- JSON helpers ---- + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} + +func writeError(w http.ResponseWriter, code int, msg, kind string) { + writeJSON(w, code, map[string]string{"error": msg, "code": kind}) +} + +func readJSON(r *http.Request, v any) error { + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return errors.New("invalid request: " + err.Error()) + } + return nil +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..732722d --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,191 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "pve-confsync/internal/demo" + "pve-confsync/internal/store" +) + +type client struct { + t *testing.T + url string + c *http.Client +} + +func newServer(t *testing.T) string { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "t.db"), bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + clusters, err := demo.Start(ctx, st) + if err != nil { + t.Fatal(err) + } + srv, err := New(Options{Store: st, Version: "test", Demo: true, + Static: fstest.MapFS{"index.html": {Data: []byte("")}, "login.html": {Data: []byte("")}}, + Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}}) + if err != nil { + t.Fatal(err) + } + hs := httptest.NewServer(srv.Handler()) + t.Cleanup(func() { hs.Close(); srv.Wait(); clusters.Close(); st.Close() }) + return hs.URL +} + +func login(t *testing.T, base, user string) *client { + jar, _ := cookiejar.New(nil) + c := &client{t: t, url: base, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} + r, err := c.c.PostForm(base+"/auth/login", url.Values{"user": {user}, "pass": {demo.Password}}) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if loc := r.Header.Get("Location"); r.StatusCode != http.StatusSeeOther || loc != "/" { + t.Fatalf("login %s: %d %s", user, r.StatusCode, loc) + } + return c +} + +func (c *client) do(method, path string, body any, csrf bool) (int, map[string]any) { + c.t.Helper() + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, c.url+path, rd) + req.Header.Set("Content-Type", "application/json") + if csrf { + req.Header.Set("X-Confsync", "1") + } + r, err := c.c.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer r.Body.Close() + var out map[string]any + _ = json.NewDecoder(r.Body).Decode(&out) + return r.StatusCode, out +} + +func digests(d map[string]any) map[string]string { + out := map[string]string{} + for _, p := range d["plans"].([]any) { + p := p.(map[string]any) + out[p["client"].(string)] = p["digest"].(string) + } + return out +} + +func TestAuthAndRoles(t *testing.T) { + base := newServer(t) + anon := &client{t: t, url: base, c: http.DefaultClient} + if code, _ := anon.do("GET", "/api/state", nil, false); code != http.StatusUnauthorized { + t.Fatalf("anonymous state: %d", code) + } + viewer := login(t, base, "lecteur") + if code, _ := viewer.do("GET", "/api/state", nil, false); code != http.StatusOK { + t.Fatalf("viewer state: %d", code) + } + if code, _ := viewer.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, true); code != http.StatusForbidden { + t.Fatalf("viewer dry-run: %d", code) + } + op := login(t, base, "operateur") + if code, _ := op.do("GET", "/api/audit", nil, false); code != http.StatusForbidden { + t.Fatalf("operator audit: %d", code) + } + if code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, false); code != http.StatusForbidden || d["code"] != "csrf" { + t.Fatalf("dry-run without header: %d %v", code, d) + } + code, d := op.do("GET", "/api/clusters", nil, false) + if code != http.StatusOK { + t.Fatalf("clusters: %d", code) + } + for _, c := range d["clusters"].([]any) { + if s, ok := c.(map[string]any)["secret"]; ok && s != "" { + t.Fatalf("secret returned: %v", c) + } + } +} + +func TestDryRunAndApply(t *testing.T) { + base := newServer(t) + op := login(t, base, "operateur") + + // ceph needs the zone stor, which site-b cannot create (no bridge mapping) + code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}}, true) + if code != http.StatusOK { + t.Fatalf("dry-run site-b: %d %v", code, d) + } + p := d["plans"].([]any)[0].(map[string]any)["plan"].(map[string]any) + if errs, _ := p["errors"].([]any); len(errs) == 0 { + t.Fatalf("ceph without its zone must be blocked: %v", p) + } + code, d = op.do("POST", "/api/apply", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}, + "digests": digests(d), "confirm": "site-b"}, true) + if code != http.StatusConflict { + t.Fatalf("blocked apply: %d %v", code, d) + } + + sel := map[string]any{"scope": "client", "client": "site-a"} + code, d = op.do("POST", "/api/dryrun", sel, true) + if code != http.StatusOK { + t.Fatalf("dry-run site-a: %d %v", code, d) + } + sel["digests"] = digests(d) + if code, d := op.do("POST", "/api/apply", sel, true); code != http.StatusBadRequest || d["code"] != "confirm" { + t.Fatalf("SDN apply without confirmation: %d %v", code, d) + } + stale := map[string]any{"scope": "client", "client": "site-a", "digests": map[string]string{"site-a": "0"}, "confirm": "site-a"} + if code, d := op.do("POST", "/api/apply", stale, true); code != http.StatusConflict || d["code"] != "stale" { + t.Fatalf("stale digest: %d %v", code, d) + } + sel["confirm"] = "site-a" + code, d = op.do("POST", "/api/apply", sel, true) + if code != http.StatusAccepted { + t.Fatalf("apply: %d %v", code, d) + } + id := int(d["run"].(float64)) + var run map[string]any + for i := 0; i < 100; i++ { + _, run = op.do("GET", "/api/runs/"+itoa(id), nil, false) + if run["status"] != "running" { + break + } + time.Sleep(50 * time.Millisecond) + } + if run["status"] != "ok" { + t.Fatalf("run: %v", run) + } + _, d = op.do("GET", "/api/state?refresh=1", nil, false) + for _, c := range d["state"].(map[string]any)["clients"].([]any) { + c := c.(map[string]any) + if c["name"] == "site-a" { + if ch, _ := c["plan"].(map[string]any)["changes"].([]any); len(ch) != 0 { + t.Fatalf("site-a still differs after the apply: %v", ch) + } + } + } + admin := login(t, base, "admin") + _, d = admin.do("GET", "/api/audit", nil, false) + if len(d["events"].([]any)) == 0 { + t.Fatal("empty audit trail") + } +} + +func itoa(i int) string { b, _ := json.Marshal(i); return string(b) } diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..31b99b6 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,428 @@ +// Package service puts the engine together for the web UI: it reads the +// reference and the clients, computes the plans and describes every object +// field by field (reference, client mapping, current value, target value). +package service + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "pve-confsync/internal/adapter" + "pve-confsync/internal/config" + "pve-confsync/internal/engine" + "pve-confsync/internal/model" + "pve-confsync/internal/pve" +) + +// Node is the PVE version of one node. +type Node struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ClusterInfo is what was read from one cluster. +type ClusterInfo struct { + Name string `json:"name"` + URL string `json:"url"` + Version string `json:"version,omitempty"` + Nodes []Node `json:"nodes,omitempty"` + Objects int `json:"objects"` + Error string `json:"error,omitempty"` + Warnings []string `json:"warnings,omitempty"` + ReadAt time.Time `json:"readAt"` + Millis int64 `json:"ms"` +} + +// Field compares one field of an object. +type Field struct { + Name string `json:"name"` + Policy adapter.Policy `json:"policy"` + Ref any `json:"ref,omitempty"` // value on the reference + Mapping any `json:"mapping,omitempty"` // value of the client mapping + Current any `json:"current,omitempty"` // value on the client + Target any `json:"target,omitempty"` // value after a synchronization + Change string `json:"change,omitempty"` // add, set, unset or empty +} + +// Row statuses. +const ( + StatusSame = "same" // in line with the reference + StatusCreate = "create" // missing on the client + StatusUpdate = "update" // different + StatusDelete = "delete" // absent from the reference, deleted (prune) + StatusExtra = "extra" // absent from the reference, kept (no prune) + StatusError = "error" // blocking error (missing mapping, immutable field) +) + +// Row is one object of a client. +type Row struct { + Kind model.Kind `json:"kind"` + ID string `json:"id"` + Key string `json:"key"` + Status string `json:"status"` + SDN bool `json:"sdn"` + Fields []Field `json:"fields"` + Errors []string `json:"errors,omitempty"` +} + +// Client is the state of one client: what it has, its plan and its rows. +type Client struct { + ClusterInfo + Plan engine.Plan `json:"plan"` + Digest string `json:"digest"` + Rows []Row `json:"rows"` + Counts map[string]int `json:"counts"` +} + +// State is a full comparison of the reference with its clients. +type State struct { + At time.Time `json:"at"` + Reference ClusterInfo `json:"reference"` + Clients []Client `json:"clients"` + Kinds []model.Kind `json:"kinds"` + Prune bool `json:"prune"` + + ref []model.Object + actuals map[string][]model.Object + cfg *config.Config +} + +// Analyze reads the reference (applied SDN configuration) and the clients +// (current configuration, in parallel), and compares them. only limits the +// clients (empty: all). A cluster that cannot be read is reported in its +// Error field, it does not stop the others. +func Analyze(ctx context.Context, cfg *config.Config, only []string) *State { + st := &State{At: time.Now().UTC(), Kinds: cfg.Kinds, Prune: cfg.Prune, cfg: cfg, actuals: map[string][]model.Object{}} + var refSnap model.Snapshot + st.Reference, refSnap = read(ctx, cfg.Reference, cfg.Kinds, adapter.ReadRunning) + st.ref = refSnap.Objects + + want := map[string]bool{} + for _, n := range only { + want[n] = true + } + var wg sync.WaitGroup + var mu sync.Mutex + for i := range cfg.Clients { + cl := &cfg.Clients[i] + if len(only) > 0 && !want[cl.Name] { + continue + } + st.Clients = append(st.Clients, Client{ClusterInfo: ClusterInfo{Name: cl.Name, URL: cl.URL}}) + idx := len(st.Clients) - 1 + wg.Add(1) + go func() { + defer wg.Done() + info, snap := read(ctx, cl.Cluster, cfg.Kinds, adapter.ReadCurrent) + mu.Lock() + defer mu.Unlock() + st.Clients[idx].ClusterInfo = info + if info.Error == "" { + st.actuals[cl.Name] = snap.Objects + } + }() + } + wg.Wait() + for i := range st.Clients { + c := &st.Clients[i] + c.Counts = map[string]int{} + if c.Error != "" || st.Reference.Error != "" { + continue + } + cl, _ := cfg.Client(c.Name) + c.Plan = engine.MakePlan(cfg, cl, st.ref, st.actuals[c.Name]) + c.Plan.Warnings = append(c.Plan.Warnings, c.Warnings...) + c.Digest = c.Plan.Digest() + c.Rows = rows(cfg, cl, st.ref, st.actuals[c.Name], c.Plan) + for _, r := range c.Rows { + c.Counts[r.Status]++ + } + } + return st +} + +func read(ctx context.Context, cl config.Cluster, kinds []model.Kind, mode adapter.ReadMode) (ClusterInfo, model.Snapshot) { + info := ClusterInfo{Name: cl.Name, URL: cl.URL, ReadAt: time.Now().UTC()} + start := time.Now() + defer func() { info.Millis = time.Since(start).Milliseconds() }() + c, err := cl.PVE() + if err != nil { + info.Error = err.Error() + return info, model.Snapshot{} + } + snap, err := engine.Read(ctx, c, kinds, mode) + if err != nil { + info.Error = err.Error() + } + info.Version = snap.PVEVersion + if vs, verr := c.Versions(ctx); verr == nil { + info.Nodes = nodes(vs) + } + info.Objects = len(snap.Objects) + info.Warnings = snap.Warnings + info.Millis = time.Since(start).Milliseconds() + return info, snap +} + +// rows describes every object of the reference and of the client, field by field. +func rows(cfg *config.Config, cl *config.Client, ref, actual []model.Object, plan engine.Plan) []Row { + changes := map[string]engine.Change{} + for _, c := range plan.Changes { + changes[c.Key()] = c + } + errs := map[string][]string{} + for _, e := range plan.Errors { + if k, ok := engine.ErrorKey(e); ok { + errs[k] = append(errs[k], e) + } + } + cur := map[string]model.Object{} + for _, o := range actual { + cur[o.Key()] = o + } + var out []Row + seen := map[string]bool{} + add := func(k model.Kind, id string, r *model.Object, c *model.Object) { + a, err := adapter.ByKind(k) + if err != nil { + return + } + key := string(k) + "/" + id + seen[key] = true + row := Row{Kind: k, ID: id, Key: key, SDN: a.SDN(), Errors: errs[key]} + ch, hasChange := changes[key] + switch { + case len(row.Errors) > 0: + row.Status = StatusError + case hasChange: + row.Status = string(ch.Op) + case r == nil: + row.Status = StatusExtra + default: + row.Status = StatusSame + } + var mapping map[string]any + if cl != nil { + mapping = model.Normalize(cl.Mapping[k][id]) + a.Canonicalize(mapping) + } + names := map[string]bool{} + for _, o := range []*model.Object{r, c} { + if o != nil { + for f := range o.Fields { + names[f] = true + } + } + } + for f := range mapping { + names[f] = true + } + for f := range ch.Set { + names[f] = true + } + list := make([]string, 0, len(names)) + for f := range names { + list = append(list, f) + } + sort.Strings(list) + unset := map[string]bool{} + for _, f := range ch.Unset { + unset[f] = true + } + for _, f := range list { + fd := Field{Name: f, Policy: cfg.Policy(cl, a, f), Mapping: mapping[f]} + if r != nil { + fd.Ref = r.Fields[f] + } + if c != nil { + fd.Current = c.Fields[f] + } + switch { + case ch.Op == OpDelete: + fd.Change = "unset" + case unset[f]: + fd.Change = "unset" + case ch.Set[f] != nil: + fd.Target = ch.Set[f] + if fd.Current != nil { + fd.Change = "set" + } else { + fd.Change = "add" + } + case fd.Policy != adapter.Ignore && fd.Current != nil: + fd.Target = fd.Current + } + row.Fields = append(row.Fields, fd) + } + out = append(out, row) + } + for i := range ref { + r := &ref[i] + if !cfg.Selected(r.Kind, r.ID) { + continue + } + var c *model.Object + if o, ok := cur[r.Key()]; ok { + c = &o + } + add(r.Kind, r.ID, r, c) + } + for i := range actual { + o := &actual[i] + if seen[o.Key()] || !cfg.Selected(o.Kind, o.ID) { + continue + } + add(o.Kind, o.ID, nil, o) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return kindRank(out[i].Kind) < kindRank(out[j].Kind) + } + return out[i].ID < out[j].ID + }) + return out +} + +// OpDelete is re-exported for the switch above (engine.Op is a string type). +const OpDelete = engine.OpDelete + +func kindRank(k model.Kind) int { + for i, x := range model.Kinds { + if x == k { + return i + } + } + return len(model.Kinds) +} + +// ClientPlan is the plan of one client restricted to a selection of objects. +type ClientPlan struct { + Client string `json:"client"` + Plan engine.Plan `json:"plan"` + Digest string `json:"digest"` + SDN bool `json:"sdn"` + Error string `json:"error,omitempty"` // client or reference unreadable +} + +// 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 { + var out []ClientPlan + for _, c := range st.Clients { + cp := ClientPlan{Client: c.Name} + switch { + case st.Reference.Error != "": + cp.Error = "reference: " + st.Reference.Error + case c.Error != "": + cp.Error = c.Error + default: + cp.Plan = engine.Filter(c.Plan, keys) + cp.Digest = cp.Plan.Digest() + cp.SDN = cp.Plan.HasSDN() + } + out = append(out, cp) + } + return out +} + +// 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 { + cl, ok := st.cfg.Client(cp.Client) + if !ok { + return fmt.Errorf("unknown client %q", cp.Client) + } + c, err := cl.PVE() + if err != nil { + return err + } + res := engine.ApplyWithLog(ctx, c, cp.Plan, log) + if res.Err != nil { + return res.Err + } + 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) + }, st.cfg.Kinds) + s := engine.Step{At: start.UTC(), Action: "verify"} + if err != nil { + s.Error = err.Error() + } + log(s) + return err +} + +// Check tests the access to a cluster: versions (9.x only) and token privileges. +type Check struct { + Nodes []Node `json:"nodes,omitempty"` + Error string `json:"error,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// CheckCluster runs the same checks as the "check" command. +func CheckCluster(ctx context.Context, cl config.Cluster, write, dcOptions bool) Check { + var res Check + c, err := cl.PVE() + if err != nil { + res.Error = err.Error() + return res + } + vs, err := c.CheckVersion(ctx) + res.Nodes = nodes(vs) + if err != nil { + res.Error = err.Error() + return res + } + perms, err := c.Permissions(ctx) + if err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("token privileges unreadable (%v)", err)) + return res + } + res.Warnings = PrivilegeWarnings(perms, write, dcOptions) + return res +} + +// PrivilegeWarnings flags a reference token able to write, and a client token +// missing the privileges needed by the synchronized kinds. +func PrivilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string { + has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok } + anyPath := func(priv string) bool { + for p := range perms { + if has(p, priv) { + return true + } + } + return false + } + var w []string + if !write { + for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} { + if anyPath(p) { + w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu") + } + } + return w + } + if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") { + w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera") + } + if dcOptions && !has("/", "Sys.Modify") { + w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites") + } + if !has("/", "Sys.Audit") { + w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles") + } + return w +} + +// nodes converts the versions read by the PVE client. +func nodes(vs []pve.NodeVersion) []Node { + out := make([]Node, 0, len(vs)) + for _, v := range vs { + out = append(out, Node{Name: v.Node, Version: v.Version}) + } + return out +} diff --git a/internal/store/crypto.go b/internal/store/crypto.go new file mode 100644 index 0000000..0302026 --- /dev/null +++ b/internal/store/crypto.go @@ -0,0 +1,158 @@ +package store + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Box encrypts the token secrets at rest (AES-256-GCM, random nonce, version byte). +type Box struct{ aead cipher.AEAD } + +const boxVersion = 1 + +// NewBox builds a Box from a 32 byte key. +func NewBox(key []byte) (*Box, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key: %d bytes, 32 expected", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Box{aead}, nil +} + +// Seal encrypts a secret: version | nonce | ciphertext+tag. +func (b *Box) Seal(plain []byte) ([]byte, error) { + nonce := make([]byte, b.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + out := append([]byte{boxVersion}, nonce...) + return b.aead.Seal(out, nonce, plain, []byte("pve-confsync token")), nil +} + +// Open decrypts a secret sealed by Seal. An empty blob gives an empty secret. +func (b *Box) Open(blob []byte) ([]byte, error) { + if len(blob) == 0 { + return nil, nil + } + n := b.aead.NonceSize() + if blob[0] != boxVersion || len(blob) < 1+n { + return nil, errors.New("unknown secret format") + } + plain, err := b.aead.Open(nil, blob[1:1+n], blob[1+n:], []byte("pve-confsync token")) + if err != nil { + return nil, errors.New("cannot decrypt (wrong encryption key)") + } + return plain, nil +} + +// LoadKey returns the encryption key of the token secrets, in this order: +// CONFSYNC_SECRET_KEY (64 hex characters or 44 base64 characters), +// CONFSYNC_SECRET_KEY_FILE (same content, docker secret), or /secret.key, +// created on first start. The last case keeps the key next to the database: fine for +// a lab, a docker secret is better in production (a stolen /data then holds no +// usable secret). +func LoadKey(dataDir string) ([]byte, string, error) { + if v := strings.TrimSpace(os.Getenv("CONFSYNC_SECRET_KEY")); v != "" { + k, err := parseKey(v) + return k, "CONFSYNC_SECRET_KEY", err + } + if f := os.Getenv("CONFSYNC_SECRET_KEY_FILE"); f != "" { + raw, err := os.ReadFile(f) + if err != nil { + return nil, "", fmt.Errorf("CONFSYNC_SECRET_KEY_FILE: %w", err) + } + k, err := parseKey(strings.TrimSpace(string(raw))) + return k, f, err + } + path := filepath.Join(dataDir, "secret.key") + if raw, err := os.ReadFile(path); err == nil { + k, err := parseKey(strings.TrimSpace(string(raw))) + return k, path, err + } + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + return nil, "", err + } + if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil { + return nil, "", fmt.Errorf("cannot save the encryption key: %w", err) + } + log.Printf("encryption key created in %s: keep a copy, the token secrets cannot be read without it", path) + return k, path, nil +} + +func parseKey(v string) ([]byte, error) { + if k, err := hex.DecodeString(v); err == nil && len(k) == 32 { + return k, nil + } + if k, err := base64.StdEncoding.DecodeString(v); err == nil && len(k) == 32 { + return k, nil + } + return nil, errors.New("encryption key: 32 bytes expected, as 64 hex or 44 base64 characters (openssl rand -hex 32)") +} + +// ---- passwords ---- + +// PBKDF2-HMAC-SHA256, 600 000 iterations (OWASP 2023 recommendation); the +// standard library only, crypto/pbkdf2 exists since Go 1.24. +const pbkdf2Iter = 600000 + +// HashPassword returns "pbkdf2-sha256$$$". +func HashPassword(pass string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + h, err := pbkdf2.Key(sha256.New, pass, salt, pbkdf2Iter, 32) + if err != nil { + return "", err + } + enc := base64.RawStdEncoding + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iter, enc.EncodeToString(salt), enc.EncodeToString(h)), nil +} + +// CheckPassword compares in constant time. +func CheckPassword(hash, pass string) bool { + p := strings.Split(hash, "$") + if len(p) != 4 || p[0] != "pbkdf2-sha256" { + return false + } + iter, err := strconv.Atoi(p[1]) + if err != nil || iter < 1 { + return false + } + enc := base64.RawStdEncoding + salt, err1 := enc.DecodeString(p[2]) + want, err2 := enc.DecodeString(p[3]) + if err1 != nil || err2 != nil { + return false + } + got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want)) + return err == nil && subtle.ConstantTimeCompare(got, want) == 1 +} + +// RandomPassword returns a password for the first admin account. +func RandomPassword() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/store/journal.go b/internal/store/journal.go new file mode 100644 index 0000000..c84e364 --- /dev/null +++ b/internal/store/journal.go @@ -0,0 +1,192 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" +) + +// Run statuses. +const ( + RunRunning = "running" + RunOK = "ok" // applied and verified, or dry-run without blocking error + RunNoop = "noop" // nothing to do + RunError = "error" // at least one client failed + RunBlocked = "blocked" // dry-run with blocking errors +) + +// Run is one entry of the execution journal: a dry-run or a synchronization. +type Run struct { + ID int64 `json:"id"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + User string `json:"user"` + Mode string `json:"mode"` // dry-run or apply + Scope string `json:"scope"` // global, client or object + Clients []string `json:"clients"` + Objects []string `json:"objects,omitempty"` + Status string `json:"status"` + Summary string `json:"summary"` + Detail json.RawMessage `json:"detail,omitempty"` +} + +// StartRun records a run and returns its id. +func (s *Store) StartRun(ctx context.Context, r Run) (int64, error) { + if r.StartedAt.IsZero() { + r.StartedAt = time.Now() + } + res, err := s.db.ExecContext(ctx, `INSERT INTO runs (started_at, user, mode, scope, clients, objects, status, summary, detail) + VALUES (?,?,?,?,?,?,?,?,?)`, r.StartedAt.UnixMilli(), r.User, r.Mode, r.Scope, + strings.Join(r.Clients, ","), strings.Join(r.Objects, ","), r.Status, r.Summary, detail(r.Detail)) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// UpdateRun stores the progress (detail) of a run; a final status sets finished_at. +func (s *Store) UpdateRun(ctx context.Context, id int64, status, summary string, d json.RawMessage) error { + fin := int64(0) + if status != RunRunning { + fin = time.Now().UnixMilli() + } + _, err := s.db.ExecContext(ctx, `UPDATE runs SET status = ?, summary = ?, detail = ?, finished_at = ? WHERE id = ?`, + status, summary, detail(d), fin, id) + return err +} + +func detail(d json.RawMessage) string { + if len(d) == 0 { + return "{}" + } + return string(d) +} + +// Runs lists the journal, newest first; before (an id) pages backwards. +func (s *Store) Runs(ctx context.Context, limit int, before int64) ([]Run, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + if before <= 0 { + before = 1 << 62 + } + rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, user, mode, scope, clients, objects, status, summary + FROM runs WHERE id < ? ORDER BY id DESC LIMIT ?`, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Run + for rows.Next() { + r, err := scanRun(rows.Scan, false) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// Run returns one run with its detail. +func (s *Store) Run(ctx context.Context, id int64) (Run, error) { + row := s.db.QueryRowContext(ctx, `SELECT id, started_at, finished_at, user, mode, scope, clients, objects, status, summary, detail + FROM runs WHERE id = ?`, id) + r, err := scanRun(row.Scan, true) + if errors.Is(err, sql.ErrNoRows) { + return r, ErrNotFound + } + return r, err +} + +// FailRunning closes the runs left "running" by a stop of the server. +func (s *Store) FailRunning(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `UPDATE runs SET status = 'error', summary = 'interrompu (arrêt du serveur)', finished_at = ? + WHERE status = 'running'`, time.Now().UnixMilli()) + return err +} + +func scanRun(scan func(...any) error, withDetail bool) (Run, error) { + var r Run + var start, fin int64 + var clients, objects, d string + dest := []any{&r.ID, &start, &fin, &r.User, &r.Mode, &r.Scope, &clients, &objects, &r.Status, &r.Summary} + if withDetail { + dest = append(dest, &d) + } + if err := scan(dest...); err != nil { + return r, err + } + r.StartedAt = time.UnixMilli(start).UTC() + if fin > 0 { + t := time.UnixMilli(fin).UTC() + r.FinishedAt = &t + } + r.Clients = split(clients) + r.Objects = split(objects) + if withDetail { + r.Detail = json.RawMessage(d) + } + return r, nil +} + +func split(s string) []string { + if s == "" { + return []string{} + } + return strings.Split(s, ",") +} + +// ---- audit ---- + +// Event is one entry of the audit trail: who did what, from where, with which result. +type Event struct { + ID int64 `json:"id"` + At time.Time `json:"at"` + User string `json:"user"` + Role string `json:"role"` + IP string `json:"ip"` + Action string `json:"action"` + Target string `json:"target"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// Audit records an event. Callers never put secrets in Detail. +func (s *Store) Audit(ctx context.Context, e Event) error { + if e.At.IsZero() { + e.At = time.Now() + } + _, err := s.db.ExecContext(ctx, `INSERT INTO audit (at, user, role, ip, action, target, ok, detail) VALUES (?,?,?,?,?,?,?,?)`, + e.At.UnixMilli(), e.User, e.Role, e.IP, e.Action, e.Target, e.OK, e.Detail) + return err +} + +// Events lists the audit trail, newest first. +func (s *Store) Events(ctx context.Context, limit int, before int64) ([]Event, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + if before <= 0 { + before = 1 << 62 + } + rows, err := s.db.QueryContext(ctx, `SELECT id, at, user, role, ip, action, target, ok, detail FROM audit + WHERE id < ? ORDER BY id DESC LIMIT ?`, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Event + for rows.Next() { + var e Event + var at int64 + if err := rows.Scan(&e.ID, &at, &e.User, &e.Role, &e.IP, &e.Action, &e.Target, &e.OK, &e.Detail); err != nil { + return nil, err + } + e.At = time.UnixMilli(at).UTC() + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..8b393cb --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,446 @@ +// Package store keeps the state of the web UI in SQLite: clusters (token +// secrets encrypted with AES-256-GCM), synchronization settings, per client +// mappings and policies, users, execution journal and audit trail. +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" // pure Go driver: the binary stays static (CGO_ENABLED=0) + + "pve-confsync/internal/adapter" + "pve-confsync/internal/config" + "pve-confsync/internal/model" +) + +// ErrNotFound is returned for an unknown cluster, user or run. +var ErrNotFound = errors.New("not found") + +// Store is the SQLite database. +type Store struct { + db *sql.DB + box *Box +} + +const schema = ` +CREATE TABLE IF NOT EXISTS clusters ( + name TEXT PRIMARY KEY, + reference INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + url TEXT NOT NULL, + token_id TEXT NOT NULL, + secret BLOB, + fingerprint TEXT NOT NULL DEFAULT '', + ca_pem TEXT NOT NULL DEFAULT '', + insecure INTEGER NOT NULL DEFAULT 0, + timeout TEXT NOT NULL DEFAULT '', + mapping TEXT NOT NULL DEFAULT '{}', + policies TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS users ( + name TEXT PRIMARY KEY, + role TEXT NOT NULL, + password TEXT NOT NULL, + epoch INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_login INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL DEFAULT 0, + user TEXT NOT NULL, + mode TEXT NOT NULL, + scope TEXT NOT NULL, + clients TEXT NOT NULL DEFAULT '', + objects TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS runs_started ON runs(started_at); +CREATE TABLE IF NOT EXISTS audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + user TEXT NOT NULL, + role TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + ok INTEGER NOT NULL DEFAULT 1, + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS audit_at ON audit(at); +` + +// Open opens (and creates) the database. key encrypts the token secrets. +func Open(path string, key []byte) (*Store, error) { + box, err := NewBox(key) + if err != nil { + return nil, err + } + // WAL: the UI reads while a synchronization writes the journal. + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // one writer; SQLite serializes anyway + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("%s: %w", path, err) + } + s := &Store{db: db, box: box} + if err := s.checkKey(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close closes the database. +func (s *Store) Close() error { return s.db.Close() } + +// checkKey refuses to start with a key that does not decrypt the stored secrets +// (lost or changed CONFSYNC_SECRET_KEY), instead of failing later on each cluster. +func (s *Store) checkKey() error { + var blob []byte + err := s.db.QueryRow(`SELECT secret FROM clusters WHERE secret IS NOT NULL AND length(secret) > 0 LIMIT 1`).Scan(&blob) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if _, err := s.box.Open(blob); err != nil { + return errors.New("the encryption key does not decrypt the stored token secrets (CONFSYNC_SECRET_KEY changed?)") + } + return nil +} + +// ---- clusters ---- + +// Cluster is a cluster as stored. The secret is never returned to the UI: +// HasSecret only says whether one is set. +type Cluster struct { + Name string `json:"name"` + Reference bool `json:"reference"` + Position int `json:"position"` + URL string `json:"url"` + TokenID string `json:"tokenId"` + Secret string `json:"secret,omitempty"` // write only + HasSecret bool `json:"hasSecret"` + Fingerprint string `json:"fingerprint"` + CAPEM string `json:"caPem"` + Insecure bool `json:"insecure"` + Timeout string `json:"timeout"` + Mapping map[model.Kind]map[string]map[string]any `json:"mapping"` + Policies map[model.Kind]map[string]adapter.Policy `json:"policies"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Clusters returns every cluster, reference first, without secrets. +func (s *Store) Clusters(ctx context.Context) ([]Cluster, error) { + rows, err := s.db.QueryContext(ctx, `SELECT name, reference, position, url, token_id, length(secret) > 0, + fingerprint, ca_pem, insecure, timeout, mapping, policies, updated_at + FROM clusters ORDER BY reference DESC, position, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Cluster + for rows.Next() { + var c Cluster + var hasSecret sql.NullBool + var mapping, policies string + var updated int64 + if err := rows.Scan(&c.Name, &c.Reference, &c.Position, &c.URL, &c.TokenID, &hasSecret, + &c.Fingerprint, &c.CAPEM, &c.Insecure, &c.Timeout, &mapping, &policies, &updated); err != nil { + return nil, err + } + c.HasSecret = hasSecret.Bool + c.UpdatedAt = time.Unix(updated, 0).UTC() + _ = json.Unmarshal([]byte(mapping), &c.Mapping) + _ = json.Unmarshal([]byte(policies), &c.Policies) + out = append(out, c) + } + return out, rows.Err() +} + +// SaveCluster creates or updates a cluster. An empty Secret keeps the stored one. +// oldName renames a cluster (empty: same name). Setting Reference on a cluster +// makes it the only reference. +func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) error { + c.Name = strings.TrimSpace(c.Name) + if c.Name == "" || c.URL == "" || c.TokenID == "" { + return errors.New("name, url and token id are required") + } + if oldName == "" { + oldName = c.Name + } + mapping, _ := json.Marshal(nonNil(c.Mapping)) + policies, _ := json.Marshal(nonNilP(c.Policies)) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var exists bool + var secret []byte + err = tx.QueryRowContext(ctx, `SELECT 1, secret FROM clusters WHERE name = ?`, oldName).Scan(&exists, &secret) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if c.Secret != "" { + if secret, err = s.box.Seal([]byte(c.Secret)); err != nil { + return err + } + } + if len(secret) == 0 { + return errors.New("the token secret is required") + } + if c.Reference { + if _, err := tx.ExecContext(ctx, `UPDATE clusters SET reference = 0`); err != nil { + return err + } + } + now := time.Now().Unix() + if exists { + _, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, reference=?, position=?, url=?, token_id=?, secret=?, + fingerprint=?, ca_pem=?, insecure=?, timeout=?, mapping=?, policies=?, updated_at=? WHERE name=?`, + c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout, + string(mapping), string(policies), now, oldName) + } else { + _, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, reference, position, url, token_id, secret, fingerprint, + ca_pem, insecure, timeout, mapping, policies, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout, + string(mapping), string(policies), now) + } + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return fmt.Errorf("cluster %q already exists", c.Name) + } + return err + } + return tx.Commit() +} + +// DeleteCluster removes a cluster. +func (s *Store) DeleteCluster(ctx context.Context, name string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM clusters WHERE name = ?`, name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func nonNil(m map[model.Kind]map[string]map[string]any) any { + if m == nil { + return map[string]any{} + } + return m +} + +func nonNilP(m map[model.Kind]map[string]adapter.Policy) any { + if m == nil { + return map[string]any{} + } + return m +} + +// ---- settings ---- + +// Settings are the global synchronization settings (the rest of the YAML file). +type Settings struct { + Kinds []model.Kind `json:"kinds"` + Objects map[model.Kind]config.Selection `json:"objects"` + Policies map[model.Kind]map[string]adapter.Policy `json:"policies"` + Prune bool `json:"prune"` +} + +// Settings returns the global settings (all kinds by default). +func (s *Store) Settings(ctx context.Context) (Settings, error) { + var st Settings + var raw string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = 'sync'`).Scan(&raw) + switch { + case errors.Is(err, sql.ErrNoRows): + case err != nil: + return st, err + default: + if err := json.Unmarshal([]byte(raw), &st); err != nil { + return st, err + } + } + if len(st.Kinds) == 0 { + st.Kinds = append(st.Kinds, model.Kinds...) + } + return st, nil +} + +// SaveSettings replaces the global settings. +func (s *Store) SaveSettings(ctx context.Context, st Settings) error { + b, err := json.Marshal(st) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('sync', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, string(b)) + return err +} + +// ---- configuration for the engine ---- + +// Config builds the engine configuration, secrets decrypted. It fails when no +// reference is defined. +func (s *Store) Config(ctx context.Context) (*config.Config, error) { + st, err := s.Settings(ctx) + if err != nil { + return nil, err + } + cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune} + rows, err := s.db.QueryContext(ctx, `SELECT name, reference, url, token_id, secret, fingerprint, ca_pem, + insecure, timeout, mapping, policies FROM clusters ORDER BY reference DESC, position, name`) + if err != nil { + return nil, err + } + defer rows.Close() + hasRef := false + for rows.Next() { + var ref bool + var blob []byte + var mapping, policies string + var cl config.Cluster + if err := rows.Scan(&cl.Name, &ref, &cl.URL, &cl.TokenID, &blob, &cl.Fingerprint, &cl.CAPEM, + &cl.Insecure, &cl.Timeout, &mapping, &policies); err != nil { + return nil, err + } + secret, err := s.box.Open(blob) + if err != nil { + return nil, fmt.Errorf("cluster %s: token secret: %w", cl.Name, err) + } + cl.Secret = string(secret) + if ref { + cfg.Reference, hasRef = cl, true + continue + } + c := config.Client{Cluster: cl} + _ = json.Unmarshal([]byte(mapping), &c.Mapping) + _ = json.Unmarshal([]byte(policies), &c.Policies) + cfg.Clients = append(cfg.Clients, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + if !hasRef { + return nil, ErrNoReference + } + return cfg, cfg.Validate() +} + +// ErrNoReference: nothing can be compared before a reference cluster is defined. +var ErrNoReference = errors.New("no reference cluster defined") + +// ExportConfig returns the configuration as the CLI YAML file, without secrets: +// each cluster gets a token_secret_env placeholder to fill. +func (s *Store) ExportConfig(ctx context.Context) (*config.Config, error) { + st, err := s.Settings(ctx) + if err != nil { + return nil, err + } + cls, err := s.Clusters(ctx) + if err != nil { + return nil, err + } + cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune} + for _, c := range cls { + cl := config.Cluster{Name: c.Name, URL: c.URL, TokenID: c.TokenID, Fingerprint: c.Fingerprint, + Insecure: c.Insecure, Timeout: c.Timeout, TokenSecretEnv: EnvName(c.Name)} + if c.Reference { + cfg.Reference = cl + continue + } + cfg.Clients = append(cfg.Clients, config.Client{Cluster: cl, Mapping: c.Mapping, Policies: c.Policies}) + } + return cfg, nil +} + +// EnvName is the environment variable proposed for the secret of a cluster in an export. +func EnvName(cluster string) string { + var b strings.Builder + b.WriteString("PVE_") + for _, r := range strings.ToUpper(cluster) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + b.WriteString("_SECRET") + return b.String() +} + +// ImportConfig loads a CLI configuration: settings are replaced, clusters are +// created or updated (the ones absent from the file are kept unless replace is +// set). Secrets are read from token_secret_env / token_secret_file when they +// are reachable from the server; otherwise the stored secret is kept. +// It returns warnings for the clusters left without a secret. +func (s *Store) ImportConfig(ctx context.Context, cfg *config.Config, replace bool) ([]string, error) { + var warns []string + st := Settings{Kinds: cfg.Kinds, Objects: cfg.Objects, Policies: cfg.Policies, Prune: cfg.Prune} + existing, err := s.Clusters(ctx) + if err != nil { + return nil, err + } + have := map[string]Cluster{} + for _, c := range existing { + have[c.Name] = c + } + save := func(cl config.Cluster, ref bool, pos int, mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error { + c := Cluster{Name: cl.Name, Reference: ref, Position: pos, URL: cl.URL, TokenID: cl.TokenID, + Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: cl.Timeout, Mapping: mapping, Policies: pol} + if cl.CAFile != "" { + warns = append(warns, fmt.Sprintf("%s: ca_file %s not imported, paste the CA certificate in the cluster settings", cl.Name, cl.CAFile)) + } + if sec, err := cl.ResolveSecret(); err == nil && sec != "" { + c.Secret = sec + } else if !have[cl.Name].HasSecret { + warns = append(warns, fmt.Sprintf("%s: token secret not readable from the server, cluster not imported (%v)", cl.Name, err)) + return nil + } + return s.SaveCluster(ctx, "", c) + } + if err := save(cfg.Reference, true, 0, nil, nil); err != nil { + return warns, err + } + keep := map[string]bool{cfg.Reference.Name: true} + for i, c := range cfg.Clients { + keep[c.Name] = true + if err := save(c.Cluster, false, i+1, c.Mapping, c.Policies); err != nil { + return warns, err + } + } + if replace { + for name := range have { + if !keep[name] { + if err := s.DeleteCluster(ctx, name); err != nil { + return warns, err + } + } + } + } + return warns, s.SaveSettings(ctx, st) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..636c870 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" +) + +func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } + +func TestSecretsEncryptedAndKeyChecked(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "t.db") + st, err := Open(path, testKey(1)) + if err != nil { + t.Fatal(err) + } + if err := st.SaveCluster(ctx, "", Cluster{Name: "ref", Reference: true, URL: "https://ref:8006", TokenID: "a@pve!ro", Secret: "s3cr3t-uuid"}); err != nil { + t.Fatal(err) + } + var blob []byte + if err := st.db.QueryRowContext(ctx, `SELECT secret FROM clusters WHERE name = 'ref'`).Scan(&blob); err != nil { + t.Fatal(err) + } + if bytes.Contains(blob, []byte("s3cr3t")) { + t.Fatal("secret stored in clear") + } + cls, _ := st.Clusters(ctx) + if len(cls) != 1 || cls[0].Secret != "" || !cls[0].HasSecret { + t.Fatalf("listing must hide the secret: %+v", cls) + } + // an empty secret on update keeps the stored one + c := cls[0] + c.URL = "https://ref2:8006" + if err := st.SaveCluster(ctx, "ref", c); err != nil { + t.Fatal(err) + } + cfg, err := st.Config(ctx) + if err != nil { + t.Fatal(err) + } + if s, _ := cfg.Reference.ResolveSecret(); s != "s3cr3t-uuid" { + t.Fatalf("secret after update: %q", s) + } + exp, _ := st.ExportConfig(ctx) + if exp.Reference.Secret != "" || exp.Reference.TokenSecretEnv != "PVE_REF_SECRET" { + t.Fatalf("export: %+v", exp.Reference) + } + st.Close() + if _, err := Open(path, testKey(2)); err == nil { + t.Fatal("a wrong key must be refused") + } +} + +func TestUsers(t *testing.T) { + ctx := context.Background() + st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1)) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.SaveUser(ctx, "admin", RoleAdmin, "short"); err == nil { + t.Fatal("short password accepted") + } + if err := st.SaveUser(ctx, "admin", RoleAdmin, "long-enough"); err != nil { + t.Fatal(err) + } + if _, ok := st.Authenticate(ctx, "admin", "wrong-pass"); ok { + t.Fatal("wrong password accepted") + } + u, ok := st.Authenticate(ctx, "admin", "long-enough") + if !ok || u.Role != RoleAdmin { + t.Fatalf("login: %+v %v", u, ok) + } + if err := st.SaveUser(ctx, "admin", RoleViewer, ""); err == nil { + t.Fatal("last admin demoted") + } + if err := st.DeleteUser(ctx, "admin"); err == nil { + t.Fatal("last admin deleted") + } + // a new password changes the epoch (open sessions are invalidated) + if err := st.SaveUser(ctx, "admin", RoleAdmin, "another-one"); err != nil { + t.Fatal(err) + } + u2, _ := st.User(ctx, "admin") + if u2.Epoch == u.Epoch { + t.Fatal("epoch unchanged after a password change") + } +} + +func TestPasswordHash(t *testing.T) { + h, err := HashPassword("p@ssw0rd!") + if err != nil { + t.Fatal(err) + } + if strings.Contains(h, "p@ssw0rd") || !CheckPassword(h, "p@ssw0rd!") || CheckPassword(h, "p@ssw0rd") { + t.Fatal("hash check") + } +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..c0da6de --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,182 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Roles, from the most to the least privileged. +const ( + RoleAdmin = "admin" // everything, including clusters, settings and users + RoleOperator = "operator" // dry-run and synchronization + RoleViewer = "viewer" // read only +) + +// ValidRole tells whether r is a known role. +func ValidRole(r string) bool { return r == RoleAdmin || r == RoleOperator || r == RoleViewer } + +// RoleRank orders the roles: admin 3, operator 2, viewer 1, unknown 0. +func RoleRank(r string) int { + switch r { + case RoleAdmin: + return 3 + case RoleOperator: + return 2 + case RoleViewer: + return 1 + } + return 0 +} + +// User is a local account (password hash never returned). +type User struct { + Name string `json:"name"` + Role string `json:"role"` + Epoch int64 `json:"-"` // changes with the password: ends the sessions + CreatedAt time.Time `json:"createdAt"` + LastLogin time.Time `json:"lastLogin"` + hash string +} + +// Users lists the local accounts. +func (s *Store) Users(ctx context.Context) ([]User, error) { + rows, err := s.db.QueryContext(ctx, `SELECT name, role, epoch, created_at, last_login FROM users ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + var c, l int64 + if err := rows.Scan(&u.Name, &u.Role, &u.Epoch, &c, &l); err != nil { + return nil, err + } + u.CreatedAt = time.Unix(c, 0).UTC() + if l > 0 { + u.LastLogin = time.Unix(l, 0).UTC() + } + out = append(out, u) + } + return out, rows.Err() +} + +// User returns one account. +func (s *Store) User(ctx context.Context, name string) (User, error) { + var u User + var c, l int64 + err := s.db.QueryRowContext(ctx, `SELECT name, role, password, epoch, created_at, last_login FROM users WHERE name = ?`, name). + Scan(&u.Name, &u.Role, &u.hash, &u.Epoch, &c, &l) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + u.CreatedAt = time.Unix(c, 0).UTC() + if l > 0 { + u.LastLogin = time.Unix(l, 0).UTC() + } + return u, err +} + +// CountUsers returns the number of local accounts. +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n) + return n, err +} + +// Authenticate checks a password and records the login. +func (s *Store) Authenticate(ctx context.Context, name, pass string) (User, bool) { + u, err := s.User(ctx, name) + if err != nil { + // same cost as a real check: the answer time does not tell whether the user exists + CheckPassword(dummyHash, pass) + return User{}, false + } + if !CheckPassword(u.hash, pass) { + return User{}, false + } + _, _ = s.db.ExecContext(ctx, `UPDATE users SET last_login = ? WHERE name = ?`, time.Now().Unix(), name) + return u, true +} + +var dummyHash, _ = HashPassword("dummy password") + +// SaveUser creates an account or changes its role and/or password (empty: unchanged). +func (s *Store) SaveUser(ctx context.Context, name, role, pass string) error { + name = strings.TrimSpace(name) + if name == "" || strings.ContainsAny(name, " \t\r\n") { + return errors.New("invalid user name") + } + if !ValidRole(role) { + return fmt.Errorf("unknown role %q", role) + } + if pass != "" && len(pass) < 8 { + return errors.New("password: 8 characters minimum") + } + var hash string + if pass != "" { + var err error + if hash, err = HashPassword(pass); err != nil { + return err + } + } + now := time.Now() + _, err := s.User(ctx, name) + switch { + case errors.Is(err, ErrNotFound): + if pass == "" { + return errors.New("a password is required for a new user") + } + _, err = s.db.ExecContext(ctx, `INSERT INTO users (name, role, password, epoch, created_at) VALUES (?,?,?,?,?)`, + name, role, hash, now.UnixNano(), now.Unix()) + return err + case err != nil: + return err + } + if err := s.keepAnAdmin(ctx, name, role); err != nil { + return err + } + if pass == "" { + _, err = s.db.ExecContext(ctx, `UPDATE users SET role = ? WHERE name = ?`, role, name) + } else { + _, err = s.db.ExecContext(ctx, `UPDATE users SET role = ?, password = ?, epoch = ? WHERE name = ?`, role, hash, now.UnixNano(), name) + } + return err +} + +// DeleteUser removes an account (never the last administrator). +func (s *Store) DeleteUser(ctx context.Context, name string) error { + if err := s.keepAnAdmin(ctx, name, ""); err != nil { + return err + } + res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE name = ?`, name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// keepAnAdmin refuses a change that would leave no local administrator +// (OIDC may be down: a local admin is the way back in). +func (s *Store) keepAnAdmin(ctx context.Context, name, newRole string) error { + if newRole == RoleAdmin { + return nil + } + var n int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND name <> ?`, name).Scan(&n); err != nil { + return err + } + var cur string + _ = s.db.QueryRowContext(ctx, `SELECT role FROM users WHERE name = ?`, name).Scan(&cur) + if cur == RoleAdmin && n == 0 { + return errors.New("this is the last local administrator") + } + return nil +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..9251d2c --- /dev/null +++ b/web/app.js @@ -0,0 +1,1366 @@ +// pve-confsync web UI: clusters, differences per client, dry-run and synchronization +// (per object, per client, global), execution journal, audit trail and settings. +// No framework and no build step, like Logstream. The DOM is built with h(): +// data coming from the clusters is always set as text, never as HTML. +(function () { + 'use strict'; + + // ---------- i18n ---------- + const I18N = { + fr: { + demo: 'DÉMO', searchPh: 'Filtrer les objets (nom, champ, valeur)', refresh: 'Actualiser', + refreshTitle: 'Relire la référence et les clients', settings: 'Paramètres', theme: 'Thème clair / sombre', + logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewRuns: 'Journal', viewAudit: 'Audit', + kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run global', syncAll: 'Tout synchroniser', + setupText: "Aucun cluster de référence n'est défini : ajoutez la référence puis les clients.", setupBtn: 'Ajouter les clusters', + stripAria: 'Clusters', colKind: 'Type', colObject: 'Objet', colStatus: 'Statut', colFields: 'Champs', + colDate: 'Date', colUser: 'Utilisateur', colMode: 'Mode', colScope: 'Portée', colClients: 'Clients', colSummary: 'Résumé', + colIP: 'Adresse', colAction: 'Action', colTarget: 'Cible', colResult: 'Résultat', colDetail: 'Détail', + 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', + kinds: { 'datacenter-options': 'Options datacenter', 'sdn-zone': 'Zones SDN', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' }, + kindShort: { 'datacenter-options': 'options', '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 référence', error: 'Erreur' }, + filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs', + reference: 'RÉF', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)', + readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run du client', syncClient: 'Synchroniser le client', + dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet', + refError: 'Référence illisible', clientError: 'Client illisible', planError: 'Bloquant', warning: 'Avertissement', + fieldsN: '{n} champ(s)', noFields: 'aucun champ', + thField: 'Champ', thPolicy: 'Politique', thRef: 'Référence', thMapping: 'Mapping client', thCurrent: 'Client actuel', thTarget: 'Après synchro', + emptyOk: 'Aucun écart pour ce client.', emptyOkHint: 'Le client est conforme à la référence pour les types synchronisés.', + emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.', + noClient: 'Aucun client.', noClientHint: 'Ajoutez des clusters clients dans les paramètres.', + connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Référence {ref} · {n} client(s) · {d} avec écarts · lu à {t}', + dryTitle: 'Dry-run : {what}', whatGlobal: 'tous les clients', whatClient: 'client {c}', whatObject: '{o} sur {c}', + reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur ce client.', + 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 clients. Tapez le nom du cluster de référence « {w} » pour confirmer.", + applyNow: 'Appliquer', applying: 'Synchronisation en cours…', applyDone: 'Synchronisation 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: 'global', client: 'client', object: 'objet' }, + allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs', + noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.', + action: { + login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro', + 'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster', + 'settings-save': 'paramètres', 'config-export': 'export YAML', 'config-import': 'import YAML', + 'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe', + }, + tabClusters: 'Clusters', tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabConfig: 'Import / export', tabAccount: 'Compte', tabAppearance: 'Apparence', + roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.', + addCluster: '+ Ajouter un cluster', + clustersHelp: "Un cluster de référence (token en lecture seule : Sys.Audit, SDN.Audit) et des clients (Sys.Audit, Sys.Modify si les options datacenter sont synchronisées, SDN.Audit, SDN.Allocate). Les secrets des tokens sont chiffrés en base et ne sont 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' }, + newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle', roleClient: 'Client', roleRef: 'Référence', + cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai', + tlsFp: 'Empreinte SHA-256 du certificat', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)', + secretKeep: 'Laisser vide pour conserver le secret enregistré.', secretNew: 'Secret du token API (UUID), chiffré en base.', + cMapping: 'Mapping (valeurs propres au client)', cMappingHelp: "Valeurs prioritaires sur la référence pour ce client : bridge, nodes, peers d'une zone, next-id… Les champs en politique « override » ne sont écrits qu'à partir d'ici.", + cPolicies: 'Politiques propres au client', cPoliciesHelp: 'Prioritaires sur les politiques globales pour ce client.', + confirmDelete: 'Supprimer le cluster {c} ? Ses mappings sont perdus.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?", + kindsTitle: 'Types synchronisés', kindsHelp: 'Hors périmètre v1 : EVPN, firewall, storage, pools/ACL, jobs, secrets.', + pruneTitle: 'Suppression', pruneLabel: 'Supprimer chez les clients les objets absents de la référence', + 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-?). Vide : tout.", + polTitle: 'Politiques par champ', polHelp: 'sync : copie de la référence ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping du client. Défauts :', + unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}', + usersHelp: "Comptes locaux (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.", + addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire', + exportTitle: 'Exporter', exportHelp: 'Configuration au format YAML de la CLI, versionnable dans Git. Les secrets ne sont pas exportés : chaque cluster reçoit une variable token_secret_env à renseigner.', + exportBtn: 'Télécharger confsync.yaml', importTitle: 'Importer', + importHelp: "Fichier YAML de la CLI : remplace les paramètres de synchronisation, crée ou met à jour les clusters. Les secrets sont lus depuis token_secret_env / token_secret_file s'ils sont accessibles au serveur, sinon le secret déjà enregistré est conservé.", + importPick: 'Choisir un fichier…', importReplace: 'Supprimer les clusters absents du fichier', importBtn: 'Importer', importOk: 'Configuration importée.', + 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 : ', + }, + en: { + demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh', + refreshTitle: 'Read the reference and the clients again', settings: 'Settings', theme: 'Light / dark theme', + logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewRuns: 'Journal', viewAudit: 'Audit', + kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Global dry-run', syncAll: 'Sync everything', + setupText: 'No reference cluster yet: add the reference, then the clients.', setupBtn: 'Add clusters', + stripAria: 'Clusters', colKind: 'Kind', colObject: 'Object', colStatus: 'Status', colFields: 'Fields', + colDate: 'Date', colUser: 'User', colMode: 'Mode', colScope: 'Scope', colClients: 'Clients', colSummary: 'Summary', + colIP: 'Address', colAction: 'Action', colTarget: 'Target', colResult: 'Result', colDetail: 'Detail', + 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', + kinds: { 'datacenter-options': 'Datacenter options', 'sdn-zone': 'SDN zones', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' }, + kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' }, + allKinds: 'All kinds', + st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not in ref.', error: 'Error' }, + filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors', + reference: 'REF', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)', + readAt: 'read at {t} ({ms} ms)', dryClient: 'Client dry-run', syncClient: 'Synchronize client', + dryObj: 'Dry-run this object', syncObj: 'Synchronize this object', + refError: 'Reference unreadable', clientError: 'Client unreadable', planError: 'Blocking', warning: 'Warning', + fieldsN: '{n} field(s)', noFields: 'no field', + thField: 'Field', thPolicy: 'Policy', thRef: 'Reference', thMapping: 'Client mapping', thCurrent: 'Client now', thTarget: 'After sync', + emptyOk: 'No difference for this client.', emptyOkHint: 'The client is in line with the reference for the synchronized kinds.', + emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.', + noClient: 'No client.', noClientHint: 'Add client clusters in the settings.', + connOk: 'Connected', connKo: 'Offline', statsLine: 'Reference {ref} · {n} client(s) · {d} with differences · read at {t}', + dryTitle: 'Dry-run: {what}', whatGlobal: 'all clients', whatClient: 'client {c}', whatObject: '{o} on {c}', + reading: 'Reading the clusters…', planEmpty: 'Nothing to do: in sync.', planErrors: 'Blocking errors: nothing will be applied on this client.', + 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 clients. Type the name of the reference cluster "{w}" to confirm.', + applyNow: 'Apply', applying: 'Synchronization in progress…', applyDone: 'Synchronization 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: 'global', client: 'client', object: 'object' }, + allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs', + noRuns: 'No run yet.', noAudit: 'No event.', + action: { + login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync', + 'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test', + 'settings-save': 'settings', 'config-export': 'YAML export', 'config-import': 'YAML import', + 'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password', + }, + tabClusters: 'Clusters', tabSync: 'Synchronization', tabUsers: 'Users', tabConfig: 'Import / export', tabAccount: 'Account', tabAppearance: 'Appearance', + roNote: 'Read only: only administrators change these settings.', + addCluster: '+ Add a cluster', + clustersHelp: 'One reference cluster (read-only token: Sys.Audit, SDN.Audit) and clients (Sys.Audit, Sys.Modify when datacenter options are synchronized, SDN.Audit, SDN.Allocate). Token secrets are encrypted in the database and never shown again.', + test: 'Test', edit: 'Edit', testing: 'Testing…', testOk: 'Access OK', role: { admin: 'admin', operator: 'operator', viewer: 'read only' }, + newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role', roleClient: 'Client', roleRef: 'Reference', + cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout', + tlsFp: 'SHA-256 fingerprint of the certificate', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)', + secretKeep: 'Leave empty to keep the stored secret.', secretNew: 'API token secret (UUID), encrypted in the database.', + cMapping: 'Mapping (client specific values)', cMappingHelp: 'Values taking precedence over the reference for this client: bridge, nodes, peers of a zone, next-id… Fields with the "override" policy are only written from here.', + cPolicies: 'Client policies', cPoliciesHelp: 'Take precedence over the global policies for this client.', + confirmDelete: 'Delete cluster {c}? Its mappings are lost.', confirmDeleteUser: 'Delete user {u}?', + kindsTitle: 'Synchronized kinds', kindsHelp: 'Out of scope in v1: EVPN, firewall, storage, pools/ACL, jobs, secrets.', + pruneTitle: 'Deletion', pruneLabel: 'Delete on the clients the objects absent from the reference', + 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-?). Empty: everything.', + polTitle: 'Field policies', polHelp: 'sync: copied from the reference; ignore: never read nor written; override: value from the client mapping. Defaults:', + unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}', + usersHelp: 'Local accounts (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.', + addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required', + exportTitle: 'Export', exportHelp: 'Configuration in the YAML format of the CLI, versionable in Git. Secrets are not exported: each cluster gets a token_secret_env variable to fill.', + exportBtn: 'Download confsync.yaml', importTitle: 'Import', + importHelp: 'CLI YAML file: replaces the synchronization settings, creates or updates the clusters. Secrets are read from token_secret_env / token_secret_file when the server can reach them, otherwise the stored secret is kept.', + importPick: 'Choose a file…', importReplace: 'Delete the clusters absent from the file', importBtn: 'Import', importOk: 'Configuration imported.', + 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: ', + }, + }; + const KINDS = ['datacenter-options', 'sdn-zone', 'sdn-vnet', 'sdn-subnet']; + const POLICIES = ['sync', 'ignore', 'override']; + const ROLES = ['admin', 'operator', 'viewer']; + + const store = { + get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } }, + set(k, v) { try { if (v == null) localStorage.removeItem('confsync.' + k); else localStorage.setItem('confsync.' + k, v); } catch (e) { /* private mode */ } }, + }; + let lang = store.get('lang'); + if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr'; + + function tr(path) { + let v = I18N[lang]; + for (const p of path.split('.')) v = v == null ? v : v[p]; + if (v == null) { + v = I18N.fr; + for (const p of path.split('.')) v = v == null ? v : v[p]; + } + return v == null ? path : v; + } + function t(key, vars) { + let s = String(tr(key)); + if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)); + return s; + } + + // ---------- DOM helpers ---------- + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); + function h(tag, attrs, ...kids) { + const e = document.createElement(tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + if (v == null || v === false) continue; + if (k === 'class') e.className = v; + else if (k === 'text') e.textContent = v; + else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); + else if (k === 'dataset') Object.assign(e.dataset, v); + else if (v === true) e.setAttribute(k, ''); + else e.setAttribute(k, v); + } + } + for (const k of kids.flat()) { + if (k == null || k === false) continue; + e.appendChild(typeof k === 'string' || typeof k === 'number' ? document.createTextNode(String(k)) : k); + } + return e; + } + const SVG_NS = 'http://www.w3.org/2000/svg'; + const ICONS = { + scan: 'M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6M16 16l-1.9-1.9', + sync: 'M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5', + trash: 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6', + check: 'M5 12.5 10 17l9-10', + plug: 'M12 22v-5M9 8V2M15 8V2M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z', + pencil: 'M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5zM15 5l4 4', + x: 'M6 6l12 12M18 6 6 18', + }; + function icon(name) { + const s = document.createElementNS(SVG_NS, 'svg'); + s.setAttribute('viewBox', '0 0 24 24'); + s.setAttribute('aria-hidden', 'true'); + const p = document.createElementNS(SVG_NS, 'path'); + p.setAttribute('d', ICONS[name]); + s.appendChild(p); + return s; + } + function iconBtn(name, title, onclick, cls) { + return h('button', { type: 'button', class: 'icon-btn' + (cls ? ' ' + cls : ''), title, 'aria-label': title, onclick }, icon(name)); + } + function fmtTime(iso, withDate) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return ''; + const o = { hour: '2-digit', minute: '2-digit', second: '2-digit' }; + if (withDate) Object.assign(o, { year: 'numeric', month: '2-digit', day: '2-digit' }); + return d.toLocaleString(lang === 'fr' ? 'fr-FR' : 'en-GB', o); + } + function fmtVal(v) { + if (v == null) return ''; + if (Array.isArray(v)) return '[' + v.join(', ') + ']'; + return String(v); + } + + let toastTimer; + function toast(msg, bad) { + const el = $('#toast'); + el.textContent = msg; + el.className = 'toast' + (bad ? ' bad' : ''); + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.hidden = true; }, bad ? 6000 : 3000); + } + let busyN = 0; + function busy(on) { + busyN += on ? 1 : -1; + document.body.classList.toggle('busy', busyN > 0); + } + + // ---------- API ---------- + async function api(method, path, body) { + const opt = { method, headers: { Accept: 'application/json' }, credentials: 'same-origin' }; + if (method !== 'GET') opt.headers['X-Confsync'] = '1'; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + let r; + try { + r = await fetch(path, opt); + } catch (e) { + setConn(false); + throw e; + } + setConn(true); + if (r.status === 401) { + location.href = 'login.html?r=' + encodeURIComponent(location.pathname + location.search + location.hash); + throw new Error('auth'); + } + const data = await r.json().catch(() => ({})); + if (!r.ok) { + const e = new Error(data.error || r.statusText); + e.status = r.status; + e.code = data.code; + e.data = data; + throw e; + } + return data; + } + function setConn(ok) { + const c = $('#conn'); + c.className = 'conn ' + (ok ? 'ok' : 'ko'); + c.textContent = t(ok ? 'connOk' : 'connKo'); + } + + // ---------- application state ---------- + const S = { + me: null, + state: null, // last comparison + setup: false, // no reference yet + view: 'diff', + client: null, // selected client + open: new Set(), // expanded object rows (client|key) + kind: '', + status: store.get('status') || 'diff', + q: '', + runs: [], runsEnd: false, runOpen: new Set(), runMode: '', + events: [], eventsEnd: false, eventOpen: new Set(), + }; + const can = { + sync: () => S.me && (S.me.role === 'admin' || S.me.role === 'operator'), + admin: () => S.me && S.me.role === 'admin', + }; + + // ---------- language, theme, density ---------- + function applyLang() { + document.documentElement.lang = lang; + $$('[data-i18n]').forEach((e) => { e.textContent = t(e.dataset.i18n); }); + $$('[data-i18n-title]').forEach((e) => { e.title = t(e.dataset.i18nTitle); }); + $$('[data-i18n-aria]').forEach((e) => { e.setAttribute('aria-label', t(e.dataset.i18nAria)); }); + $$('[data-i18n-ph]').forEach((e) => { e.placeholder = t(e.dataset.i18nPh); }); + $$('#langSwitch [data-lang]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.lang === lang))); + fillSelects(); + renderAll(); + } + function fillSelects() { + const ks = $('#kindSel'); + ks.replaceChildren(h('option', { value: '', text: t('allKinds') }), ...KINDS.map((k) => h('option', { value: k, text: t('kinds.' + k) }))); + ks.value = S.kind; + ks.classList.toggle('set', !!S.kind); + const ss = $('#statusSel'); + ss.replaceChildren(...[['diff', 'filterDiff'], ['all', 'filterAll'], ['same', 'filterSame'], ['error', 'filterError']] + .map(([v, k]) => h('option', { value: v, text: t(k) }))); + ss.value = S.status; + ss.classList.toggle('set', S.status !== 'diff'); + const ms = $('#runModeSel'); + ms.replaceChildren(...[['', 'allModes'], ['apply', 'onlyApply'], ['dry-run', 'onlyDry']].map(([v, k]) => h('option', { value: v, text: t(k) }))); + ms.value = S.runMode; + } + function themeOpt() { return store.get('theme') || 'system'; } + function setTheme(v) { + if (v === 'system') delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = v; + store.set('theme', v === 'system' ? null : v); + $$('#themeSwitch [data-theme-opt]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.themeOpt === v))); + } + function setDensity(v) { + if (v === 'compact') document.documentElement.dataset.density = 'compact'; + else delete document.documentElement.dataset.density; + store.set('density', v === 'compact' ? v : null); + $$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal')))); + } + + // ---------- routing (#diff/, #runs, #audit) ---------- + function readHash() { + const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/'); + S.view = ['diff', 'runs', 'audit'].includes(v) ? v : 'diff'; + if (S.view === 'audit' && !can.admin()) S.view = 'diff'; + if (c) S.client = c; + } + function writeHash() { + const hsh = '#' + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client) : ''); + if (location.hash !== hsh) history.replaceState(null, '', hsh); + } + function setView(v) { + S.view = v; + writeHash(); + renderView(); + if (v === 'runs') loadRuns(true); + if (v === 'audit') loadAudit(true); + } + + // ---------- loading ---------- + async function loadState(refresh) { + busy(true); + $('#refreshBtn').classList.add('spin'); + try { + const d = await api('GET', 'api/state' + (refresh ? '?refresh=1' : '')); + S.setup = !!d.setup; + S.state = d.state || null; + $('#error').hidden = true; + if (S.state) { + const names = S.state.clients.map((c) => c.name); + if (!names.includes(S.client)) { + // first client with something to do, else the first one + const first = S.state.clients.find((c) => c.error || (c.plan && (c.plan.changes || []).length) || (c.plan && (c.plan.errors || []).length)); + S.client = first ? first.name : names[0] || null; + } + } + writeHash(); + } catch (e) { + if (e.message !== 'auth') showError(e.message); + } finally { + busy(false); + $('#refreshBtn').classList.remove('spin'); + } + renderAll(); + } + function showError(msg) { + const el = $('#error'); + el.textContent = msg; + el.hidden = !msg; + } + + // ---------- rendering: differences ---------- + function renderAll() { + renderView(); + renderStatus(); + } + function renderView() { + $$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view))); + $('#diffView').hidden = S.view !== 'diff'; + $('#runsView').hidden = S.view !== 'runs'; + $('#auditView').hidden = S.view !== 'audit'; + $$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; }); + $$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; }); + $('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync(); + $('#setup').hidden = !S.setup; + if (S.view === 'diff') renderDiff(); + if (S.view === 'runs') renderRuns(); + if (S.view === 'audit') renderAudit(); + } + + function clientStatus(c) { + if (c.error || (S.state && S.state.reference.error)) return 'error'; + const p = c.plan || {}; + if ((p.errors || []).length) return 'error'; + if ((p.changes || []).length) return 'diff'; + return 'ok'; + } + + function renderDiff() { + const strip = $('#strip'); + strip.replaceChildren(); + const st = S.state; + if (!st) { + $('#clientBar').hidden = true; + $('#objTable').hidden = true; + $('#objEmpty').hidden = true; + $('#notes').replaceChildren(); + $('#count').textContent = ''; + return; + } + // reference card + const r = st.reference; + strip.appendChild(h('div', { class: 'ccard ref' + (r.error ? ' st-error' : '') }, + 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 }) + : h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('fieldsN', { n: r.objects }).replace(/champ\(s\)|field\(s\)/, lang === 'fr' ? 'objet(s)' : 'object(s)') }), + r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null))); + for (const c of st.clients) { + const cs = clientStatus(c); + const counts = h('div', { class: 'c-counts' }); + if (c.error) { + counts.appendChild(h('span', { class: 'cnt error', text: t('unreadable') })); + } else if (!r.error) { + const n = c.counts || {}; + let any = false; + for (const [k, sym] of [['create', '+'], ['update', '~'], ['delete', '−'], ['error', '!'], ['extra', '?']]) { + if (n[k]) { any = true; counts.appendChild(h('span', { class: 'cnt ' + k, title: t('st.' + k), text: sym + n[k] })); } + } + if (!any) counts.appendChild(h('span', { class: 'cnt ok', text: '✓ ' + t('inLine') })); + } + strip.appendChild(h('button', { + type: 'button', role: 'tab', class: 'ccard st-' + cs, 'aria-selected': String(c.name === S.client), + onclick: () => { S.client = c.name; S.open.clear(); writeHash(); renderDiff(); }, + }, + 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)); + } + renderClient(); + } + + function selected() { + return S.state && S.state.clients.find((c) => c.name === S.client); + } + + function renderClient() { + const c = selected(); + const bar = $('#clientBar'); + const notes = $('#notes'); + notes.replaceChildren(); + const st = S.state; + if (st.reference.error) notes.appendChild(note('err', t('refError'), st.reference.error)); + for (const w of st.reference.warnings || []) notes.appendChild(note('info', t('warning'), w)); + if (!c) { + bar.hidden = true; + $('#objTable').hidden = true; + emptyState(t('noClient'), t('noClientHint')); + $('#count').textContent = ''; + return; + } + const p = c.plan || { changes: [], errors: [], warnings: [] }; + const nChanges = (p.changes || []).length; + const blocked = (p.errors || []).length > 0 || !!c.error || !!st.reference.error; + const meta = []; + if (c.version) meta.push('PVE ' + c.version); + if (c.nodes && c.nodes.length) meta.push(t('nodes', { n: c.nodes.length })); + if (c.readAt) meta.push(t('readAt', { t: fmtTime(c.readAt), ms: c.ms })); + bar.hidden = false; + bar.replaceChildren( + h('h2', { text: c.name }), + h('span', { class: 'meta', text: meta.join(' · ') }), + can.sync() ? h('div', { class: 'acts' }, + h('button', { type: 'button', class: 'btn tool', disabled: !!c.error || !!st.reference.error, onclick: () => openPlan({ scope: 'client', client: c.name }) }, + icon('scan'), t('dryClient')), + h('button', { type: 'button', class: 'btn tool sync', disabled: blocked || nChanges === 0, onclick: () => openPlan({ scope: 'client', client: c.name }, true) }, + icon('sync'), t('syncClient'))) : null); + if (c.error) notes.appendChild(note('err', t('clientError'), c.error)); + for (const e of p.errors || []) notes.appendChild(note('err', t('planError'), e)); + for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), w)); + + const rows = (c.rows || []).filter(match); + $('#count').textContent = (c.rows || []).length ? rows.length + ' / ' + c.rows.length : ''; + const list = $('#objList'); + list.replaceChildren(); + if (!rows.length) { + $('#objTable').hidden = true; + if (c.error) { $('#objEmpty').hidden = true; return; } + const filtered = (c.rows || []).length && (S.kind || S.q || S.status !== 'diff'); + if (filtered) emptyState(t('emptyFilter'), t('emptyFilterHint')); + else emptyState(t('emptyOk'), t('emptyOkHint')); + return; + } + $('#objEmpty').hidden = true; + $('#objTable').hidden = false; + for (const r of rows) list.appendChild(objRow(c, r)); + } + function emptyState(title, hint) { + $('#objEmpty').hidden = false; + $('#objEmptyTitle').textContent = title; + $('#objEmptyHint').textContent = hint; + } + function note(kind, label, text) { + return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text })); + } + + function match(r) { + if (S.kind && r.kind !== S.kind) return false; + switch (S.status) { + case 'diff': if (r.status === 'same' || r.status === 'extra') return false; break; + case 'same': if (r.status !== 'same') return false; break; + case 'error': if (r.status !== 'error') return false; break; + } + if (S.q) { + const q = S.q.toLowerCase(); + const hay = [r.id, r.kind, ...(r.fields || []).flatMap((f) => [f.name, fmtVal(f.ref), fmtVal(f.current), fmtVal(f.mapping)])].join('\n').toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + } + + function summary(r) { + const s = h('span', { class: 'sum' }); + if (r.status === 'error') { s.textContent = (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 }); + return s; + } + ch.forEach((f, i) => { + if (i) s.appendChild(document.createTextNode(' ')); + const sym = f.change === 'add' ? '+' : f.change === 'set' ? '~' : '−'; + s.appendChild(h('span', { class: 'f-' + f.change, text: sym + f.name })); + }); + s.title = ch.map((f) => f.name).join(', '); + return s; + } + + function objRow(c, r) { + const key = c.name + '|' + r.key; + const open = S.open.has(key); + const actionable = ['create', 'update', 'delete'].includes(r.status); + const row = h('div', { + class: 'row st-' + r.status + (open ? ' open' : ''), tabindex: '0', role: 'button', 'aria-expanded': String(open), + onclick: (ev) => { if (ev.target.closest('.acts') || ev.target.closest('.details')) return; toggle(); }, + onkeydown: (ev) => { if ((ev.key === 'Enter' || ev.key === ' ') && ev.target === row) { ev.preventDefault(); toggle(); } }, + }, + h('span', { class: 'kind ' + r.kind, text: t('kindShort.' + r.kind), title: t('kinds.' + r.kind) }), + h('span', { class: 'id', text: r.id, title: r.id }), + h('span', null, h('span', { class: 'badge ' + r.status, text: t('st.' + r.status) })), + summary(r), + h('span', { class: 'acts' }, can.sync() && actionable && !c.error ? [ + iconBtn('scan', t('dryObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] })), + iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go'), + ] : null)); + function toggle() { + if (S.open.has(key)) S.open.delete(key); else S.open.add(key); + renderClient(); + } + if (open) row.appendChild(details(r)); + return row; + } + + function details(r) { + const d = h('div', { class: 'details' }); + if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.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) }); + tb.appendChild(h('tr', { class: (f.change ? 'ch-' + f.change : '') + ' pol-' + f.policy }, + h('td', { class: 'name', text: f.name }), + h('td', null, h('span', { class: 'pol ' + f.policy, text: f.policy })), + cell(f.ref, 'ref'), cell(f.mapping, 'map'), cell(f.current, 'cur'), + cell(f.change === 'unset' ? null : f.target, 'target'))); + } + if (!(r.fields || []).length) { + d.appendChild(h('p', { class: 'muted', text: t('noFields') })); + return d; + } + d.appendChild(h('table', { class: 'fields' }, + h('thead', null, h('tr', null, ...['thField', 'thPolicy', 'thRef', 'thMapping', 'thCurrent', 'thTarget'].map((k) => h('th', { text: t(k) })))), + tb)); + return d; + } + + function renderStatus() { + const st = S.state; + const s = $('#stats'); + if (!st) { s.textContent = ''; return; } + const d = st.clients.filter((c) => clientStatus(c) !== 'ok').length; + s.textContent = t('statsLine', { ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) }); + } + + // ---------- dry-run, confirmation, synchronization ---------- + const plan = { sel: null, plans: null, run: 0, poll: 0 }; + + function whatLabel(sel) { + if (sel.scope === 'global') return t('whatGlobal'); + if (sel.scope === 'client') return t('whatClient', { c: sel.client }); + return t('whatObject', { o: sel.objects.join(', '), c: sel.client }); + } + + async function openPlan(sel, intentSync) { + plan.sel = sel; + plan.plans = null; + clearInterval(plan.poll); + const dlg = $('#planDlg'); + $('#planTitle').textContent = t('dryTitle', { what: whatLabel(sel) }); + $('#planBody').replaceChildren(h('p', { class: 'muted', text: t('reading') })); + $('#planNote').textContent = ''; + $('#planApply').hidden = true; + if (!dlg.open) dlg.showModal(); + busy(true); + try { + const d = await api('POST', 'api/dryrun', sel); + plan.plans = d.plans; + 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); + const blocked = d.plans.some((p) => p.error || ((p.plan && p.plan.errors) || []).length); + const ap = $('#planApply'); + ap.hidden = !can.sync() || total === 0 || blocked; + ap.disabled = false; + if (!ap.hidden && intentSync) ap.focus(); + // the dry-run read the clusters again: show what it read + loadState(false); + } catch (e) { + if (e.message === 'auth') return; + $('#planBody').replaceChildren(h('div', { class: 'error-banner', text: e.message })); + } finally { + busy(false); + } + } + + function changeList(changes) { + const ul = h('ul', { class: 'plan' }); + for (const ch of changes || []) { + const li = h('li', null, h('div', { class: 'ph' }, + h('span', { class: 'badge ' + ch.op, text: t('st.' + ch.op) }), + h('span', { class: 'kind ' + ch.kind, text: t('kindShort.' + ch.kind) }), + h('strong', { text: ch.id }))); + const pf = h('ul', { class: 'pf' }); + const set = ch.set || {}; + const old = ch.old || {}; + for (const k of Object.keys(set).sort()) { + if (k in old) pf.appendChild(h('li', { class: 'set' }, '~ ' + k + ' : ', h('del', { text: fmtVal(old[k]) }), ' → ', h('ins', { text: fmtVal(set[k]) }))); + else pf.appendChild(h('li', { class: 'add' }, '+ ' + k + ' = ', h('ins', { text: fmtVal(set[k]) }))); + } + for (const k of ch.unset || []) pf.appendChild(h('li', { class: 'unset', text: '− ' + k + ' (' + t('was') + ' ' + fmtVal(old[k]) + ')' })); + if (ch.op === 'delete') pf.appendChild(h('li', { class: 'unset', text: Object.keys(old).sort().map((k) => k + '=' + fmtVal(old[k])).join(', ') })); + if (pf.children.length) li.appendChild(pf); + ul.appendChild(li); + } + return ul; + } + + function renderPlans(plans) { + const body = $('#planBody'); + body.replaceChildren(); + for (const p of plans) { + const changes = (p.plan && p.plan.changes) || []; + const errs = (p.plan && p.plan.errors) || []; + body.appendChild(h('div', { class: 'run-client' }, + h('h4', null, p.client, + h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'), + text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }), + p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null), + p.error ? note('err', t('clientError'), p.error) : null, + errs.length ? h('div', { class: 'notes' }, note('err', t('planError'), t('planErrors')), ...errs.map((e) => note('err', '', e))) : null, + ((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), w))) : null, + changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null))); + } + } + + async function applyPlan() { + const sel = plan.sel; + const plans = plan.plans || []; + const sdn = plans.some((p) => p.sdn && ((p.plan && p.plan.changes) || []).length); + const want = sel.scope === 'global' ? S.state.reference.name : sel.client; + let confirmText = ''; + if (sdn) { + let box = $('#planBody .confirm-box'); + if (!box) { + const inp = h('input', { class: 'inp mono', autocomplete: 'off', spellcheck: 'false', 'aria-label': want }); + box = h('div', { class: 'confirm-box' }, + h('span', { text: sel.scope === 'global' ? t('sdnConfirmGlobal', { w: want }) : t('sdnConfirm', { c: sel.client, w: want }) }), inp); + $('#planBody').appendChild(box); + $('#planApply').disabled = true; + inp.addEventListener('input', () => { $('#planApply').disabled = inp.value !== want; }); + inp.addEventListener('keydown', (e) => { if (e.key === 'Enter' && inp.value === want) applyPlan(); }); + inp.focus(); + box.scrollIntoView({ block: 'nearest' }); + return; + } + confirmText = $('input', box).value; + if (confirmText !== want) return; + } + const digests = {}; + for (const p of plans) digests[p.client] = p.digest; + $('#planApply').disabled = true; + 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; } + plan.run = d.run; + $('#planApply').hidden = true; + $('#planTitle').textContent = t('applying'); + $('#planNote').textContent = t('runN', { id: d.run }); + pollRun(); + plan.poll = setInterval(pollRun, 1000); + } catch (e) { + if (e.message === 'auth') return; + $('#planApply').disabled = false; + if (e.code === 'stale') { + $('#planBody').prepend(h('div', { class: 'notes' }, note('warn', t('warning'), t('stale')), + h('div', null, h('button', { type: 'button', class: 'btn tool', onclick: () => openPlan(sel), text: t('relaunch') })))); + $('#planApply').hidden = true; + } else { + toast(t('errorPrefix') + e.message, true); + } + } finally { + busy(false); + } + } + + async function pollRun() { + let run; + try { + run = await api('GET', 'api/runs/' + plan.run); + } catch (e) { + return; + } + $('#planBody').replaceChildren(runDetail(run)); + if (run.status !== 'running') { + clearInterval(plan.poll); + $('#planTitle').textContent = t('applyDone') + ' — ' + t('res.' + run.status); + $('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + run.summary; + loadState(true); + if (S.view === 'runs') loadRuns(true); + } + } + + function runDetail(run) { + const d = run.detail || {}; + const wrap = h('div'); + const results = d.results || {}; + for (const p of d.plans || []) { + const res = results[p.client]; + const changes = (p.plan && p.plan.changes) || []; + const errs = (p.plan && p.plan.errors) || []; + const box = h('div', { class: 'run-client' }, + h('h4', null, p.client, + res ? h('span', { class: 'badge ' + res.status, text: t('res.' + res.status) }) + : h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'), + text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }), + p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null)); + if (p.error) box.appendChild(note('err', t('clientError'), p.error)); + for (const e of errs) box.appendChild(note('err', t('planError'), e)); + if (changes.length) box.appendChild(changeList(changes)); + if (res && res.steps && res.steps.length) { + box.appendChild(h('ul', { class: 'steps' }, ...res.steps.map((s) => h('li', null, + h('time', { text: fmtTime(s.at) }), h('span', { class: s.error ? 'ko' : 'ok', text: s.error ? '✗' : '✓' }), + h('span', { text: s.action }), s.error ? h('span', { class: 'e', text: s.error }) : null)))); + } + if (res && res.error) box.appendChild(note('err', t('st.error'), res.error)); + wrap.appendChild(box); + } + return wrap; + } + + // ---------- journal ---------- + async function loadRuns(reset) { + if (reset) { S.runs = []; S.runsEnd = false; } + const before = S.runs.length ? S.runs[S.runs.length - 1].id : 0; + busy(true); + try { + const d = await api('GET', 'api/runs?limit=100' + (before ? '&before=' + before : '')); + S.runs = S.runs.concat(d.runs); + S.runsEnd = d.runs.length < 100; + } catch (e) { + if (e.message !== 'auth') showError(e.message); + } finally { + busy(false); + } + renderRuns(); + } + function renderRuns() { + const list = $('#runList'); + list.replaceChildren(); + const runs = S.runs.filter((r) => !S.runMode || r.mode === S.runMode) + .filter((r) => !S.q || [r.user, r.summary, ...(r.clients || []), ...(r.objects || [])].join(' ').toLowerCase().includes(S.q.toLowerCase())); + $('#count').textContent = S.runs.length ? String(runs.length) : ''; + if (!runs.length) list.appendChild(h('div', { class: 'row', role: 'presentation' }, h('span', { class: 'muted txt', text: t('noRuns') }))); + for (const r of runs) { + const open = S.runOpen.has(r.id); + const row = h('div', { class: 'row' + (open ? ' open' : '') + (r.status === 'error' ? ' st-error' : r.mode === 'apply' ? ' st-update' : ''), tabindex: '0', role: 'button', + onclick: (ev) => { if (ev.target.closest('.details')) return; toggleRun(r.id); }, + onkeydown: (ev) => { if (ev.key === 'Enter' && ev.target === row) toggleRun(r.id); } }, + h('time', { text: fmtTime(r.startedAt, true) }), + h('span', { class: 'user', text: r.user }), + h('span', null, h('span', { class: 'badge ' + r.mode, text: t('mode.' + r.mode) })), + 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 })); + if (open) { + const det = h('div', { class: 'details' }, h('p', { class: 'muted', text: '…' })); + row.appendChild(det); + api('GET', 'api/runs/' + r.id).then((full) => { + det.replaceChildren(runDetail(full)); + if ((full.objects || []).length) det.prepend(h('p', { class: 'muted small mono', text: full.objects.join(', ') })); + }).catch((e) => { det.replaceChildren(h('p', { class: 'errs', text: e.message })); }); + } + list.appendChild(row); + } + $('#runMore').hidden = S.runsEnd; + } + function toggleRun(id) { + if (S.runOpen.has(id)) S.runOpen.delete(id); else S.runOpen.add(id); + renderRuns(); + } + + // ---------- audit ---------- + async function loadAudit(reset) { + if (reset) { S.events = []; S.eventsEnd = false; } + const before = S.events.length ? S.events[S.events.length - 1].id : 0; + busy(true); + try { + const d = await api('GET', 'api/audit?limit=200' + (before ? '&before=' + before : '')); + S.events = S.events.concat(d.events); + S.eventsEnd = d.events.length < 200; + } catch (e) { + if (e.message !== 'auth') showError(e.message); + } finally { + busy(false); + } + renderAudit(); + } + function renderAudit() { + const list = $('#auditList'); + list.replaceChildren(); + const q = S.q.toLowerCase(); + const evs = S.events.filter((e) => !q || [e.user, e.ip, e.action, e.target, e.detail].join(' ').toLowerCase().includes(q)); + $('#count').textContent = S.events.length ? String(evs.length) : ''; + if (!evs.length) list.appendChild(h('div', { class: 'row', role: 'presentation' }, h('span', { class: 'muted txt', text: t('noAudit') }))); + for (const e of evs) { + const open = S.eventOpen.has(e.id); + const row = h('div', { class: 'row' + (e.ok ? '' : ' st-error') + (open ? ' open' : ''), tabindex: '0', role: 'button', + onclick: () => { if (open) S.eventOpen.delete(e.id); else S.eventOpen.add(e.id); renderAudit(); } }, + h('time', { text: fmtTime(e.at, true) }), + h('span', { class: 'user', text: e.user || '—', title: e.role ? t('role.' + e.role) : '' }), + h('span', { class: 'txt mono', text: e.ip }), + h('span', { class: 'txt', text: tr('action.' + e.action) === 'action.' + e.action ? e.action : t('action.' + e.action) }), + h('span', { class: 'txt mono', text: e.target, title: e.target }), + h('span', null, h('span', { class: 'badge ' + (e.ok ? 'ok' : 'ko'), text: e.ok ? 'OK' : '✗' })), + h('span', { class: 'txt', text: e.detail, title: e.detail })); + if (open && e.detail) row.appendChild(h('div', { class: 'details mono', text: e.detail })); + list.appendChild(row); + } + $('#auditMore').hidden = S.eventsEnd; + } + + // ---------- settings ---------- + const set = { tab: 'clusters', clusters: [], settings: null, defaults: {}, dirty: false }; + + function openSettings(tab) { + const dlg = $('#settingsDlg'); + const admin = can.admin(); + $$('#setNav [data-admin]').forEach((b) => { b.hidden = !admin; }); + $$('[data-ro]').forEach((e) => { e.hidden = admin; }); + $$('[data-admin-only]').forEach((e) => { e.hidden = !admin; }); + $$('#settingsDlg [data-edit]').forEach((e) => { e.inert = !admin; }); + dlg.classList.toggle('read-only', !admin); + if (!dlg.open) dlg.showModal(); + showTab(tab || set.tab); + } + function showTab(tab) { + if ((tab === 'users' || tab === 'config') && !can.admin()) tab = 'clusters'; + set.tab = tab; + $$('#setNav [data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === tab))); + $$('#settingsDlg [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; }); + $('#setActions').hidden = tab !== 'sync' || !can.admin(); + if (tab === 'clusters') loadClusters(); + if (tab === 'sync') loadSettings(); + if (tab === 'users') loadUsers(); + if (tab === 'account') renderAccount(); + if (tab === 'appearance') { + setTheme(themeOpt()); + setDensity(store.get('density') || 'normal'); + applyLang(); + } + } + + // clusters + async function loadClusters() { + try { + const d = await api('GET', 'api/clusters'); + set.clusters = d.clusters; + } catch (e) { + if (e.message !== 'auth') toast(e.message, true); + return; + } + const list = $('#clusterList'); + list.replaceChildren(); + if (!set.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupText') })); + for (const c of set.clusters) { + const chk = h('div', { class: 'chk', hidden: true }); + list.appendChild(h('div', { class: 'citem' + (c.reference ? ' ref' : '') }, + h('div', { class: 'n' }, c.reference ? h('span', { class: 'c-role', text: t('reference') }) : null, h('span', { text: c.name })), + h('div', { class: 'u', text: c.url + (c.tokenId ? ' · ' + c.tokenId : ''), title: c.url }), + can.admin() ? h('div', { class: 'acts' }, + h('button', { type: 'button', class: 'btn tool', onclick: () => checkCluster(c.name, chk) }, icon('plug'), t('test')), + h('button', { type: 'button', class: 'btn tool', onclick: () => editCluster(c) }, icon('pencil'), t('edit'))) : h('span'), + chk)); + } + } + async function checkCluster(name, el) { + el.hidden = false; + el.className = 'chk muted'; + el.textContent = t('testing'); + try { + const r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check'); + const nodes = (r.nodes || []).map((n) => (n.name ? n.name + ' ' : '') + 'PVE ' + n.version).join(', '); + if (r.error) { el.className = 'chk ko'; el.textContent = '✗ ' + r.error; return; } + el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok'); + el.textContent = '✓ ' + t('testOk') + (nodes ? ' — ' + nodes : '') + ((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : ''); + } catch (e) { + el.className = 'chk ko'; + el.textContent = '✗ ' + e.message; + } + } + + let editing = null; + function editCluster(c) { + editing = c || null; + const isNew = !c; + c = c || { name: '', reference: !set.clusters.some((x) => x.reference), url: '', tokenId: '', fingerprint: '', caPem: '', insecure: false, timeout: '', mapping: {}, policies: {} }; + $('#clusterTitle').textContent = isNew ? t('newCluster') : t('editCluster', { c: c.name }); + $('#cName').value = c.name; + setSeg('#cRole', c.reference ? 'ref' : 'client'); + $('#cURL').value = c.url; + $('#cToken').value = c.tokenId; + $('#cSecret').value = ''; + $('#cSecret').required = isNew || !c.hasSecret; + $('#cSecretHint').textContent = isNew || !c.hasSecret ? t('secretNew') : t('secretKeep'); + $('#cTLS').value = c.fingerprint ? 'fp' : c.caPem ? 'ca' : c.insecure ? 'insecure' : (isNew ? 'fp' : 'sys'); + $('#cFp').value = c.fingerprint || ''; + $('#cCA').value = c.caPem || ''; + $('#cTimeout').value = c.timeout || ''; + $('#cDelete').hidden = isNew; + $('#cErr').hidden = true; + tlsFields(); + roleFields(); + const mr = $('#cMapRows'); + mr.replaceChildren(); + for (const [k, objs] of Object.entries(c.mapping || {})) { + for (const [id, fields] of Object.entries(objs || {})) { + for (const [f, v] of Object.entries(fields || {})) mr.appendChild(mapRow(k, id, f, fmtVal(v))); + } + } + const pr = $('#cPolRows'); + pr.replaceChildren(); + for (const [k, fields] of Object.entries(c.policies || {})) { + for (const [f, p] of Object.entries(fields || {})) pr.appendChild(polRow(k, f, p)); + } + $('#clusterDlg').showModal(); + $('#cName').focus(); + } + function setSeg(sel, v) { $$(sel + ' [data-v]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.v === v))); } + function segVal(sel) { const b = $(sel + ' [aria-checked="true"]'); return b ? b.dataset.v : ''; } + function tlsFields() { + const v = $('#cTLS').value; + $$('#clusterDlg [data-tls]').forEach((e) => { e.hidden = e.dataset.tls !== v; }); + } + function roleFields() { $('#cClientOnly').hidden = segVal('#cRole') === 'ref'; } + + function kindSelect(v) { + const s = h('select', { class: 'field' }, ...KINDS.map((k) => h('option', { value: k, text: t('kindShort.' + k) }))); + s.value = v || KINDS[1]; + return s; + } + function refIds(kind) { + const st = S.state; + if (!st) return []; + const ids = new Set(); + for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) ids.add(r.id); + return Array.from(ids).sort(); + } + function knownFields(kind) { + const st = S.state; + const f = new Set(); + if (st) for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) for (const x of r.fields || []) f.add(x.name); + if (kind === 'sdn-zone') ['bridge', 'nodes', 'peers', 'fabric', 'dns', 'reversedns', 'dnszone', 'mtu'].forEach((x) => f.add(x)); + return Array.from(f).sort(); + } + let dlN = 0; + function datalist(values) { + const id = 'dl' + (++dlN); + return [id, h('datalist', { id }, ...values.map((v) => h('option', { value: v })))]; + } + function mapRow(kind, id, field, value) { + const ks = kindSelect(kind); + const [idl, idList] = datalist(refIds(ks.value)); + const [fdl, fList] = datalist(knownFields(ks.value)); + const row = h('div', { class: 'erow map' }, ks, + h('input', { class: 'inp mono', list: idl, value: kind === 'datacenter-options' && !id ? 'datacenter' : id || '', placeholder: 'id', 'data-k': 'id' }), + h('input', { class: 'inp mono', list: fdl, value: field || '', placeholder: 'bridge', 'data-k': 'field' }), + h('input', { class: 'inp mono', value: value || '', placeholder: 'vmbr1', 'data-k': 'value' }), + iconBtn('trash', t('delete'), () => row.remove(), 'danger'), idList, fList); + ks.addEventListener('change', () => { + idList.replaceChildren(...refIds(ks.value).map((v) => h('option', { value: v }))); + fList.replaceChildren(...knownFields(ks.value).map((v) => h('option', { value: v }))); + if (ks.value === 'datacenter-options') $('[data-k="id"]', row).value = 'datacenter'; + }); + return row; + } + function polRow(kind, field, pol, onchange) { + const ks = kindSelect(kind); + const [fdl, fList] = datalist(knownFields(ks.value)); + const ps = h('select', { class: 'field', 'data-k': 'policy' }, ...POLICIES.map((p) => h('option', { value: p, text: p }))); + ps.value = pol || 'ignore'; + const row = h('div', { class: 'erow pol' }, ks, + h('input', { class: 'inp mono', list: fdl, value: field || '', placeholder: 'mtu', 'data-k': 'field' }), + ps, iconBtn('trash', t('delete'), () => { row.remove(); if (onchange) onchange(); }, 'danger'), fList); + ks.addEventListener('change', () => fList.replaceChildren(...knownFields(ks.value).map((v) => h('option', { value: v })))); + if (onchange) row.addEventListener('input', onchange); + if (onchange) row.addEventListener('change', onchange); + return row; + } + function readPolicies(container) { + const out = {}; + for (const row of $$('.erow.pol', container)) { + const k = $('select', row).value; + const f = $('[data-k="field"]', row).value.trim(); + if (!f) continue; + (out[k] = out[k] || {})[f] = $('[data-k="policy"]', row).value; + } + return out; + } + function parseValue(v) { + v = v.trim(); + if (v.startsWith('[') && v.endsWith(']')) { + try { const a = JSON.parse(v); if (Array.isArray(a)) return a.map(String); } catch (e) { /* plain text */ } + return v.slice(1, -1).split(',').map((x) => x.trim()).filter(Boolean); + } + return v; + } + + async function saveCluster(ev) { + ev.preventDefault(); + const tls = $('#cTLS').value; + const c = { + name: $('#cName').value.trim(), + reference: segVal('#cRole') === 'ref', + position: editing ? editing.position : set.clusters.length, + url: $('#cURL').value.trim(), + tokenId: $('#cToken').value.trim(), + secret: $('#cSecret').value.trim(), + hasSecret: false, + fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '', + caPem: tls === 'ca' ? $('#cCA').value.trim() : '', + insecure: tls === 'insecure', + timeout: $('#cTimeout').value.trim(), + mapping: {}, + policies: {}, + updatedAt: new Date().toISOString(), + }; + if (!c.reference) { + for (const row of $$('#cMapRows .erow')) { + const k = $('select', row).value; + const id = $('[data-k="id"]', row).value.trim(); + const f = $('[data-k="field"]', row).value.trim(); + if (!id || !f) continue; + ((c.mapping[k] = c.mapping[k] || {})[id] = c.mapping[k][id] || {})[f] = parseValue($('[data-k="value"]', row).value); + } + c.policies = readPolicies($('#cPolRows')); + } + if (!c.secret) delete c.secret; + try { + await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c); + $('#clusterDlg').close(); + toast(t('saved')); + loadClusters(); + loadState(true); + } catch (e) { + $('#cErr').textContent = e.message; + $('#cErr').hidden = false; + } + } + async function deleteCluster() { + if (!editing || !confirm(t('confirmDelete', { c: editing.name }))) return; + try { + await api('DELETE', 'api/clusters/' + encodeURIComponent(editing.name)); + $('#clusterDlg').close(); + loadClusters(); + loadState(true); + } catch (e) { + $('#cErr').textContent = e.message; + $('#cErr').hidden = false; + } + } + + // synchronization settings (Save / Cancel like Logstream) + async function loadSettings() { + try { + const d = await api('GET', 'api/settings'); + set.settings = d.settings; + set.defaults = d.defaults || {}; + } catch (e) { + if (e.message !== 'auth') toast(e.message, true); + return; + } + renderSettings(); + setDirty(false); + } + function renderSettings() { + const st = set.settings; + const chips = $('#kindChips'); + chips.replaceChildren(...KINDS.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) => { ev.currentTarget.classList.toggle('on'); ev.currentTarget.setAttribute('aria-pressed', String(ev.currentTarget.classList.contains('on'))); setDirty(true); }, + }, t('kinds.' + k)))); + $('#pruneSw').checked = !!st.prune; + const sel = $('#selRows'); + sel.replaceChildren(...KINDS.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] || ''))); + const pr = $('#polRows'); + pr.replaceChildren(); + for (const [k, fields] of Object.entries(st.policies || {})) { + for (const [f, p] of Object.entries(fields || {})) pr.appendChild(polRow(k, f, p, () => setDirty(true))); + } + } + function setDirty(on) { + set.dirty = on; + $('#setActions').classList.toggle('dirty', on); + $('#setSave').disabled = !on; + $('#setCancel').disabled = !on; + $('#setState').textContent = on ? t('unsaved') : ''; + } + async function saveSettings() { + const splitP = (v) => v.split(',').map((x) => x.trim()).filter(Boolean); + const objects = {}; + for (const row of $$('#selRows .erow')) { + const inc = splitP($('[data-k="include"]', row).value); + const exc = splitP($('[data-k="exclude"]', row).value); + if (inc.length || exc.length) objects[row.dataset.kind] = { include: inc, exclude: exc }; + } + const body = { + kinds: $$('#kindChips .chip.on').map((c) => c.dataset.kind), + objects, + policies: readPolicies($('#polRows')), + prune: $('#pruneSw').checked, + }; + try { + await api('PUT', 'api/settings', body); + setDirty(false); + $('#setState').textContent = t('saved'); + loadState(true); + } catch (e) { + $('#setState').textContent = t('saveFailed', { e: e.message }); + } + } + + // users + async function loadUsers() { + let users; + try { + users = (await api('GET', 'api/users')).users; + } catch (e) { + if (e.message !== 'auth') toast(e.message, true); + return; + } + $('#userRows').replaceChildren(...users.map((u) => userRow(u))); + } + function userRow(u) { + const isNew = !u; + u = u || { name: '', role: 'viewer' }; + const name = h('input', { class: 'inp mono', value: u.name, readonly: !isNew, placeholder: 'prenom.nom', autocomplete: 'off', 'data-k': 'name' }); + const role = h('select', { class: 'field' }, ...ROLES.map((r) => h('option', { value: r, text: t('role.' + r) }))); + role.value = u.role; + const pwd = h('input', { class: 'inp', type: 'password', autocomplete: 'new-password', placeholder: isNew ? t('pwdRequired') : t('pwdKeep') }); + const row = h('div', { class: 'erow user' }, name, role, pwd, + h('span', { class: 'muted small', text: u.lastLogin ? fmtTime(u.lastLogin, true) : t('never') }), + h('span', { class: 'acts' }, + iconBtn('check', t('save'), async () => { + try { + await api('PUT', 'api/users/' + encodeURIComponent(name.value.trim()), { role: role.value, password: pwd.value }); + toast(t('saved')); + loadUsers(); + } catch (e) { toast(e.message, true); } + }), + isNew ? iconBtn('x', t('cancel'), () => row.remove()) : iconBtn('trash', t('delete'), async () => { + if (!confirm(t('confirmDeleteUser', { u: u.name }))) return; + try { await api('DELETE', 'api/users/' + encodeURIComponent(u.name)); loadUsers(); } catch (e) { toast(e.message, true); } + }, 'danger'))); + return row; + } + + // import + async function importConfig() { + const res = $('#importRes'); + res.replaceChildren(); + try { + const d = await api('POST', 'api/config/import', { yaml: $('#importText').value, replace: $('#importReplace').checked }); + res.appendChild(note('info', 'OK', t('importOk'))); + for (const w of d.warnings || []) res.appendChild(note('warn', t('warning'), w)); + loadState(true); + } catch (e) { + res.appendChild(note('err', t('st.error'), e.message)); + } + } + + // account + function renderAccount() { + const me = S.me; + $('#accountInfo').replaceChildren( + h('dt', { class: 'lbl', text: t('accUser') }), h('dd', { class: 'mono', text: me.user }), + h('dt', { class: 'lbl', text: t('accRole') }), h('dd', { text: t('role.' + me.role) }), + h('dt', { class: 'lbl', text: t('accMode') }), h('dd', { text: me.mode === 'oidc' ? t('modeOidc') : t('modeLocal') })); + $('#pwdForm').hidden = me.mode !== 'local'; + let p = $('#pwdSection .oidc-note'); + if (me.mode !== 'local' && !p) $('#pwdSection').appendChild(h('p', { class: 'muted oidc-note', text: t('pwdOidc') })); + } + async function changePassword(ev) { + ev.preventDefault(); + if ($('#pwdNew').value !== $('#pwdNew2').value) { toast(t('pwdMismatch'), true); return; } + try { + await api('POST', 'api/account/password', { old: $('#pwdOld').value, new: $('#pwdNew').value }); + $('#pwdForm').reset(); + toast(t('pwdDone')); + } catch (e) { toast(e.message, true); } + } + + // ---------- events ---------- + function bind() { + $('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); }); + $('#settingsBtn').addEventListener('click', () => openSettings()); + $('#setupBtn').addEventListener('click', () => openSettings('clusters')); + $('#themeBtn').addEventListener('click', () => { + const root = document.documentElement; + const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; + setTheme(dark ? 'light' : 'dark'); + }); + $$('#viewMode [data-view]').forEach((b) => b.addEventListener('click', () => setView(b.dataset.view))); + $('#kindSel').addEventListener('change', (e) => { S.kind = e.target.value; e.target.classList.toggle('set', !!S.kind); renderClient(); }); + $('#statusSel').addEventListener('change', (e) => { S.status = e.target.value; store.set('status', S.status); e.target.classList.toggle('set', S.status !== 'diff'); renderClient(); }); + $('#runModeSel').addEventListener('change', (e) => { S.runMode = e.target.value; renderRuns(); }); + let qTimer; + $('#q').addEventListener('input', (e) => { + clearTimeout(qTimer); + qTimer = setTimeout(() => { S.q = e.target.value.trim(); renderView(); }, 120); + }); + $('#dryAllBtn').addEventListener('click', () => openPlan({ scope: 'global' })); + $('#syncAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }, true)); + $('#planApply').addEventListener('click', applyPlan); + $('#runMore').addEventListener('click', () => loadRuns(false)); + $('#auditMore').addEventListener('click', () => loadAudit(false)); + $$('dialog [data-close]').forEach((b) => b.addEventListener('click', () => b.closest('dialog').close())); + $('#planDlg').addEventListener('close', () => clearInterval(plan.poll)); + + // settings + $$('#setNav [data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab))); + $('#addCluster').addEventListener('click', () => editCluster(null)); + $('#clusterForm').addEventListener('submit', saveCluster); + $('#cDelete').addEventListener('click', deleteCluster); + $('#cTLS').addEventListener('change', tlsFields); + $$('#cRole [data-v]').forEach((b) => b.addEventListener('click', () => { setSeg('#cRole', b.dataset.v); roleFields(); })); + $('#cMapAdd').addEventListener('click', () => $('#cMapRows').appendChild(mapRow('sdn-zone', '', '', ''))); + $('#cPolAdd').addEventListener('click', () => $('#cPolRows').appendChild(polRow('sdn-zone', '', 'ignore'))); + $('#polAdd').addEventListener('click', () => { $('#polRows').appendChild(polRow('datacenter-options', '', 'sync', () => setDirty(true))); setDirty(true); }); + $('#pruneSw').addEventListener('change', () => setDirty(true)); + $('#setSave').addEventListener('click', saveSettings); + $('#setCancel').addEventListener('click', () => { renderSettings(); setDirty(false); }); + $('#userAdd').addEventListener('click', () => { const r = userRow(null); $('#userRows').appendChild(r); $('input', r).focus(); }); + $('#importPick').addEventListener('click', () => $('#importFile').click()); + $('#importFile').addEventListener('change', async (e) => { + const f = e.target.files[0]; + if (f) $('#importText').value = await f.text(); + }); + $('#importBtn').addEventListener('click', importConfig); + $('#pwdForm').addEventListener('submit', changePassword); + $$('#langSwitch [data-lang]').forEach((b) => b.addEventListener('click', () => { lang = b.dataset.lang; store.set('lang', lang); applyLang(); })); + $$('#themeSwitch [data-theme-opt]').forEach((b) => b.addEventListener('click', () => setTheme(b.dataset.themeOpt))); + $$('#densitySwitch [data-density]').forEach((b) => b.addEventListener('click', () => setDensity(b.dataset.density))); + + // sticky table headers under the top bar; "back to top" + const top = () => document.documentElement.style.setProperty('--topbar-h', $('.topbar').offsetHeight + 'px'); + window.addEventListener('resize', top); + top(); + window.addEventListener('scroll', () => { $('#toTop').hidden = window.scrollY < 400; }, { passive: true }); + $('#toTop').addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' })); + window.addEventListener('hashchange', () => { readHash(); renderView(); }); + document.addEventListener('keydown', (e) => { + if (e.key === '/' && !e.target.closest('input, textarea, select, dialog')) { e.preventDefault(); $('#q').focus(); } + }); + } + + async function init() { + bind(); + setDensity(store.get('density') || 'normal'); + try { + S.me = await api('GET', 'auth/me'); + } catch (e) { + if (e.message !== 'auth') showError(e.message); + return; + } + const w = $('#whoami'); + w.hidden = false; + $('.who-name', w).textContent = S.me.user; + const badge = $('.who-badge', w); + badge.className = 'who-badge ' + S.me.role; + badge.textContent = t('role.' + S.me.role) + (S.me.mode === 'oidc' ? ' · OIDC' : ''); + $('#demoBadge').hidden = !S.me.demo; + $('#auditTab').hidden = !can.admin(); + $('#ver').textContent = 'pve-confsync ' + S.me.version; + readHash(); + applyLang(); + await loadState(false); + if (S.view === 'runs') loadRuns(true); + if (S.view === 'audit') loadAudit(true); + } + init(); +})(); diff --git a/web/boot.js b/web/boot.js new file mode 100644 index 0000000..0b164a3 --- /dev/null +++ b/web/boot.js @@ -0,0 +1,10 @@ +// Saved theme, language and density applied before the first paint (no flash). +// External file: the Content-Security-Policy forbids inline scripts. +try { + var t = localStorage.getItem('confsync.theme'); + if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; + var l = localStorage.getItem('confsync.lang'); + if (l) document.documentElement.lang = l; + var d = localStorage.getItem('confsync.density'); + if (d === 'compact') document.documentElement.dataset.density = d; +} catch (e) {} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..c78bc7f --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +// Package web holds the pages of the web UI, embedded in the binary. +package web + +import "embed" + +// FS is index.html, login.html, style.css, boot.js, app.js and favicon.svg. +// +//go:embed *.html *.css *.js *.svg +var FS embed.FS diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..197ca9f --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c4345d1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,419 @@ + + + + + + pve-confsync + + + + + +
+
+ + pve-confsync + +
+ + + +
+ + + + + + + + +
+ +
+ +
+
+ + + +
+ + + + + + +
+ + + + + +
+
+ +
+ + +
+ + + + + + + +
+ + + + + +
+ + + +
+
+

+ +
+
+
+ + + + +
+
+
+ + + +
+
+

+ +
+
+
+ + + Rôle +
+ + +
+ + + + + + +

+ TLS + + + + + + + +
+ +
+

Mapping (valeurs propres au client)

+

+
TypeObjetChampValeur
+
+
+ +

Politiques propres au client

+

+
TypeChampPolitique
+
+
+
+ +
+
+ + + + +
+
+
+ + +
+
+

Paramètres

+ +
+
+
+ + +
+ +
+ +
+ +
+
+

Clusters

+ +
+

+
+
+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + + diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..592cd82 --- /dev/null +++ b/web/login.html @@ -0,0 +1,47 @@ + + + + + + pve-confsync + + + + + + + +
+
+ + pve-confsync +
+

Synchronisation de configuration Proxmox VE

+ + + + + + +
+ + + + diff --git a/web/login.js b/web/login.js new file mode 100644 index 0000000..559a1ee --- /dev/null +++ b/web/login.js @@ -0,0 +1,67 @@ +// 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', + 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}.' }, + en: { intro: 'Proxmox VE configuration synchronization', 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}.' }, + }; + function get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } } + function set(k, v) { try { localStorage.setItem('confsync.' + k, v); } catch (e) {} } + var lang = get('lang'); + if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr'; + var cfg = { local: true, oidc: false, demo: false }; + + function el(id) { return document.getElementById(id); } + function code(s) { var c = document.createElement('code'); c.textContent = s; return c; } + function applyLang() { + var d = I18N[lang]; + document.documentElement.lang = lang; + document.querySelectorAll('[data-i18n]').forEach(function (e) { e.textContent = d[e.dataset.i18n]; }); + document.querySelectorAll('[data-i18n-title]').forEach(function (e) { e.title = d[e.dataset.i18nTitle]; }); + document.querySelectorAll('[data-i18n-aria]').forEach(function (e) { e.setAttribute('aria-label', d[e.dataset.i18nAria]); }); + document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); }); + var h = el('demoHint'); + h.textContent = ''; + if (cfg.demo) { + // "{a}" placeholders become elements, never HTML from a string + d.demo.split(/(\{[aolp]\})/).forEach(function (part) { + var v = { '{a}': 'admin', '{o}': 'operateur', '{l}': 'lecteur', '{p}': 'confsync-demo' }[part]; + h.appendChild(v ? code(v) : document.createTextNode(part)); + }); + } + } + document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { + b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); }); + }); + el('themeBtn').addEventListener('click', function () { + var root = document.documentElement; + var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; + root.dataset.theme = dark ? 'light' : 'dark'; + set('theme', root.dataset.theme); + }); + + var q = new URLSearchParams(location.search); + var ret = q.get('r') || '/'; + el('ret').value = ret; + el('err').hidden = q.get('e') !== '1'; + el('ssoBtn').href = 'auth/oidc?r=' + encodeURIComponent(ret); + + function show() { + el('localForm').hidden = !cfg.local; + el('ssoBtn').hidden = !cfg.oidc; + el('sep').hidden = !(cfg.local && cfg.oidc); + el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary'; + el('demoHint').hidden = !cfg.demo; + applyLang(); + } + show(); + fetch('auth/config', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.json(); }) + .then(function (c) { cfg = c; show(); }) + .catch(function () {}); +})(); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..c7a99fe --- /dev/null +++ b/web/style.css @@ -0,0 +1,690 @@ +/* pve-confsync: same look and feel as Logstream (variables, top bar, filters, + lists with a colored edge, settings dialog with a side menu, login page). */ + +/* ---------- Theme: everything goes through these variables ---------- */ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --panel-2: #f0f2f5; + --border: #e2e5ea; + --text: #1a1d23; + --muted: #6b7280; + --accent: #6366f1; + --accent-soft: rgba(99, 102, 241, .12); + --row-hover: #f5f6fa; + --shadow: 0 10px 30px rgba(15, 23, 42, .12); + + --err: #dc2626; + --warn: #d97706; + --ok: #0d9488; + --info: #2563eb; + --debug: #9ca3af; + + /* change badges: create, update, delete, kept */ + --op-create: #16a34a; + --op-update: #d97706; + --op-delete: #dc2626; + --op-extra: #64748b; + --op-same: #94a3b8; + + /* object kinds */ + --k-dc: #7c4dbd; + --k-zone: #1d6fd6; + --k-vnet: #1f9aa3; + --k-subnet: #5f8a1f; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --radius: 10px; + --row-size: 13px; + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #0f1115; + --panel: #161a20; + --panel-2: #1d222a; + --border: #2a3039; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #818cf8; + --accent-soft: rgba(129, 140, 248, .16); + --row-hover: #1a1f26; + --shadow: 0 10px 30px rgba(0, 0, 0, .5); + --err: #f87171; + --warn: #fbbf24; + --ok: #2dd4bf; + --info: #60a5fa; + --debug: #6b7280; + --op-create: #4ade80; + --op-update: #fbbf24; + --op-delete: #f87171; + --op-extra: #94a3b8; + --op-same: #64748b; + --k-dc: #a983e6; + --k-zone: #5aa2f5; + --k-vnet: #3cc3cc; + --k-subnet: #9ccc54; + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f1115; + --panel: #161a20; + --panel-2: #1d222a; + --border: #2a3039; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #818cf8; + --accent-soft: rgba(129, 140, 248, .16); + --row-hover: #1a1f26; + --shadow: 0 10px 30px rgba(0, 0, 0, .5); + --err: #f87171; + --warn: #fbbf24; + --ok: #2dd4bf; + --info: #60a5fa; + --debug: #6b7280; + --op-create: #4ade80; + --op-update: #fbbf24; + --op-delete: #f87171; + --op-extra: #94a3b8; + --op-same: #64748b; + --k-dc: #a983e6; + --k-zone: #5aa2f5; + --k-vnet: #3cc3cc; + --k-subnet: #9ccc54; + color-scheme: dark; + } +} + +/* ---------- Base ---------- */ +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 var(--sans); + padding-bottom: 44px; +} +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } +svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.muted { color: var(--muted); } +.small { font-size: 12.5px; } +.mono { font-family: var(--mono); } +[hidden] { display: none !important; } +code { font-family: var(--mono); font-size: .92em; } + +/* ---------- Top bar ---------- */ +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 16px; + padding: 10px 20px; + background: color-mix(in srgb, var(--panel) 88%, transparent); + backdrop-filter: saturate(160%) blur(10px); + -webkit-backdrop-filter: saturate(160%) blur(10px); + border-bottom: 1px solid var(--border); +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 16px; letter-spacing: -.01em; white-space: nowrap; } +.brand svg { width: 26px; height: 26px; } +.brand svg rect { fill: var(--accent); stroke: none; } +.brand svg path { stroke: #fff; stroke-width: 2.6; } +.demo-badge { + padding: 1px 7px; border-radius: 6px; font-size: 10.5px; font-weight: 700; letter-spacing: .04em; + color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warn) 45%, transparent); +} + +.search { + flex: 1; max-width: 560px; + display: flex; align-items: center; gap: 8px; + padding: 0 6px 0 12px; height: 38px; + background: var(--panel-2); + border: 1px solid transparent; + border-radius: var(--radius); + transition: border-color .15s, box-shadow .15s; +} +.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search .ico { width: 17px; height: 17px; color: var(--muted); flex: none; } +.search input { + flex: 1; min-width: 0; height: 100%; + border: 0; outline: 0; background: transparent; + font-family: var(--mono); font-size: 13px; +} + +.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } +.whoami { display: flex; align-items: center; gap: 6px; min-width: 0; margin-left: 6px; font-size: 13px; color: var(--muted); } +.whoami .who-name { font-weight: 600; color: var(--text); max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.whoami .who-badge { + flex: none; border: 1px solid var(--border); border-radius: 6px; + padding: 1px 6px; font-size: 10.5px; font-weight: 650; letter-spacing: .02em; +} +.whoami .who-badge.admin { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.whoami .who-badge.operator { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 60%, var(--border)); } +.icon-btn { + width: 36px; height: 36px; display: grid; place-items: center; flex: none; + border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted); +} +.icon-btn:hover { background: var(--panel-2); color: var(--text); } +.icon-btn svg { width: 19px; height: 19px; } +.icon-btn.danger:hover { color: var(--err); } +.icon-btn:disabled { opacity: .4; cursor: not-allowed; } + +#themeBtn .moon { display: none; } +:root[data-theme="dark"] #themeBtn .sun { display: none; } +:root[data-theme="dark"] #themeBtn .moon { display: block; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) #themeBtn .sun { display: none; } + :root:not([data-theme="light"]) #themeBtn .moon { display: block; } +} + +.btn { + display: inline-flex; align-items: center; gap: 7px; + height: 34px; padding: 0 12px; + border: 1px solid var(--border); border-radius: 9px; + background: var(--panel); font-weight: 550; font-size: 13px; white-space: nowrap; +} +.btn:hover { background: var(--panel-2); } +.btn svg { width: 16px; height: 16px; flex: none; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); } +.btn.ghost:hover { color: var(--text); background: var(--panel-2); } +.btn.tool { height: 32px; padding: 0 10px; } +.btn.sync { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: var(--accent-soft); } +.btn.sync:hover:not(:disabled) { background: var(--accent); color: #fff; } +.btn.danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 45%, var(--border)); } +.btn.danger:hover:not(:disabled) { background: var(--err); border-color: var(--err); color: #fff; } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn.spin svg { animation: rot 1s linear infinite; } +@keyframes rot { to { transform: rotate(360deg); } } +.link-btn { border: 0; background: none; padding: 0; font-size: 12.5px; font-weight: 550; color: var(--accent); white-space: nowrap; } +.link-btn:hover { text-decoration: underline; } + +.progress { position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; overflow: hidden; } +body.busy .progress::after { + content: ""; position: absolute; inset: 0; width: 30%; + background: var(--accent); + animation: slide 1s ease-in-out infinite; +} +@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(400%); } } + +/* ---------- Filters ---------- */ +.filters { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + padding: 12px 20px 8px; +} +.filters select { + height: 32px; padding: 0 28px 0 10px; + border: 1px solid var(--border); border-radius: 8px; + background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b93a1' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 8px center / 14px; + appearance: none; -webkit-appearance: none; + font-size: 13px; max-width: 220px; +} +.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); } +.view-mode { padding: 2px; gap: 2px; border-radius: 9px; } +.view-mode button { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; } +.view-mode svg { width: 15px; height: 15px; } +.spacer { flex: 1; } +#count { font-size: 12.5px; font-variant-numeric: tabular-nums; } +.global-acts { display: flex; gap: 6px; } + +.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } +.seg button { + border: 0; background: transparent; border-radius: 7px; + height: 30px; padding: 0 14px; font-size: 13px; font-weight: 600; color: var(--muted); +} +.seg button:hover { color: var(--text); } +.seg button[aria-checked="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); } + +/* ---------- Cluster strip ---------- */ +.strip { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; + margin: 6px 20px 0; +} +.ccard { + position: relative; display: flex; flex-direction: column; gap: 4px; min-width: 0; + padding: 10px 12px 10px 14px; text-align: left; + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + border-left: 3px solid var(--st, var(--border)); +} +button.ccard:hover { background: var(--row-hover); } +.ccard[aria-selected="true"] { border-color: var(--accent); border-left-color: var(--st, var(--accent)); box-shadow: 0 0 0 3px var(--accent-soft); } +.ccard.ref { --st: var(--accent); background: color-mix(in srgb, var(--accent) 5%, var(--panel)); cursor: default; } +.ccard.st-ok { --st: var(--ok); } +.ccard.st-diff { --st: var(--warn); } +.ccard.st-error { --st: var(--err); } +.ccard .c-head { display: flex; align-items: center; gap: 8px; min-width: 0; } +.ccard .c-name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ccard .c-role { + flex: none; padding: 0 5px; border-radius: 5px; font-size: 10px; font-weight: 700; letter-spacing: .04em; + color: var(--accent); background: var(--accent-soft); +} +.ccard .c-ver { margin-left: auto; flex: none; font: 11.5px var(--mono); color: var(--muted); } +.ccard .c-url { font: 11.5px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ccard .c-counts { display: flex; flex-wrap: wrap; gap: 4px; min-height: 20px; align-items: center; } +.ccard .c-err { font-size: 12px; color: var(--err); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.cnt { + display: inline-flex; align-items: center; gap: 3px; padding: 0 6px; border-radius: 5px; + font: 700 11px/1.6 var(--mono); color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); +} +.cnt.create { --c: var(--op-create); } +.cnt.update { --c: var(--op-update); } +.cnt.delete { --c: var(--op-delete); } +.cnt.error { --c: var(--err); } +.cnt.extra { --c: var(--op-extra); } +.cnt.ok { --c: var(--ok); } + +/* ---------- Selected client ---------- */ +.client-bar { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; + margin: 14px 20px 0; +} +.client-bar h2 { margin: 0; font-size: 16px; font-weight: 650; } +.client-bar .meta { font-size: 12.5px; color: var(--muted); } +.client-bar .acts { margin-left: auto; display: flex; gap: 6px; } +.notes { margin: 8px 20px 0; display: flex; flex-direction: column; gap: 6px; } +.note { + display: flex; gap: 8px; align-items: baseline; + padding: 7px 12px; border-radius: var(--radius); font-size: 12.5px; + border: 1px solid color-mix(in srgb, var(--c) 40%, transparent); + background: color-mix(in srgb, var(--c) 9%, transparent); +} +.note b { color: var(--c); font-size: 11px; letter-spacing: .03em; text-transform: uppercase; flex: none; } +.note.err { --c: var(--err); } +.note.warn { --c: var(--warn); } +.note.info { --c: var(--info); } + +/* ---------- Lists (objects, journal, audit) ---------- */ +.table { + display: grid; column-gap: 12px; + margin: 8px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + overflow: clip; +} +.table.objects { grid-template-columns: 5.6rem minmax(8rem, max-content) 6.4rem minmax(0, 1fr) max-content; } +.table.runs { grid-template-columns: max-content minmax(5rem, max-content) 5.6rem 5.2rem minmax(6rem, max-content) 6rem minmax(0, 1fr); } +.table.audit { grid-template-columns: max-content minmax(5rem, max-content) minmax(5rem, max-content) 9rem minmax(6rem, max-content) 3.2rem minmax(0, 1fr); } +.table > .list { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; } +.list-head { + display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; + position: sticky; top: var(--topbar-h, 0px); z-index: 5; + padding: 0 14px 0 11px; border-left: 3px solid transparent; + background: var(--panel-2); border-bottom: 1px solid var(--border); + font-size: 10.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); +} +.list-head > span { min-width: 0; padding: 5px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row { + display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; + align-items: center; + padding: 5px 14px 5px 11px; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--border); + font-size: var(--row-size); line-height: 1.35; + cursor: pointer; +} +:root[data-density="compact"] .row { padding-top: 2px; padding-bottom: 2px; } +.row:last-child { border-bottom: 0; } +.row:hover { background: var(--row-hover); } +.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.row > * { min-width: 0; } +.row.open { background: var(--row-hover); } +.row .id { font-family: var(--mono); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .sum { font-family: var(--mono); font-size: .92em; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .sum .f-add { color: var(--op-create); } +.row .sum .f-set { color: var(--op-update); } +.row .sum .f-unset { color: var(--op-delete); } +.row time { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: .92em; } +.row .user { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .acts { display: flex; gap: 2px; justify-content: flex-end; } +.row .acts .icon-btn { width: 28px; height: 28px; border-radius: 7px; } +.row .acts .icon-btn svg { width: 16px; height: 16px; } +.row .acts .icon-btn.go { color: var(--accent); } + +.row.st-create { border-left-color: var(--op-create); } +.row.st-update { border-left-color: var(--op-update); } +.row.st-delete { border-left-color: var(--op-delete); background: color-mix(in srgb, var(--op-delete) 5%, transparent); } +.row.st-error { border-left-color: var(--err); background: color-mix(in srgb, var(--err) 6%, transparent); } +.row.st-same .id { font-weight: 500; color: var(--muted); } +.row.st-extra .id { font-style: italic; } + +/* kind badge, fixed width, one color per kind */ +.kind { + justify-self: start; box-sizing: content-box; width: 4.6rem; padding: 0 4px; border-radius: 4px; text-align: center; + font: 700 10px/1.5 var(--mono); letter-spacing: .02em; + color: var(--k); background: color-mix(in srgb, var(--k) 15%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--k) 40%, transparent); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.kind.datacenter-options { --k: var(--k-dc); } +.kind.sdn-zone { --k: var(--k-zone); } +.kind.sdn-vnet { --k: var(--k-vnet); } +.kind.sdn-subnet { --k: var(--k-subnet); } + +/* status / mode badges */ +.badge { + justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; + padding: 0 6px; border-radius: 5px; line-height: 1.6; + color: var(--c, var(--muted)); background: color-mix(in srgb, var(--c, var(--muted)) 14%, transparent); +} +.badge.create { --c: var(--op-create); } +.badge.update { --c: var(--op-update); } +.badge.delete { --c: var(--op-delete); } +.badge.extra { --c: var(--op-extra); } +.badge.same { --c: var(--op-same); } +.badge.error, .badge.blocked, .badge.ko { --c: var(--err); } +.badge.ok { --c: var(--ok); } +.badge.noop { --c: var(--op-same); } +.badge.running, .badge.pending { --c: var(--info); } +.badge.apply { --c: var(--accent); background: var(--accent); color: #fff; } +.badge.dry-run { --c: var(--accent); } +.badge.sdn { --c: var(--k-zone); margin-left: 4px; } +.badge.running::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: currentColor; vertical-align: 1px; animation: blink 1s infinite; } +@keyframes blink { 50% { opacity: .25; } } + +/* expanded row */ +.details { + grid-column: 1 / -1; + margin: 8px 0 6px; padding: 10px 12px; + background: var(--panel-2); border-radius: 8px; + cursor: auto; font-size: 12.5px; +} +.details .errs { margin: 0 0 8px; color: var(--err); font-family: var(--mono); font-size: 12px; white-space: pre-wrap; } +.fields { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; } +.fields th { + text-align: left; font: 650 10.5px var(--sans); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); + padding: 2px 10px 4px 0; border-bottom: 1px solid var(--border); +} +.fields td { padding: 3px 10px 3px 0; vertical-align: top; word-break: break-all; border-bottom: 1px dashed color-mix(in srgb, var(--border) 70%, transparent); } +.fields tr:last-child td { border-bottom: 0; } +.fields td.name { font-weight: 650; white-space: nowrap; word-break: normal; } +.fields td.none { color: var(--muted); opacity: .6; } +.fields tr.ch-add td.target, .fields tr.ch-set td.target { color: var(--op-create); font-weight: 650; } +.fields tr.ch-set td.cur, .fields tr.ch-unset td.cur { color: var(--op-delete); text-decoration: line-through; text-decoration-thickness: 1px; } +.fields tr.ch-unset td.target { color: var(--op-delete); } +.fields tr.pol-ignore { opacity: .55; } +.pol { + display: inline-block; padding: 0 5px; border-radius: 4px; font: 600 10.5px/1.5 var(--sans); + color: var(--muted); border: 1px solid var(--border); +} +.pol.sync { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, var(--border)); } +.pol.override { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } +.details .acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.details .acts .btn { height: 28px; font-size: 12px; } +.steps { margin: 6px 0 0; padding: 0; list-style: none; font-family: var(--mono); font-size: 12px; } +.steps li { display: grid; grid-template-columns: max-content max-content minmax(0, 1fr); gap: 10px; padding: 2px 0; } +.steps li .ok { color: var(--ok); } +.steps li .ko { color: var(--err); } +.steps li .e { grid-column: 3; color: var(--err); white-space: pre-wrap; word-break: break-word; } +.run-client { margin-top: 10px; } +.run-client:first-child { margin-top: 0; } +.run-client h4 { margin: 0 0 4px; font-size: 13px; display: flex; gap: 8px; align-items: center; } + +/* plan (dry-run) */ +.plan { margin: 0; padding: 0; list-style: none; font-family: var(--mono); font-size: 12.5px; } +.plan > li { padding: 6px 0; border-bottom: 1px solid var(--border); } +.plan > li:last-child { border-bottom: 0; } +.plan .ph { display: flex; align-items: center; gap: 8px; } +.plan .pf { margin: 3px 0 0 1.2rem; padding: 0; list-style: none; color: var(--muted); } +.plan .pf .add { color: var(--op-create); } +.plan .pf .set { color: var(--op-update); } +.plan .pf .unset { color: var(--op-delete); } +.plan .pf del { color: var(--op-delete); } +.plan .pf ins { color: var(--op-create); text-decoration: none; font-weight: 650; } + +/* ---------- States ---------- */ +.empty { text-align: center; padding: 50px 20px; } +.empty svg { width: 40px; height: 40px; color: var(--muted); opacity: .6; } +.empty p { margin: 6px 0; } +.error-banner { + margin: 6px 20px 0; padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--err) 40%, transparent); + background: color-mix(in srgb, var(--err) 10%, transparent); + color: var(--err); border-radius: var(--radius); + font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; +} +.setup { + display: flex; flex-wrap: wrap; align-items: center; gap: 10px; + margin: 10px 20px 0; padding: 14px 16px; + border: 1px dashed color-mix(in srgb, var(--accent) 50%, var(--border)); border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 6%, transparent); +} +.setup p { margin: 0; flex: 1 1 300px; } +.ro-note { + margin: 0 0 12px; padding: 8px 12px; border-radius: var(--radius); font-size: 12.5px; + background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); +} +.status { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; + display: flex; gap: 16px; align-items: center; + padding: 6px 20px; font-size: 12px; + background: var(--panel); border-top: 1px solid var(--border); + font-variant-numeric: tabular-nums; +} +.conn { white-space: nowrap; } +.conn::before { content: "●"; margin-right: 6px; color: var(--muted); } +.conn.ok::before { color: var(--ok); } +.conn.ko::before { color: var(--err); } +.status .sp { flex: 1; } +.to-top { width: 24px; height: 24px; margin: -5px -6px -5px 0; } +.to-top svg { width: 16px; height: 16px; } +.toast { + position: fixed; bottom: 48px; left: 50%; transform: translateX(-50%); z-index: 60; + background: var(--text); color: var(--bg); + padding: 8px 14px; border-radius: 8px; font-size: 13px; box-shadow: var(--shadow); + max-width: calc(100vw - 32px); +} +.toast.bad { background: var(--err); color: #fff; } +.more-row { display: flex; justify-content: center; padding: 10px; } + +/* ---------- Dialogs ---------- */ +dialog { + width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 64px); + padding: 0; border: 1px solid var(--border); border-radius: 14px; + background: var(--panel); color: var(--text); box-shadow: var(--shadow); +} +dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } +.dlg { display: flex; flex-direction: column; max-height: calc(100vh - 66px); } +.dlg > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 16px 12px 20px; border-bottom: 1px solid var(--border); } +.dlg h2 { margin: 0; font-size: 17px; } +.dlg .dlg-body { padding: 14px 20px; overflow-y: auto; min-height: 0; } +.dlg > footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); } +.dlg > footer .sp { flex: 1; } +.confirm-box { + display: flex; flex-direction: column; gap: 6px; margin-top: 12px; padding: 10px 12px; border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 10%, transparent); + font-size: 13px; +} + +/* settings dialog (side menu, sections as cards) */ +dialog.settings { width: min(1280px, calc(100vw - 32px)); height: calc(100vh - 48px); max-height: 960px; } +dialog.settings .dlg { height: 100%; max-height: none; } +.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 15rem minmax(0, 1fr); } +.set-side { display: flex; flex-direction: column; min-height: 0; overflow-y: auto; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--panel-2) 50%, transparent); } +.set-nav { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px; } +.set-nav button { + display: flex; align-items: center; gap: 10px; + height: 38px; padding: 0 12px; border: 0; border-radius: 9px; + background: transparent; color: var(--muted); font-weight: 560; font-size: 13.5px; text-align: left; white-space: nowrap; +} +.set-nav button:hover { background: var(--panel-2); color: var(--text); } +.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); } +.set-nav svg { width: 18px; height: 18px; flex: none; } +.set-actions { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 10px 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.set-actions .btn { flex: 1; justify-content: center; } +.set-actions .btn.save { color: var(--muted); } +.set-actions.dirty .btn.save { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 0 0 3px var(--accent-soft); } +.set-state { flex-basis: 100%; margin: 2px 2px 0; font-size: 12px; color: var(--muted); } +.set-actions.dirty .set-state { color: var(--accent); font-weight: 550; } +.set-panels { overflow-y: auto; padding: 16px 20px 20px; } +.set-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 14px; align-items: start; } +.set-panel[hidden] { display: none; } +.set-panel.wide { grid-template-columns: minmax(0, 1fr); } +.set-section { + margin: 0; padding: 14px 16px; min-width: 0; + border: 1px solid var(--border); border-radius: 12px; + background: color-mix(in srgb, var(--panel-2) 35%, var(--panel)); +} +.set-section.span { grid-column: 1 / -1; } +.set-panel > .ro-note { grid-column: 1 / -1; margin: 0; } +.set-section h3 { margin: 0 0 8px; font-size: 13px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.set-section.danger h3 { color: var(--err); } +.set-section p { margin: 0 0 8px; } +.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.read-only .set-section[inert] { opacity: .6; } + +.field-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 12px; align-items: center; } +.field-grid label, .lbl { font-size: 13px; font-weight: 550; } +.field-grid .hint { grid-column: 2; margin: -4px 0 2px; } +.inp, select.field, textarea.inp { + height: 32px; padding: 0 9px; min-width: 0; width: 100%; + border: 1px solid var(--border); border-radius: 8px; background: var(--panel); font-size: 13px; outline: 0; +} +.inp.mono, textarea.inp { font-family: var(--mono); font-size: 12.5px; } +textarea.inp { height: auto; min-height: 140px; padding: 8px 9px; resize: vertical; line-height: 1.4; } +select.field { + padding-right: 30px; appearance: none; -webkit-appearance: none; + background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b93a1' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 8px center / 14px; +} +.inp:focus, select.field:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.inp.bad { border-color: var(--err); } + +/* editable rows (mappings, policies, users, clusters) */ +.erows { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } +.erow { + display: grid; align-items: center; gap: 6px; + padding: 4px 4px 4px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); +} +.erow.map { grid-template-columns: 10rem minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(8rem, 2fr) 28px; } +.erow.pol { grid-template-columns: 10rem minmax(6rem, 1fr) 8rem 28px; } +.erow.sel { grid-template-columns: 10rem minmax(6rem, 1fr) minmax(6rem, 1fr); } +.erow.user { grid-template-columns: minmax(6rem, 1fr) 9rem minmax(8rem, 1fr) 9rem 60px; } +.erow .inp, .erow select.field { height: 28px; } +.erow .icon-btn { width: 28px; height: 28px; border-radius: 7px; } +.erow .icon-btn svg { width: 15px; height: 15px; } +.erow-head { display: grid; gap: 6px; padding: 0 4px 0 9px; font-size: 10.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); } +.erow-head.map { grid-template-columns: 10rem minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(8rem, 2fr) 28px; } +.erow-head.pol { grid-template-columns: 10rem minmax(6rem, 1fr) 8rem 28px; } +.erow-head.sel { grid-template-columns: 10rem minmax(6rem, 1fr) minmax(6rem, 1fr); } +.erow-head.user { grid-template-columns: minmax(6rem, 1fr) 9rem minmax(8rem, 1fr) 9rem 60px; } +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } + +/* cluster list in the settings */ +.clist { display: flex; flex-direction: column; gap: 6px; } +.citem { + display: grid; grid-template-columns: minmax(8rem, max-content) minmax(0, 1fr) max-content; align-items: center; gap: 4px 12px; + padding: 8px 8px 8px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); + border-left: 3px solid var(--border); +} +.citem.ref { border-left-color: var(--accent); } +.citem .n { font-weight: 650; display: flex; gap: 8px; align-items: center; } +.citem .u { font: 12px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.citem .acts { display: flex; gap: 4px; } +.citem .chk { grid-column: 1 / -1; font-size: 12px; } +.citem .chk.ok { color: var(--ok); } +.citem .chk.ko { color: var(--err); } +.citem .chk.warn { color: var(--warn); } + +.chip-field { display: flex; flex-wrap: wrap; gap: 5px; } +.chip { + display: inline-flex; align-items: center; gap: 4px; + height: 26px; padding: 0 9px; + border: 1px solid var(--border); border-radius: 6px; background: transparent; + color: var(--muted); font: 600 12px/1 var(--mono); white-space: nowrap; cursor: pointer; +} +.chip:hover { color: var(--text); border-color: var(--muted); } +.chip.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } + +.switch-row { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 550; cursor: pointer; } +input.switch { + appearance: none; -webkit-appearance: none; flex: none; + width: 34px; height: 20px; margin: 0; border-radius: 999px; + background: var(--border); position: relative; cursor: pointer; transition: background .15s; +} +input.switch::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; + background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .15s; +} +input.switch:checked { background: var(--accent); } +input.switch:checked::after { transform: translateX(14px); } +input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ---------- Mobile ---------- */ +@media (max-width: 760px) { + body { font-size: 13.5px; } + .topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; } + .search { order: 3; flex-basis: 100%; max-width: none; } + #refreshBtn .lbl { display: none; } + .whoami .who-name { max-width: 12ch; } + .filters { padding: 10px 16px 6px; } + .filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; } + .view-mode { flex: 1 1 100%; } + .view-mode button { flex: 1; justify-content: center; } + .spacer { display: none; } + .global-acts { flex: 1 1 100%; } + .global-acts .btn { flex: 1; justify-content: center; } + .strip, .client-bar, .notes, .table, .error-banner, .setup { margin-left: 16px; margin-right: 16px; } + .strip { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } + .client-bar .acts { margin-left: 0; flex: 1 1 100%; } + .client-bar .acts .btn { flex: 1; justify-content: center; } + .table { display: block; } + .table > .list { display: block; } + .list-head { display: none; } + .row { display: grid; grid-template-columns: auto 1fr auto; gap: 3px 8px; padding: 8px 12px 8px 10px; } + .table.objects .row .sum { grid-column: 1 / -1; } + .table.runs .row, .table.audit .row { grid-template-columns: auto auto 1fr; } + .table.runs .row .txt, .table.audit .row .txt { grid-column: 1 / -1; white-space: normal; } + .details { grid-column: 1 / -1; overflow-x: auto; } + #stats, #ver { display: none; } + .field-grid { grid-template-columns: 1fr; gap: 4px; } + .field-grid .hint { grid-column: 1; } + dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); } + .set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .set-side { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; } + .set-nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 6px; } + .set-nav button { flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px; font-size: 11.5px; } + .set-actions { margin: 0 6px 6px; padding-top: 6px; } + .set-panels { padding: 12px 12px 18px; } + .set-panel { grid-template-columns: minmax(0, 1fr); gap: 10px; } + .erow.map, .erow-head.map { grid-template-columns: 1fr 1fr 28px; } + .erow.map > :nth-child(4) { grid-column: 1 / 3; } + .erow.pol, .erow-head.pol { grid-template-columns: 1fr 1fr 28px; } + .erow.user, .erow-head.user { grid-template-columns: 1fr 1fr 28px; } + .erow-head { display: none; } + .citem { grid-template-columns: 1fr max-content; } + .citem .u { grid-column: 1 / -1; grid-row: 2; } + .status { padding: 6px 16px; } +} + +/* ---------- Login page ---------- */ +body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items: center; } +.login-tools { position: fixed; top: 12px; right: 16px; display: flex; align-items: center; gap: 8px; } +.login-card { + width: 100%; max-width: 360px; + display: flex; flex-direction: column; align-items: center; gap: 10px; + padding: 32px 28px 28px; + background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow); +} +.login-card .brand { font-size: 20px; } +.login-card .brand svg { width: 32px; height: 32px; } +.login-card > p { margin: 0 0 8px; text-align: center; } +.login-card form { width: 100%; display: flex; flex-direction: column; gap: 6px; } +.login-card label { font-size: 13px; font-weight: 550; } +.login-card input { + height: 38px; padding: 0 11px; margin-bottom: 6px; + border: 1px solid var(--border); border-radius: 9px; background: var(--bg); +} +.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.login-card .btn { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; width: 100%; } +.login-err { margin: 0; color: var(--err); font-size: 13px; } +.login-sep { display: flex; align-items: center; gap: 10px; width: 100%; color: var(--muted); font-size: 12px; } +.login-sep::before, .login-sep::after { content: ""; flex: 1; border-top: 1px solid var(--border); } +.demo-hint { width: 100%; margin: 6px 0 0; padding: 8px 10px; border-radius: 8px; font-size: 12.5px; background: color-mix(in srgb, var(--warn) 12%, transparent); } +.demo-hint code { font-weight: 650; } +.mt { margin-top: 18px; } +.mt-s { margin-top: 8px !important; } +.erow .acts { display: flex; gap: 2px; } +.import-res { margin: 10px 0 0; }