From 3e9cb563beaea759d82491329d0f6bc9a9b9762f Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 23 Oct 2026 17:20:16 +0200 Subject: [PATCH] =?UTF-8?q?Full=20web=20:=20clusters=20g=C3=A9r=C3=A9s=20d?= =?UTF-8?q?ans=20l'UI,=20primaire=20et=20cibles,=20comptes=20pve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 23 ++-- Dockerfile | 6 +- cmd/pve-confsync/main_test.go | 53 --------- cmd/pve-confsync/serve.go | 199 --------------------------------- docker-compose.demo.yml | 4 +- docker-compose.yml | 50 ++------- examples/confsync.example.yaml | 63 ----------- go.mod | 4 +- go.sum | 12 +- internal/config/config_test.go | 54 ++++----- internal/service/check_test.go | 39 +++++++ 11 files changed, 93 insertions(+), 414 deletions(-) delete mode 100644 cmd/pve-confsync/main_test.go delete mode 100644 cmd/pve-confsync/serve.go delete mode 100644 examples/confsync.example.yaml create mode 100644 internal/service/check_test.go diff --git a/.env.example b/.env.example index 954ba59..2684029 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copier en .env (ignoré par Git) puis adapter : cp .env.example .env -# Lu par docker compose (substitution ${...} et env_file des services) ; avec la CLI -# ou `serve` hors conteneur : set -a; . ./.env; set +a +# Lu par docker compose (substitution ${...} et env_file des services) ; hors +# conteneur : set -a; . ./.env; set +a # Les lignes commentées donnent la valeur par défaut. Pour les secrets, préférer la # variante _FILE (secret docker monté dans /run/secrets) à une valeur en clair ici. @@ -9,29 +9,27 @@ TZ=Europe/Paris # Port publié par docker-compose.demo.yml # CONFSYNC_PORT=8080 -# ---------- Interface web (pve-confsync serve) ---------- +# ---------- Interface web ---------- # LISTEN=:8080 # DATA_DIR=/data # base SQLite, secret.key, session.key # SESSION_TTL=12h # durée Go : 30m, 8h, 24h… # CONFSYNC_DEMO=false # true : faux clusters PVE, base temporaire -# Clé AES-256 des secrets en base (64 caractères hex ou base64 de 32 octets) : +# Clé AES-256 des mots de passe root en base (64 caractères hex ou base64 de 32 octets) : # openssl rand -hex 32 # Ordre de lecture : CONFSYNC_SECRET_KEY, puis CONFSYNC_SECRET_KEY_FILE, sinon DATA_DIR/secret.key # (créée au 1er démarrage). Le compose fournit déjà CONFSYNC_SECRET_KEY_FILE ; une valeur -# CONFSYNC_SECRET_KEY ici serait prioritaire. Sans cette clé, les secrets enregistrés sont perdus. +# CONFSYNC_SECRET_KEY ici serait prioritaire. Sans cette clé, les mots de passe enregistrés sont perdus. # CONFSYNC_SECRET_KEY= # CONFSYNC_SECRET_KEY_FILE=/run/secrets/confsync_key -# Premier administrateur, créé seulement si la base n'a aucun utilisateur. -# Sans mot de passe fourni, il est tiré au hasard et écrit une fois dans les logs. +# Premier administrateur, créé au démarrage seulement si la base n'a aucun utilisateur +# et qu'un mot de passe est fourni. Sinon, la page de connexion le fait créer : le premier +# qui atteint l'instance devient administrateur. # CONFSYNC_ADMIN_USER=admin # CONFSYNC_ADMIN_PASSWORD= # CONFSYNC_ADMIN_PASSWORD_FILE=/run/secrets/confsync_admin -# Import initial du YAML de la CLI si la base n'a aucun cluster -# CONFSYNC_IMPORT=/config/confsync.yaml - # Comptes locaux (false exige OIDC) # AUTH_LOCAL=true @@ -47,8 +45,3 @@ TZ=Europe/Paris # OIDC_ADMIN_GROUP=confsync-admins # OIDC_OPERATOR_GROUP=confsync-operators # OIDC_DEFAULT_ROLE=viewer # admin, operator ou viewer - -# ---------- CLI (pve-confsync check | export | diff | apply) ---------- -# CONFSYNC_CONFIG=/config/confsync.yaml -# Secrets des tokens référencés par token_secret_env dans confsync.yaml (un par cluster) : -# PVE_SITE_B_SECRET= diff --git a/Dockerfile b/Dockerfile index e6e1d23..15cf3a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,14 +16,12 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" FROM alpine:3.24.2 RUN apk add --no-cache ca-certificates \ && adduser -D -H -u 10001 confsync \ - && mkdir /data /config && chown confsync /data + && mkdir /data && chown confsync /data COPY --from=build /out/pve-confsync /usr/local/bin/pve-confsync USER confsync WORKDIR /data VOLUME /data -ENV CONFSYNC_CONFIG=/config/confsync.yaml DATA_DIR=/data LISTEN=:8080 +ENV 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"] -# interface web ; la CLI reste disponible : docker compose run --rm confsync diff -CMD ["serve"] diff --git a/cmd/pve-confsync/main_test.go b/cmd/pve-confsync/main_test.go deleted file mode 100644 index b8cc6d1..0000000 --- a/cmd/pve-confsync/main_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - "testing" - - "pve-confsync/internal/pve/pvetest" -) - -func TestCLIDiffExportApply(t *testing.T) { - ref := pvetest.New("9.1.4") - defer ref.Close() - cli := pvetest.New("9.1.4") - defer cli.Close() - ref.Options["registered-tags"] = "prod" - ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0"} - ref.Pending.VNets["v10"] = map[string]any{"zone": "vlanz", "tag": 10} - ref.Commit() - - dir := t.TempDir() - t.Setenv("SECRET", "s3cr3t") - cfg := filepath.Join(dir, "c.yaml") - _ = os.WriteFile(cfg, []byte(fmt.Sprintf(`reference: {name: ref, url: %s, token_id: a@pve!ro, token_secret_env: SECRET, insecure: true} -clients: - - {name: c1, url: %s, token_id: a@pve!rw, token_secret_env: SECRET, insecure: true, - mapping: {sdn-zone: {vlanz: {bridge: vmbr1}}}} -`, ref.URL, cli.URL)), 0o600) - ctx := context.Background() - - if code, err := cmdDiff(ctx, cfg, nil); err != nil || code != 2 { - t.Fatalf("diff: code=%d err=%v", code, err) - } - snap := filepath.Join(dir, "ref.yaml") - if err := cmdExport(ctx, cfg, []string{"-o", snap}); err != nil { - t.Fatal(err) - } - // dry-run by default: nothing written - if err := cmdApply(ctx, cfg, []string{"-source", snap}); err != nil || len(cli.Calls) != 0 { - t.Fatalf("dry-run wrote: %v %v", err, cli.Calls) - } - if err := cmdApply(ctx, cfg, []string{"-source", snap, "-confirm", "-yes"}); err != nil { - t.Fatal(err) - } - if cli.Running.VNets["v10"] == nil || cli.Options["registered-tags"] != "prod" { - t.Fatalf("not applied: %v %v", cli.Running.VNets, cli.Options) - } - if code, err := cmdDiff(ctx, cfg, nil); err != nil || code != 0 { - t.Fatalf("diff after apply: code=%d err=%v", code, err) - } -} diff --git a/cmd/pve-confsync/serve.go b/cmd/pve-confsync/serve.go deleted file mode 100644 index cab609b..0000000 --- a/cmd/pve-confsync/serve.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "io/fs" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "pve-confsync/internal/config" - "pve-confsync/internal/demo" - "pve-confsync/internal/server" - "pve-confsync/internal/store" - "pve-confsync/web" -) - -// cmdServe starts the web UI (phases 1 and 2). Settings come from flags or from -// the environment, like Logstream (see README). -func cmdServe(ctx context.Context, args []string) error { - fl := flag.NewFlagSet("serve", flag.ExitOnError) - listen := fl.String("listen", envOr("LISTEN", ":8080"), "adresse d'écoute HTTP") - dataDir := fl.String("data", envOr("DATA_DIR", "/data"), "répertoire des données (base SQLite, clés)") - demoMode := fl.Bool("demo", envBool("CONFSYNC_DEMO", false), "mode démo : faux clusters PVE en mémoire, base temporaire") - webDir := fl.String("web", os.Getenv("WEB_DIR"), "servir les pages depuis ce répertoire au lieu de celles embarquées (développement)") - _ = fl.Parse(args) - - if *demoMode && os.Getenv("DATA_DIR") == "" && !flagSet(fl, "data") { - dir, err := os.MkdirTemp("", "confsync-demo-") - if err != nil { - return err - } - defer os.RemoveAll(dir) - *dataDir = dir - } - if err := os.MkdirAll(*dataDir, 0o700); err != nil { - return err - } - key, keySrc, err := store.LoadKey(*dataDir) - if err != nil { - return err - } - st, err := store.Open(filepath.Join(*dataDir, "confsync.db"), key) - if err != nil { - return err - } - defer st.Close() - log.Printf("database %s, encryption key from %s", filepath.Join(*dataDir, "confsync.db"), keySrc) - - if *demoMode { - clusters, err := demo.Start(ctx, st) - if err != nil { - return fmt.Errorf("demo: %w", err) - } - defer clusters.Close() - log.Printf("DEMO mode: fake PVE clusters started; accounts admin, operateur, lecteur, password %q", demo.Password) - } else if err := bootstrap(ctx, st); err != nil { - return err - } - - ttl, err := time.ParseDuration(envOr("SESSION_TTL", "12h")) - if err != nil { - return fmt.Errorf("SESSION_TTL: %w", err) - } - var static fs.FS = web.FS - if *webDir != "" { - static = os.DirFS(*webDir) - } - srv, err := server.New(server.Options{ - Store: st, - Static: static, - Version: version, - Demo: *demoMode, - Auth: server.AuthConfig{ - Local: envBool("AUTH_LOCAL", true), - Issuer: os.Getenv("OIDC_ISSUER"), - ClientID: os.Getenv("OIDC_CLIENT_ID"), - ClientSecret: secretEnv("OIDC_CLIENT_SECRET"), - RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), - Scopes: os.Getenv("OIDC_SCOPES"), - GroupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"), - AdminGroup: os.Getenv("OIDC_ADMIN_GROUP"), - OperatorGroup: os.Getenv("OIDC_OPERATOR_GROUP"), - DefaultRole: os.Getenv("OIDC_DEFAULT_ROLE"), - SessionTTL: ttl, - DataDir: *dataDir, - Demo: *demoMode, - }, - }) - if err != nil { - return err - } - hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} - errc := make(chan error, 1) - go func() { errc <- hs.ListenAndServe() }() - log.Printf("pve-confsync %s: web UI on %s", version, *listen) - select { - case err := <-errc: - return err - case <-ctx.Done(): - } - log.Printf("stopping: waiting for the synchronizations in progress") - sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = hs.Shutdown(sctx) - srv.Wait() - return nil -} - -// bootstrap creates the first administrator and imports a YAML configuration -// on an empty database. -func bootstrap(ctx context.Context, st *store.Store) error { - n, err := st.CountUsers(ctx) - if err != nil { - return err - } - if n == 0 { - user := envOr("CONFSYNC_ADMIN_USER", "admin") - pass := secretEnv("CONFSYNC_ADMIN_PASSWORD") - generated := pass == "" - if generated { - pass = store.RandomPassword() - } - if err := st.SaveUser(ctx, user, store.RoleAdmin, pass); err != nil { - return fmt.Errorf("first administrator: %w", err) - } - if generated { - log.Printf("first administrator %q created, password: %s (change it in Settings > Account)", user, pass) - } else { - log.Printf("first administrator %q created", user) - } - } - if file := os.Getenv("CONFSYNC_IMPORT"); file != "" { - cls, err := st.Clusters(ctx) - if err != nil { - return err - } - if len(cls) > 0 { - return nil // imported once: the database is the reference from now on - } - cfg, err := config.Load(file) - if err != nil { - return fmt.Errorf("CONFSYNC_IMPORT: %w", err) - } - warns, err := st.ImportConfig(ctx, cfg, false) - if err != nil { - return fmt.Errorf("CONFSYNC_IMPORT: %w", err) - } - for _, w := range warns { - log.Printf("import: %s", w) - } - log.Printf("configuration imported from %s", file) - } - return nil -} - -// secretEnv reads NAME, or the file named by NAME_FILE (docker secret). -func secretEnv(name string) string { - if v := os.Getenv(name); v != "" { - return v - } - if f := os.Getenv(name + "_FILE"); f != "" { - b, err := os.ReadFile(f) - if err != nil { - log.Printf("%s_FILE: %v", name, err) - return "" - } - return strings.TrimSpace(string(b)) - } - return "" -} - -func envBool(name string, def bool) bool { - v := os.Getenv(name) - if v == "" { - return def - } - b, err := strconv.ParseBool(v) - if err != nil { - log.Printf("%s=%q: true or false expected, using %t", name, v, def) - return def - } - return b -} - -func flagSet(fs *flag.FlagSet, name string) bool { - found := false - fs.Visit(func(f *flag.Flag) { - if f.Name == name { - found = true - } - }) - return found -} diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml index 66a128e..7db69da 100644 --- a/docker-compose.demo.yml +++ b/docker-compose.demo.yml @@ -1,14 +1,14 @@ # Démo de l'interface web, sans cluster PVE ni secret à créer : # docker compose -f docker-compose.demo.yml up -d --build # http://:8080 ; comptes admin, operateur, lecteur ; mot de passe confsync-demo -# Faux clusters PVE en mémoire (référence 9.1, deux clients avec écarts, un PVE 8 refusé). +# Faux clusters PVE en mémoire (primaire 9.1, deux cibles avec écarts, un PVE 8 refusé). # Base temporaire : tout est remis à zéro au redémarrage du conteneur. services: confsync-demo: build: . image: pve-confsync:dev container_name: pve-confsync-demo - command: ["serve", "-demo"] + command: ["-demo"] restart: unless-stopped ports: - "${CONFSYNC_PORT:-8080}:8080" diff --git a/docker-compose.yml b/docker-compose.yml index e7848ca..0458a1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,20 @@ -# Interface web (phases 1-2) : -# docker compose up -d confsync-web # http://:8080 ; mot de passe admin dans les logs au 1er démarrage -# CLI de la phase 0, lancée à la demande avec confsync.yaml : -# docker compose run --rm confsync check -# docker compose run --rm confsync diff -# docker compose run --rm confsync export -o /data/ref.yaml -# docker compose run --rm confsync apply # dry-run -# docker compose run --rm confsync apply -confirm # application (confirmation SDN interactive) +# Interface web : +# docker compose up -d # http://:8080 +# Base vide : la page de connexion crée le premier administrateur (ne pas exposer +# l'instance avant), sauf si CONFSYNC_ADMIN_PASSWORD(_FILE) est fourni. +# Clusters, mots de passe root (chiffrés en base) et réglages : tout se fait dans l'UI. services: - confsync-web: + confsync: build: . image: pve-confsync:dev - container_name: pve-confsync-web - command: ["serve"] + container_name: pve-confsync restart: unless-stopped ports: - "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure) environment: TZ: ${TZ:-Europe/Paris} - CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key # clé des secrets en base, hors volume /data + CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key # clé AES des mots de passe 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 @@ -34,36 +29,11 @@ services: secrets: - confsync_key - confsync: - image: pve-confsync:dev - container_name: pve-confsync - profiles: ["cli"] # pas démarré par "up" ; "run" l'active - command: ["check"] - environment: - TZ: ${TZ:-Europe/Paris} - PVE_SITE_B_SECRET: ${PVE_SITE_B_SECRET:-} # secrets par variable (voir token_secret_env) - env_file: - - path: .env - required: false - volumes: - - ./confsync.yaml:/config/confsync.yaml:ro - - confsync-cli:/data # exports YAML - secrets: - - pve_ref - - pve_site_a - stdin_open: true - tty: true - -# Fichiers chmod 600, propriétaire 10001, montés dans /run/secrets/. -# confsync_key : openssl rand -hex 32 > secrets/confsync_key +# Fichier chmod 600, propriétaire 10001, monté dans /run/secrets/confsync_key. +# mkdir -p secrets && openssl rand -hex 32 > secrets/confsync_key secrets: confsync_key: file: ./secrets/confsync_key - pve_ref: - file: ./secrets/pve_ref - pve_site_a: - file: ./secrets/pve_site_a volumes: confsync-data: - confsync-cli: diff --git a/examples/confsync.example.yaml b/examples/confsync.example.yaml deleted file mode 100644 index 90a2c74..0000000 --- a/examples/confsync.example.yaml +++ /dev/null @@ -1,63 +0,0 @@ -# pve-confsync - exemple de configuration (phase 0, CLI) -# Les secrets des tokens ne sont JAMAIS écrits ici : variable d'environnement -# (token_secret_env) ou fichier, par exemple un secret docker (token_secret_file). - -reference: - name: ref - url: https://pve-ref.lab:8006 - token_id: confsync@pve!ro # token en lecture seule (Sys.Audit, SDN.Audit) - token_secret_file: /run/secrets/pve_ref # secret docker (voir docker-compose.yml) - fingerprint: "AA:BB:CC:...:FF" # SHA-256 du certificat (pinning, certificat auto-signé) - -clients: - - name: site-a - url: https://pve-a.lab:8006 - token_id: confsync@pve!rw - token_secret_file: /run/secrets/pve_site_a - ca_file: /config/ca-site-a.pem # ou fingerprint, ou insecure: true (labo uniquement) - # Valeurs propres au cluster, prioritaires sur la référence : - # type d'objet -> id -> champ -> valeur - mapping: - sdn-zone: - vlanz: - bridge: vmbr1 # obligatoire pour créer une zone VLAN - nodes: pve-a1,pve-a2 - vxz: - peers: 192.168.10.1,192.168.10.2 - datacenter-options: - datacenter: - next-id: lower=2000,upper=2999 - # Politiques propres à ce client (prioritaires sur les politiques globales) - policies: - sdn-zone: - mtu: ignore - - - name: site-b - url: https://pve-b.lab:8006 - token_id: confsync@pve!rw - token_secret_env: PVE_SITE_B_SECRET - insecure: true - mapping: - sdn-zone: - vlanz: - bridge: vmbr0 - -# Types synchronisés (défaut : tous) -kinds: [datacenter-options, sdn-zone, sdn-vnet, sdn-subnet] - -# Sélection des objets par motif (glob) sur l'id -objects: - sdn-zone: - exclude: ["lab*"] - -# Politique par champ : sync | ignore | override (valeur fournie par le mapping du client) -# Défauts : options datacenter = tag-style, registered-tags, user-tag-access, next-id en sync, -# le reste ignoré ; zones SDN = nodes, bridge, peers, fabric, dns, reversedns, dnszone en -# override, le reste en sync ; vnets et subnets = sync. -policies: - datacenter-options: - keyboard: sync - language: sync - -# Suppression chez les clients des objets absents de la référence (défaut : non) -prune: false diff --git a/go.mod b/go.mod index 507796d..3c3b460 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module pve-confsync go 1.24.0 require ( - gopkg.in/yaml.v3 v3.0.1 + golang.org/x/crypto v0.43.0 modernc.org/sqlite v1.40.0 ) @@ -14,7 +14,7 @@ require ( 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 + golang.org/x/sys v0.37.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d2d5d13..c702d35 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh 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/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 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= @@ -17,14 +19,12 @@ 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/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 75211db..d9cdc31 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,44 +1,38 @@ package config import ( - "os" - "path/filepath" + "strings" "testing" "pve-confsync/internal/adapter" "pve-confsync/internal/model" ) -func TestLoadExample(t *testing.T) { - c, err := Load(filepath.Join("..", "..", "examples", "confsync.example.yaml")) - if err != nil { - t.Fatal(err) +func TestValidate(t *testing.T) { + c := &Config{ + Reference: Cluster{Name: "ref"}, + Clients: []Client{{Cluster: Cluster{Name: "ref"}}}, + Kinds: []model.Kind{model.KindPassword}, + Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"u2f": adapter.Sync, "x": "bogus"}}, } - a, _ := adapter.ByKind(model.KindZone) - cl, _ := c.Client("site-a") - if p := c.Policy(cl, a, "mtu"); p != adapter.Ignore { - t.Fatalf("site-a mtu policy = %s", p) + err := c.Validate() + if err == nil { + t.Fatal("expected errors") } - if p := c.Policy(nil, a, "bridge"); p != adapter.Override { - t.Fatalf("bridge policy = %s", p) - } - if c.Selected(model.KindZone, "lab1") || !c.Selected(model.KindZone, "vlanz") { - t.Fatal("selection") - } -} - -func TestRejects(t *testing.T) { - for name, body := range map[string]string{ - "clear secret": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret: s}\n", - "no secret": "reference: {name: r, url: https://x, token_id: a@pve!b}\n", - "forbidden": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret_env: X}\npolicies: {datacenter-options: {webauthn: sync}}\n", - "bad policy": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret_env: X}\npolicies: {sdn-zone: {mtu: copy}}\n", - "unknown kind": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret_env: X}\nkinds: [firewall]\n", - } { - f := filepath.Join(t.TempDir(), "c.yaml") - _ = os.WriteFile(f, []byte(body), 0o600) - if _, err := Load(f); err == nil { - t.Errorf("%s: expected an error", name) + for _, want := range []string{"duplicate name", "cannot be synchronized", "unknown policy", "pve-password needs pve-user"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missing %q in %v", want, err) } } } + +func TestSSHOptions(t *testing.T) { + cl := Cluster{URL: "https://pve1.lab:8006", Password: "p"} + if o := cl.SSHOptions(); o.Host != "pve1.lab" || o.User != "root" { + t.Fatalf("%+v", o) + } + cl.SSHHost = "10.0.0.1" + if o := cl.SSHOptions(); o.Host != "10.0.0.1" { + t.Fatalf("%+v", o) + } +} diff --git a/internal/service/check_test.go b/internal/service/check_test.go new file mode 100644 index 0000000..8305884 --- /dev/null +++ b/internal/service/check_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + + "pve-confsync/internal/config" + "pve-confsync/internal/pve/pvetest" +) + +// A node that presents another SSH host key than the recorded one is refused, +// until the new key is explicitly trusted. +func TestCheckSSHHostKeyMismatch(t *testing.T) { + s := pvetest.New("9.1.4") + defer s.Close() + pub, _, _ := ed25519.GenerateKey(rand.Reader) + other, _ := ssh.NewPublicKey(pub) + sum := sha256.Sum256(s.Certificate().Raw) + host, port := s.SSHAddr() + cl := config.Cluster{Name: "c", URL: s.URL, User: "root@pam", Password: pvetest.Password, + Fingerprint: hex.EncodeToString(sum[:]), SSHHost: host, SSHPort: port, + SSHHostKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(other)))} + + res := CheckCluster(context.Background(), cl, false, false) + if res.OK || !res.SSHMismatch || !res.API || res.SSHHostKey != "" || !strings.Contains(res.Error, "clé d'hôte") { + t.Fatalf("mismatch expected: %+v", res) + } + res = CheckCluster(context.Background(), cl, false, true) + if !res.OK || res.SSHHostKey != s.SSHHostKey() { + t.Fatalf("trusted key expected: %+v", res) + } +}