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" ) 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.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.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.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.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 } if err := r.ParseMultipartForm(1 << 20); err != nil { 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 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 != "" { 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 } if err := r.ParseMultipartForm(1 << 20); err != nil { r.ParseForm() } certPEM := r.FormValue("cert_pem") keyPEM := r.FormValue("key_pem") 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 == "" { 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.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) } func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) { 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 } } 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() }