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
|
# 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
|
# Lu par docker compose (substitution ${...} et env_file des services) ; hors
|
||||||
# ou `serve` hors conteneur : set -a; . ./.env; set +a
|
# conteneur : set -a; . ./.env; set +a
|
||||||
# Les lignes commentées donnent la valeur par défaut. Pour les secrets, préférer la
|
# 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.
|
# 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
|
# Port publié par docker-compose.demo.yml
|
||||||
# CONFSYNC_PORT=8080
|
# CONFSYNC_PORT=8080
|
||||||
|
|
||||||
# ---------- Interface web (pve-confsync serve) ----------
|
# ---------- Interface web ----------
|
||||||
# LISTEN=:8080
|
# LISTEN=:8080
|
||||||
# DATA_DIR=/data # base SQLite, secret.key, session.key
|
# DATA_DIR=/data # base SQLite, secret.key, session.key
|
||||||
# SESSION_TTL=12h # durée Go : 30m, 8h, 24h…
|
# SESSION_TTL=12h # durée Go : 30m, 8h, 24h…
|
||||||
# CONFSYNC_DEMO=false # true : faux clusters PVE, base temporaire
|
# 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
|
# openssl rand -hex 32
|
||||||
# Ordre de lecture : CONFSYNC_SECRET_KEY, puis CONFSYNC_SECRET_KEY_FILE, sinon DATA_DIR/secret.key
|
# 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
|
# (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=
|
||||||
# CONFSYNC_SECRET_KEY_FILE=/run/secrets/confsync_key
|
# CONFSYNC_SECRET_KEY_FILE=/run/secrets/confsync_key
|
||||||
|
|
||||||
# Premier administrateur, créé seulement si la base n'a aucun utilisateur.
|
# Premier administrateur, créé au démarrage 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.
|
# 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_USER=admin
|
||||||
# CONFSYNC_ADMIN_PASSWORD=
|
# CONFSYNC_ADMIN_PASSWORD=
|
||||||
# CONFSYNC_ADMIN_PASSWORD_FILE=/run/secrets/confsync_admin
|
# 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)
|
# Comptes locaux (false exige OIDC)
|
||||||
# AUTH_LOCAL=true
|
# AUTH_LOCAL=true
|
||||||
|
|
||||||
@@ -47,8 +45,3 @@ TZ=Europe/Paris
|
|||||||
# OIDC_ADMIN_GROUP=confsync-admins
|
# OIDC_ADMIN_GROUP=confsync-admins
|
||||||
# OIDC_OPERATOR_GROUP=confsync-operators
|
# OIDC_OPERATOR_GROUP=confsync-operators
|
||||||
# OIDC_DEFAULT_ROLE=viewer # admin, operator ou viewer
|
# 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
|
FROM alpine:3.24.2
|
||||||
RUN apk add --no-cache ca-certificates \
|
RUN apk add --no-cache ca-certificates \
|
||||||
&& adduser -D -H -u 10001 confsync \
|
&& 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
|
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 DATA_DIR=/data LISTEN=:8080
|
ENV DATA_DIR=/data LISTEN=:8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
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"]
|
||||||
# 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 :
|
# Démo de l'interface web, sans cluster PVE ni secret à créer :
|
||||||
# docker compose -f docker-compose.demo.yml up -d --build
|
# docker compose -f docker-compose.demo.yml up -d --build
|
||||||
# http://<hôte>:8080 ; comptes admin, operateur, lecteur ; mot de passe confsync-demo
|
# 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.
|
# Base temporaire : tout est remis à zéro au redémarrage du conteneur.
|
||||||
services:
|
services:
|
||||||
confsync-demo:
|
confsync-demo:
|
||||||
build: .
|
build: .
|
||||||
image: pve-confsync:dev
|
image: pve-confsync:dev
|
||||||
container_name: pve-confsync-demo
|
container_name: pve-confsync-demo
|
||||||
command: ["serve", "-demo"]
|
command: ["-demo"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${CONFSYNC_PORT:-8080}:8080"
|
- "${CONFSYNC_PORT:-8080}:8080"
|
||||||
|
|||||||
+10
-40
@@ -1,25 +1,20 @@
|
|||||||
# Interface web (phases 1-2) :
|
# Interface web :
|
||||||
# docker compose up -d confsync-web # http://<hôte>:8080 ; mot de passe admin dans les logs au 1er démarrage
|
# docker compose up -d # http://<hôte>:8080
|
||||||
# CLI de la phase 0, lancée à la demande avec confsync.yaml :
|
# Base vide : la page de connexion crée le premier administrateur (ne pas exposer
|
||||||
# docker compose run --rm confsync check
|
# l'instance avant), sauf si CONFSYNC_ADMIN_PASSWORD(_FILE) est fourni.
|
||||||
# docker compose run --rm confsync diff
|
# Clusters, mots de passe root (chiffrés en base) et réglages : tout se fait dans l'UI.
|
||||||
# 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)
|
|
||||||
services:
|
services:
|
||||||
confsync-web:
|
confsync:
|
||||||
build: .
|
build: .
|
||||||
image: pve-confsync:dev
|
image: pve-confsync:dev
|
||||||
container_name: pve-confsync-web
|
container_name: pve-confsync
|
||||||
command: ["serve"]
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure)
|
- "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure)
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Europe/Paris}
|
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_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_ISSUER: https://auth.example.org/application/o/confsync/
|
||||||
# OIDC_CLIENT_ID: confsync
|
# OIDC_CLIENT_ID: confsync
|
||||||
# OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client
|
# OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client
|
||||||
@@ -34,36 +29,11 @@ services:
|
|||||||
secrets:
|
secrets:
|
||||||
- confsync_key
|
- confsync_key
|
||||||
|
|
||||||
confsync:
|
# Fichier chmod 600, propriétaire 10001, monté dans /run/secrets/confsync_key.
|
||||||
image: pve-confsync:dev
|
# mkdir -p secrets && openssl rand -hex 32 > secrets/confsync_key
|
||||||
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
|
|
||||||
secrets:
|
secrets:
|
||||||
confsync_key:
|
confsync_key:
|
||||||
file: ./secrets/confsync_key
|
file: ./secrets/confsync_key
|
||||||
pve_ref:
|
|
||||||
file: ./secrets/pve_ref
|
|
||||||
pve_site_a:
|
|
||||||
file: ./secrets/pve_site_a
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
confsync-data:
|
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
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
golang.org/x/crypto v0.43.0
|
||||||
modernc.org/sqlite v1.40.0
|
modernc.org/sqlite v1.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 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/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 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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
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 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
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 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
|||||||
@@ -1,44 +1,38 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"strings"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"pve-confsync/internal/adapter"
|
"pve-confsync/internal/adapter"
|
||||||
"pve-confsync/internal/model"
|
"pve-confsync/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadExample(t *testing.T) {
|
func TestValidate(t *testing.T) {
|
||||||
c, err := Load(filepath.Join("..", "..", "examples", "confsync.example.yaml"))
|
c := &Config{
|
||||||
if err != nil {
|
Reference: Cluster{Name: "ref"},
|
||||||
t.Fatal(err)
|
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)
|
err := c.Validate()
|
||||||
cl, _ := c.Client("site-a")
|
if err == nil {
|
||||||
if p := c.Policy(cl, a, "mtu"); p != adapter.Ignore {
|
t.Fatal("expected errors")
|
||||||
t.Fatalf("site-a mtu policy = %s", p)
|
|
||||||
}
|
}
|
||||||
if p := c.Policy(nil, a, "bridge"); p != adapter.Override {
|
for _, want := range []string{"duplicate name", "cannot be synchronized", "unknown policy", "pve-password needs pve-user"} {
|
||||||
t.Fatalf("bridge policy = %s", p)
|
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) {
|
func TestSSHOptions(t *testing.T) {
|
||||||
for name, body := range map[string]string{
|
cl := Cluster{URL: "https://pve1.lab:8006", Password: "p"}
|
||||||
"clear secret": "reference: {name: r, url: https://x, token_id: a@pve!b, token_secret: s}\n",
|
if o := cl.SSHOptions(); o.Host != "pve1.lab" || o.User != "root" {
|
||||||
"no secret": "reference: {name: r, url: https://x, token_id: a@pve!b}\n",
|
t.Fatalf("%+v", o)
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
|
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