diff --git a/internal/api/diagnostics_pprof.go b/internal/api/diagnostics_pprof.go new file mode 100644 index 000000000..ddac620d3 --- /dev/null +++ b/internal/api/diagnostics_pprof.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" + "net/http/pprof" + "os" + "strings" +) + +const pprofRoutePrefix = "/api/diagnostics/pprof" + +func pprofEnabled() bool { + return strings.EqualFold(os.Getenv("PULSE_ENABLE_PPROF"), "true") +} + +func (r *Router) handlePprofRedirect(w http.ResponseWriter, req *http.Request) { + if !pprofEnabled() { + http.NotFound(w, req) + return + } + + target := pprofRoutePrefix + "/" + if req.URL.RawQuery != "" { + target += "?" + req.URL.RawQuery + } + http.Redirect(w, req, target, http.StatusMovedPermanently) +} + +func (r *Router) handlePprof(w http.ResponseWriter, req *http.Request) { + if !pprofEnabled() { + http.NotFound(w, req) + return + } + + relative := strings.TrimPrefix(req.URL.Path, pprofRoutePrefix) + switch relative { + case "", "/": + pprof.Index(w, req) + return + case "/cmdline": + pprof.Cmdline(w, req) + return + case "/profile": + pprof.Profile(w, req) + return + case "/symbol": + pprof.Symbol(w, req) + return + case "/trace": + pprof.Trace(w, req) + return + default: + profile := strings.TrimPrefix(relative, "/") + pprof.Handler(profile).ServeHTTP(w, req) + } +} diff --git a/internal/api/diagnostics_pprof_test.go b/internal/api/diagnostics_pprof_test.go new file mode 100644 index 000000000..53e402ce6 --- /dev/null +++ b/internal/api/diagnostics_pprof_test.go @@ -0,0 +1,67 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestHandlePprofDisabledReturnsNotFound(t *testing.T) { + router := &Router{} + + req := httptest.NewRequest(http.MethodGet, "/api/diagnostics/pprof/", nil) + rec := httptest.NewRecorder() + + router.handlePprof(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestHandlePprofEnabledServesIndex(t *testing.T) { + t.Setenv("PULSE_ENABLE_PPROF", "true") + router := &Router{} + + req := httptest.NewRequest(http.MethodGet, "/api/diagnostics/pprof/", nil) + rec := httptest.NewRecorder() + + router.handlePprof(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !strings.Contains(rec.Body.String(), "heap") { + t.Fatalf("expected pprof index to include heap profile") + } +} + +func TestPprofRouteRequiresAdmin(t *testing.T) { + t.Setenv("PULSE_ENABLE_PPROF", "true") + t.Setenv("ALLOW_ADMIN_BYPASS", "") + t.Setenv("PULSE_DEV", "") + t.Setenv("NODE_ENV", "") + resetAdminBypassState() + + dataDir := t.TempDir() + cfg := &config.Config{ + DataPath: dataDir, + ConfigPath: dataDir, + AuthUser: "admin", + AuthPass: "secret", + } + + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + req := httptest.NewRequest(http.MethodGet, "/api/diagnostics/pprof/", nil) + rec := httptest.NewRecorder() + + router.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} diff --git a/internal/api/proxy_admin_gating_test.go b/internal/api/proxy_admin_gating_test.go index e6333e1d3..c22f25f35 100644 --- a/internal/api/proxy_admin_gating_test.go +++ b/internal/api/proxy_admin_gating_test.go @@ -11,6 +11,7 @@ import ( func TestProxyAuthAdminGatesAdminEndpoints(t *testing.T) { t.Setenv("PULSE_DEV", "true") + t.Setenv("PULSE_ENABLE_PPROF", "true") record := newTokenRecord(t, "proxy-admin-gate-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) @@ -32,6 +33,7 @@ func TestProxyAuthAdminGatesAdminEndpoints(t *testing.T) { {method: http.MethodPost, path: "/api/logs/level", body: `{"level":"info"}`}, {method: http.MethodGet, path: "/api/diagnostics", body: ""}, {method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token", body: `{}`}, + {method: http.MethodGet, path: "/api/diagnostics/pprof/", body: ""}, {method: http.MethodGet, path: "/api/system/settings", body: ""}, {method: http.MethodPost, path: "/api/system/settings/update", body: `{}`}, {method: http.MethodPost, path: "/api/security/oidc", body: `{}`}, diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index b66cdc37b..6182ea198 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -310,6 +310,8 @@ var allRouteAllowlist = []string{ "/api/metrics-store/history", "/api/diagnostics", "/api/diagnostics/docker/prepare-token", + "/api/diagnostics/pprof", + "/api/diagnostics/pprof/", "/api/install/install-docker.sh", "/api/install/install.sh", "/api/install/install.ps1", diff --git a/internal/api/router.go b/internal/api/router.go index 5dfc8873f..9e27eaeae 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -325,6 +325,10 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/metrics-store/history", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsHistory))) r.mux.HandleFunc("/api/diagnostics", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleDiagnostics))) r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken))) + if pprofEnabled() { + r.mux.HandleFunc("/api/diagnostics/pprof", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handlePprofRedirect))) + r.mux.HandleFunc("/api/diagnostics/pprof/", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handlePprof))) + } r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript) r.mux.HandleFunc("/api/install/install.sh", r.handleDownloadUnifiedInstallScript) r.mux.HandleFunc("/api/install/install.ps1", r.handleDownloadUnifiedInstallScriptPS)