From e61f2e4740c0874303d06007a7d70c5c03a7efd7 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 13 Oct 2026 15:56:10 +0200 Subject: [PATCH] =?UTF-8?q?cmd,=20config,=20demo=20:=20commande=20serve,?= =?UTF-8?q?=20secrets=20venant=20de=20la=20base,=20mode=20d=C3=A9mo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy --- cmd/pve-confsync/main.go | 42 ++------ cmd/pve-confsync/serve.go | 199 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 18 +++- internal/demo/demo.go | 129 ++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 37 deletions(-) create mode 100644 cmd/pve-confsync/serve.go create mode 100644 internal/demo/demo.go diff --git a/cmd/pve-confsync/main.go b/cmd/pve-confsync/main.go index 6bf87b5..14eb719 100644 --- a/cmd/pve-confsync/main.go +++ b/cmd/pve-confsync/main.go @@ -1,6 +1,7 @@ // pve-confsync synchronizes part of the configuration of a reference Proxmox VE // cluster (datacenter options, SDN VLAN/VXLAN zones, vnets, subnets) to client -// clusters through the PVE API. Phase 0: command line only. +// clusters through the PVE API: command line (check, export, diff, apply) and +// web UI (serve). package main import ( @@ -24,6 +25,7 @@ import ( "pve-confsync/internal/engine" "pve-confsync/internal/model" "pve-confsync/internal/pve" + "pve-confsync/internal/service" ) var version = "dev" @@ -37,6 +39,7 @@ Commandes : export exporte l'état normalisé d'un cluster en YAML (défaut : la référence) diff affiche les écarts référence -> clients (code retour 2 s'il y en a) apply dry-run par défaut ; applique avec -confirm + serve interface web (clusters, diff, dry-run, synchronisation, journal) version affiche la version Lancer "pve-confsync -h" pour les options d'une commande. @@ -69,6 +72,8 @@ func main() { code, err = cmdDiff(ctx, *cfgFile, args[1:]) case "apply": err = cmdApply(ctx, *cfgFile, args[1:]) + case "serve": + err = cmdServe(ctx, args[1:]) default: global.Usage() os.Exit(1) @@ -116,7 +121,7 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error { fmt.Printf(" avertissement : droits du token non lisibles (%v)\n", err) return } - for _, w := range privilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) { + for _, w := range service.PrivilegeWarnings(perms, wantWrite, cfg.HasKind(model.KindDatacenter)) { fmt.Println(" avertissement :", w) } } @@ -127,39 +132,6 @@ func cmdCheck(ctx context.Context, cfgFile string, args []string) error { return errors.Join(errs...) } -// privilegeWarnings flags a reference token able to write, and a client token -// missing the privileges needed by the synchronized kinds. -func privilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string { - has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok } - any := func(priv string) bool { - for p := range perms { - if has(p, priv) { - return true - } - } - return false - } - var w []string - if !write { - for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} { - if any(p) { - w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu") - } - } - return w - } - if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") { - w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera") - } - if dcOptions && !has("/", "Sys.Modify") { - w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites") - } - if !has("/", "Sys.Audit") { - w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles") - } - return w -} - // ---- export ---- func cmdExport(ctx context.Context, cfgFile string, args []string) error { diff --git a/cmd/pve-confsync/serve.go b/cmd/pve-confsync/serve.go new file mode 100644 index 0000000..cab609b --- /dev/null +++ b/cmd/pve-confsync/serve.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "pve-confsync/internal/config" + "pve-confsync/internal/demo" + "pve-confsync/internal/server" + "pve-confsync/internal/store" + "pve-confsync/web" +) + +// cmdServe starts the web UI (phases 1 and 2). Settings come from flags or from +// the environment, like Logstream (see README). +func cmdServe(ctx context.Context, args []string) error { + fl := flag.NewFlagSet("serve", flag.ExitOnError) + listen := fl.String("listen", envOr("LISTEN", ":8080"), "adresse d'écoute HTTP") + dataDir := fl.String("data", envOr("DATA_DIR", "/data"), "répertoire des données (base SQLite, clés)") + demoMode := fl.Bool("demo", envBool("CONFSYNC_DEMO", false), "mode démo : faux clusters PVE en mémoire, base temporaire") + webDir := fl.String("web", os.Getenv("WEB_DIR"), "servir les pages depuis ce répertoire au lieu de celles embarquées (développement)") + _ = fl.Parse(args) + + if *demoMode && os.Getenv("DATA_DIR") == "" && !flagSet(fl, "data") { + dir, err := os.MkdirTemp("", "confsync-demo-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + *dataDir = dir + } + if err := os.MkdirAll(*dataDir, 0o700); err != nil { + return err + } + key, keySrc, err := store.LoadKey(*dataDir) + if err != nil { + return err + } + st, err := store.Open(filepath.Join(*dataDir, "confsync.db"), key) + if err != nil { + return err + } + defer st.Close() + log.Printf("database %s, encryption key from %s", filepath.Join(*dataDir, "confsync.db"), keySrc) + + if *demoMode { + clusters, err := demo.Start(ctx, st) + if err != nil { + return fmt.Errorf("demo: %w", err) + } + defer clusters.Close() + log.Printf("DEMO mode: fake PVE clusters started; accounts admin, operateur, lecteur, password %q", demo.Password) + } else if err := bootstrap(ctx, st); err != nil { + return err + } + + ttl, err := time.ParseDuration(envOr("SESSION_TTL", "12h")) + if err != nil { + return fmt.Errorf("SESSION_TTL: %w", err) + } + var static fs.FS = web.FS + if *webDir != "" { + static = os.DirFS(*webDir) + } + srv, err := server.New(server.Options{ + Store: st, + Static: static, + Version: version, + Demo: *demoMode, + Auth: server.AuthConfig{ + Local: envBool("AUTH_LOCAL", true), + Issuer: os.Getenv("OIDC_ISSUER"), + ClientID: os.Getenv("OIDC_CLIENT_ID"), + ClientSecret: secretEnv("OIDC_CLIENT_SECRET"), + RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + Scopes: os.Getenv("OIDC_SCOPES"), + GroupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"), + AdminGroup: os.Getenv("OIDC_ADMIN_GROUP"), + OperatorGroup: os.Getenv("OIDC_OPERATOR_GROUP"), + DefaultRole: os.Getenv("OIDC_DEFAULT_ROLE"), + SessionTTL: ttl, + DataDir: *dataDir, + Demo: *demoMode, + }, + }) + if err != nil { + return err + } + hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} + errc := make(chan error, 1) + go func() { errc <- hs.ListenAndServe() }() + log.Printf("pve-confsync %s: web UI on %s", version, *listen) + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + log.Printf("stopping: waiting for the synchronizations in progress") + sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = hs.Shutdown(sctx) + srv.Wait() + return nil +} + +// bootstrap creates the first administrator and imports a YAML configuration +// on an empty database. +func bootstrap(ctx context.Context, st *store.Store) error { + n, err := st.CountUsers(ctx) + if err != nil { + return err + } + if n == 0 { + user := envOr("CONFSYNC_ADMIN_USER", "admin") + pass := secretEnv("CONFSYNC_ADMIN_PASSWORD") + generated := pass == "" + if generated { + pass = store.RandomPassword() + } + if err := st.SaveUser(ctx, user, store.RoleAdmin, pass); err != nil { + return fmt.Errorf("first administrator: %w", err) + } + if generated { + log.Printf("first administrator %q created, password: %s (change it in Settings > Account)", user, pass) + } else { + log.Printf("first administrator %q created", user) + } + } + if file := os.Getenv("CONFSYNC_IMPORT"); file != "" { + cls, err := st.Clusters(ctx) + if err != nil { + return err + } + if len(cls) > 0 { + return nil // imported once: the database is the reference from now on + } + cfg, err := config.Load(file) + if err != nil { + return fmt.Errorf("CONFSYNC_IMPORT: %w", err) + } + warns, err := st.ImportConfig(ctx, cfg, false) + if err != nil { + return fmt.Errorf("CONFSYNC_IMPORT: %w", err) + } + for _, w := range warns { + log.Printf("import: %s", w) + } + log.Printf("configuration imported from %s", file) + } + return nil +} + +// secretEnv reads NAME, or the file named by NAME_FILE (docker secret). +func secretEnv(name string) string { + if v := os.Getenv(name); v != "" { + return v + } + if f := os.Getenv(name + "_FILE"); f != "" { + b, err := os.ReadFile(f) + if err != nil { + log.Printf("%s_FILE: %v", name, err) + return "" + } + return strings.TrimSpace(string(b)) + } + return "" +} + +func envBool(name string, def bool) bool { + v := os.Getenv(name) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + log.Printf("%s=%q: true or false expected, using %t", name, v, def) + return def + } + return b +} + +func flagSet(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} diff --git a/internal/config/config.go b/internal/config/config.go index 3baa10c..a92b3c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,10 @@ type Cluster struct { Fingerprint string `yaml:"fingerprint,omitempty"` Insecure bool `yaml:"insecure,omitempty"` Timeout string `yaml:"timeout,omitempty"` + + // Set by the web UI from its database, never read from nor written to YAML. + Secret string `yaml:"-"` // token secret, decrypted + CAPEM string `yaml:"-"` // CA bundle, PEM } // Client is a target cluster with its mapping (kind -> id -> field -> value) @@ -74,6 +78,9 @@ func Load(file string) (*Config, error) { return &c, c.validate() } +// Validate checks a configuration built elsewhere (web UI database, YAML import). +func (c *Config) Validate() error { return c.validate() } + func (c *Config) validate() error { var errs []error seen := map[string]bool{} @@ -86,7 +93,7 @@ func (c *Config) validate() error { errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name)) } seen[cl.Name] = true - if cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" { + if cl.Secret == "" && cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" { errs = append(errs, fmt.Errorf("cluster %s: token_secret_env or token_secret_file is required", cl.Name)) } } @@ -158,11 +165,18 @@ func (cl Cluster) PVE() (*pve.Client, error) { } return pve.New(pve.Options{ Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret, - CAFile: cl.CAFile, Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout, + CAFile: cl.CAFile, CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout, }) } +// ResolveSecret reads the token secret from the environment variable or file of +// the YAML configuration (used when a YAML file is imported in the web UI). +func (cl Cluster) ResolveSecret() (string, error) { return cl.secret() } + func (cl Cluster) secret() (string, error) { + if cl.Secret != "" { + return cl.Secret, nil + } if cl.TokenSecretEnv != "" { if v := os.Getenv(cl.TokenSecretEnv); v != "" { return v, nil diff --git a/internal/demo/demo.go b/internal/demo/demo.go new file mode 100644 index 0000000..3b7419e --- /dev/null +++ b/internal/demo/demo.go @@ -0,0 +1,129 @@ +// Package demo starts fake PVE clusters (pvetest) and fills a database with +// them, to try the web UI without a Proxmox cluster: a reference, two clients +// with differences (missing objects, different fields, a missing mapping) and +// a PVE 8 cluster that is refused. +package demo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + + "pve-confsync/internal/adapter" + "pve-confsync/internal/model" + "pve-confsync/internal/pve/pvetest" + "pve-confsync/internal/store" +) + +// Password of the demo accounts admin, operateur and lecteur. +const Password = "confsync-demo" + +// Clusters are the fake clusters; Close stops them. +type Clusters struct{ servers []*pvetest.Server } + +// Close stops the fake clusters. +func (c *Clusters) Close() { + for _, s := range c.servers { + s.Close() + } +} + +// Start starts the fake clusters and records them (and the demo accounts) in st. +func Start(ctx context.Context, st *store.Store) (*Clusters, error) { + ref := pvetest.New("9.1.4") + ref.Options = map[string]string{ + "registered-tags": "backup;dev;prod", + "tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full", + "next-id": "lower=100,upper=999999", + "user-tag-access": "user-allow=list,user-allow-list=team-a;team-b", + "keyboard": "fr", + } + ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"} + ref.Pending.Zones["stor"] = map[string]any{"type": "vlan", "bridge": "vmbr2", "mtu": 9000, "ipam": "pve"} + ref.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "10.0.0.1,10.0.0.2", "mtu": 1450, "ipam": "pve"} + ref.Pending.Zones["evpnz"] = map[string]any{"type": "evpn", "controller": "evpnctl", "vrf-vxlan": 4000} + ref.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"} + ref.Pending.VNets["v200"] = map[string]any{"zone": "vlanz", "tag": 200, "alias": "dev"} + ref.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"} + ref.Pending.VNets["vx1"] = map[string]any{"zone": "vxz", "tag": 10001, "alias": "overlay"} + ref.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1", + "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} + ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"} + ref.Commit() + + // site-a: tags and an MTU behind, the VXLAN zone and the dev network missing + a := pvetest.New("9.0.10") + a.Options = map[string]string{ + "registered-tags": "dev;prod", + "tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full", + "next-id": "lower=2000,upper=2999", + "keyboard": "en-us", + } + a.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr1", "mtu": 9000, "ipam": "pve"} + a.Pending.Zones["stor"] = map[string]any{"type": "vlan", "bridge": "vmbr2", "mtu": 9000, "ipam": "pve", "nodes": "pve-a1,pve-a2"} + a.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "production"} + a.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"} + a.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"} + a.Commit() + + // site-b: nearly in line, but no mapping for the bridge of the "stor" zone + // (blocking error) and a local zone absent from the reference (kept, no prune) + b := pvetest.New("9.1.2") + b.Options = map[string]string{ + "registered-tags": "backup;dev;prod", + "tag-style": "color-map=prod:c0392b:ffffff;dev:2980b9:ffffff,ordering=config,shape=full", + "next-id": "lower=100,upper=999999", + "user-tag-access": "user-allow=list,user-allow-list=team-a;team-b", + } + b.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"} + b.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "172.16.0.1,172.16.0.2", "mtu": 1450, "ipam": "pve"} + b.Pending.Zones["labz"] = map[string]any{"type": "vlan", "bridge": "vmbr9", "ipam": "pve"} + b.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"} + b.Pending.VNets["v200"] = map[string]any{"zone": "vlanz", "tag": 200, "alias": "dev"} + b.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1", + "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} + b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"} + b.Commit() + + // legacy: PVE 8, refused by the version check + old := pvetest.New("8.4.1") + + c := &Clusters{servers: []*pvetest.Server{ref, a, b, old}} + save := func(s *pvetest.Server, name, token string, refer bool, pos int, + mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error { + sum := sha256.Sum256(s.Certificate().Raw) + return st.SaveCluster(ctx, "", store.Cluster{Name: name, Reference: refer, Position: pos, URL: s.URL, + TokenID: token, Secret: "demo-" + name, Fingerprint: hex.EncodeToString(sum[:]), + Mapping: mapping, Policies: pol}) + } + steps := []error{ + save(ref, "ref-rennes", "confsync@pve!ro", true, 0, nil, nil), + save(a, "site-a", "confsync@pve!rw", false, 1, map[model.Kind]map[string]map[string]any{ + model.KindDatacenter: {"datacenter": {"next-id": "lower=2000,upper=2999"}}, + model.KindZone: { + "vlanz": {"bridge": "vmbr1"}, + "stor": {"bridge": "vmbr2", "nodes": "pve-a1,pve-a2"}, + "vxz": {"peers": "192.168.10.1,192.168.10.2"}, + }, + }, nil), + save(b, "site-b", "confsync@pve!rw", false, 2, map[model.Kind]map[string]map[string]any{ + model.KindZone: { + "vlanz": {"bridge": "vmbr0"}, + "vxz": {"peers": "172.16.0.1,172.16.0.2"}, + }, + }, map[model.Kind]map[string]adapter.Policy{model.KindZone: {"mtu": adapter.Ignore}}), + save(old, "legacy-pve8", "confsync@pve!rw", false, 3, nil, nil), + st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds, + Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}), + } + for _, u := range []struct{ name, role string }{{"admin", store.RoleAdmin}, {"operateur", store.RoleOperator}, {"lecteur", store.RoleViewer}} { + steps = append(steps, st.SaveUser(ctx, u.name, u.role, Password)) + } + for _, err := range steps { + if err != nil { + c.Close() + return nil, err + } + } + return c, nil +}