package server import ( "context" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "html/template" "io" "net/http" "path/filepath" "strings" "time" "github.com/go-chi/chi/v5" mcias "git.wntrmute.dev/kyle/mcias/clients/go" "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/crypto" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" "git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/seal" ) func (s *Server) registerRoutes(r chi.Router) { // Static files. r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) // Web UI routes. r.Get("/", s.handleWebRoot) r.HandleFunc("/init", s.handleWebInit) r.HandleFunc("/unseal", s.handleWebUnseal) r.HandleFunc("/login", s.handleWebLogin) r.Get("/dashboard", s.requireAuthWeb(s.handleWebDashboard)) r.Post("/dashboard/mount-ca", s.requireAuthWeb(s.handleWebDashboardMountCA)) r.Route("/pki", func(r chi.Router) { r.Get("/", s.requireAuthWeb(s.handleWebPKI)) r.Post("/import-root", s.requireAuthWeb(s.handleWebImportRoot)) r.Post("/create-issuer", s.requireAuthWeb(s.handleWebCreateIssuer)) r.Get("/{issuer}", s.requireAuthWeb(s.handleWebPKIIssuer)) }) // API routes. r.Get("/v1/status", s.handleStatus) r.Post("/v1/init", s.handleInit) r.Post("/v1/unseal", s.handleUnseal) r.Post("/v1/seal", s.requireAdmin(s.handleSeal)) r.Post("/v1/auth/login", s.handleLogin) r.Post("/v1/auth/logout", s.requireAuth(s.handleLogout)) r.Get("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo)) r.Get("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts)) r.Post("/v1/engine/mount", s.requireAdmin(s.handleEngineMount)) r.Post("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount)) r.Post("/v1/engine/request", s.requireAuth(s.handleEngineRequest)) // Public PKI routes (no auth required, but must be unsealed). r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot)) r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain)) r.Get("/v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer)) r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules)) r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule)) } // --- API Handlers --- func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ "state": s.seal.State().String(), }) } func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) { var req struct { Password string `json:"password"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Password == "" { http.Error(w, `{"error":"password is required"}`, http.StatusBadRequest) return } params := crypto.Argon2Params{ Time: s.cfg.Seal.Argon2Time, Memory: s.cfg.Seal.Argon2Memory, Threads: s.cfg.Seal.Argon2Threads, } if err := s.seal.Initialize(r.Context(), []byte(req.Password), params); err != nil { if err == seal.ErrAlreadyInitialized { http.Error(w, `{"error":"already initialized"}`, http.StatusConflict) return } s.logger.Error("init failed", "error", err) http.Error(w, `{"error":"initialization failed"}`, http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "state": s.seal.State().String(), }) } func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) { var req struct { Password string `json:"password"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if err := s.seal.Unseal([]byte(req.Password)); err != nil { switch err { case seal.ErrNotInitialized: http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed) case seal.ErrInvalidPassword: http.Error(w, `{"error":"invalid password"}`, http.StatusUnauthorized) case seal.ErrRateLimited: http.Error(w, `{"error":"too many attempts, try again later"}`, http.StatusTooManyRequests) case seal.ErrNotSealed: http.Error(w, `{"error":"already unsealed"}`, http.StatusConflict) default: s.logger.Error("unseal failed", "error", err) http.Error(w, `{"error":"unseal failed"}`, http.StatusInternalServerError) } return } if err := s.engines.UnsealAll(r.Context()); err != nil { s.logger.Error("engine unseal failed", "error", err) http.Error(w, `{"error":"engine unseal failed"}`, http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "state": s.seal.State().String(), }) } func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) { if err := s.engines.SealAll(); err != nil { s.logger.Error("seal engines failed", "error", err) } if err := s.seal.Seal(); err != nil { s.logger.Error("seal failed", "error", err) http.Error(w, `{"error":"seal failed"}`, http.StatusInternalServerError) return } s.auth.ClearCache() writeJSON(w, http.StatusOK, map[string]interface{}{ "state": s.seal.State().String(), }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if s.seal.State() != seal.StateUnsealed { http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) return } var req struct { Username string `json:"username"` Password string `json:"password"` TOTPCode string `json:"totp_code"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } token, expiresAt, err := s.auth.Login(req.Username, req.Password, req.TOTPCode) if err != nil { http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "token": token, "expires_at": expiresAt, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { token := extractToken(r) client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{ CACertPath: s.cfg.MCIAS.CACert, Token: token, }) if err == nil { s.auth.Logout(client) } // Clear cookie. http.SetCookie(w, &http.Cookie{ Name: "metacrypt_token", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) writeJSON(w, http.StatusOK, map[string]interface{}{ "username": info.Username, "roles": info.Roles, "is_admin": info.IsAdmin, }) } func (s *Server) handleEngineMounts(w http.ResponseWriter, r *http.Request) { mounts := s.engines.ListMounts() writeJSON(w, http.StatusOK, mounts) } func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Type string `json:"type"` Config map[string]interface{} `json:"config"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Name == "" || req.Type == "" { http.Error(w, `{"error":"name and type are required"}`, http.StatusBadRequest) return } if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil { s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err) http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if err := s.engines.Unmount(r.Context(), req.Name); err != nil { http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { var req struct { Mount string `json:"mount"` Operation string `json:"operation"` Path string `json:"path"` Data map[string]interface{} `json:"data"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Mount == "" || req.Operation == "" { http.Error(w, `{"error":"mount and operation are required"}`, http.StatusBadRequest) return } info := TokenInfoFromContext(r.Context()) engReq := &engine.Request{ Operation: req.Operation, Path: req.Path, Data: req.Data, CallerInfo: &engine.CallerInfo{ Username: info.Username, Roles: info.Roles, IsAdmin: info.IsAdmin, }, } resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq) if err != nil { status := http.StatusInternalServerError switch { case errors.Is(err, engine.ErrMountNotFound): status = http.StatusNotFound case strings.Contains(err.Error(), "forbidden"): status = http.StatusForbidden case strings.Contains(err.Error(), "authentication required"): status = http.StatusUnauthorized case strings.Contains(err.Error(), "not found"): status = http.StatusNotFound } http.Error(w, `{"error":"`+err.Error()+`"}`, status) return } writeJSON(w, http.StatusOK, resp.Data) } func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) switch r.Method { case http.MethodGet: if !info.IsAdmin { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } rules, err := s.policy.ListRules(r.Context()) if err != nil { s.logger.Error("list policies", "error", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } if rules == nil { rules = []policy.Rule{} } writeJSON(w, http.StatusOK, rules) case http.MethodPost: if !info.IsAdmin { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } var rule policy.Rule if err := readJSON(r, &rule); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if rule.ID == "" { http.Error(w, `{"error":"id is required"}`, http.StatusBadRequest) return } if err := s.policy.CreateRule(r.Context(), &rule); err != nil { s.logger.Error("create policy", "error", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, rule) default: http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) } } func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } id := r.URL.Query().Get("id") if id == "" { http.Error(w, `{"error":"id parameter required"}`, http.StatusBadRequest) return } switch r.Method { case http.MethodGet: rule, err := s.policy.GetRule(r.Context(), id) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } writeJSON(w, http.StatusOK, rule) case http.MethodDelete: if err := s.policy.DeleteRule(r.Context(), id); err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) default: http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) } } // --- Public PKI Handlers --- func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") caEng, err := s.getCAEngine(mountName) if err != nil { http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) return } certPEM, err := caEng.GetRootCertPEM() if err != nil { http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/x-pem-file") w.Write(certPEM) } func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") issuerName := r.URL.Query().Get("issuer") if issuerName == "" { http.Error(w, `{"error":"issuer query parameter required"}`, http.StatusBadRequest) return } caEng, err := s.getCAEngine(mountName) if err != nil { http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) return } chainPEM, err := caEng.GetChainPEM(issuerName) if err != nil { if errors.Is(err, ca.ErrIssuerNotFound) { http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/x-pem-file") w.Write(chainPEM) } func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) { mountName := chi.URLParam(r, "mount") issuerName := chi.URLParam(r, "name") caEng, err := s.getCAEngine(mountName) if err != nil { http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) return } certPEM, err := caEng.GetIssuerCertPEM(issuerName) if err != nil { if errors.Is(err, ca.ErrIssuerNotFound) { http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/x-pem-file") w.Write(certPEM) } func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) { mount, err := s.engines.GetMount(mountName) if err != nil { return nil, err } if mount.Type != engine.EngineTypeCA { return nil, errors.New("mount is not a CA engine") } caEng, ok := mount.Engine.(*ca.CAEngine) if !ok { return nil, errors.New("mount is not a CA engine") } return caEng, nil } // findCAMount returns the name of the first CA engine mount. func (s *Server) findCAMount() (string, error) { for _, m := range s.engines.ListMounts() { if m.Type == engine.EngineTypeCA { return m.Name, nil } } return "", errors.New("no CA engine mounted") } // --- Web Handlers --- func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) { state := s.seal.State() switch state { case seal.StateUninitialized: http.Redirect(w, r, "/init", http.StatusFound) case seal.StateSealed: http.Redirect(w, r, "/unseal", http.StatusFound) case seal.StateInitializing: http.Redirect(w, r, "/init", http.StatusFound) case seal.StateUnsealed: http.Redirect(w, r, "/dashboard", http.StatusFound) } } func (s *Server) handleWebInit(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: if s.seal.State() != seal.StateUninitialized { http.Redirect(w, r, "/", http.StatusFound) return } s.renderTemplate(w, "init.html", nil) case http.MethodPost: r.ParseForm() password := r.FormValue("password") if password == "" { s.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"}) return } params := crypto.Argon2Params{ Time: s.cfg.Seal.Argon2Time, Memory: s.cfg.Seal.Argon2Memory, Threads: s.cfg.Seal.Argon2Threads, } if err := s.seal.Initialize(r.Context(), []byte(password), params); err != nil { s.renderTemplate(w, "init.html", map[string]interface{}{"Error": err.Error()}) return } http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleWebUnseal(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: state := s.seal.State() if state == seal.StateUninitialized { http.Redirect(w, r, "/init", http.StatusFound) return } if state == seal.StateUnsealed { http.Redirect(w, r, "/dashboard", http.StatusFound) return } s.renderTemplate(w, "unseal.html", nil) case http.MethodPost: r.ParseForm() password := r.FormValue("password") if err := s.seal.Unseal([]byte(password)); err != nil { msg := "Invalid password" if err == seal.ErrRateLimited { msg = "Too many attempts. Please wait 60 seconds." } s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg}) return } if err := s.engines.UnsealAll(r.Context()); err != nil { s.logger.Error("engine unseal failed", "error", err) s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": "Engine reload failed: " + err.Error()}) return } http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleWebLogin(w http.ResponseWriter, r *http.Request) { if s.seal.State() != seal.StateUnsealed { http.Redirect(w, r, "/", http.StatusFound) return } switch r.Method { case http.MethodGet: s.renderTemplate(w, "login.html", nil) case http.MethodPost: r.ParseForm() username := r.FormValue("username") password := r.FormValue("password") totpCode := r.FormValue("totp_code") token, _, err := s.auth.Login(username, password, totpCode) if err != nil { s.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"}) return } http.SetCookie(w, &http.Cookie{ Name: "metacrypt_token", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) mounts := s.engines.ListMounts() s.renderTemplate(w, "dashboard.html", map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "Roles": info.Roles, "Mounts": mounts, "State": s.seal.State().String(), "Version": s.version, }) } func (s *Server) handleWebDashboardMountCA(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } if err := r.ParseMultipartForm(1 << 20); err != nil { r.ParseForm() } mountName := r.FormValue("name") if mountName == "" { s.renderDashboardWithError(w, r, info, "Mount name is required") return } config := map[string]interface{}{} if org := r.FormValue("organization"); org != "" { config["organization"] = org } // Optional root CA import. var certPEM, keyPEM string if f, _, err := r.FormFile("cert_file"); err == nil { defer f.Close() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) certPEM = string(data) } if f, _, err := r.FormFile("key_file"); err == nil { defer f.Close() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) keyPEM = string(data) } if certPEM != "" && keyPEM != "" { config["root_cert_pem"] = certPEM config["root_key_pem"] = keyPEM } if err := s.engines.Mount(r.Context(), mountName, engine.EngineTypeCA, config); err != nil { s.renderDashboardWithError(w, r, info, err.Error()) return } http.Redirect(w, r, "/pki", http.StatusFound) } func (s *Server) renderDashboardWithError(w http.ResponseWriter, _ *http.Request, info *auth.TokenInfo, errMsg string) { mounts := s.engines.ListMounts() s.renderTemplate(w, "dashboard.html", map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "Roles": info.Roles, "Mounts": mounts, "State": s.seal.State().String(), "MountError": errMsg, }) } func (s *Server) handleWebPKI(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) mountName, err := s.findCAMount() if err != nil { http.Redirect(w, r, "/dashboard", http.StatusFound) return } caEng, err := s.getCAEngine(mountName) if err != nil { http.Redirect(w, r, "/dashboard", http.StatusFound) return } data := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "MountName": mountName, } // Get root cert info. rootPEM, err := caEng.GetRootCertPEM() if err == nil && rootPEM != nil { if cert, err := parsePEMCert(rootPEM); err == nil { data["RootCN"] = cert.Subject.CommonName data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ") data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339) data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339) data["RootExpired"] = time.Now().After(cert.NotAfter) data["HasRoot"] = true } } // Get issuers. callerInfo := &engine.CallerInfo{ Username: info.Username, Roles: info.Roles, IsAdmin: info.IsAdmin, } resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ Operation: "list-issuers", CallerInfo: callerInfo, }) if err == nil { data["Issuers"] = resp.Data["issuers"] } s.renderTemplate(w, "pki.html", data) } func (s *Server) handleWebImportRoot(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } mountName, err := s.findCAMount() if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } if err := r.ParseMultipartForm(1 << 20); err != nil { r.ParseForm() } certPEM := r.FormValue("cert_pem") keyPEM := r.FormValue("key_pem") // Also support file uploads. if certPEM == "" { if f, _, err := r.FormFile("cert_file"); err == nil { defer f.Close() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) certPEM = string(data) } } if keyPEM == "" { if f, _, err := r.FormFile("key_file"); err == nil { defer f.Close() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) keyPEM = string(data) } } if certPEM == "" || keyPEM == "" { s.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required") return } callerInfo := &engine.CallerInfo{ Username: info.Username, Roles: info.Roles, IsAdmin: info.IsAdmin, } _, err = s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ Operation: "import-root", CallerInfo: callerInfo, Data: map[string]interface{}{ "cert_pem": certPEM, "key_pem": keyPEM, }, }) if err != nil { s.renderPKIWithError(w, r, mountName, info, err.Error()) return } http.Redirect(w, r, "/pki", http.StatusFound) } func (s *Server) handleWebCreateIssuer(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } mountName, err := s.findCAMount() if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } r.ParseForm() name := r.FormValue("name") if name == "" { s.renderPKIWithError(w, r, mountName, info, "Issuer name is required") return } data := map[string]interface{}{ "name": name, } if v := r.FormValue("expiry"); v != "" { data["expiry"] = v } if v := r.FormValue("max_ttl"); v != "" { data["max_ttl"] = v } if v := r.FormValue("key_algorithm"); v != "" { data["key_algorithm"] = v } if v := r.FormValue("key_size"); v != "" { // Parse as float64 to match JSON number convention used by the engine. var size float64 if _, err := fmt.Sscanf(v, "%f", &size); err == nil { data["key_size"] = size } } callerInfo := &engine.CallerInfo{ Username: info.Username, Roles: info.Roles, IsAdmin: info.IsAdmin, } _, err = s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ Operation: "create-issuer", CallerInfo: callerInfo, Data: data, }) if err != nil { s.renderPKIWithError(w, r, mountName, info, err.Error()) return } http.Redirect(w, r, "/pki", http.StatusFound) } func (s *Server) handleWebPKIIssuer(w http.ResponseWriter, r *http.Request) { mountName, err := s.findCAMount() if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } issuerName := chi.URLParam(r, "issuer") caEng, err := s.getCAEngine(mountName) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } certPEM, err := caEng.GetIssuerCertPEM(issuerName) if err != nil { http.Error(w, "issuer not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/x-pem-file") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.pem", issuerName)) w.Write(certPEM) } func (s *Server) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *auth.TokenInfo, errMsg string) { data := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "MountName": mountName, "Error": errMsg, } // Try to load existing root info. mount, merr := s.engines.GetMount(mountName) if merr == nil && mount.Type == engine.EngineTypeCA { if caEng, ok := mount.Engine.(*ca.CAEngine); ok { rootPEM, err := caEng.GetRootCertPEM() if err == nil && rootPEM != nil { if cert, err := parsePEMCert(rootPEM); err == nil { data["RootCN"] = cert.Subject.CommonName data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ") data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339) data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339) data["RootExpired"] = time.Now().After(cert.NotAfter) data["HasRoot"] = true } } } } s.renderTemplate(w, "pki.html", data) } func parsePEMCert(pemData []byte) (*x509.Certificate, error) { block, _ := pem.Decode(pemData) if block == nil { return nil, errors.New("no PEM block found") } return x509.ParseCertificate(block.Bytes) } // requireAuthWeb redirects to login for web pages instead of returning 401. func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.seal.State() != seal.StateUnsealed { http.Redirect(w, r, "/", http.StatusFound) return } token := extractToken(r) if token == "" { http.Redirect(w, r, "/login", http.StatusFound) return } info, err := s.auth.ValidateToken(token) if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } ctx := r.Context() ctx = context.WithValue(ctx, tokenInfoKey, info) next(w, r.WithContext(ctx)) } } func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) { tmpl, err := template.ParseFiles( filepath.Join("web", "templates", "layout.html"), filepath.Join("web", "templates", name), ) if err != nil { s.logger.Error("parse template", "name", name, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { s.logger.Error("execute template", "name", name, "error", err) } } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func readJSON(r *http.Request, v interface{}) error { defer r.Body.Close() body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit if err != nil { return err } return json.Unmarshal(body, v) }