Files
cedricandClaude Opus 5.5 1e667ce8b9 Réplication de nœuds : réseau et DNS par l'API, multipath, chrony, sysctl par SSH
Deuxième mode de réplication, séparé de la réplication cluster vers cluster
(sélecteur « Clusters (API) | Nœuds (fichiers) » en haut de page) : un profil
réplique la configuration système d'un nœud maître vers des nœuds cibles.

- réseau par /nodes/{node}/network (interfaces.new puis rechargement), adresses
  et passerelles propres à chaque nœud, mapping des noms d'interfaces par cible ;
  DNS par /nodes/{node}/dns ; multipath.conf, wwids (ajout seul), chrony,
  sysctl.d, interfaces.d (hors sdn) par SSH
- découverte des nœuds (/cluster/status), accès SSH par nœud (mot de passe du
  cluster ou propre), clé d'hôte épinglée, contrôle du nom du nœud
- dry-run avec diff des fichiers ; apply (admin) avec contrôle SHA-256,
  sauvegarde et restore.sh sur le nœud, écriture atomique, rechargements,
  restauration si un rechargement échoue, timer systemd de restauration si le
  nœud est injoignable après le rechargement du réseau, confirmation tapée
- vue Nœuds et profils, journal séparé par mode, démo avec nœuds simulés

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:29:55 +02:00

197 lines
7.2 KiB
Go

