180 lines
6.2 KiB
Go
180 lines
6.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/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("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/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
|
|
}
|