Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1

Merged
claude Bot merged 8 commits from phase-1-ui into main 2026-10-13 16:47:35 +02:00
31 changed files with 6743 additions and 60 deletions

No files matched your search

+6 -2
View File
@@ -7,6 +7,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd ./cmd COPY cmd ./cmd
COPY internal ./internal COPY internal ./internal
COPY web ./web
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o /out/pve-confsync ./cmd/pve-confsync -o /out/pve-confsync ./cmd/pve-confsync
@@ -20,6 +21,9 @@ COPY --from=build /out/pve-confsync /usr/local/bin/pve-confsync
USER confsync USER confsync
WORKDIR /data WORKDIR /data
VOLUME /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"] ENTRYPOINT ["/usr/local/bin/pve-confsync"]
CMD ["check"] # interface web ; la CLI reste disponible : docker compose run --rm confsync diff
CMD ["serve"]
+64 -10
View File
@@ -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. « 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. 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 **État : phases 0 à 2.** CLI (export, diff et apply en YAML) et interface web
et le SDN (zones VLAN/VXLAN, VNets, subnets). Testé contre un faux serveur PVE ; (écarts par client, dry-run et synchronisation par objet, par client ou global, journal,
**pas encore testé sur les clusters de labo.** 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 ## Périmètre
@@ -71,12 +72,59 @@ docker compose run --rm confsync check
docker compose run --rm confsync apply -confirm 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é ## Sécurité
- Secrets des tokens : jamais dans le YAML (`token_secret` en clair est refusé) ; lus depuis - 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`, 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 secret docker). Ils ne sont ni affichés ni journalisés. Dans l'interface, ils sont chiffrés en base
le stockage SQLite (phase 1). (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), - TLS : `fingerprint` (pinning SHA-256 du certificat, adapté aux certificats auto-signés PVE),
`ca_file`, ou `insecure: true` réservé au labo. `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) : - Tokens (privilege separation activée, l'ACL doit être posée sur l'utilisateur et le token) :
@@ -99,7 +147,12 @@ un droit.
## Arborescence ## 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/ client API : token, TLS, form-encoding, version, tâches
internal/pve/pvetest/ faux serveur PVE 9.x pour les tests internal/pve/pvetest/ faux serveur PVE 9.x pour les tests
internal/model/ objets génériques, snapshot YAML, valeurs canoniques 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 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 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 ## 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 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) ; (`web/index.html`, `style.css`, `app.js`, `login.html`, auth locale + OIDC, audit) ;
SQLite (clusters, profils, mappings, historique, audit), secrets chiffrés. 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. 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`, - 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`. booléens), comportement de `PUT /cluster/options` avec `delete`.
+7 -35
View File
@@ -1,6 +1,7 @@
// pve-confsync synchronizes part of the configuration of a reference Proxmox VE // pve-confsync synchronizes part of the configuration of a reference Proxmox VE
// cluster (datacenter options, SDN VLAN/VXLAN zones, vnets, subnets) to client // 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 package main
import ( import (
@@ -24,6 +25,7 @@ import (
"pve-confsync/internal/engine" "pve-confsync/internal/engine"
"pve-confsync/internal/model" "pve-confsync/internal/model"
"pve-confsync/internal/pve" "pve-confsync/internal/pve"
"pve-confsync/internal/service"
) )
var version = "dev" var version = "dev"
@@ -37,6 +39,7 @@ Commandes :
export exporte l'état normalisé d'un cluster en YAML (défaut : la référence) 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) 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 apply dry-run par défaut ; applique avec -confirm
serve interface web (clusters, diff, dry-run, synchronisation, journal)
version affiche la version version affiche la version
Lancer "pve-confsync <commande> -h" pour les options d'une commande. Lancer "pve-confsync <commande> -h" pour les options d'une commande.
@@ -69,6 +72,8 @@ func main() {
code, err = cmdDiff(ctx, *cfgFile, args[1:]) code, err = cmdDiff(ctx, *cfgFile, args[1:])
case "apply": case "apply":
err = cmdApply(ctx, *cfgFile, args[1:]) err = cmdApply(ctx, *cfgFile, args[1:])
case "serve":
err = cmdServe(ctx, args[1:])
default: default:
global.Usage() global.Usage()
os.Exit(1) 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) fmt.Printf(" avertissement : droits du token non lisibles (%v)\n", err)
return 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) fmt.Println(" avertissement :", w)
} }
} }
@@ -127,39 +132,6 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error {
return errors.Join(errs...) 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 ---- // ---- export ----
func cmdExport(ctx context.Context, cfgFile string, args []string) error { func cmdExport(ctx context.Context, cfgFile string, args []string) error {
+199
View File
@@ -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
}
+38 -4
View File
@@ -1,14 +1,44 @@
# Phase 0 : outil en ligne de commande, lancé à la demande : # Interface web (phases 1-2) :
# docker compose up -d confsync-web # http://<hôte>: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 check
# docker compose run --rm confsync diff # docker compose run --rm confsync diff
# docker compose run --rm confsync export -o /data/ref.yaml # docker compose run --rm confsync export -o /data/ref.yaml
# docker compose run --rm confsync apply # dry-run # docker compose run --rm confsync apply # dry-run
# docker compose run --rm confsync apply -confirm # application (confirmation SDN interactive) # docker compose run --rm confsync apply -confirm # application (confirmation SDN interactive)
services: services:
confsync: confsync-web:
build: . build: .
image: pve-confsync:dev
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}
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 image: pve-confsync:dev
container_name: pve-confsync container_name: pve-confsync
profiles: ["cli"] # pas démarré par "up" ; "run" l'active
command: ["check"]
environment: environment:
TZ: ${TZ:-Europe/Paris} TZ: ${TZ:-Europe/Paris}
PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env) PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env)
@@ -17,15 +47,18 @@ services:
required: false required: false
volumes: volumes:
- ./confsync.yaml:/config/confsync.yaml:ro - ./confsync.yaml:/config/confsync.yaml:ro
- confsync-data:/data # exports YAML, plus tard SQLite (phase 1) - confsync-cli:/data # exports YAML
secrets: secrets:
- pve_ref - pve_ref
- pve_site_a - pve_site_a
stdin_open: true stdin_open: true
tty: true tty: true
# Secrets des tokens API sous forme de fichiers (chmod 600), montés dans /run/secrets/<nom> # Fichiers chmod 600, propriétaire 10001, montés dans /run/secrets/<nom>.
# confsync_key : openssl rand -hex 32 > secrets/confsync_key
secrets: secrets:
confsync_key:
file: ./secrets/confsync_key
pve_ref: pve_ref:
file: ./secrets/pve_ref file: ./secrets/pve_ref
pve_site_a: pve_site_a:
@@ -33,3 +66,4 @@ secrets:
volumes: volumes:
confsync-data: confsync-data:
confsync-cli:
+18 -2
View File
@@ -1,5 +1,21 @@
module pve-confsync 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
)
+50
View File
@@ -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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=
+16 -2
View File
@@ -30,6 +30,10 @@ type Cluster struct {
Fingerprint string `yaml:"fingerprint,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"`
Insecure bool `yaml:"insecure,omitempty"` Insecure bool `yaml:"insecure,omitempty"`
Timeout string `yaml:"timeout,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) // 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() 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 { func (c *Config) validate() error {
var errs []error var errs []error
seen := map[string]bool{} seen := map[string]bool{}
@@ -86,7 +93,7 @@ func (c *Config) validate() error {
errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name)) errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name))
} }
seen[cl.Name] = true 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)) 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{ return pve.New(pve.Options{
Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret, 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) { func (cl Cluster) secret() (string, error) {
if cl.Secret != "" {
return cl.Secret, nil
}
if cl.TokenSecretEnv != "" { if cl.TokenSecretEnv != "" {
if v := os.Getenv(cl.TokenSecretEnv); v != "" { if v := os.Getenv(cl.TokenSecretEnv); v != "" {
return v, nil return v, nil
+129
View File
@@ -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
}
+12 -1
View File
@@ -24,6 +24,8 @@ type Result struct {
Client string `json:"client"` Client string `json:"client"`
Steps []Step `json:"steps"` Steps []Step `json:"steps"`
Err error `json:"-"` Err error `json:"-"`
notify func(Step)
} }
func (r *Result) log(action string, err error) { func (r *Result) log(action string, err error) {
@@ -32,6 +34,9 @@ func (r *Result) log(action string, err error) {
s.Error = err.Error() s.Error = err.Error()
} }
r.Steps = append(r.Steps, s) r.Steps = append(r.Steps, s)
if r.notify != nil {
r.notify(s)
}
} }
// TaskPoll is the poll interval of the SDN reload task. // 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 // committed with PUT /cluster/sdn. Any failure before the commit rolls the
// pending SDN changes back (POST /cluster/sdn/rollback). // pending SDN changes back (POST /cluster/sdn/rollback).
func Apply(ctx context.Context, c *pve.Client, plan Plan) Result { 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 { if len(plan.Errors) > 0 {
res.Err = fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors)) res.Err = fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors))
res.log("plan check", res.Err)
return res return res
} }
if _, err := c.CheckVersion(ctx); err != nil { if _, err := c.CheckVersion(ctx); err != nil {
+144
View File
@@ -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])
}
+92
View File
@@ -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")
}
}
+9 -4
View File
@@ -29,6 +29,7 @@ type Options struct {
TokenID string // user@realm!tokenname TokenID string // user@realm!tokenname
TokenSecret string // never logged TokenSecret string // never logged
CAFile string // PEM bundle to trust (optional) 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) Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
Insecure bool // skip TLS verification (lab only) Insecure bool // skip TLS verification (lab only)
Timeout time.Duration Timeout time.Duration
@@ -93,14 +94,18 @@ func New(o Options) (*Client, error) {
} }
return nil return nil
} }
case o.CAFile != "": case o.CAFile != "" || len(o.CAPEM) > 0:
pem, err := os.ReadFile(o.CAFile) pem, src := o.CAPEM, "the CA certificate"
if err != nil { 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) return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
} }
src = o.CAFile
}
pool := x509.NewCertPool() pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) { 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 tlsCfg.RootCAs = pool
case o.Insecure: case o.Insecure:
+689
View File
@@ -0,0 +1,689 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
"pve-confsync/internal/config"
"pve-confsync/internal/engine"
"pve-confsync/internal/model"
"pve-confsync/internal/service"
"pve-confsync/internal/store"
)
// readTimeout bounds a comparison (reading every cluster).
const readTimeout = 2 * time.Minute
// ---- state (phase 1: clusters and visual diff) ----
// handleState returns the last comparison; refresh=1 reads the clusters again.
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
s.stateMu.Lock()
defer s.stateMu.Unlock()
if s.state == nil || r.URL.Query().Get("refresh") == "1" {
cfg, err := s.store.Config(r.Context())
if errors.Is(err, store.ErrNoReference) {
writeJSON(w, http.StatusOK, map[string]any{"setup": true})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "config")
return
}
ctx, cancel := context.WithTimeout(r.Context(), readTimeout)
defer cancel()
s.state, s.stateAt = service.Analyze(ctx, cfg, nil), time.Now()
}
writeJSON(w, http.StatusOK, map[string]any{"state": s.state})
}
// invalidate forgets the cached comparison (configuration changed, apply done).
func (s *Server) invalidate() {
s.stateMu.Lock()
s.state = nil
s.stateMu.Unlock()
}
// remember merges the clients of a partial comparison into the cached one, so
// that the screen shows what a dry-run just read.
func (s *Server) remember(st *service.State, all bool) {
s.stateMu.Lock()
defer s.stateMu.Unlock()
if all || s.state == nil {
if all {
s.state, s.stateAt = st, time.Now()
}
return
}
s.state.Reference = st.Reference
for _, c := range st.Clients {
for i := range s.state.Clients {
if s.state.Clients[i].Name == c.Name {
s.state.Clients[i] = c
}
}
}
}
// ---- dry-run and apply (phase 2) ----
type selection struct {
Scope string `json:"scope"` // global, client or object
Client string `json:"client"` // client and object scopes
Objects []string `json:"objects"` // object scope: "kind/id"
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
Confirm string `json:"confirm"` // apply with SDN changes: client name (reference name for global)
}
func (sel *selection) check() error {
switch sel.Scope {
case "global":
sel.Client, sel.Objects = "", nil
case "client":
sel.Objects = nil
if sel.Client == "" {
return errors.New("client required")
}
case "object":
if sel.Client == "" || len(sel.Objects) == 0 {
return errors.New("client and objects required")
}
default:
return fmt.Errorf("unknown scope %q", sel.Scope)
}
return nil
}
func (sel selection) clients() []string {
if sel.Client == "" {
return nil
}
return []string{sel.Client}
}
// plan reads the clusters and returns the plans of the selection.
func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) {
cfg, err := s.store.Config(ctx)
if err != nil {
return nil, nil, nil, err
}
if sel.Client != "" {
if _, ok := cfg.Client(sel.Client); !ok {
return nil, nil, nil, fmt.Errorf("unknown client %q", sel.Client)
}
}
rctx, cancel := context.WithTimeout(ctx, readTimeout)
defer cancel()
st := service.Analyze(rctx, cfg, sel.clients())
s.remember(st, sel.Scope == "global")
return cfg, st, st.Plans(sel.Objects), nil
}
type runDetail struct {
Plans []service.ClientPlan `json:"plans"`
Results map[string]*clientResult `json:"results,omitempty"`
}
type clientResult struct {
Status string `json:"status"` // running, ok, error, noop, skipped
Error string `json:"error,omitempty"`
Steps []engine.Step `json:"steps"`
}
func (s *Server) handleDryRun(w http.ResponseWriter, r *http.Request) {
var sel selection
if err := readJSON(r, &sel); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
if err := sel.check(); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
id := identity(r)
_, _, plans, err := s.plan(r.Context(), sel)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "config")
return
}
status, summary := dryRunSummary(plans)
d, _ := json.Marshal(runDetail{Plans: plans})
run := store.Run{User: id.User, Mode: "dry-run", Scope: sel.Scope, Clients: planClients(plans), Objects: sel.Objects,
Status: status, Summary: summary, Detail: d}
runID, err := s.store.StartRun(r.Context(), run)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
_ = s.store.UpdateRun(r.Context(), runID, status, summary, d)
s.audit(r, id, "dry-run", target(sel), true, summary)
writeJSON(w, http.StatusOK, map[string]any{"run": runID, "plans": plans, "summary": summary})
}
func planClients(plans []service.ClientPlan) []string {
out := make([]string, 0, len(plans))
for _, p := range plans {
out = append(out, p.Client)
}
return out
}
func target(sel selection) string {
switch sel.Scope {
case "client":
return sel.Client
case "object":
return sel.Client + ": " + strings.Join(sel.Objects, ", ")
}
return "global"
}
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
func dryRunSummary(plans []service.ClientPlan) (string, string) {
changes, clients, blocked := 0, 0, 0
for _, p := range plans {
if p.Error != "" || len(p.Plan.Errors) > 0 {
blocked++
continue
}
if n := len(p.Plan.Changes); n > 0 {
changes += n
clients++
}
}
switch {
case blocked > 0:
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d client(s), %d client(s) en erreur", changes, clients, blocked)
case changes == 0:
return store.RunNoop, "aucun écart"
}
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d client(s)", changes, clients)
}
// handleApply recomputes the plans, checks that they are the ones reviewed in the
// dry-run (digests) and the confirmation of the SDN changes, then applies them in
// the background, client after client. Progress goes to the journal.
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
var sel selection
if err := readJSON(r, &sel); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
if err := sel.check(); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
id := identity(r)
if !s.applyMu.TryLock() {
writeError(w, http.StatusConflict, "une synchronisation est déjà en cours", "busy")
return
}
started := false
defer func() {
if !started {
s.applyMu.Unlock()
}
}()
cfg, st, plans, err := s.plan(r.Context(), sel)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "config")
return
}
var todo []service.ClientPlan
sdn := false
for _, p := range plans {
switch {
case p.Error != "":
s.audit(r, id, "apply", target(sel), false, p.Client+": "+p.Error)
writeError(w, http.StatusConflict, p.Client+": "+p.Error, "read")
return
case len(p.Plan.Errors) > 0:
s.audit(r, id, "apply", target(sel), false, p.Client+": plan en erreur")
writeError(w, http.StatusConflict, p.Client+": "+strings.Join(p.Plan.Errors, "; "), "plan")
return
case sel.Digests[p.Client] != p.Digest:
s.audit(r, id, "apply", target(sel), false, p.Client+": l'état a changé depuis le dry-run")
writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": l'état a changé depuis le dry-run, relancer le dry-run",
"code": "stale", "plans": plans})
return
case p.Plan.Empty():
continue
}
sdn = sdn || p.SDN
todo = append(todo, p)
}
if len(todo) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "aucun écart"})
return
}
if sdn {
want := sel.Client
if sel.Scope == "global" {
want = cfg.Reference.Name
}
if sel.Confirm != want {
writeError(w, http.StatusBadRequest, "changements SDN : confirmation requise ("+want+")", "confirm")
return
}
}
det := &runDetail{Plans: todo, Results: map[string]*clientResult{}}
for _, p := range todo {
det.Results[p.Client] = &clientResult{Status: "pending", Steps: []engine.Step{}}
}
d, _ := json.Marshal(det)
runID, err := s.store.StartRun(r.Context(), store.Run{User: id.User, Mode: "apply", Scope: sel.Scope,
Clients: planClients(todo), Objects: sel.Objects, Status: store.RunRunning, Summary: "en cours", Detail: d})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
s.audit(r, id, "apply-start", target(sel), true, fmt.Sprintf("journal #%d", runID))
started = true
s.jobs.Add(1)
// the request ends now: the job keeps a copy that outlives it (audit IP)
rr := r.Clone(context.WithoutCancel(r.Context()))
go func() {
defer s.jobs.Done()
defer s.applyMu.Unlock()
s.runApply(rr, id, runID, sel, st, det)
}()
writeJSON(w, http.StatusAccepted, map[string]any{"run": runID})
}
// runApply applies the plans one client after the other; a failed client does
// not stop the next ones (each has its own SDN lock and rollback).
func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selection, st *service.State, det *runDetail) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
var mu sync.Mutex // det is read by save while a step is appended
save := func(status, summary string) {
mu.Lock()
d, _ := json.Marshal(det)
mu.Unlock()
if err := s.store.UpdateRun(ctx, runID, status, summary, d); err != nil {
log.Printf("journal #%d: %v", runID, err)
}
}
failed, done := 0, 0
for _, p := range det.Plans {
res := det.Results[p.Client]
res.Status = store.RunRunning
save(store.RunRunning, fmt.Sprintf("%s en cours", p.Client))
err := st.Apply(ctx, p, sel.Objects, func(step engine.Step) {
mu.Lock()
res.Steps = append(res.Steps, step)
mu.Unlock()
save(store.RunRunning, fmt.Sprintf("%s : %s", p.Client, step.Action))
})
if err != nil {
failed++
res.Status, res.Error = store.RunError, err.Error()
log.Printf("apply %s (journal #%d): %v", p.Client, runID, err)
s.audit(r, id, "apply", p.Client, false, err.Error())
} else {
done++
res.Status = store.RunOK
s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d changement(s), vérifié", len(p.Plan.Changes)))
}
}
status := store.RunOK
summary := fmt.Sprintf("%d client(s) synchronisé(s) et vérifié(s)", done)
if failed > 0 {
status = store.RunError
summary = fmt.Sprintf("%d client(s) en échec, %d synchronisé(s)", failed, done)
}
save(status, summary)
s.invalidate()
}
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
runs, err := s.store.Runs(r.Context(), limit, before)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
if runs == nil {
runs = []store.Run{}
}
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
}
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
run, err := s.store.Run(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "unknown run", "notfound")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
writeJSON(w, http.StatusOK, run)
}
// ---- clusters and settings (admin) ----
func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) {
cls, err := s.store.Clusters(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
if cls == nil {
cls = []store.Cluster{}
}
// connection details are for the administrators
if identity(r).Role != store.RoleAdmin {
for i := range cls {
cls[i] = store.Cluster{Name: cls[i].Name, Reference: cls[i].Reference, URL: cls[i].URL, Mapping: cls[i].Mapping, Policies: cls[i].Policies}
}
}
writeJSON(w, http.StatusOK, map[string]any{"clusters": cls})
}
func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
var c store.Cluster
if err := readJSON(r, &c); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
old := r.PathValue("name")
if old == "-" { // creation
old = ""
}
if err := validateCluster(c); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
err := s.store.SaveCluster(r.Context(), old, c)
detail := fmt.Sprintf("url=%s token=%s reference=%t", c.URL, c.TokenID, c.Reference)
if c.Secret != "" {
detail += " secret=modifié"
}
if old != "" && old != c.Name {
detail += " renommé depuis " + old
}
s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// validateCluster checks the mapping and policies of a cluster with the rules of
// the YAML configuration.
func validateCluster(c store.Cluster) error {
if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") {
return errors.New("url: https:// expected")
}
if c.Timeout != "" {
if _, err := time.ParseDuration(c.Timeout); err != nil {
return fmt.Errorf("timeout: %w", err)
}
}
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref", Secret: "x"},
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name, Secret: "x"}, Mapping: c.Mapping, Policies: c.Policies}}}
return cfg.Validate()
}
func errText(err error, ok string) string {
if err != nil {
return err.Error()
}
return ok
}
func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
err := s.store.DeleteCluster(r.Context(), name)
s.audit(r, Identity{}, "cluster-delete", name, err == nil, errText(err, ""))
if err != nil {
writeError(w, http.StatusNotFound, err.Error(), "notfound")
return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleCheckCluster tests the access, the version and the token privileges.
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
cfg, err := s.store.Config(r.Context())
if err != nil && !errors.Is(err, store.ErrNoReference) {
writeError(w, http.StatusInternalServerError, err.Error(), "config")
return
}
var cl config.Cluster
write := true
switch {
case cfg != nil && cfg.Reference.Name == name:
cl, write = cfg.Reference, false
case cfg != nil:
c, ok := cfg.Client(name)
if !ok {
writeError(w, http.StatusNotFound, "unknown cluster", "notfound")
return
}
cl = c.Cluster
default:
writeError(w, http.StatusBadRequest, "define a reference cluster first", "config")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
res := service.CheckCluster(ctx, cl, write, cfg.HasKind(model.KindDatacenter))
s.audit(r, Identity{}, "cluster-check", name, res.Error == "", res.Error)
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
st, err := s.store.Settings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "defaults": defaultPolicies()})
}
// defaultPolicies describes the built-in policies, shown in the settings.
func defaultPolicies() map[model.Kind]string {
return map[model.Kind]string{
model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais",
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping du client) ; le reste : sync",
model.KindVNet: "sync",
model.KindSubnet: "sync",
}
}
func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
var st store.Settings
if err := readJSON(r, &st); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
Reference: config.Cluster{Name: "\x00ref", Secret: "x"}}
if len(cfg.Kinds) == 0 {
cfg.Kinds = model.Kinds
}
err := cfg.Validate()
if err == nil {
err = s.store.SaveSettings(r.Context(), st)
}
b, _ := json.Marshal(st)
s.audit(r, Identity{}, "settings-save", "synchronisation", err == nil, errText(err, string(b)))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleExport returns the configuration as a CLI YAML file (no secret).
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
cfg, err := s.store.ExportConfig(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
b, err := yaml.Marshal(cfg)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "yaml")
return
}
s.audit(r, Identity{}, "config-export", "", true, "")
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="confsync.yaml"`)
_, _ = fmt.Fprintf(w, "# pve-confsync : configuration exportée le %s par %s\n# Secrets des tokens non exportés : renseigner les variables token_secret_env (ou token_secret_file).\n\n",
time.Now().Format("2006-01-02 15:04"), identity(r).User)
_, _ = w.Write(b)
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
var req struct {
YAML string `json:"yaml"`
Replace bool `json:"replace"`
}
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
var cfg config.Config
dec := yaml.NewDecoder(strings.NewReader(req.YAML))
dec.KnownFields(true)
err := dec.Decode(&cfg)
if err == nil {
if len(cfg.Kinds) == 0 {
cfg.Kinds = append(cfg.Kinds, model.Kinds...)
}
err = cfg.Validate()
}
var warns []string
if err == nil {
warns, err = s.store.ImportConfig(r.Context(), &cfg, req.Replace)
}
s.audit(r, Identity{}, "config-import", fmt.Sprintf("%d cluster(s), remplacement=%t", len(cfg.Clients)+1, req.Replace),
err == nil, errText(err, strings.Join(warns, " ; ")))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
s.invalidate()
if warns == nil {
warns = []string{}
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "warnings": warns})
}
// ---- users and audit ----
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
us, err := s.store.Users(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
if us == nil {
us = []store.User{}
}
writeJSON(w, http.StatusOK, map[string]any{"users": us})
}
func (s *Server) handleSaveUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Role string `json:"role"`
Password string `json:"password"`
}
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
name := r.PathValue("name")
err := s.store.SaveUser(r.Context(), name, req.Role, req.Password)
detail := "rôle " + req.Role
if req.Password != "" {
detail += ", mot de passe défini"
}
s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
err := s.store.DeleteUser(r.Context(), name)
s.audit(r, Identity{}, "user-delete", name, err == nil, errText(err, ""))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handlePassword lets a local user change their own password.
func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) {
id := identity(r)
var req struct {
Old string `json:"old"`
New string `json:"new"`
}
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
if id.Mode != "local" {
writeError(w, http.StatusBadRequest, "password managed by the identity provider", "oidc")
return
}
u, ok := s.store.Authenticate(r.Context(), id.User, req.Old)
if !ok {
time.Sleep(loginFailDelay)
s.audit(r, id, "password-change", id.User, false, "ancien mot de passe incorrect")
writeError(w, http.StatusForbidden, "wrong current password", "password")
return
}
err := s.store.SaveUser(r.Context(), id.User, u.Role, req.New)
s.audit(r, id, "password-change", id.User, err == nil, errText(err, ""))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
// the new epoch ends the other sessions; this one gets a new cookie
if nu, err := s.store.User(r.Context(), id.User); err == nil {
s.auth.setSession(w, r, session{User: nu.Name, Role: nu.Role, Mode: "local", Epoch: nu.Epoch})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
ev, err := s.store.Events(r.Context(), limit, before)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
if ev == nil {
ev = []store.Event{}
}
writeJSON(w, http.StatusOK, map[string]any{"events": ev})
}
+785
View File
@@ -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
}
+178
View File
@@ -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
}
+191
View File
@@ -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("<html>")}, "login.html": {Data: []byte("<html>")}},
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) }
+428
View File
@@ -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
}
+158
View File
@@ -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 <dataDir>/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$<iter>$<salt>$<hash>".
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)
}
+192
View File
@@ -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()
}
+446
View File
@@ -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)
}
+101
View File
@@ -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")
}
}
+182
View File
@@ -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
}
+1366
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -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) {}
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#6366f1"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 312 B

