// 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 }