Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1
No files matched your search
@@ -0,0 +1,689 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"pve-confsync/internal/config"
|
||||||
|
"pve-confsync/internal/engine"
|
||||||
|
"pve-confsync/internal/model"
|
||||||
|
"pve-confsync/internal/service"
|
||||||
|
"pve-confsync/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readTimeout bounds a comparison (reading every cluster).
|
||||||
|
const readTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
// ---- state (phase 1: clusters and visual diff) ----
|
||||||
|
|
||||||
|
// handleState returns the last comparison; refresh=1 reads the clusters again.
|
||||||
|
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.stateMu.Lock()
|
||||||
|
defer s.stateMu.Unlock()
|
||||||
|
if s.state == nil || r.URL.Query().Get("refresh") == "1" {
|
||||||
|
cfg, err := s.store.Config(r.Context())
|
||||||
|
if errors.Is(err, store.ErrNoReference) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"setup": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), readTimeout)
|
||||||
|
defer cancel()
|
||||||
|
s.state, s.stateAt = service.Analyze(ctx, cfg, nil), time.Now()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"state": s.state})
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidate forgets the cached comparison (configuration changed, apply done).
|
||||||
|
func (s *Server) invalidate() {
|
||||||
|
s.stateMu.Lock()
|
||||||
|
s.state = nil
|
||||||
|
s.stateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember merges the clients of a partial comparison into the cached one, so
|
||||||
|
// that the screen shows what a dry-run just read.
|
||||||
|
func (s *Server) remember(st *service.State, all bool) {
|
||||||
|
s.stateMu.Lock()
|
||||||
|
defer s.stateMu.Unlock()
|
||||||
|
if all || s.state == nil {
|
||||||
|
if all {
|
||||||
|
s.state, s.stateAt = st, time.Now()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.state.Reference = st.Reference
|
||||||
|
for _, c := range st.Clients {
|
||||||
|
for i := range s.state.Clients {
|
||||||
|
if s.state.Clients[i].Name == c.Name {
|
||||||
|
s.state.Clients[i] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dry-run and apply (phase 2) ----
|
||||||
|
|
||||||
|
type selection struct {
|
||||||
|
Scope string `json:"scope"` // global, client or object
|
||||||
|
Client string `json:"client"` // client and object scopes
|
||||||
|
Objects []string `json:"objects"` // object scope: "kind/id"
|
||||||
|
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
|
||||||
|
Confirm string `json:"confirm"` // apply with SDN changes: client name (reference name for global)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *selection) check() error {
|
||||||
|
switch sel.Scope {
|
||||||
|
case "global":
|
||||||
|
sel.Client, sel.Objects = "", nil
|
||||||
|
case "client":
|
||||||
|
sel.Objects = nil
|
||||||
|
if sel.Client == "" {
|
||||||
|
return errors.New("client required")
|
||||||
|
}
|
||||||
|
case "object":
|
||||||
|
if sel.Client == "" || len(sel.Objects) == 0 {
|
||||||
|
return errors.New("client and objects required")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown scope %q", sel.Scope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel selection) clients() []string {
|
||||||
|
if sel.Client == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{sel.Client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// plan reads the clusters and returns the plans of the selection.
|
||||||
|
func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) {
|
||||||
|
cfg, err := s.store.Config(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if sel.Client != "" {
|
||||||
|
if _, ok := cfg.Client(sel.Client); !ok {
|
||||||
|
return nil, nil, nil, fmt.Errorf("unknown client %q", sel.Client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rctx, cancel := context.WithTimeout(ctx, readTimeout)
|
||||||
|
defer cancel()
|
||||||
|
st := service.Analyze(rctx, cfg, sel.clients())
|
||||||
|
s.remember(st, sel.Scope == "global")
|
||||||
|
return cfg, st, st.Plans(sel.Objects), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type runDetail struct {
|
||||||
|
Plans []service.ClientPlan `json:"plans"`
|
||||||
|
Results map[string]*clientResult `json:"results,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientResult struct {
|
||||||
|
Status string `json:"status"` // running, ok, error, noop, skipped
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Steps []engine.Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDryRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var sel selection
|
||||||
|
if err := readJSON(r, &sel); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sel.check(); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := identity(r)
|
||||||
|
_, _, plans, err := s.plan(r.Context(), sel)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, summary := dryRunSummary(plans)
|
||||||
|
d, _ := json.Marshal(runDetail{Plans: plans})
|
||||||
|
run := store.Run{User: id.User, Mode: "dry-run", Scope: sel.Scope, Clients: planClients(plans), Objects: sel.Objects,
|
||||||
|
Status: status, Summary: summary, Detail: d}
|
||||||
|
runID, err := s.store.StartRun(r.Context(), run)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.UpdateRun(r.Context(), runID, status, summary, d)
|
||||||
|
s.audit(r, id, "dry-run", target(sel), true, summary)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"run": runID, "plans": plans, "summary": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func planClients(plans []service.ClientPlan) []string {
|
||||||
|
out := make([]string, 0, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
out = append(out, p.Client)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func target(sel selection) string {
|
||||||
|
switch sel.Scope {
|
||||||
|
case "client":
|
||||||
|
return sel.Client
|
||||||
|
case "object":
|
||||||
|
return sel.Client + ": " + strings.Join(sel.Objects, ", ")
|
||||||
|
}
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
|
||||||
|
func dryRunSummary(plans []service.ClientPlan) (string, string) {
|
||||||
|
changes, clients, blocked := 0, 0, 0
|
||||||
|
for _, p := range plans {
|
||||||
|
if p.Error != "" || len(p.Plan.Errors) > 0 {
|
||||||
|
blocked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := len(p.Plan.Changes); n > 0 {
|
||||||
|
changes += n
|
||||||
|
clients++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case blocked > 0:
|
||||||
|
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d client(s), %d client(s) en erreur", changes, clients, blocked)
|
||||||
|
case changes == 0:
|
||||||
|
return store.RunNoop, "aucun écart"
|
||||||
|
}
|
||||||
|
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d client(s)", changes, clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleApply recomputes the plans, checks that they are the ones reviewed in the
|
||||||
|
// dry-run (digests) and the confirmation of the SDN changes, then applies them in
|
||||||
|
// the background, client after client. Progress goes to the journal.
|
||||||
|
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var sel selection
|
||||||
|
if err := readJSON(r, &sel); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sel.check(); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := identity(r)
|
||||||
|
if !s.applyMu.TryLock() {
|
||||||
|
writeError(w, http.StatusConflict, "une synchronisation est déjà en cours", "busy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
started := false
|
||||||
|
defer func() {
|
||||||
|
if !started {
|
||||||
|
s.applyMu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cfg, st, plans, err := s.plan(r.Context(), sel)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var todo []service.ClientPlan
|
||||||
|
sdn := false
|
||||||
|
for _, p := range plans {
|
||||||
|
switch {
|
||||||
|
case p.Error != "":
|
||||||
|
s.audit(r, id, "apply", target(sel), false, p.Client+": "+p.Error)
|
||||||
|
writeError(w, http.StatusConflict, p.Client+": "+p.Error, "read")
|
||||||
|
return
|
||||||
|
case len(p.Plan.Errors) > 0:
|
||||||
|
s.audit(r, id, "apply", target(sel), false, p.Client+": plan en erreur")
|
||||||
|
writeError(w, http.StatusConflict, p.Client+": "+strings.Join(p.Plan.Errors, "; "), "plan")
|
||||||
|
return
|
||||||
|
case sel.Digests[p.Client] != p.Digest:
|
||||||
|
s.audit(r, id, "apply", target(sel), false, p.Client+": l'état a changé depuis le dry-run")
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": l'état a changé depuis le dry-run, relancer le dry-run",
|
||||||
|
"code": "stale", "plans": plans})
|
||||||
|
return
|
||||||
|
case p.Plan.Empty():
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sdn = sdn || p.SDN
|
||||||
|
todo = append(todo, p)
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "aucun écart"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sdn {
|
||||||
|
want := sel.Client
|
||||||
|
if sel.Scope == "global" {
|
||||||
|
want = cfg.Reference.Name
|
||||||
|
}
|
||||||
|
if sel.Confirm != want {
|
||||||
|
writeError(w, http.StatusBadRequest, "changements SDN : confirmation requise ("+want+")", "confirm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
det := &runDetail{Plans: todo, Results: map[string]*clientResult{}}
|
||||||
|
for _, p := range todo {
|
||||||
|
det.Results[p.Client] = &clientResult{Status: "pending", Steps: []engine.Step{}}
|
||||||
|
}
|
||||||
|
d, _ := json.Marshal(det)
|
||||||
|
runID, err := s.store.StartRun(r.Context(), store.Run{User: id.User, Mode: "apply", Scope: sel.Scope,
|
||||||
|
Clients: planClients(todo), Objects: sel.Objects, Status: store.RunRunning, Summary: "en cours", Detail: d})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(r, id, "apply-start", target(sel), true, fmt.Sprintf("journal #%d", runID))
|
||||||
|
started = true
|
||||||
|
s.jobs.Add(1)
|
||||||
|
// the request ends now: the job keeps a copy that outlives it (audit IP)
|
||||||
|
rr := r.Clone(context.WithoutCancel(r.Context()))
|
||||||
|
go func() {
|
||||||
|
defer s.jobs.Done()
|
||||||
|
defer s.applyMu.Unlock()
|
||||||
|
s.runApply(rr, id, runID, sel, st, det)
|
||||||
|
}()
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"run": runID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runApply applies the plans one client after the other; a failed client does
|
||||||
|
// not stop the next ones (each has its own SDN lock and rollback).
|
||||||
|
func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selection, st *service.State, det *runDetail) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
var mu sync.Mutex // det is read by save while a step is appended
|
||||||
|
save := func(status, summary string) {
|
||||||
|
mu.Lock()
|
||||||
|
d, _ := json.Marshal(det)
|
||||||
|
mu.Unlock()
|
||||||
|
if err := s.store.UpdateRun(ctx, runID, status, summary, d); err != nil {
|
||||||
|
log.Printf("journal #%d: %v", runID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failed, done := 0, 0
|
||||||
|
for _, p := range det.Plans {
|
||||||
|
res := det.Results[p.Client]
|
||||||
|
res.Status = store.RunRunning
|
||||||
|
save(store.RunRunning, fmt.Sprintf("%s en cours", p.Client))
|
||||||
|
err := st.Apply(ctx, p, sel.Objects, func(step engine.Step) {
|
||||||
|
mu.Lock()
|
||||||
|
res.Steps = append(res.Steps, step)
|
||||||
|
mu.Unlock()
|
||||||
|
save(store.RunRunning, fmt.Sprintf("%s : %s", p.Client, step.Action))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
res.Status, res.Error = store.RunError, err.Error()
|
||||||
|
log.Printf("apply %s (journal #%d): %v", p.Client, runID, err)
|
||||||
|
s.audit(r, id, "apply", p.Client, false, err.Error())
|
||||||
|
} else {
|
||||||
|
done++
|
||||||
|
res.Status = store.RunOK
|
||||||
|
s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d changement(s), vérifié", len(p.Plan.Changes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := store.RunOK
|
||||||
|
summary := fmt.Sprintf("%d client(s) synchronisé(s) et vérifié(s)", done)
|
||||||
|
if failed > 0 {
|
||||||
|
status = store.RunError
|
||||||
|
summary = fmt.Sprintf("%d client(s) en échec, %d synchronisé(s)", failed, done)
|
||||||
|
}
|
||||||
|
save(status, summary)
|
||||||
|
s.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
|
||||||
|
runs, err := s.store.Runs(r.Context(), limit, before)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runs == nil {
|
||||||
|
runs = []store.Run{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
run, err := s.store.Run(r.Context(), id)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "unknown run", "notfound")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- clusters and settings (admin) ----
|
||||||
|
|
||||||
|
func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cls, err := s.store.Clusters(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cls == nil {
|
||||||
|
cls = []store.Cluster{}
|
||||||
|
}
|
||||||
|
// connection details are for the administrators
|
||||||
|
if identity(r).Role != store.RoleAdmin {
|
||||||
|
for i := range cls {
|
||||||
|
cls[i] = store.Cluster{Name: cls[i].Name, Reference: cls[i].Reference, URL: cls[i].URL, Mapping: cls[i].Mapping, Policies: cls[i].Policies}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"clusters": cls})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var c store.Cluster
|
||||||
|
if err := readJSON(r, &c); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
old := r.PathValue("name")
|
||||||
|
if old == "-" { // creation
|
||||||
|
old = ""
|
||||||
|
}
|
||||||
|
if err := validateCluster(c); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.store.SaveCluster(r.Context(), old, c)
|
||||||
|
detail := fmt.Sprintf("url=%s token=%s reference=%t", c.URL, c.TokenID, c.Reference)
|
||||||
|
if c.Secret != "" {
|
||||||
|
detail += " secret=modifié"
|
||||||
|
}
|
||||||
|
if old != "" && old != c.Name {
|
||||||
|
detail += " renommé depuis " + old
|
||||||
|
}
|
||||||
|
s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invalidate()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCluster checks the mapping and policies of a cluster with the rules of
|
||||||
|
// the YAML configuration.
|
||||||
|
func validateCluster(c store.Cluster) error {
|
||||||
|
if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") {
|
||||||
|
return errors.New("url: https:// expected")
|
||||||
|
}
|
||||||
|
if c.Timeout != "" {
|
||||||
|
if _, err := time.ParseDuration(c.Timeout); err != nil {
|
||||||
|
return fmt.Errorf("timeout: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref", Secret: "x"},
|
||||||
|
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name, Secret: "x"}, Mapping: c.Mapping, Policies: c.Policies}}}
|
||||||
|
return cfg.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func errText(err error, ok string) string {
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
err := s.store.DeleteCluster(r.Context(), name)
|
||||||
|
s.audit(r, Identity{}, "cluster-delete", name, err == nil, errText(err, ""))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error(), "notfound")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invalidate()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCheckCluster tests the access, the version and the token privileges.
|
||||||
|
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
cfg, err := s.store.Config(r.Context())
|
||||||
|
if err != nil && !errors.Is(err, store.ErrNoReference) {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cl config.Cluster
|
||||||
|
write := true
|
||||||
|
switch {
|
||||||
|
case cfg != nil && cfg.Reference.Name == name:
|
||||||
|
cl, write = cfg.Reference, false
|
||||||
|
case cfg != nil:
|
||||||
|
c, ok := cfg.Client(name)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "unknown cluster", "notfound")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl = c.Cluster
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "define a reference cluster first", "config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
res := service.CheckCluster(ctx, cl, write, cfg.HasKind(model.KindDatacenter))
|
||||||
|
s.audit(r, Identity{}, "cluster-check", name, res.Error == "", res.Error)
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, err := s.store.Settings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "defaults": defaultPolicies()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultPolicies describes the built-in policies, shown in the settings.
|
||||||
|
func defaultPolicies() map[model.Kind]string {
|
||||||
|
return map[model.Kind]string{
|
||||||
|
model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais",
|
||||||
|
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping du client) ; le reste : sync",
|
||||||
|
model.KindVNet: "sync",
|
||||||
|
model.KindSubnet: "sync",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var st store.Settings
|
||||||
|
if err := readJSON(r, &st); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
|
||||||
|
Reference: config.Cluster{Name: "\x00ref", Secret: "x"}}
|
||||||
|
if len(cfg.Kinds) == 0 {
|
||||||
|
cfg.Kinds = model.Kinds
|
||||||
|
}
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
err = s.store.SaveSettings(r.Context(), st)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(st)
|
||||||
|
s.audit(r, Identity{}, "settings-save", "synchronisation", err == nil, errText(err, string(b)))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invalidate()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExport returns the configuration as a CLI YAML file (no secret).
|
||||||
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := s.store.ExportConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "yaml")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(r, Identity{}, "config-export", "", true, "")
|
||||||
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="confsync.yaml"`)
|
||||||
|
_, _ = fmt.Fprintf(w, "# pve-confsync : configuration exportée le %s par %s\n# Secrets des tokens non exportés : renseigner les variables token_secret_env (ou token_secret_file).\n\n",
|
||||||
|
time.Now().Format("2006-01-02 15:04"), identity(r).User)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
YAML string `json:"yaml"`
|
||||||
|
Replace bool `json:"replace"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cfg config.Config
|
||||||
|
dec := yaml.NewDecoder(strings.NewReader(req.YAML))
|
||||||
|
dec.KnownFields(true)
|
||||||
|
err := dec.Decode(&cfg)
|
||||||
|
if err == nil {
|
||||||
|
if len(cfg.Kinds) == 0 {
|
||||||
|
cfg.Kinds = append(cfg.Kinds, model.Kinds...)
|
||||||
|
}
|
||||||
|
err = cfg.Validate()
|
||||||
|
}
|
||||||
|
var warns []string
|
||||||
|
if err == nil {
|
||||||
|
warns, err = s.store.ImportConfig(r.Context(), &cfg, req.Replace)
|
||||||
|
}
|
||||||
|
s.audit(r, Identity{}, "config-import", fmt.Sprintf("%d cluster(s), remplacement=%t", len(cfg.Clients)+1, req.Replace),
|
||||||
|
err == nil, errText(err, strings.Join(warns, " ; ")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invalidate()
|
||||||
|
if warns == nil {
|
||||||
|
warns = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "warnings": warns})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- users and audit ----
|
||||||
|
|
||||||
|
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
us, err := s.store.Users(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if us == nil {
|
||||||
|
us = []store.User{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": us})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
err := s.store.SaveUser(r.Context(), name, req.Role, req.Password)
|
||||||
|
detail := "rôle " + req.Role
|
||||||
|
if req.Password != "" {
|
||||||
|
detail += ", mot de passe défini"
|
||||||
|
}
|
||||||
|
s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
err := s.store.DeleteUser(r.Context(), name)
|
||||||
|
s.audit(r, Identity{}, "user-delete", name, err == nil, errText(err, ""))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePassword lets a local user change their own password.
|
||||||
|
func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := identity(r)
|
||||||
|
var req struct {
|
||||||
|
Old string `json:"old"`
|
||||||
|
New string `json:"new"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id.Mode != "local" {
|
||||||
|
writeError(w, http.StatusBadRequest, "password managed by the identity provider", "oidc")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, ok := s.store.Authenticate(r.Context(), id.User, req.Old)
|
||||||
|
if !ok {
|
||||||
|
time.Sleep(loginFailDelay)
|
||||||
|
s.audit(r, id, "password-change", id.User, false, "ancien mot de passe incorrect")
|
||||||
|
writeError(w, http.StatusForbidden, "wrong current password", "password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.store.SaveUser(r.Context(), id.User, u.Role, req.New)
|
||||||
|
s.audit(r, id, "password-change", id.User, err == nil, errText(err, ""))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// the new epoch ends the other sessions; this one gets a new cookie
|
||||||
|
if nu, err := s.store.User(r.Context(), id.User); err == nil {
|
||||||
|
s.auth.setSession(w, r, session{User: nu.Name, Role: nu.Role, Mode: "local", Epoch: nu.Epoch})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
|
||||||
|
ev, err := s.store.Events(r.Context(), limit, before)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev == nil {
|
||||||
|
ev = []store.Event{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"events": ev})
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pve-confsync/internal/demo"
|
||||||
|
"pve-confsync/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
t *testing.T
|
||||||
|
url string
|
||||||
|
c *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "t.db"), bytes.Repeat([]byte{7}, 32))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clusters, err := demo.Start(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := New(Options{Store: st, Version: "test", Demo: true,
|
||||||
|
Static: fstest.MapFS{"index.html": {Data: []byte("<html>")}, "login.html": {Data: []byte("<html>")}},
|
||||||
|
Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hs := httptest.NewServer(srv.Handler())
|
||||||
|
t.Cleanup(func() { hs.Close(); srv.Wait(); clusters.Close(); st.Close() })
|
||||||
|
return hs.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(t *testing.T, base, user string) *client {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
c := &client{t: t, url: base, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
r, err := c.c.PostForm(base+"/auth/login", url.Values{"user": {user}, "pass": {demo.Password}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.Body.Close()
|
||||||
|
if loc := r.Header.Get("Location"); r.StatusCode != http.StatusSeeOther || loc != "/" {
|
||||||
|
t.Fatalf("login %s: %d %s", user, r.StatusCode, loc)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) do(method, path string, body any, csrf bool) (int, map[string]any) {
|
||||||
|
c.t.Helper()
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, c.url+path, rd)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if csrf {
|
||||||
|
req.Header.Set("X-Confsync", "1")
|
||||||
|
}
|
||||||
|
r, err := c.c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&out)
|
||||||
|
return r.StatusCode, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func digests(d map[string]any) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, p := range d["plans"].([]any) {
|
||||||
|
p := p.(map[string]any)
|
||||||
|
out[p["client"].(string)] = p["digest"].(string)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthAndRoles(t *testing.T) {
|
||||||
|
base := newServer(t)
|
||||||
|
anon := &client{t: t, url: base, c: http.DefaultClient}
|
||||||
|
if code, _ := anon.do("GET", "/api/state", nil, false); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anonymous state: %d", code)
|
||||||
|
}
|
||||||
|
viewer := login(t, base, "lecteur")
|
||||||
|
if code, _ := viewer.do("GET", "/api/state", nil, false); code != http.StatusOK {
|
||||||
|
t.Fatalf("viewer state: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := viewer.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, true); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("viewer dry-run: %d", code)
|
||||||
|
}
|
||||||
|
op := login(t, base, "operateur")
|
||||||
|
if code, _ := op.do("GET", "/api/audit", nil, false); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("operator audit: %d", code)
|
||||||
|
}
|
||||||
|
if code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, false); code != http.StatusForbidden || d["code"] != "csrf" {
|
||||||
|
t.Fatalf("dry-run without header: %d %v", code, d)
|
||||||
|
}
|
||||||
|
code, d := op.do("GET", "/api/clusters", nil, false)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("clusters: %d", code)
|
||||||
|
}
|
||||||
|
for _, c := range d["clusters"].([]any) {
|
||||||
|
if s, ok := c.(map[string]any)["secret"]; ok && s != "" {
|
||||||
|
t.Fatalf("secret returned: %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDryRunAndApply(t *testing.T) {
|
||||||
|
base := newServer(t)
|
||||||
|
op := login(t, base, "operateur")
|
||||||
|
|
||||||
|
// ceph needs the zone stor, which site-b cannot create (no bridge mapping)
|
||||||
|
code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}}, true)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("dry-run site-b: %d %v", code, d)
|
||||||
|
}
|
||||||
|
p := d["plans"].([]any)[0].(map[string]any)["plan"].(map[string]any)
|
||||||
|
if errs, _ := p["errors"].([]any); len(errs) == 0 {
|
||||||
|
t.Fatalf("ceph without its zone must be blocked: %v", p)
|
||||||
|
}
|
||||||
|
code, d = op.do("POST", "/api/apply", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"},
|
||||||
|
"digests": digests(d), "confirm": "site-b"}, true)
|
||||||
|
if code != http.StatusConflict {
|
||||||
|
t.Fatalf("blocked apply: %d %v", code, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
sel := map[string]any{"scope": "client", "client": "site-a"}
|
||||||
|
code, d = op.do("POST", "/api/dryrun", sel, true)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("dry-run site-a: %d %v", code, d)
|
||||||
|
}
|
||||||
|
sel["digests"] = digests(d)
|
||||||
|
if code, d := op.do("POST", "/api/apply", sel, true); code != http.StatusBadRequest || d["code"] != "confirm" {
|
||||||
|
t.Fatalf("SDN apply without confirmation: %d %v", code, d)
|
||||||
|
}
|
||||||
|
stale := map[string]any{"scope": "client", "client": "site-a", "digests": map[string]string{"site-a": "0"}, "confirm": "site-a"}
|
||||||
|
if code, d := op.do("POST", "/api/apply", stale, true); code != http.StatusConflict || d["code"] != "stale" {
|
||||||
|
t.Fatalf("stale digest: %d %v", code, d)
|
||||||
|
}
|
||||||
|
sel["confirm"] = "site-a"
|
||||||
|
code, d = op.do("POST", "/api/apply", sel, true)
|
||||||
|
if code != http.StatusAccepted {
|
||||||
|
t.Fatalf("apply: %d %v", code, d)
|
||||||
|
}
|
||||||
|
id := int(d["run"].(float64))
|
||||||
|
var run map[string]any
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
_, run = op.do("GET", "/api/runs/"+itoa(id), nil, false)
|
||||||
|
if run["status"] != "running" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if run["status"] != "ok" {
|
||||||
|
t.Fatalf("run: %v", run)
|
||||||
|
}
|
||||||
|
_, d = op.do("GET", "/api/state?refresh=1", nil, false)
|
||||||
|
for _, c := range d["state"].(map[string]any)["clients"].([]any) {
|
||||||
|
c := c.(map[string]any)
|
||||||
|
if c["name"] == "site-a" {
|
||||||
|
if ch, _ := c["plan"].(map[string]any)["changes"].([]any); len(ch) != 0 {
|
||||||
|
t.Fatalf("site-a still differs after the apply: %v", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
admin := login(t, base, "admin")
|
||||||
|
_, d = admin.do("GET", "/api/audit", nil, false)
|
||||||
|
if len(d["events"].([]any)) == 0 {
|
||||||
|
t.Fatal("empty audit trail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
// Saved theme, language and density applied before the first paint (no flash).
|
||||||
|
// External file: the Content-Security-Policy forbids inline scripts.
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('confsync.theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||||
|
var l = localStorage.getItem('confsync.lang');
|
||||||
|
if (l) document.documentElement.lang = l;
|
||||||
|
var d = localStorage.getItem('confsync.density');
|
||||||
|
if (d === 'compact') document.documentElement.dataset.density = d;
|
||||||
|
} catch (e) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Package web holds the pages of the web UI, embedded in the binary.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// FS is index.html, login.html, style.css, boot.js, app.js and favicon.svg.
|
||||||
|
//
|
||||||
|
//go:embed *.html *.css *.js *.svg
|
||||||
|
var FS embed.FS
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#6366f1"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 312 B |
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>pve-confsync</title>
|
||||||
|
<link rel="icon" href="favicon.svg">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script src="boot.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-tools">
|
||||||
|
<div class="seg" role="radiogroup" id="langSwitch">
|
||||||
|
<button type="button" role="radio" data-lang="fr">FR</button>
|
||||||
|
<button type="button" role="radio" data-lang="en">EN</button>
|
||||||
|
</div>
|
||||||
|
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
||||||
|
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||||
|
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="login-card">
|
||||||
|
<div class="brand">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
|
||||||
|
<span>pve-confsync</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
|
||||||
|
|
||||||
|
<a id="ssoBtn" class="btn primary" href="auth/oidc" hidden data-i18n="sso">Se connecter avec le SSO</a>
|
||||||
|
<div id="sep" class="login-sep" hidden data-i18n="or">ou</div>
|
||||||
|
|
||||||
|
<form id="localForm" method="post" action="auth/login" hidden>
|
||||||
|
<input type="hidden" name="r" id="ret">
|
||||||
|
<label for="user" data-i18n="user">Utilisateur</label>
|
||||||
|
<input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
||||||
|
<label for="pass" data-i18n="pass">Mot de passe</label>
|
||||||
|
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
|
||||||
|
<p id="err" class="login-err" role="alert" data-i18n="error" hidden>Utilisateur ou mot de passe incorrect.</p>
|
||||||
|
<button id="submitBtn" class="btn" type="submit" data-i18n="submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
<p id="demoHint" class="demo-hint" hidden></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Login page: language, theme, local form and/or OIDC button (from /auth/config).
|
||||||
|
(function () {
|
||||||
|
var I18N = {
|
||||||
|
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||||
|
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
|
||||||
|
error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre',
|
||||||
|
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.' },
|
||||||
|
en: { intro: 'Proxmox VE configuration synchronization', user: 'User', pass: 'Password',
|
||||||
|
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
|
||||||
|
error: 'Wrong user or password.', theme: 'Light / dark theme',
|
||||||
|
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.' },
|
||||||
|
};
|
||||||
|
function get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } }
|
||||||
|
function set(k, v) { try { localStorage.setItem('confsync.' + k, v); } catch (e) {} }
|
||||||
|
var lang = get('lang');
|
||||||
|
if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr';
|
||||||
|
var cfg = { local: true, oidc: false, demo: false };
|
||||||
|
|
||||||
|
function el(id) { return document.getElementById(id); }
|
||||||
|
function code(s) { var c = document.createElement('code'); c.textContent = s; return c; }
|
||||||
|
function applyLang() {
|
||||||
|
var d = I18N[lang];
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(function (e) { e.textContent = d[e.dataset.i18n]; });
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(function (e) { e.title = d[e.dataset.i18nTitle]; });
|
||||||
|
document.querySelectorAll('[data-i18n-aria]').forEach(function (e) { e.setAttribute('aria-label', d[e.dataset.i18nAria]); });
|
||||||
|
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
|
||||||
|
var h = el('demoHint');
|
||||||
|
h.textContent = '';
|
||||||
|
if (cfg.demo) {
|
||||||
|
// "{a}" placeholders become <code> elements, never HTML from a string
|
||||||
|
d.demo.split(/(\{[aolp]\})/).forEach(function (part) {
|
||||||
|
var v = { '{a}': 'admin', '{o}': 'operateur', '{l}': 'lecteur', '{p}': 'confsync-demo' }[part];
|
||||||
|
h.appendChild(v ? code(v) : document.createTextNode(part));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); });
|
||||||
|
});
|
||||||
|
el('themeBtn').addEventListener('click', function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
root.dataset.theme = dark ? 'light' : 'dark';
|
||||||
|
set('theme', root.dataset.theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var ret = q.get('r') || '/';
|
||||||
|
el('ret').value = ret;
|
||||||
|
el('err').hidden = q.get('e') !== '1';
|
||||||
|
el('ssoBtn').href = 'auth/oidc?r=' + encodeURIComponent(ret);
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
el('localForm').hidden = !cfg.local;
|
||||||
|
el('ssoBtn').hidden = !cfg.oidc;
|
||||||
|
el('sep').hidden = !(cfg.local && cfg.oidc);
|
||||||
|
el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary';
|
||||||
|
el('demoHint').hidden = !cfg.demo;
|
||||||
|
applyLang();
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
fetch('auth/config', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (c) { cfg = c; show(); })
|
||||||
|
.catch(function () {});
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user