package webserver import ( "archive/tar" "bytes" "compress/gzip" "crypto/rand" "encoding/hex" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "git.wntrmute.dev/mc/mcdsl/web" ) // splitLines splits a newline-delimited string into non-empty trimmed lines. func splitLines(s string) []interface{} { var out []interface{} for _, line := range strings.Split(s, "\n") { if v := strings.TrimSpace(line); v != "" { out = append(out, v) } } return out } func (ws *WebServer) registerRoutes(r chi.Router) { r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS)))) r.Get("/", ws.handleRoot) r.HandleFunc("/init", ws.handleInit) r.HandleFunc("/unseal", ws.handleUnseal) r.HandleFunc("/login", ws.handleLogin) r.Get("/dashboard", ws.requireAuth(ws.handleDashboard)) r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA)) r.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine)) r.Route("/policy", func(r chi.Router) { r.Get("/", ws.requireAuth(ws.handlePolicy)) r.Post("/", ws.requireAuth(ws.handlePolicyCreate)) r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete)) }) r.Route("/sshca", func(r chi.Router) { r.Get("/", ws.requireAuth(ws.handleSSHCA)) r.Post("/sign-user", ws.requireAuth(ws.handleSSHCASignUser)) r.Post("/sign-host", ws.requireAuth(ws.handleSSHCASignHost)) r.Get("/cert/{serial}", ws.requireAuth(ws.handleSSHCACertDetail)) r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleSSHCACertRevoke)) r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleSSHCACertDelete)) r.Post("/profile/create", ws.requireAuth(ws.handleSSHCACreateProfile)) r.Get("/profile/{name}", ws.requireAuth(ws.handleSSHCAProfileDetail)) r.Post("/profile/{name}/update", ws.requireAuth(ws.handleSSHCAUpdateProfile)) r.Post("/profile/{name}/delete", ws.requireAuth(ws.handleSSHCADeleteProfile)) }) r.Route("/transit", func(r chi.Router) { r.Get("/", ws.requireAuth(ws.handleTransit)) r.Get("/key/{name}", ws.requireAuth(ws.handleTransitKeyDetail)) r.Post("/key/create", ws.requireAuth(ws.handleTransitCreateKey)) r.Post("/key/{name}/rotate", ws.requireAuth(ws.handleTransitRotateKey)) r.Post("/key/{name}/config", ws.requireAuth(ws.handleTransitUpdateConfig)) r.Post("/key/{name}/trim", ws.requireAuth(ws.handleTransitTrimKey)) r.Post("/key/{name}/delete", ws.requireAuth(ws.handleTransitDeleteKey)) r.Post("/encrypt", ws.requireAuth(ws.handleTransitEncrypt)) r.Post("/decrypt", ws.requireAuth(ws.handleTransitDecrypt)) r.Post("/rewrap", ws.requireAuth(ws.handleTransitRewrap)) r.Post("/sign", ws.requireAuth(ws.handleTransitSign)) r.Post("/verify", ws.requireAuth(ws.handleTransitVerify)) r.Post("/hmac", ws.requireAuth(ws.handleTransitHMAC)) }) r.Route("/user", func(r chi.Router) { r.Get("/", ws.requireAuth(ws.handleUser)) r.Post("/register", ws.requireAuth(ws.handleUserRegister)) r.Post("/provision", ws.requireAuth(ws.handleUserProvision)) r.Get("/key/{username}", ws.requireAuth(ws.handleUserKeyDetail)) r.Post("/encrypt", ws.requireAuth(ws.handleUserEncrypt)) r.Post("/decrypt", ws.requireAuth(ws.handleUserDecrypt)) r.Post("/re-encrypt", ws.requireAuth(ws.handleUserReEncrypt)) r.Post("/rotate", ws.requireAuth(ws.handleUserRotateKey)) r.Post("/delete/{username}", ws.requireAuth(ws.handleUserDeleteUser)) }) r.Route("/pki", func(r chi.Router) { r.Get("/", ws.requireAuth(ws.handlePKI)) r.Post("/import-root", ws.requireAuth(ws.handleImportRoot)) r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer)) r.Post("/issue", ws.requireAuth(ws.handleIssueCert)) r.Post("/sign-csr", ws.requireAuth(ws.handleSignCSR)) r.Get("/download/{token}", ws.requireAuth(ws.handleTGZDownload)) r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail)) r.Get("/cert/{serial}", ws.requireAuth(ws.handleCertDetail)) r.Get("/cert/{serial}/download", ws.requireAuth(ws.handleCertDownload)) r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleCertRevoke)) r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleCertDelete)) r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer)) }) } // requireAuth validates the token cookie against the vault and injects TokenInfo. func (ws *WebServer) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { state, err := ws.vault.Status(r.Context()) if err != nil || state != "unsealed" { http.Redirect(w, r, "/", http.StatusFound) return } token := extractCookie(r) if token == "" { http.Redirect(w, r, "/login", http.StatusFound) return } info, err := ws.vault.ValidateToken(r.Context(), token) if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } info.Username = ws.resolveUser(info.Username) r = r.WithContext(withTokenInfo(r.Context(), info)) next(w, r) } } func (ws *WebServer) handleRoot(w http.ResponseWriter, r *http.Request) { state, err := ws.vault.Status(r.Context()) if err != nil { ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": "Cannot reach vault"}) return } switch state { case "uninitialized", "initializing": http.Redirect(w, r, "/init", http.StatusFound) case "sealed": http.Redirect(w, r, "/unseal", http.StatusFound) case "unsealed": http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Redirect(w, r, "/unseal", http.StatusFound) } } func (ws *WebServer) handleInit(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: state, _ := ws.vault.Status(r.Context()) if state != "uninitialized" && state != "" { http.Redirect(w, r, "/", http.StatusFound) return } ws.renderTemplate(w, "init.html", nil) case http.MethodPost: r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() password := r.FormValue("password") if password == "" { ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"}) return } if err := ws.vault.Init(r.Context(), password); err != nil { ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": grpcMessage(err)}) return } http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (ws *WebServer) handleUnseal(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: state, _ := ws.vault.Status(r.Context()) if state == "uninitialized" { http.Redirect(w, r, "/init", http.StatusFound) return } if state == "unsealed" { http.Redirect(w, r, "/dashboard", http.StatusFound) return } ws.renderTemplate(w, "unseal.html", nil) case http.MethodPost: r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() password := r.FormValue("password") if err := ws.vault.Unseal(r.Context(), password); err != nil { msg := "Invalid password" if st, ok := status.FromError(err); ok && st.Code() == codes.ResourceExhausted { msg = "Too many attempts. Please wait 60 seconds." } ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg}) return } http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) { state, _ := ws.vault.Status(r.Context()) if state != "unsealed" { http.Redirect(w, r, "/", http.StatusFound) return } switch r.Method { case http.MethodGet: ws.renderTemplate(w, "login.html", nil) case http.MethodPost: r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() token, err := ws.vault.Login(r.Context(), r.FormValue("username"), r.FormValue("password"), r.FormValue("totp_code"), ) if err != nil { ws.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"}) return } web.SetSessionCookie(w, "metacrypt_token", token) http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) mounts, _ := ws.vault.ListMounts(r.Context(), token) state, _ := ws.vault.Status(r.Context()) data := ws.baseData(r, info) data["Roles"] = info.Roles data["Mounts"] = mounts data["State"] = state ws.renderTemplate(w, "dashboard.html", data) } func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) if err := r.ParseMultipartForm(1 << 20); err != nil { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() } mountName := r.FormValue("name") if mountName == "" { ws.renderDashboardWithError(w, r, info, "Mount name is required") return } cfg := map[string]interface{}{} if org := r.FormValue("organization"); org != "" { cfg["organization"] = org } var certPEM, keyPEM string if f, _, err := r.FormFile("cert_file"); err == nil { defer func() { _ = f.Close() }() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) certPEM = string(data) } if f, _, err := r.FormFile("key_file"); err == nil { defer func() { _ = f.Close() }() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) keyPEM = string(data) } if certPEM != "" && keyPEM != "" { cfg["root_cert_pem"] = certPEM cfg["root_key_pem"] = keyPEM } token := extractCookie(r) if err := ws.vault.Mount(r.Context(), token, mountName, "ca", cfg); err != nil { ws.renderDashboardWithError(w, r, info, grpcMessage(err)) return } http.Redirect(w, r, "/pki", http.StatusFound) } func (ws *WebServer) handleDashboardMountEngine(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() mountName := r.FormValue("name") engineType := r.FormValue("type") if mountName == "" || engineType == "" { ws.renderDashboardWithError(w, r, info, "Mount name and engine type are required") return } cfg := map[string]interface{}{} if v := r.FormValue("key_algorithm"); v != "" { cfg["key_algorithm"] = v } token := extractCookie(r) if err := ws.vault.Mount(r.Context(), token, mountName, engineType, cfg); err != nil { ws.renderDashboardWithError(w, r, info, grpcMessage(err)) return } // Redirect to the appropriate engine page. switch engineType { case "sshca": http.Redirect(w, r, "/sshca", http.StatusFound) case "transit": http.Redirect(w, r, "/transit", http.StatusFound) case "user": http.Redirect(w, r, "/user", http.StatusFound) default: http.Redirect(w, r, "/dashboard", http.StatusFound) } } func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) { token := extractCookie(r) mounts, _ := ws.vault.ListMounts(r.Context(), token) state, _ := ws.vault.Status(r.Context()) data := ws.baseData(r, info) data["Roles"] = info.Roles data["Mounts"] = mounts data["State"] = state data["MountError"] = errMsg ws.renderTemplate(w, "dashboard.html", data) } func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Redirect(w, r, "/dashboard", http.StatusFound) return } data := ws.baseData(r, info) data["MountName"] = mountName if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { 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 } } if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil { data["Issuers"] = issuers } ws.renderTemplate(w, "pki.html", data) } func (ws *WebServer) handleImportRoot(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) if err := r.ParseMultipartForm(1 << 20); err != nil { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() } certPEM := r.FormValue("cert_pem") keyPEM := r.FormValue("key_pem") if certPEM == "" { if f, _, err := r.FormFile("cert_file"); err == nil { defer func() { _ = 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 func() { _ = f.Close() }() data, _ := io.ReadAll(io.LimitReader(f, 1<<20)) keyPEM = string(data) } } if certPEM == "" || keyPEM == "" { ws.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required") return } if err = ws.vault.ImportRoot(r.Context(), token, mountName, certPEM, keyPEM); err != nil { ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err)) return } http.Redirect(w, r, "/pki", http.StatusFound) } func (ws *WebServer) handleCreateIssuer(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() name := r.FormValue("name") if name == "" { ws.renderPKIWithError(w, r, mountName, info, "Issuer name is required") return } issuerReq := CreateIssuerRequest{ Mount: mountName, Name: name, } if v := r.FormValue("expiry"); v != "" { issuerReq.Expiry = v } if v := r.FormValue("max_ttl"); v != "" { issuerReq.MaxTTL = v } if v := r.FormValue("key_algorithm"); v != "" { issuerReq.KeyAlgorithm = v } if v := r.FormValue("key_size"); v != "" { var size int32 if _, err := fmt.Sscanf(v, "%d", &size); err == nil { issuerReq.KeySize = size } } if err = ws.vault.CreateIssuer(r.Context(), token, issuerReq); err != nil { ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err)) return } http.Redirect(w, r, "/pki", http.StatusFound) } func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) { token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } issuerName := chi.URLParam(r, "issuer") certPEM, err := ws.vault.GetIssuerCert(r.Context(), mountName, 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) //nolint:gosec } func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } issuerName := chi.URLParam(r, "issuer") allCerts, err := ws.vault.ListCerts(r.Context(), token, mountName) if err != nil { http.Error(w, "failed to list certificates", http.StatusInternalServerError) return } nameFilter := strings.ToLower(r.URL.Query().Get("name")) sortBy := r.URL.Query().Get("sort") if sortBy == "" { sortBy = "cn" } var certs []CertSummary for _, cs := range allCerts { if cs.Issuer != issuerName { continue } if nameFilter != "" && !strings.Contains(strings.ToLower(cs.CommonName), strings.ToLower(nameFilter)) { continue } certs = append(certs, cs) } // Sort: by expiry date or by common name (default). if sortBy == "expiry" { for i := 1; i < len(certs); i++ { for j := i; j > 0; j-- { if certs[j-1].ExpiresAt > certs[j].ExpiresAt { certs[j-1], certs[j] = certs[j], certs[j-1] } } } } else { for i := 1; i < len(certs); i++ { for j := i; j > 0; j-- { if strings.ToLower(certs[j-1].CommonName) > strings.ToLower(certs[j].CommonName) { certs[j-1], certs[j] = certs[j], certs[j-1] } } } } for i := range certs { certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy) } data := ws.baseData(r, info) data["MountName"] = mountName data["IssuerName"] = issuerName data["Certs"] = certs data["NameFilter"] = r.URL.Query().Get("name") data["SortBy"] = sortBy ws.renderTemplate(w, "issuer_detail.html", data) } func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() commonName := r.FormValue("common_name") if commonName == "" { ws.renderPKIWithError(w, r, mountName, info, "Common name is required") return } issuer := r.FormValue("issuer") if issuer == "" { ws.renderPKIWithError(w, r, mountName, info, "Issuer is required") return } certReq := IssueCertRequest{ Mount: mountName, Issuer: issuer, CommonName: commonName, } if v := r.FormValue("profile"); v != "" { certReq.Profile = v } if v := r.FormValue("ttl"); v != "" { certReq.TTL = v } if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 { for _, l := range lines { certReq.DNSNames = append(certReq.DNSNames, l.(string)) } } if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 { for _, l := range lines { certReq.IPAddresses = append(certReq.IPAddresses, l.(string)) } } certReq.KeyUsages = r.Form["key_usages"] certReq.ExtKeyUsages = r.Form["ext_key_usages"] issuedCert, err := ws.vault.IssueCert(r.Context(), token, certReq) if err != nil { ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err)) return } // Build the tgz archive in memory, store it in the cache, then redirect // the browser to the one-time download URL so the archive is only served // once and then discarded. var buf bytes.Buffer gw := gzip.NewWriter(&buf) tw := tar.NewWriter(gw) writeTarFile := func(name string, data []byte) error { hdr := &tar.Header{ Name: name, Mode: 0600, Size: int64(len(data)), ModTime: time.Now(), } if err := tw.WriteHeader(hdr); err != nil { return err } _, err := tw.Write(data) return err } if err := writeTarFile("key.pem", []byte(issuedCert.KeyPEM)); err != nil { ws.logger.Error("build tgz key", "error", err) http.Error(w, "failed to build archive", http.StatusInternalServerError) return } if err := writeTarFile("cert.pem", []byte(issuedCert.CertPEM)); err != nil { ws.logger.Error("build tgz cert", "error", err) http.Error(w, "failed to build archive", http.StatusInternalServerError) return } _ = tw.Close() _ = gw.Close() // Generate a random one-time token for the download URL. var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { ws.logger.Error("generate download token", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } dlToken := hex.EncodeToString(raw[:]) ws.tgzCache.Store(dlToken, &tgzEntry{ filename: issuedCert.Serial + ".tgz", data: buf.Bytes(), }) http.Redirect(w, r, "/pki/download/"+dlToken, http.StatusSeeOther) } func (ws *WebServer) handleTGZDownload(w http.ResponseWriter, r *http.Request) { dlToken := chi.URLParam(r, "token") val, ok := ws.tgzCache.LoadAndDelete(dlToken) if !ok { http.Error(w, "download not found or already used", http.StatusNotFound) return } entry := val.(*tgzEntry) w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Disposition", "attachment; filename=\""+entry.filename+"\"") _, _ = w.Write(entry.data) } func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } serial := chi.URLParam(r, "serial") cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial) if err != nil { st, _ := status.FromError(err) if st.Code() == codes.NotFound { http.Error(w, "certificate not found", http.StatusNotFound) return } http.Error(w, grpcMessage(err), http.StatusInternalServerError) return } cert.IssuedBy = ws.resolveUser(cert.IssuedBy) cert.RevokedBy = ws.resolveUser(cert.RevokedBy) data := ws.baseData(r, info) data["MountName"] = mountName data["Cert"] = cert ws.renderTemplate(w, "cert_detail.html", data) } func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) { token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } serial := chi.URLParam(r, "serial") cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial) if err != nil { st, _ := status.FromError(err) if st.Code() == codes.NotFound { http.Error(w, "certificate not found", http.StatusNotFound) return } http.Error(w, grpcMessage(err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-pem-file") w.Header().Set("Content-Disposition", "attachment; filename=\""+serial+".pem\"") _, _ = w.Write([]byte(cert.CertPEM)) } func (ws *WebServer) handleCertRevoke(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } serial := chi.URLParam(r, "serial") if err := ws.vault.RevokeCert(r.Context(), token, mountName, serial); err != nil { st, _ := status.FromError(err) if st.Code() == codes.NotFound { http.Error(w, "certificate not found", http.StatusNotFound) return } http.Error(w, grpcMessage(err), http.StatusInternalServerError) return } http.Redirect(w, r, "/pki/cert/"+serial, http.StatusSeeOther) } func (ws *WebServer) handleCertDelete(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } serial := chi.URLParam(r, "serial") // Fetch the cert to get the issuer for the redirect. cert, certErr := ws.vault.GetCert(r.Context(), token, mountName, serial) if err := ws.vault.DeleteCert(r.Context(), token, mountName, serial); err != nil { st, _ := status.FromError(err) if st.Code() == codes.NotFound { http.Error(w, "certificate not found", http.StatusNotFound) return } http.Error(w, grpcMessage(err), http.StatusInternalServerError) return } if certErr == nil && cert != nil { http.Redirect(w, r, "/pki/issuer/"+cert.Issuer, http.StatusSeeOther) return } http.Redirect(w, r, "/pki", http.StatusSeeOther) } func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) mountName, err := ws.findCAMount(r, token) if err != nil { http.Error(w, "no CA engine mounted", http.StatusNotFound) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) _ = r.ParseForm() issuer := r.FormValue("issuer") if issuer == "" { ws.renderPKIWithError(w, r, mountName, info, "Issuer is required") return } csrPEM := r.FormValue("csr_pem") if csrPEM == "" { ws.renderPKIWithError(w, r, mountName, info, "CSR PEM is required") return } req := SignCSRRequest{ Mount: mountName, Issuer: issuer, CSRPEM: csrPEM, Profile: r.FormValue("profile"), TTL: r.FormValue("ttl"), } signed, err := ws.vault.SignCSR(r.Context(), token, req) if err != nil { ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err)) return } data := ws.baseData(r, info) data["MountName"] = mountName data["SignedCert"] = signed if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { 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 } } if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil { data["Issuers"] = issuers } ws.renderTemplate(w, "pki.html", data) } func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) { token := extractCookie(r) data := ws.baseData(r, info) data["MountName"] = mountName data["Error"] = errMsg if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { 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 } } if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil { data["Issuers"] = issuers } ws.renderTemplate(w, "pki.html", data) } func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) { return ws.findMount(r, token, "ca") } func (ws *WebServer) findSSHCAMount(r *http.Request, token string) (string, error) { return ws.findMount(r, token, "sshca") } func (ws *WebServer) findTransitMount(r *http.Request, token string) (string, error) { return ws.findMount(r, token, "transit") } func (ws *WebServer) findUserMount(r *http.Request, token string) (string, error) { return ws.findMount(r, token, "user") } func (ws *WebServer) findMount(r *http.Request, token, engineType string) (string, error) { mounts, err := ws.vault.ListMounts(r.Context(), token) if err != nil { return "", err } for _, m := range mounts { if m.Type == engineType { return m.Name, nil } } return "", fmt.Errorf("no %s engine mounted", engineType) } // mountTypes returns a set of engine types that are currently mounted. func (ws *WebServer) mountTypes(r *http.Request, token string) map[string]bool { mounts, err := ws.vault.ListMounts(r.Context(), token) if err != nil { return nil } types := make(map[string]bool, len(mounts)) for _, m := range mounts { types[m.Type] = true } return types } func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) rules, err := ws.vault.ListPolicies(r.Context(), token) if err != nil { rules = []PolicyRule{} } data := ws.baseData(r, info) data["Rules"] = rules ws.renderTemplate(w, "policy.html", data) } func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) _ = r.ParseForm() priorityStr := r.FormValue("priority") priority := 50 if priorityStr != "" { if p, err := strconv.Atoi(priorityStr); err == nil { priority = p } } splitCSV := func(s string) []string { var out []string for _, v := range strings.Split(s, ",") { v = strings.TrimSpace(v) if v != "" { out = append(out, v) } } return out } rule := PolicyRule{ ID: r.FormValue("id"), Priority: priority, Effect: r.FormValue("effect"), Usernames: splitCSV(r.FormValue("usernames")), Roles: splitCSV(r.FormValue("roles")), Resources: splitCSV(r.FormValue("resources")), Actions: splitCSV(r.FormValue("actions")), } if rule.ID == "" || rule.Effect == "" { ws.renderPolicyWithError(w, r, info, token, "ID and effect are required") return } if _, err := ws.vault.CreatePolicy(r.Context(), token, rule); err != nil { ws.renderPolicyWithError(w, r, info, token, grpcMessage(err)) return } http.Redirect(w, r, "/policy", http.StatusFound) } func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) if !info.IsAdmin { http.Error(w, "forbidden", http.StatusForbidden) return } token := extractCookie(r) _ = r.ParseForm() id := r.FormValue("id") if id == "" { http.Redirect(w, r, "/policy", http.StatusFound) return } if err := ws.vault.DeletePolicy(r.Context(), token, id); err != nil { ws.renderPolicyWithError(w, r, info, token, grpcMessage(err)) return } http.Redirect(w, r, "/policy", http.StatusFound) } func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) { rules, _ := ws.vault.ListPolicies(r.Context(), token) data := ws.baseData(r, info) data["Rules"] = rules data["Error"] = errMsg ws.renderTemplate(w, "policy.html", data) } // baseData returns a template data map pre-populated with user info and nav flags. func (ws *WebServer) baseData(r *http.Request, info *TokenInfo) map[string]interface{} { token := extractCookie(r) types := ws.mountTypes(r, token) return map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "HasSSHCA": types["sshca"], "HasTransit": types["transit"], "HasUser": types["user"], } } // grpcMessage extracts a human-readable message from a gRPC error. func grpcMessage(err error) string { if st, ok := status.FromError(err); ok { return st.Message() } return err.Error() }