Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1
No files matched your search
+6
-2
@@ -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"]
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+40
-6
@@ -1,31 +1,64 @@
|
|||||||
# Phase 0 : outil en ligne de commande, lancé à la demande :
|
# Interface web (phases 1-2) :
|
||||||
|
# docker compose up -d confsync-web # http://<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
|
image: pve-confsync:dev
|
||||||
container_name: pve-confsync
|
container_name: pve-confsync-web
|
||||||
|
command: ["serve"]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure)
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env)
|
CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key # clé des secrets en base, hors volume /data
|
||||||
|
# CONFSYNC_ADMIN_PASSWORD_FILE: /run/secrets/confsync_admin
|
||||||
|
# CONFSYNC_IMPORT: /config/confsync.yaml # import initial de la config CLI (base vide)
|
||||||
|
# OIDC_ISSUER: https://auth.example.org/application/o/confsync/
|
||||||
|
# OIDC_CLIENT_ID: confsync
|
||||||
|
# OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client
|
||||||
|
# OIDC_REDIRECT_URL: https://confsync.example.org/auth/callback
|
||||||
|
# OIDC_ADMIN_GROUP: confsync-admins
|
||||||
|
# OIDC_OPERATOR_GROUP: confsync-operators
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- confsync-data:/data # SQLite, session.key
|
||||||
|
secrets:
|
||||||
|
- confsync_key
|
||||||
|
|
||||||
|
confsync:
|
||||||
|
image: pve-confsync:dev
|
||||||
|
container_name: pve-confsync
|
||||||
|
profiles: ["cli"] # pas démarré par "up" ; "run" l'active
|
||||||
|
command: ["check"]
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Europe/Paris}
|
||||||
|
PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env)
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-5
@@ -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 != "" {
|
||||||
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
|
var err error
|
||||||
|
if pem, err = os.ReadFile(o.CAFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
|
||||||
|
}
|
||||||
|
src = o.CAFile
|
||||||
}
|
}
|
||||||
pool := x509.NewCertPool()
|
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:
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+10
@@ -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) {}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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: name: ref …"></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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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; }
|
||||||
Reference in new issue
Block a user