package webserver import ( "fmt" "io" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // 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.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.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail)) 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 } 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 } 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 (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()) ws.renderTemplate(w, "dashboard.html", map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "Roles": info.Roles, "Mounts": mounts, "State": state, }) } 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) 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()) ws.renderTemplate(w, "dashboard.html", map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "Roles": info.Roles, "Mounts": mounts, "State": state, "MountError": errMsg, }) } 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 := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "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 resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil { data["Issuers"] = resp["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 } _, err = ws.vault.EngineRequest(r.Context(), token, mountName, "import-root", map[string]interface{}{ "cert_pem": certPEM, "key_pem": keyPEM, }) if 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 } reqData := map[string]interface{}{"name": name} if v := r.FormValue("expiry"); v != "" { reqData["expiry"] = v } if v := r.FormValue("max_ttl"); v != "" { reqData["max_ttl"] = v } if v := r.FormValue("key_algorithm"); v != "" { reqData["key_algorithm"] = v } if v := r.FormValue("key_size"); v != "" { var size float64 if _, err := fmt.Sscanf(v, "%f", &size); err == nil { reqData["key_size"] = size } } _, err = ws.vault.EngineRequest(r.Context(), token, mountName, "create-issuer", reqData) if 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") resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-certs", nil) 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 []map[string]interface{} if raw, ok := resp["certs"]; ok { if list, ok := raw.([]interface{}); ok { for _, item := range list { if m, ok := item.(map[string]interface{}); ok { issuer, _ := m["issuer"].(string) if issuer != issuerName { continue } if nameFilter != "" { cn, _ := m["cn"].(string) if !strings.Contains(strings.ToLower(cn), nameFilter) { continue } } certs = append(certs, m) } } } } // 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-- { a, _ := certs[j-1]["expires_at"].(string) b, _ := certs[j]["expires_at"].(string) if a > b { certs[j-1], certs[j] = certs[j], certs[j-1] } } } } else { for i := 1; i < len(certs); i++ { for j := i; j > 0; j-- { a, _ := certs[j-1]["cn"].(string) b, _ := certs[j]["cn"].(string) if strings.ToLower(a) > strings.ToLower(b) { certs[j-1], certs[j] = certs[j], certs[j-1] } } } } data := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "MountName": mountName, "IssuerName": issuerName, "Certs": certs, "NameFilter": r.URL.Query().Get("name"), "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 } reqData := map[string]interface{}{ "common_name": commonName, "issuer": issuer, } if v := r.FormValue("profile"); v != "" { reqData["profile"] = v } if v := r.FormValue("ttl"); v != "" { reqData["ttl"] = v } if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 { reqData["dns_names"] = lines } if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 { reqData["ip_addresses"] = lines } resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "issue", reqData) if err != nil { ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err)) return } // Re-render the PKI page with the issued certificate displayed. data := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "MountName": mountName, "IssuedCert": resp, } 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 issuerResp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil { data["Issuers"] = issuerResp["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 := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, "MountName": mountName, "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 resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil { data["Issuers"] = resp["issuers"] } ws.renderTemplate(w, "pki.html", data) } func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) { mounts, err := ws.vault.ListMounts(r.Context(), token) if err != nil { return "", err } for _, m := range mounts { if m.Type == "ca" { return m.Name, nil } } return "", fmt.Errorf("no CA engine mounted") } // 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() }