Full web : clusters gérés dans l'UI, primaire et cibles, comptes pve

This commit is contained in:
claude Bot committed 2026-10-23 17:20:16 +02:00
1 parent 0fb88cb4b0
commit 3e9cb563be
11 files changed
+89 -410

No files matched your search

+8 -15
View File
@@ -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=
+2 -4
View File
@@ -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"]
-53
View File
@@ -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)
}
}
-199
View File
@@ -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
}
+2 -2
View File
@@ -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://<hôte>: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"
+10 -40
View File
@@ -1,25 +1,20 @@
# 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 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://<hôte>: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/<nom>.
# 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:
-63
View File
@@ -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
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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=
+20 -26
View File
@@ -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)
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)
}
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)
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)
}
}
+39
View File
@@ -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)
}
}