Full web : clusters gérés dans l'UI, primaire et cibles, comptes pve
This commit is contained in:
1 parent
0fb88cb4b0
commit
3e9cb563be
11 files changed
+89
-410
No files matched your search
+8
-15
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user