+419
View File
@@ -0,0 +1,419 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pve-confsync</title>
<link rel="icon" href="favicon.svg">
<link rel="stylesheet" href="style.css">
<script src="boot.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
<span>pve-confsync</span>
<span id="demoBadge" class="demo-badge" hidden data-i18n="demo">DÉMO</span>
</div>
<div class="search">
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-ph="searchPh" data-i18n-aria="searchPh">
</div>
<div class="actions">
<button id="refreshBtn" class="btn" type="button" data-i18n-title="refreshTitle">
<!-- Lucide "refresh-cw" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
<span class="lbl" data-i18n="refresh">Actualiser</span>
</button>
<button id="settingsBtn" class="icon-btn" type="button" data-i18n-title="settings" data-i18n-aria="settings">
<!-- Lucide "settings" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<span id="whoami" class="whoami" hidden><span class="who-name"></span><span class="who-badge"></span></span>
<a id="logoutBtn" class="icon-btn" href="auth/logout" data-i18n-title="logout" data-i18n-aria="logout">
<!-- Lucide "log-out" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
</a>
</div>
<div class="progress" aria-hidden="true"></div>
</header>
<section class="filters">
<div id="viewMode" class="seg view-mode" role="radiogroup" data-i18n-aria="viewAria">
<button type="button" role="radio" data-view="diff">
<!-- Lucide "git-compare" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
<span data-i18n="viewDiff">Écarts</span>
</button>
<button type="button" role="radio" data-view="runs">
<!-- Lucide "history" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
<span data-i18n="viewRuns">Journal</span>
</button>
<button type="button" role="radio" data-view="audit" id="auditTab" hidden>
<!-- Lucide "shield-check" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
<span data-i18n="viewAudit">Audit</span>
</button>
</div>
<select id="kindSel" data-diff data-i18n-aria="kindAria"></select>
<select id="statusSel" data-diff data-i18n-aria="statusAria"></select>
<select id="runModeSel" data-runs data-i18n-aria="modeAria" hidden></select>
<span class="spacer"></span>
<span id="count" class="muted"></span>
<div id="globalActs" class="global-acts" data-diff hidden>
<button id="dryAllBtn" class="btn tool" type="button">
<!-- Lucide "scan-search" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="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-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/></svg>
<span data-i18n="dryAll">Dry-run global</span>
</button>
<button id="syncAllBtn" class="btn tool sync" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
<span data-i18n="syncAll">Tout synchroniser</span>
</button>
</div>
</section>
<div id="error" class="error-banner" hidden></div>
<div id="setup" class="setup" hidden>
<p data-i18n="setupText">Aucun cluster de référence n'est défini.</p>
<button id="setupBtn" class="btn primary" type="button" data-i18n="setupBtn">Ajouter les clusters</button>
</div>
<!-- Differences: clusters, then the objects of the selected client -->
<section id="diffView">
<div id="strip" class="strip" role="tablist" data-i18n-aria="stripAria"></div>
<div id="clientBar" class="client-bar" hidden></div>
<div id="notes" class="notes"></div>
<div id="objTable" class="table objects" hidden>
<div class="list-head" role="presentation">
<span data-i18n="colKind">Type</span>
<span data-i18n="colObject">Objet</span>
<span data-i18n="colStatus">Statut</span>
<span data-i18n="colFields">Champs</span>
<span></span>
</div>
<main id="objList" class="list"></main>
</div>
<div id="objEmpty" class="empty" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
<p><strong id="objEmptyTitle"></strong></p>
<p class="muted" id="objEmptyHint"></p>
</div>
</section>
<!-- Execution journal -->
<section id="runsView" hidden>
<div class="table runs">
<div class="list-head" role="presentation">
<span data-i18n="colDate">Date</span>
<span data-i18n="colUser">Utilisateur</span>
<span data-i18n="colMode">Mode</span>
<span data-i18n="colScope">Portée</span>
<span data-i18n="colClients">Clients</span>
<span data-i18n="colStatus">Statut</span>
<span data-i18n="colSummary">Résumé</span>
</div>
<main id="runList" class="list"></main>
</div>
<div class="more-row"><button id="runMore" class="btn ghost" type="button" data-i18n="more" hidden>Plus</button></div>
</section>
<!-- Audit trail (administrators) -->
<section id="auditView" hidden>
<div class="table audit">
<div class="list-head" role="presentation">
<span data-i18n="colDate">Date</span>
<span data-i18n="colUser">Utilisateur</span>
<span data-i18n="colIP">Adresse</span>
<span data-i18n="colAction">Action</span>
<span data-i18n="colTarget">Cible</span>
<span data-i18n="colResult">Résultat</span>
<span data-i18n="colDetail">Détail</span>
</div>
<main id="auditList" class="list"></main>
</div>
<div class="more-row"><button id="auditMore" class="btn ghost" type="button" data-i18n="more" hidden>Plus</button></div>
</section>
<footer class="status">
<span id="conn" class="conn"></span>
<span id="stats" class="muted"></span>
<span class="sp"></span>
<span id="ver" class="muted"></span>
<button id="toTop" class="icon-btn to-top" type="button" data-i18n-title="toTop" data-i18n-aria="toTop" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
</footer>
<!-- Dry-run, confirmation and progress of a synchronization -->
<dialog id="planDlg" aria-labelledby="planTitle">
<div class="dlg">
<header>
<h2 id="planTitle"></h2>
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</header>
<div id="planBody" class="dlg-body"></div>
<footer>
<span id="planNote" class="muted small"></span>
<span class="sp"></span>
<button id="planClose" class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
<button id="planApply" class="btn primary" type="button" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
<span data-i18n="applyBtn">Synchroniser</span>
</button>
</footer>
</div>
</dialog>
<!-- Cluster editor (administrators) -->
<dialog id="clusterDlg" aria-labelledby="clusterTitle">
<form class="dlg" id="clusterForm" method="dialog">
<header>
<h2 id="clusterTitle"></h2>
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</header>
<div class="dlg-body">
<div class="field-grid">
<label for="cName" data-i18n="cName">Nom</label>
<input id="cName" class="inp" required autocomplete="off" spellcheck="false">
<span class="lbl" data-i18n="cRole">Rôle</span>
<div id="cRole" class="seg" role="radiogroup">
<button type="button" role="radio" data-v="client" data-i18n="roleClient">Client</button>
<button type="button" role="radio" data-v="ref" data-i18n="roleRef">Référence</button>
</div>
<label for="cURL">URL</label>
<input id="cURL" class="inp mono" required placeholder="https://pve.example.org:8006" autocomplete="off" spellcheck="false">
<label for="cToken" data-i18n="cToken">Token</label>
<input id="cToken" class="inp mono" required placeholder="confsync@pve!rw" autocomplete="off" spellcheck="false">
<label for="cSecret" data-i18n="cSecret">Secret</label>
<input id="cSecret" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
<p class="muted small hint" id="cSecretHint"></p>
<span class="lbl" data-i18n="cTLS">TLS</span>
<select id="cTLS" class="field">
<option value="fp" data-i18n="tlsFp">Empreinte SHA-256 du certificat</option>
<option value="ca" data-i18n="tlsCa">Certificat d'autorité (PEM)</option>
<option value="sys" data-i18n="tlsSys">Autorités du système</option>
<option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option>
</select>
<label for="cFp" data-tls="fp" data-i18n="cFp">Empreinte</label>
<input id="cFp" class="inp mono" data-tls="fp" placeholder="AA:BB:…" autocomplete="off" spellcheck="false">
<label for="cCA" data-tls="ca">PEM</label>
<textarea id="cCA" class="inp" data-tls="ca" spellcheck="false" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
<label for="cTimeout" data-i18n="cTimeout">Délai</label>
<input id="cTimeout" class="inp mono" placeholder="30s" autocomplete="off">
</div>
<div id="cClientOnly">
<div class="sec-head mt"><h3 class="lbl" data-i18n="cMapping">Mapping (valeurs propres au client)</h3></div>
<p class="muted small" data-i18n="cMappingHelp"></p>
<div class="erow-head map"><span data-i18n="colKind">Type</span><span data-i18n="colObject">Objet</span><span data-i18n="colField">Champ</span><span data-i18n="colValue">Valeur</span><span></span></div>
<div id="cMapRows" class="erows"></div>
<div class="toolbar"><button id="cMapAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div>
<div class="sec-head mt"><h3 class="lbl" data-i18n="cPolicies">Politiques propres au client</h3></div>
<p class="muted small" data-i18n="cPoliciesHelp"></p>
<div class="erow-head pol"><span data-i18n="colKind">Type</span><span data-i18n="colField">Champ</span><span data-i18n="colPolicy">Politique</span><span></span></div>
<div id="cPolRows" class="erows"></div>
<div class="toolbar"><button id="cPolAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div>
</div>
<p id="cErr" class="login-err" role="alert" hidden></p>
</div>
<footer>
<button id="cDelete" class="btn danger" type="button" data-i18n="delete">Supprimer</button>
<span class="sp"></span>
<button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button>
<button class="btn primary" type="submit" data-i18n="save">Enregistrer</button>
</footer>
</form>
</dialog>
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
<div class="dlg">
<header>
<h2 id="settingsTitle" data-i18n="settings">Paramètres</h2>
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</header>
<div class="set-body">
<div class="set-side">
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
<button type="button" role="tab" data-tab="clusters">
<!-- Lucide "server" (ISC license) -->
<svg viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
<span data-i18n="tabClusters">Clusters</span>
</button>
<button type="button" role="tab" data-tab="sync">
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
<span data-i18n="tabSync">Synchronisation</span>
</button>
<button type="button" role="tab" data-tab="users" data-admin>
<!-- Lucide "users" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="tabUsers">Utilisateurs</span>
</button>
<button type="button" role="tab" data-tab="config" data-admin>
<!-- Lucide "file-code" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4M10 12l-2 2 2 2M14 12l2 2-2 2"/></svg>
<span data-i18n="tabConfig">Import / export</span>
</button>
<button type="button" role="tab" data-tab="account">
<!-- Lucide "key-round" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>
<span data-i18n="tabAccount">Compte</span>
</button>
<button type="button" role="tab" data-tab="appearance">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
<span data-i18n="tabAppearance">Apparence</span>
</button>
</nav>
<div id="setActions" class="set-actions" hidden>
<button id="setSave" class="btn save" type="button" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17l9-10"/></svg>
<span data-i18n="save">Enregistrer</span>
</button>
<button id="setCancel" class="btn ghost" type="button" disabled data-i18n="cancel">Annuler</button>
<p id="setState" class="set-state" role="status"></p>
</div>
</div>
<div class="set-panels">
<!-- Clusters -->
<div class="set-panel wide" role="tabpanel" data-panel="clusters">
<p class="ro-note" data-ro data-i18n="roNote" hidden></p>
<section class="set-section">
<div class="sec-head">
<h3 data-i18n="tabClusters">Clusters</h3>
<button id="addCluster" class="btn tool primary" type="button" data-admin-only data-i18n="addCluster">+ Ajouter un cluster</button>
</div>
<p class="muted small" data-i18n="clustersHelp"></p>
<div id="clusterList" class="clist"></div>
</section>
</div>
<!-- Synchronization settings -->
<div class="set-panel" role="tabpanel" data-panel="sync" hidden>
<p class="ro-note" data-ro data-i18n="roNote" hidden></p>
<section class="set-section" data-edit>
<h3 data-i18n="kindsTitle">Types synchronisés</h3>
<div id="kindChips" class="chip-field"></div>
<p class="muted small mt-s" data-i18n="kindsHelp"></p>
</section>
<section class="set-section danger" data-edit>
<h3 data-i18n="pruneTitle">Suppression</h3>
<label class="switch-row"><input id="pruneSw" type="checkbox" class="switch"><span data-i18n="pruneLabel">Supprimer chez les clients les objets absents de la référence</span></label>
<p class="muted small mt-s" data-i18n="pruneHelp"></p>
</section>
<section class="set-section span" data-edit>
<h3 data-i18n="selTitle">Sélection des objets</h3>
<p class="muted small" data-i18n="selHelp"></p>
<div class="erow-head sel"><span data-i18n="colKind">Type</span><span data-i18n="colInclude">Inclure</span><span data-i18n="colExclude">Exclure</span></div>
<div id="selRows" class="erows"></div>
</section>
<section class="set-section span" data-edit>
<h3 data-i18n="polTitle">Politiques par champ</h3>
<p class="muted small" data-i18n="polHelp"></p>
<ul id="polDefaults" class="muted small"></ul>
<div class="erow-head pol"><span data-i18n="colKind">Type</span><span data-i18n="colField">Champ</span><span data-i18n="colPolicy">Politique</span><span></span></div>
<div id="polRows" class="erows"></div>
<div class="toolbar"><button id="polAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div>
</section>
</div>
<!-- Users -->
<div class="set-panel wide" role="tabpanel" data-panel="users" hidden>
<section class="set-section">
<h3 data-i18n="tabUsers">Utilisateurs</h3>
<p class="muted small" data-i18n="usersHelp"></p>
<div class="erow-head user"><span data-i18n="colUser">Utilisateur</span><span data-i18n="colRole">Rôle</span><span data-i18n="colPassword">Nouveau mot de passe</span><span data-i18n="colLastLogin">Dernière connexion</span><span></span></div>
<div id="userRows" class="erows"></div>
<div class="toolbar"><button id="userAdd" class="btn tool" type="button" data-i18n="addUser">+ Ajouter un utilisateur</button></div>
</section>
</div>
<!-- Import / export -->
<div class="set-panel" role="tabpanel" data-panel="config" hidden>
<section class="set-section">
<h3 data-i18n="exportTitle">Exporter</h3>
<p class="muted small" data-i18n="exportHelp"></p>
<a id="exportBtn" class="btn" href="api/config/export" download="confsync.yaml">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
<span data-i18n="exportBtn">Télécharger confsync.yaml</span>
</a>
</section>
<section class="set-section">
<h3 data-i18n="importTitle">Importer</h3>
<p class="muted small" data-i18n="importHelp"></p>
<textarea id="importText" class="inp" spellcheck="false" placeholder="reference:&#10; name: ref&#10; …"></textarea>
<div class="toolbar">
<input id="importFile" type="file" accept=".yaml,.yml,text/yaml" hidden>
<button id="importPick" class="btn tool" type="button" data-i18n="importPick">Choisir un fichier…</button>
<label class="switch-row"><input id="importReplace" type="checkbox" class="switch"><span data-i18n="importReplace">Supprimer les clusters absents du fichier</span></label>
<span class="spacer"></span>
<button id="importBtn" class="btn primary" type="button" data-i18n="importBtn">Importer</button>
</div>
<div id="importRes" class="notes import-res"></div>
</section>
</div>
<!-- Account -->
<div class="set-panel" role="tabpanel" data-panel="account" hidden>
<section class="set-section">
<h3 data-i18n="accountTitle">Mon compte</h3>
<dl id="accountInfo" class="field-grid small"></dl>
</section>
<section class="set-section" id="pwdSection">
<h3 data-i18n="pwdTitle">Changer de mot de passe</h3>
<form id="pwdForm" class="field-grid">
<input type="text" autocomplete="username" hidden>
<label for="pwdOld" data-i18n="pwdOld">Mot de passe actuel</label>
<input id="pwdOld" class="inp" type="password" autocomplete="current-password" required>
<label for="pwdNew" data-i18n="pwdNew">Nouveau</label>
<input id="pwdNew" class="inp" type="password" autocomplete="new-password" minlength="8" required>
<label for="pwdNew2" data-i18n="pwdNew2">Confirmation</label>
<input id="pwdNew2" class="inp" type="password" autocomplete="new-password" minlength="8" required>
<span></span>
<div><button class="btn primary" type="submit" data-i18n="pwdBtn">Changer le mot de passe</button></div>
</form>
</section>
</div>
<!-- Appearance -->
<div class="set-panel" role="tabpanel" data-panel="appearance" hidden>
<section class="set-section">
<h3 data-i18n="language">Langue</h3>
<div id="langSwitch" class="seg" role="radiogroup" data-i18n-aria="language">
<button type="button" role="radio" data-lang="fr">Français</button>
<button type="button" role="radio" data-lang="en">English</button>
</div>
</section>
<section class="set-section">
<h3 data-i18n="themeTitle">Thème</h3>
<div id="themeSwitch" class="seg" role="radiogroup" data-i18n-aria="themeTitle">
<button type="button" role="radio" data-theme-opt="system" data-i18n="themeSystem">Système</button>
<button type="button" role="radio" data-theme-opt="light" data-i18n="themeLight">Clair</button>
<button type="button" role="radio" data-theme-opt="dark" data-i18n="themeDark">Sombre</button>
</div>
</section>
<section class="set-section">
<h3 data-i18n="density">Densité</h3>
<div id="densitySwitch" class="seg" role="radiogroup" data-i18n-aria="density">
<button type="button" role="radio" data-density="normal" data-i18n="densityNormal">Normale</button>
<button type="button" role="radio" data-density="compact" data-i18n="densityCompact">Compacte</button>
</div>
</section>
</div>
</div>
</div>
</div>
</dialog>
<div id="toast" class="toast" role="status" hidden></div>
<script src="app.js"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pve-confsync</title>
<link rel="icon" href="favicon.svg">
<link rel="stylesheet" href="style.css">
<script src="boot.js"></script>
</head>
<body class="login-page">
<div class="login-tools">
<div class="seg" role="radiogroup" id="langSwitch">
<button type="button" role="radio" data-lang="fr">FR</button>
<button type="button" role="radio" data-lang="en">EN</button>
</div>
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</div>
<main class="login-card">
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
<span>pve-confsync</span>
</div>
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
<a id="ssoBtn" class="btn primary" href="auth/oidc" hidden data-i18n="sso">Se connecter avec le SSO</a>
<div id="sep" class="login-sep" hidden data-i18n="or">ou</div>
<form id="localForm" method="post" action="auth/login" hidden>
<input type="hidden" name="r" id="ret">
<label for="user" data-i18n="user">Utilisateur</label>
<input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<label for="pass" data-i18n="pass">Mot de passe</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<p id="err" class="login-err" role="alert" data-i18n="error" hidden>Utilisateur ou mot de passe incorrect.</p>
<button id="submitBtn" class="btn" type="submit" data-i18n="submit">Se connecter</button>
</form>
<p id="demoHint" class="demo-hint" hidden></p>
</main>
<script src="login.js"></script>
</body>
</html>
+67
View File
@@ -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 <code> 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 () {});
})();
+690
View File
@@ -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; }