Interface web : écarts, dry-run et synchronisation (phases 1 et 2) #1
No files matched your search
@@ -0,0 +1,785 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
_ "crypto/sha512" // SHA-384/512 for RS384, ES384, RS512…
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pve-confsync/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authentication of the web UI, on the model of Logstream (auth.go, auth_local.go):
|
||||||
|
// local accounts (SQLite, PBKDF2) shown on a login page, and/or an OpenID Connect
|
||||||
|
// provider (Keycloak, Authentik, Authelia…) with the authorization code flow and
|
||||||
|
// PKCE. Both can be enabled together: the local accounts stay the way in when the
|
||||||
|
// provider is down. Sessions are HMAC signed cookies. Only the standard library.
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "confsync_session"
|
||||||
|
loginCookie = "confsync_login_" // + state: one cookie per OIDC login in progress
|
||||||
|
loginTTL = 10 * time.Minute
|
||||||
|
clockSkew = time.Minute
|
||||||
|
loginPage = "/login.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
var loginFailDelay = time.Second // slows down password guessing
|
||||||
|
|
||||||
|
// AuthConfig is read from the environment (see README).
|
||||||
|
type AuthConfig struct {
|
||||||
|
Local bool // local accounts (AUTH_LOCAL, default true)
|
||||||
|
Issuer string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
RedirectURL string
|
||||||
|
Scopes string
|
||||||
|
GroupsClaim string
|
||||||
|
AdminGroup string // OIDC_ADMIN_GROUP: members are admins
|
||||||
|
OperatorGroup string // OIDC_OPERATOR_GROUP: members are operators
|
||||||
|
DefaultRole string // OIDC_DEFAULT_ROLE: everyone else (default viewer)
|
||||||
|
SessionTTL time.Duration
|
||||||
|
DataDir string
|
||||||
|
Demo bool // demo mode: the login page lists the demo accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCEnabled tells whether an OpenID Connect provider is configured.
|
||||||
|
func (c AuthConfig) OIDCEnabled() bool { return c.Issuer != "" }
|
||||||
|
|
||||||
|
// Identity is the logged in user.
|
||||||
|
type Identity struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Mode string `json:"mode"` // local or oidc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
// identity returns the user of a request (set by the middleware).
|
||||||
|
func identity(r *http.Request) Identity {
|
||||||
|
id, _ := r.Context().Value(ctxKey{}).(Identity)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
User string `json:"u"`
|
||||||
|
Role string `json:"r"`
|
||||||
|
Mode string `json:"m"`
|
||||||
|
Epoch int64 `json:"p,omitempty"` // local: password epoch, a new password ends the sessions
|
||||||
|
Exp int64 `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is the middleware protecting the UI and the API.
|
||||||
|
type Auth struct {
|
||||||
|
cfg AuthConfig
|
||||||
|
store *store.Store
|
||||||
|
key []byte
|
||||||
|
secure bool // cookies HTTPS only (OIDC_REDIRECT_URL in https)
|
||||||
|
audit func(r *http.Request, id Identity, action, target string, ok bool, detail string)
|
||||||
|
next http.Handler
|
||||||
|
|
||||||
|
callback string
|
||||||
|
client *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
meta *oidcMeta
|
||||||
|
keys map[string]crypto.PublicKey
|
||||||
|
keysAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuth checks the configuration and loads the session key.
|
||||||
|
func NewAuth(c AuthConfig, st *store.Store) (*Auth, error) {
|
||||||
|
if c.SessionTTL <= 0 {
|
||||||
|
c.SessionTTL = 12 * time.Hour
|
||||||
|
}
|
||||||
|
if c.DefaultRole == "" {
|
||||||
|
c.DefaultRole = store.RoleViewer
|
||||||
|
}
|
||||||
|
if !store.ValidRole(c.DefaultRole) {
|
||||||
|
return nil, fmt.Errorf("OIDC_DEFAULT_ROLE=%q: expected admin, operator or viewer", c.DefaultRole)
|
||||||
|
}
|
||||||
|
a := &Auth{cfg: c, store: st, key: sessionKey(c.DataDir), client: &http.Client{Timeout: 10 * time.Second}}
|
||||||
|
if !c.Local && !c.OIDCEnabled() {
|
||||||
|
return nil, errors.New("AUTH_LOCAL=false needs an OIDC provider (OIDC_ISSUER)")
|
||||||
|
}
|
||||||
|
if c.OIDCEnabled() {
|
||||||
|
var missing []string
|
||||||
|
for _, v := range [][2]string{{"OIDC_CLIENT_ID", c.ClientID}, {"OIDC_CLIENT_SECRET", c.ClientSecret}, {"OIDC_REDIRECT_URL", c.RedirectURL}} {
|
||||||
|
if v[1] == "" {
|
||||||
|
missing = append(missing, v[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("OIDC: missing %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
ru, err := url.Parse(c.RedirectURL)
|
||||||
|
if err != nil || ru.Host == "" || ru.Path == "" || ru.Path == "/" {
|
||||||
|
return nil, fmt.Errorf("OIDC_REDIRECT_URL=%q: expected a full URL such as https://confsync.example.org/auth/callback", c.RedirectURL)
|
||||||
|
}
|
||||||
|
a.callback, a.secure = ru.Path, ru.Scheme == "https"
|
||||||
|
if a.cfg.Scopes == "" {
|
||||||
|
a.cfg.Scopes = "openid profile email"
|
||||||
|
}
|
||||||
|
if !strings.Contains(" "+a.cfg.Scopes+" ", " openid ") {
|
||||||
|
a.cfg.Scopes = "openid " + a.cfg.Scopes
|
||||||
|
}
|
||||||
|
if a.cfg.GroupsClaim == "" {
|
||||||
|
a.cfg.GroupsClaim = "groups"
|
||||||
|
}
|
||||||
|
if c.AdminGroup == "" && c.OperatorGroup == "" {
|
||||||
|
log.Printf("oidc: no OIDC_ADMIN_GROUP / OIDC_OPERATOR_GROUP, every OIDC user gets the role %s", c.DefaultRole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckProvider reads the provider configuration at startup so a mistake shows in the logs.
|
||||||
|
func (a *Auth) CheckProvider() {
|
||||||
|
if a.cfg.OIDCEnabled() {
|
||||||
|
if _, err := a.discover(); err != nil {
|
||||||
|
log.Printf("oidc: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("oidc authentication enabled (issuer %s)", a.cfg.Issuer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionKey is kept in the data directory so sessions survive a restart.
|
||||||
|
func sessionKey(dir string) []byte {
|
||||||
|
path := filepath.Join(dir, "session.key")
|
||||||
|
if k, err := os.ReadFile(path); err == nil && len(k) >= 32 {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
k := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(k); err != nil {
|
||||||
|
log.Fatalf("session key: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, k, 0o600); err != nil {
|
||||||
|
log.Printf("auth: cannot save %s (%v): sessions end when the server restarts", path, err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// public paths: reachable without a session.
|
||||||
|
func public(p string) bool {
|
||||||
|
switch p {
|
||||||
|
case "/healthz", loginPage, "/style.css", "/boot.js", "/login.js", "/favicon.svg", "/auth/config", "/auth/login", "/auth/logout", "/auth/oidc":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/auth/login":
|
||||||
|
a.handleLogin(w, r)
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/auth/logout":
|
||||||
|
a.handleLogout(w, r)
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/auth/oidc" && a.cfg.OIDCEnabled():
|
||||||
|
a.startLogin(w, r)
|
||||||
|
return
|
||||||
|
case a.callback != "" && r.URL.Path == a.callback:
|
||||||
|
a.handleCallback(w, r)
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/auth/config":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"local": a.cfg.Local, "oidc": a.cfg.OIDCEnabled(), "demo": a.cfg.Demo})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := a.sessionIdentity(r)
|
||||||
|
if ok {
|
||||||
|
if r.URL.Path == loginPage {
|
||||||
|
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case public(r.URL.Path):
|
||||||
|
if r.URL.Path == loginPage {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
|
a.next.ServeHTTP(w, r)
|
||||||
|
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
||||||
|
target := loginPage
|
||||||
|
if ret := r.URL.RequestURI(); ret != "/" {
|
||||||
|
target += "?" + url.Values{"r": {ret}}.Encode()
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required", "code": "auth"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionIdentity checks the session cookie. Local accounts are read again from
|
||||||
|
// the database: a deleted user, a new role or a new password apply at once.
|
||||||
|
func (a *Auth) sessionIdentity(r *http.Request) (Identity, bool) {
|
||||||
|
var s session
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil || !verifyCookie(a.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
||||||
|
return Identity{}, false
|
||||||
|
}
|
||||||
|
switch s.Mode {
|
||||||
|
case "local":
|
||||||
|
if !a.cfg.Local {
|
||||||
|
return Identity{}, false
|
||||||
|
}
|
||||||
|
u, err := a.store.User(r.Context(), s.User)
|
||||||
|
if err != nil || u.Epoch != s.Epoch {
|
||||||
|
return Identity{}, false
|
||||||
|
}
|
||||||
|
return Identity{User: u.Name, Role: u.Role, Mode: "local"}, true
|
||||||
|
case "oidc":
|
||||||
|
if !a.cfg.OIDCEnabled() || !store.ValidRole(s.Role) {
|
||||||
|
return Identity{}, false
|
||||||
|
}
|
||||||
|
return Identity{User: s.User, Role: s.Role, Mode: "oidc"}, true
|
||||||
|
}
|
||||||
|
return Identity{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) setSession(w http.ResponseWriter, r *http.Request, s session) {
|
||||||
|
s.Exp = time.Now().Add(a.cfg.SessionTTL).Unix()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: signCookie(a.key, s),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(a.cfg.SessionTTL.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: a.secure || isHTTPS(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || !a.cfg.Local {
|
||||||
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, pass := strings.TrimSpace(r.PostFormValue("user")), r.PostFormValue("pass")
|
||||||
|
ret := safeReturn(r.PostFormValue("r"))
|
||||||
|
u, ok := a.store.Authenticate(r.Context(), user, pass)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||||
|
a.audit(r, Identity{User: user, Mode: "local"}, "login", "", false, "mot de passe ou utilisateur incorrect")
|
||||||
|
time.Sleep(loginFailDelay)
|
||||||
|
q := url.Values{"e": {"1"}}
|
||||||
|
if ret != "/" {
|
||||||
|
q.Set("r", ret)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := Identity{User: u.Name, Role: u.Role, Mode: "local"}
|
||||||
|
log.Printf("auth: %s logged in from %s (%s)", u.Name, clientIP(r), u.Role)
|
||||||
|
a.audit(r, id, "login", "", true, "")
|
||||||
|
a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch})
|
||||||
|
http.Redirect(w, r, ret, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout ends the session; with OIDC the provider's session ends on its logout page if it has one.
|
||||||
|
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := a.sessionIdentity(r)
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.secure || isHTTPS(r)})
|
||||||
|
if ok {
|
||||||
|
a.audit(r, id, "logout", "", true, "")
|
||||||
|
}
|
||||||
|
if ok && id.Mode == "oidc" {
|
||||||
|
if meta, err := a.discover(); err == nil && meta.EndSession != "" {
|
||||||
|
http.Redirect(w, r, addQuery(meta.EndSession, url.Values{"client_id": {a.cfg.ClientID}}), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeReturn keeps the page to open after login inside the application.
|
||||||
|
func safeReturn(ret string) string {
|
||||||
|
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
|
||||||
|
func isHTTPS(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OpenID Connect ----
|
||||||
|
|
||||||
|
type oidcMeta struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
AuthEndpoint string `json:"authorization_endpoint"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
EndSession string `json:"end_session_endpoint"`
|
||||||
|
TokenAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginState struct {
|
||||||
|
Nonce string `json:"n"`
|
||||||
|
Verifier string `json:"v"`
|
||||||
|
Return string `json:"r"`
|
||||||
|
Exp int64 `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) startLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
meta, err := a.discover()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("oidc: %v", err)
|
||||||
|
http.Error(w, "identity provider unreachable, try again later", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, nonce, verifier := randomString(), randomString(), randomString()+randomString()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: loginCookie + state,
|
||||||
|
Value: signCookie(a.key, loginState{Nonce: nonce, Verifier: verifier, Return: safeReturn(r.URL.Query().Get("r")), Exp: time.Now().Add(loginTTL).Unix()}),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(loginTTL.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: a.secure,
|
||||||
|
SameSite: http.SameSiteLaxMode, // sent back on the redirect from the provider
|
||||||
|
})
|
||||||
|
challenge := sha256.Sum256([]byte(verifier))
|
||||||
|
q := url.Values{
|
||||||
|
"response_type": {"code"},
|
||||||
|
"client_id": {a.cfg.ClientID},
|
||||||
|
"redirect_uri": {a.cfg.RedirectURL},
|
||||||
|
"scope": {a.cfg.Scopes},
|
||||||
|
"state": {state},
|
||||||
|
"nonce": {nonce},
|
||||||
|
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, addQuery(meta.AuthEndpoint, q), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if e := q.Get("error"); e != "" {
|
||||||
|
log.Printf("oidc: login refused by the provider: %s %s", e, q.Get("error_description"))
|
||||||
|
http.Error(w, "login refused by the identity provider: "+e, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := q.Get("state")
|
||||||
|
var ls loginState
|
||||||
|
c, err := r.Cookie(loginCookie + state)
|
||||||
|
if state == "" || err != nil || !verifyCookie(a.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
||||||
|
http.Error(w, "login expired or started in another browser: open pve-confsync again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.secure})
|
||||||
|
|
||||||
|
user, role, err := a.exchange(r, q.Get("code"), ls)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("oidc: login failed: %v", err)
|
||||||
|
a.audit(r, Identity{Mode: "oidc"}, "login", "", false, err.Error())
|
||||||
|
http.Error(w, "login failed, see the pve-confsync logs", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := Identity{User: user, Role: role, Mode: "oidc"}
|
||||||
|
log.Printf("oidc: %s logged in (%s)", user, role)
|
||||||
|
a.audit(r, id, "login", "", true, "")
|
||||||
|
a.setSession(w, r, session{User: user, Role: role, Mode: "oidc"})
|
||||||
|
http.Redirect(w, r, ls.Return, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange trades the code for tokens and returns the user name from the verified
|
||||||
|
// ID token and the role given by its groups.
|
||||||
|
func (a *Auth) exchange(r *http.Request, code string, ls loginState) (string, string, error) {
|
||||||
|
if code == "" {
|
||||||
|
return "", "", errors.New("no code in the callback")
|
||||||
|
}
|
||||||
|
meta, err := a.discover()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {a.cfg.RedirectURL},
|
||||||
|
"code_verifier": {ls.Verifier},
|
||||||
|
}
|
||||||
|
// client_secret_basic is the default; some providers only accept client_secret_post.
|
||||||
|
post := len(meta.TokenAuthMethods) > 0 && !contains(meta.TokenAuthMethods, "client_secret_basic") && contains(meta.TokenAuthMethods, "client_secret_post")
|
||||||
|
if post {
|
||||||
|
form.Set("client_id", a.cfg.ClientID)
|
||||||
|
form.Set("client_secret", a.cfg.ClientSecret)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if !post {
|
||||||
|
req.SetBasicAuth(url.QueryEscape(a.cfg.ClientID), url.QueryEscape(a.cfg.ClientSecret))
|
||||||
|
}
|
||||||
|
res, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("token endpoint: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
|
||||||
|
}
|
||||||
|
var tok struct {
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
|
||||||
|
return "", "", errors.New("token endpoint: no id_token in the response")
|
||||||
|
}
|
||||||
|
claims, err := a.verifyIDToken(tok.IDToken, ls.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
role := a.cfg.DefaultRole
|
||||||
|
switch {
|
||||||
|
case a.cfg.AdminGroup != "" && hasGroup(claims[a.cfg.GroupsClaim], a.cfg.AdminGroup):
|
||||||
|
role = store.RoleAdmin
|
||||||
|
case a.cfg.OperatorGroup != "" && hasGroup(claims[a.cfg.GroupsClaim], a.cfg.OperatorGroup):
|
||||||
|
role = store.RoleOperator
|
||||||
|
}
|
||||||
|
for _, k := range []string{"preferred_username", "email", "name", "sub"} {
|
||||||
|
if v, _ := claims[k].(string); v != "" {
|
||||||
|
return v, role, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", errors.New("id_token: no sub")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasGroup tells whether the groups claim (a list, or a single string) holds
|
||||||
|
// group; a leading "/" (Keycloak group paths) is ignored.
|
||||||
|
func hasGroup(claim any, group string) bool {
|
||||||
|
group = strings.TrimPrefix(group, "/")
|
||||||
|
var groups []string
|
||||||
|
switch v := claim.(type) {
|
||||||
|
case string:
|
||||||
|
groups = strings.Fields(strings.ReplaceAll(v, ",", " "))
|
||||||
|
case []any:
|
||||||
|
for _, g := range v {
|
||||||
|
if s, ok := g.(string); ok {
|
||||||
|
groups = append(groups, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
if strings.TrimPrefix(g, "/") == group {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token.
|
||||||
|
func (a *Auth) verifyIDToken(raw, nonce string) (map[string]any, error) {
|
||||||
|
parts := strings.Split(raw, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, errors.New("id_token: not a JWT")
|
||||||
|
}
|
||||||
|
var hdr struct {
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
}
|
||||||
|
if err := decodeSegment(parts[0], &hdr); err != nil {
|
||||||
|
return nil, fmt.Errorf("id_token header: %w", err)
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("id_token: bad signature encoding")
|
||||||
|
}
|
||||||
|
key, err := a.keyFor(hdr.Kid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := verifySignature(hdr.Alg, key, []byte(parts[0]+"."+parts[1]), sig); err != nil {
|
||||||
|
return nil, fmt.Errorf("id_token: %w", err)
|
||||||
|
}
|
||||||
|
var claims map[string]any
|
||||||
|
if err := decodeSegment(parts[1], &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("id_token claims: %w", err)
|
||||||
|
}
|
||||||
|
if iss, _ := claims["iss"].(string); iss != a.cfg.Issuer {
|
||||||
|
return nil, fmt.Errorf("id_token: issuer %q, expected %q", iss, a.cfg.Issuer)
|
||||||
|
}
|
||||||
|
var aud []string
|
||||||
|
switch v := claims["aud"].(type) {
|
||||||
|
case string:
|
||||||
|
aud = []string{v}
|
||||||
|
case []any:
|
||||||
|
for _, x := range v {
|
||||||
|
if s, ok := x.(string); ok {
|
||||||
|
aud = append(aud, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(aud, a.cfg.ClientID) {
|
||||||
|
return nil, fmt.Errorf("id_token: audience %v does not include %q", aud, a.cfg.ClientID)
|
||||||
|
}
|
||||||
|
if azp, ok := claims["azp"].(string); ok && len(aud) > 1 && azp != a.cfg.ClientID {
|
||||||
|
return nil, fmt.Errorf("id_token: azp %q", azp)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
exp, _ := claims["exp"].(float64)
|
||||||
|
if exp == 0 || now.After(time.Unix(int64(exp), 0).Add(clockSkew)) {
|
||||||
|
return nil, errors.New("id_token: expired (check the clocks)")
|
||||||
|
}
|
||||||
|
if iat, ok := claims["iat"].(float64); ok && time.Unix(int64(iat), 0).After(now.Add(clockSkew)) {
|
||||||
|
return nil, errors.New("id_token: issued in the future (check the clocks)")
|
||||||
|
}
|
||||||
|
if n, _ := claims["nonce"].(string); subtle.ConstantTimeCompare([]byte(n), []byte(nonce)) != 1 {
|
||||||
|
return nil, errors.New("id_token: wrong nonce")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature(alg string, key crypto.PublicKey, signed, sig []byte) error {
|
||||||
|
if len(alg) != 5 {
|
||||||
|
return fmt.Errorf("unsupported algorithm %q", alg)
|
||||||
|
}
|
||||||
|
var h crypto.Hash
|
||||||
|
switch alg[2:] {
|
||||||
|
case "256":
|
||||||
|
h = crypto.SHA256
|
||||||
|
case "384":
|
||||||
|
h = crypto.SHA384
|
||||||
|
case "512":
|
||||||
|
h = crypto.SHA512
|
||||||
|
}
|
||||||
|
if h == 0 {
|
||||||
|
return fmt.Errorf("unsupported algorithm %q", alg)
|
||||||
|
}
|
||||||
|
hh := h.New()
|
||||||
|
hh.Write(signed)
|
||||||
|
digest := hh.Sum(nil)
|
||||||
|
switch k := key.(type) {
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
switch alg[:2] {
|
||||||
|
case "RS":
|
||||||
|
return rsa.VerifyPKCS1v15(k, h, digest, sig)
|
||||||
|
case "PS":
|
||||||
|
return rsa.VerifyPSS(k, h, digest, sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
|
||||||
|
}
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
size := (k.Curve.Params().BitSize + 7) / 8
|
||||||
|
if alg[:2] != "ES" || len(sig) != 2*size {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:])
|
||||||
|
if ecdsa.Verify(k, digest, r, s) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("bad signature")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("algorithm %q does not match the key", alg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// discover reads the provider configuration once (and again after a failure).
|
||||||
|
func (a *Auth) discover() (*oidcMeta, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.meta != nil {
|
||||||
|
return a.meta, nil
|
||||||
|
}
|
||||||
|
u := strings.TrimSuffix(a.cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||||||
|
var m oidcMeta
|
||||||
|
if err := a.getJSON(u, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("discovery: %w", err)
|
||||||
|
}
|
||||||
|
if m.Issuer != a.cfg.Issuer {
|
||||||
|
return nil, fmt.Errorf("discovery: the provider says its issuer is %q, set OIDC_ISSUER to that exact value", m.Issuer)
|
||||||
|
}
|
||||||
|
if m.AuthEndpoint == "" || m.TokenEndpoint == "" || m.JWKSURI == "" {
|
||||||
|
return nil, errors.New("discovery: incomplete provider configuration")
|
||||||
|
}
|
||||||
|
a.meta = &m
|
||||||
|
return a.meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyFor returns the signing key kid; the key set is reloaded when the provider rotates its keys.
|
||||||
|
func (a *Auth) keyFor(kid string) (crypto.PublicKey, error) {
|
||||||
|
meta, err := a.discover()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
pick := func() crypto.PublicKey {
|
||||||
|
if k, ok := a.keys[kid]; ok {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
if kid == "" && len(a.keys) == 1 {
|
||||||
|
for _, k := range a.keys {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if k := pick(); k != nil {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
if time.Since(a.keysAt) < 10*time.Second {
|
||||||
|
return nil, fmt.Errorf("id_token: unknown key %q", kid)
|
||||||
|
}
|
||||||
|
var set struct {
|
||||||
|
Keys []struct {
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Use string `json:"use"`
|
||||||
|
N string `json:"n"`
|
||||||
|
E string `json:"e"`
|
||||||
|
Crv string `json:"crv"`
|
||||||
|
X string `json:"x"`
|
||||||
|
Y string `json:"y"`
|
||||||
|
} `json:"keys"`
|
||||||
|
}
|
||||||
|
if err := a.getJSON(meta.JWKSURI, &set); err != nil {
|
||||||
|
return nil, fmt.Errorf("jwks: %w", err)
|
||||||
|
}
|
||||||
|
keys := map[string]crypto.PublicKey{}
|
||||||
|
for _, k := range set.Keys {
|
||||||
|
if k.Use != "" && k.Use != "sig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k.Kty {
|
||||||
|
case "RSA":
|
||||||
|
n, e := decodeBig(k.N), decodeBig(k.E)
|
||||||
|
if n != nil && e != nil && e.IsInt64() {
|
||||||
|
keys[k.Kid] = &rsa.PublicKey{N: n, E: int(e.Int64())}
|
||||||
|
}
|
||||||
|
case "EC":
|
||||||
|
var c elliptic.Curve
|
||||||
|
switch k.Crv {
|
||||||
|
case "P-256":
|
||||||
|
c = elliptic.P256()
|
||||||
|
case "P-384":
|
||||||
|
c = elliptic.P384()
|
||||||
|
case "P-521":
|
||||||
|
c = elliptic.P521()
|
||||||
|
}
|
||||||
|
x, y := decodeBig(k.X), decodeBig(k.Y)
|
||||||
|
if c != nil && x != nil && y != nil && c.IsOnCurve(x, y) {
|
||||||
|
keys[k.Kid] = &ecdsa.PublicKey{Curve: c, X: x, Y: y}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.keys, a.keysAt = keys, time.Now()
|
||||||
|
if k := pick(); k != nil {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("id_token: unknown key %q", kid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) getJSON(u string, v any) error {
|
||||||
|
res, err := a.client.Get(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s: %s", u, res.Status)
|
||||||
|
}
|
||||||
|
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
|
||||||
|
func signCookie(key []byte, v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
p := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
m := hmac.New(sha256.New, key)
|
||||||
|
m.Write([]byte(p))
|
||||||
|
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyCookie(key []byte, s string, v any) bool {
|
||||||
|
p, sig, ok := strings.Cut(s, ".")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got, err := base64.RawURLEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m := hmac.New(sha256.New, key)
|
||||||
|
m.Write([]byte(p))
|
||||||
|
if !hmac.Equal(got, m.Sum(nil)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return decodeSegment(p, v) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSegment(s string, v any) error {
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBig(s string) *big.Int {
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil || len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return new(big.Int).SetBytes(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomString() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addQuery(endpoint string, q url.Values) string {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(endpoint, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
return endpoint + sep + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// Package service puts the engine together for the web UI: it reads the
|
||||||
|
// reference and the clients, computes the plans and describes every object
|
||||||
|
// field by field (reference, client mapping, current value, target value).
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pve-confsync/internal/adapter"
|
||||||
|
"pve-confsync/internal/config"
|
||||||
|
"pve-confsync/internal/engine"
|
||||||
|
"pve-confsync/internal/model"
|
||||||
|
"pve-confsync/internal/pve"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is the PVE version of one node.
|
||||||
|
type Node struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterInfo is what was read from one cluster.
|
||||||
|
type ClusterInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Nodes []Node `json:"nodes,omitempty"`
|
||||||
|
Objects int `json:"objects"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
ReadAt time.Time `json:"readAt"`
|
||||||
|
Millis int64 `json:"ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field compares one field of an object.
|
||||||
|
type Field struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Policy adapter.Policy `json:"policy"`
|
||||||
|
Ref any `json:"ref,omitempty"` // value on the reference
|
||||||
|
Mapping any `json:"mapping,omitempty"` // value of the client mapping
|
||||||
|
Current any `json:"current,omitempty"` // value on the client
|
||||||
|
Target any `json:"target,omitempty"` // value after a synchronization
|
||||||
|
Change string `json:"change,omitempty"` // add, set, unset or empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row statuses.
|
||||||
|
const (
|
||||||
|
StatusSame = "same" // in line with the reference
|
||||||
|
StatusCreate = "create" // missing on the client
|
||||||
|
StatusUpdate = "update" // different
|
||||||
|
StatusDelete = "delete" // absent from the reference, deleted (prune)
|
||||||
|
StatusExtra = "extra" // absent from the reference, kept (no prune)
|
||||||
|
StatusError = "error" // blocking error (missing mapping, immutable field)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Row is one object of a client.
|
||||||
|
type Row struct {
|
||||||
|
Kind model.Kind `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SDN bool `json:"sdn"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is the state of one client: what it has, its plan and its rows.
|
||||||
|
type Client struct {
|
||||||
|
ClusterInfo
|
||||||
|
Plan engine.Plan `json:"plan"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Rows []Row `json:"rows"`
|
||||||
|
Counts map[string]int `json:"counts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is a full comparison of the reference with its clients.
|
||||||
|
type State struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Reference ClusterInfo `json:"reference"`
|
||||||
|
Clients []Client `json:"clients"`
|
||||||
|
Kinds []model.Kind `json:"kinds"`
|
||||||
|
Prune bool `json:"prune"`
|
||||||
|
|
||||||
|
ref []model.Object
|
||||||
|
actuals map[string][]model.Object
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze reads the reference (applied SDN configuration) and the clients
|
||||||
|
// (current configuration, in parallel), and compares them. only limits the
|
||||||
|
// clients (empty: all). A cluster that cannot be read is reported in its
|
||||||
|
// Error field, it does not stop the others.
|
||||||
|
func Analyze(ctx context.Context, cfg *config.Config, only []string) *State {
|
||||||
|
st := &State{At: time.Now().UTC(), Kinds: cfg.Kinds, Prune: cfg.Prune, cfg: cfg, actuals: map[string][]model.Object{}}
|
||||||
|
var refSnap model.Snapshot
|
||||||
|
st.Reference, refSnap = read(ctx, cfg.Reference, cfg.Kinds, adapter.ReadRunning)
|
||||||
|
st.ref = refSnap.Objects
|
||||||
|
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, n := range only {
|
||||||
|
want[n] = true
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
for i := range cfg.Clients {
|
||||||
|
cl := &cfg.Clients[i]
|
||||||
|
if len(only) > 0 && !want[cl.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Clients = append(st.Clients, Client{ClusterInfo: ClusterInfo{Name: cl.Name, URL: cl.URL}})
|
||||||
|
idx := len(st.Clients) - 1
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
info, snap := read(ctx, cl.Cluster, cfg.Kinds, adapter.ReadCurrent)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
st.Clients[idx].ClusterInfo = info
|
||||||
|
if info.Error == "" {
|
||||||
|
st.actuals[cl.Name] = snap.Objects
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i := range st.Clients {
|
||||||
|
c := &st.Clients[i]
|
||||||
|
c.Counts = map[string]int{}
|
||||||
|
if c.Error != "" || st.Reference.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cl, _ := cfg.Client(c.Name)
|
||||||
|
c.Plan = engine.MakePlan(cfg, cl, st.ref, st.actuals[c.Name])
|
||||||
|
c.Plan.Warnings = append(c.Plan.Warnings, c.Warnings...)
|
||||||
|
c.Digest = c.Plan.Digest()
|
||||||
|
c.Rows = rows(cfg, cl, st.ref, st.actuals[c.Name], c.Plan)
|
||||||
|
for _, r := range c.Rows {
|
||||||
|
c.Counts[r.Status]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func read(ctx context.Context, cl config.Cluster, kinds []model.Kind, mode adapter.ReadMode) (ClusterInfo, model.Snapshot) {
|
||||||
|
info := ClusterInfo{Name: cl.Name, URL: cl.URL, ReadAt: time.Now().UTC()}
|
||||||
|
start := time.Now()
|
||||||
|
defer func() { info.Millis = time.Since(start).Milliseconds() }()
|
||||||
|
c, err := cl.PVE()
|
||||||
|
if err != nil {
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info, model.Snapshot{}
|
||||||
|
}
|
||||||
|
snap, err := engine.Read(ctx, c, kinds, mode)
|
||||||
|
if err != nil {
|
||||||
|
info.Error = err.Error()
|
||||||
|
}
|
||||||
|
info.Version = snap.PVEVersion
|
||||||
|
if vs, verr := c.Versions(ctx); verr == nil {
|
||||||
|
info.Nodes = nodes(vs)
|
||||||
|
}
|
||||||
|
info.Objects = len(snap.Objects)
|
||||||
|
info.Warnings = snap.Warnings
|
||||||
|
info.Millis = time.Since(start).Milliseconds()
|
||||||
|
return info, snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// rows describes every object of the reference and of the client, field by field.
|
||||||
|
func rows(cfg *config.Config, cl *config.Client, ref, actual []model.Object, plan engine.Plan) []Row {
|
||||||
|
changes := map[string]engine.Change{}
|
||||||
|
for _, c := range plan.Changes {
|
||||||
|
changes[c.Key()] = c
|
||||||
|
}
|
||||||
|
errs := map[string][]string{}
|
||||||
|
for _, e := range plan.Errors {
|
||||||
|
if k, ok := engine.ErrorKey(e); ok {
|
||||||
|
errs[k] = append(errs[k], e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur := map[string]model.Object{}
|
||||||
|
for _, o := range actual {
|
||||||
|
cur[o.Key()] = o
|
||||||
|
}
|
||||||
|
var out []Row
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(k model.Kind, id string, r *model.Object, c *model.Object) {
|
||||||
|
a, err := adapter.ByKind(k)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := string(k) + "/" + id
|
||||||
|
seen[key] = true
|
||||||
|
row := Row{Kind: k, ID: id, Key: key, SDN: a.SDN(), Errors: errs[key]}
|
||||||
|
ch, hasChange := changes[key]
|
||||||
|
switch {
|
||||||
|
case len(row.Errors) > 0:
|
||||||
|
row.Status = StatusError
|
||||||
|
case hasChange:
|
||||||
|
row.Status = string(ch.Op)
|
||||||
|
case r == nil:
|
||||||
|
row.Status = StatusExtra
|
||||||
|
default:
|
||||||
|
row.Status = StatusSame
|
||||||
|
}
|
||||||
|
var mapping map[string]any
|
||||||
|
if cl != nil {
|
||||||
|
mapping = model.Normalize(cl.Mapping[k][id])
|
||||||
|
a.Canonicalize(mapping)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, o := range []*model.Object{r, c} {
|
||||||
|
if o != nil {
|
||||||
|
for f := range o.Fields {
|
||||||
|
names[f] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for f := range mapping {
|
||||||
|
names[f] = true
|
||||||
|
}
|
||||||
|
for f := range ch.Set {
|
||||||
|
names[f] = true
|
||||||
|
}
|
||||||
|
list := make([]string, 0, len(names))
|
||||||
|
for f := range names {
|
||||||
|
list = append(list, f)
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
unset := map[string]bool{}
|
||||||
|
for _, f := range ch.Unset {
|
||||||
|
unset[f] = true
|
||||||
|
}
|
||||||
|
for _, f := range list {
|
||||||
|
fd := Field{Name: f, Policy: cfg.Policy(cl, a, f), Mapping: mapping[f]}
|
||||||
|
if r != nil {
|
||||||
|
fd.Ref = r.Fields[f]
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
fd.Current = c.Fields[f]
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case ch.Op == OpDelete:
|
||||||
|
fd.Change = "unset"
|
||||||
|
case unset[f]:
|
||||||
|
fd.Change = "unset"
|
||||||
|
case ch.Set[f] != nil:
|
||||||
|
fd.Target = ch.Set[f]
|
||||||
|
if fd.Current != nil {
|
||||||
|
fd.Change = "set"
|
||||||
|
} else {
|
||||||
|
fd.Change = "add"
|
||||||
|
}
|
||||||
|
case fd.Policy != adapter.Ignore && fd.Current != nil:
|
||||||
|
fd.Target = fd.Current
|
||||||
|
}
|
||||||
|
row.Fields = append(row.Fields, fd)
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
for i := range ref {
|
||||||
|
r := &ref[i]
|
||||||
|
if !cfg.Selected(r.Kind, r.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var c *model.Object
|
||||||
|
if o, ok := cur[r.Key()]; ok {
|
||||||
|
c = &o
|
||||||
|
}
|
||||||
|
add(r.Kind, r.ID, r, c)
|
||||||
|
}
|
||||||
|
for i := range actual {
|
||||||
|
o := &actual[i]
|
||||||
|
if seen[o.Key()] || !cfg.Selected(o.Kind, o.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(o.Kind, o.ID, nil, o)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Kind != out[j].Kind {
|
||||||
|
return kindRank(out[i].Kind) < kindRank(out[j].Kind)
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpDelete is re-exported for the switch above (engine.Op is a string type).
|
||||||
|
const OpDelete = engine.OpDelete
|
||||||
|
|
||||||
|
func kindRank(k model.Kind) int {
|
||||||
|
for i, x := range model.Kinds {
|
||||||
|
if x == k {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(model.Kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientPlan is the plan of one client restricted to a selection of objects.
|
||||||
|
type ClientPlan struct {
|
||||||
|
Client string `json:"client"`
|
||||||
|
Plan engine.Plan `json:"plan"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
SDN bool `json:"sdn"`
|
||||||
|
Error string `json:"error,omitempty"` // client or reference unreadable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plans returns the plans of the given clients restricted to the given objects
|
||||||
|
// (empty: everything), as a dry-run shows them and an apply checks them.
|
||||||
|
func (st *State) Plans(keys []string) []ClientPlan {
|
||||||
|
var out []ClientPlan
|
||||||
|
for _, c := range st.Clients {
|
||||||
|
cp := ClientPlan{Client: c.Name}
|
||||||
|
switch {
|
||||||
|
case st.Reference.Error != "":
|
||||||
|
cp.Error = "reference: " + st.Reference.Error
|
||||||
|
case c.Error != "":
|
||||||
|
cp.Error = c.Error
|
||||||
|
default:
|
||||||
|
cp.Plan = engine.Filter(c.Plan, keys)
|
||||||
|
cp.Digest = cp.Plan.Digest()
|
||||||
|
cp.SDN = cp.Plan.HasSDN()
|
||||||
|
}
|
||||||
|
out = append(out, cp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply writes one plan on a client, then checks that the selected objects are
|
||||||
|
// in line with the reference (same reference objects as the plan).
|
||||||
|
func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log func(engine.Step)) error {
|
||||||
|
cl, ok := st.cfg.Client(cp.Client)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown client %q", cp.Client)
|
||||||
|
}
|
||||||
|
c, err := cl.PVE()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := engine.ApplyWithLog(ctx, c, cp.Plan, log)
|
||||||
|
if res.Err != nil {
|
||||||
|
return res.Err
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
err = engine.Verify(ctx, c, func(actual []model.Object) engine.Plan {
|
||||||
|
return engine.Filter(engine.MakePlan(st.cfg, cl, st.ref, actual), keys)
|
||||||
|
}, st.cfg.Kinds)
|
||||||
|
s := engine.Step{At: start.UTC(), Action: "verify"}
|
||||||
|
if err != nil {
|
||||||
|
s.Error = err.Error()
|
||||||
|
}
|
||||||
|
log(s)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tests the access to a cluster: versions (9.x only) and token privileges.
|
||||||
|
type Check struct {
|
||||||
|
Nodes []Node `json:"nodes,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCluster runs the same checks as the "check" command.
|
||||||
|
func CheckCluster(ctx context.Context, cl config.Cluster, write, dcOptions bool) Check {
|
||||||
|
var res Check
|
||||||
|
c, err := cl.PVE()
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
vs, err := c.CheckVersion(ctx)
|
||||||
|
res.Nodes = nodes(vs)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
perms, err := c.Permissions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
res.Warnings = append(res.Warnings, fmt.Sprintf("token privileges unreadable (%v)", err))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.Warnings = PrivilegeWarnings(perms, write, dcOptions)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivilegeWarnings flags a reference token able to write, and a client token
|
||||||
|
// missing the privileges needed by the synchronized kinds.
|
||||||
|
func PrivilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string {
|
||||||
|
has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok }
|
||||||
|
anyPath := func(priv string) bool {
|
||||||
|
for p := range perms {
|
||||||
|
if has(p, priv) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var w []string
|
||||||
|
if !write {
|
||||||
|
for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} {
|
||||||
|
if anyPath(p) {
|
||||||
|
w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") {
|
||||||
|
w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera")
|
||||||
|
}
|
||||||
|
if dcOptions && !has("/", "Sys.Modify") {
|
||||||
|
w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites")
|
||||||
|
}
|
||||||
|
if !has("/", "Sys.Audit") {
|
||||||
|
w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles")
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodes converts the versions read by the PVE client.
|
||||||
|
func nodes(vs []pve.NodeVersion) []Node {
|
||||||
|
out := make([]Node, 0, len(vs))
|
||||||
|
for _, v := range vs {
|
||||||
|
out = append(out, Node{Name: v.Node, Version: v.Version})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in new issue
Block a user