Files
pve-confsync/internal/server/server.go
T

179 lines
6.1 KiB
Go

// Package server is the web UI of pve-confsync (phases 1 and 2): cluster list,
// visual diff per client, dry-run and synchronization per object, per client or
// global, 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/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
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("GET /api/settings", s.role(viewer, s.handleSettings))
mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings))
mux.Handle("GET /api/config/export", s.role(admin, s.handleExport))
mux.Handle("POST /api/config/import", s.role(admin, s.handleImport))
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
}