service, server : analyse par champ, HTTP, auth locale + OIDC (sur le modèle de Logstream)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
This commit is contained in:
1 parent
2bbfb5dfbe
commit
3aae9ce925
3 files changed
+1391
No files matched your search
@@ -0,0 +1,178 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user