diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..f681832 --- /dev/null +++ b/internal/server/auth.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..bf5a75e --- /dev/null +++ b/internal/server/server.go @@ -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 +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..31b99b6 --- /dev/null +++ b/internal/service/service.go @@ -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 +}