// Package server is the web UI of pve-confsync, the only way to use it: first
// administrator, clusters (root credentials, primary and targets), replicated
// kinds, visual diff per target, dry-run and synchronization per object, per
// target or towards several targets, execution journal and audit trail. The
// pages (web/) are embedded in the binary; the API is JSON under /api/.
package server
import (
"context"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"strings"
"sync"
"time"
"pve-confsync/internal/nodesync"
"pve-confsync/internal/service"
"pve-confsync/internal/store"
)
// Options of the server.
type Options struct {
Store *store.Store
Auth AuthConfig
Static fs.FS // web/ (index.html, login.html, style.css, app.js…)
Version string // shown in the UI
Demo bool // demo mode: fake PVE clusters, shown in the UI
}
// Server holds the state shared by the handlers.
type Server struct {
opts Options
store *store.Store
auth *Auth
// last comparison, shown until the next refresh (reading N clusters takes time)
stateMu sync.Mutex
state *service.State
stateAt time.Time
// last comparison of each node replication profile
nodeMu sync.Mutex
nodeStates map[string]*nodesync.State
applyMu sync.Mutex // one synchronization at a time
jobs sync.WaitGroup
}
// New builds the server.
func New(o Options) (*Server, error) {
a, err := NewAuth(o.Auth, o.Store)
if err != nil {
return nil, err
}
s := &Server{opts: o, store: o.Store, auth: a}
a.audit = s.audit
if err := o.Store.FailRunning(context.Background()); err != nil {
return nil, err
}
return s, nil
}
// Wait waits for the synchronizations in progress (graceful stop).
func (s *Server) Wait() { s.jobs.Wait() }
// Handler returns the HTTP handler: authentication, then API and static files.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) })
mux.HandleFunc("GET /auth/me", s.handleMe)
viewer, operator, admin := store.RoleViewer, store.RoleOperator, store.RoleAdmin
mux.Handle("GET /api/state", s.role(viewer, s.handleState))
mux.Handle("POST /api/dryrun", s.role(operator, s.handleDryRun))
mux.Handle("POST /api/apply", s.role(operator, s.handleApply))
mux.Handle("GET /api/runs", s.role(viewer, s.handleRuns))
mux.Handle("GET /api/runs/{id}", s.role(viewer, s.handleRun))
mux.Handle("GET /api/clusters", s.role(viewer, s.handleClusters))
mux.Handle("PUT /api/clusters/{name}", s.role(admin, s.handleSaveCluster))
mux.Handle("DELETE /api/clusters/{name}", s.role(admin, s.handleDeleteCluster))
mux.Handle("POST /api/clusters/{name}/check", s.role(admin, s.handleCheckCluster))
mux.Handle("POST /api/clusters/{name}/role", s.role(admin, s.handleRole))
mux.Handle("POST /api/clusters/{name}/forget", s.role(admin, s.handleForget))
mux.Handle("GET /api/settings", s.role(viewer, s.handleSettings))
mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings))
mux.Handle("GET /api/nodes", s.role(viewer, s.handleNodes))
mux.Handle("POST /api/nodes/discover", s.role(admin, s.handleDiscover))
mux.Handle("PUT /api/nodes/{cluster}/{node}", s.role(admin, s.handleSaveNode))
mux.Handle("POST /api/nodes/{cluster}/{node}/check", s.role(admin, s.handleCheckNode))
mux.Handle("POST /api/nodes/{cluster}/{node}/forget", s.role(admin, s.handleForgetNode))
mux.Handle("PUT /api/profiles/{name}", s.role(admin, s.handleSaveProfile))
mux.Handle("DELETE /api/profiles/{name}", s.role(admin, s.handleDeleteProfile))
mux.Handle("GET /api/nodestate", s.role(viewer, s.handleNodeState))
mux.Handle("POST /api/nodedryrun", s.role(operator, s.handleNodeDryRun))
// writing system files as root on the nodes: administrators only
mux.Handle("POST /api/nodeapply", s.role(admin, s.handleNodeApply))
mux.Handle("GET /api/users", s.role(admin, s.handleUsers))
mux.Handle("PUT /api/users/{name}", s.role(admin, s.handleSaveUser))
mux.Handle("DELETE /api/users/{name}", s.role(admin, s.handleDeleteUser))
mux.Handle("POST /api/account/password", s.role(viewer, s.handlePassword))
mux.Handle("GET /api/audit", s.role(admin, s.handleAudit))
static := http.FileServerFS(s.opts.Static)
mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".html") || r.URL.Path == "/" {
w.Header().Set("Cache-Control", "no-cache")
}
static.ServeHTTP(w, r)
}))
s.auth.next = mux
return securityHeaders(s.auth)
}
// securityHeaders: no inline script, no framing, no MIME sniffing.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
// role restricts a handler to a minimum role. Writes must carry the header
// X-Confsync (set by app.js): a page of another site cannot add it to a
// cross-site request without a CORS preflight, which is never granted (CSRF).
func (s *Server) role(min string, h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := identity(r)
if r.Method != http.MethodGet && r.Header.Get("X-Confsync") != "1" {
writeError(w, http.StatusForbidden, "missing X-Confsync header", "csrf")
return
}
if store.RoleRank(id.Role) < store.RoleRank(min) {
writeError(w, http.StatusForbidden, "role "+min+" required", "forbidden")
return
}
h(w, r)
})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
id := identity(r)
if id.User == "" {
writeError(w, http.StatusUnauthorized, "authentication required", "auth")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user": id.User, "role": id.Role, "mode": id.Mode,
"version": s.opts.Version, "demo": s.opts.Demo,
"local": s.opts.Auth.Local, "oidc": s.opts.Auth.OIDCEnabled(),
})
}
// audit records an event of the audit trail (never a secret in detail).
func (s *Server) audit(r *http.Request, id Identity, action, target string, ok bool, detail string) {
if id.User == "" {
id = identity(r)
}
e := store.Event{User: id.User, Role: id.Role, IP: clientIP(r), Action: action, Target: target, OK: ok, Detail: detail}
if err := s.store.Audit(context.WithoutCancel(r.Context()), e); err != nil {
log.Printf("audit: %v", err)
}
}
// ---- JSON helpers ----
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg, kind string) {
writeJSON(w, code, map[string]string{"error": msg, "code": kind})
}
func readJSON(r *http.Request, v any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return errors.New("invalid request: " + err.Error())
}
return nil
}