From 9f73d032e6c1c10b7d0dafa986e32985aa3b6f66 Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:00:04 +0200 Subject: [PATCH] feat(metrics): add public metrics endpoint configuration and update documentation (#92) --- backend/internal/config/config.go | 10 +- backend/internal/config/config_test.go | 46 +++++++++ backend/internal/routes/routes.go | 13 ++- .../internal/routes/routes_metrics_test.go | 93 +++++++++++++++++++ config.example.yaml | 6 ++ helm/garage-ui/README.md | 9 +- helm/garage-ui/values.schema.json | 7 +- helm/garage-ui/values.yaml | 12 ++- 8 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 backend/internal/routes/routes_metrics_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4e96acb..7687c07 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -49,10 +49,11 @@ type GarageConfig struct { // AuthConfig contains authentication configuration type AuthConfig struct { - Admin AdminAuthConfig `mapstructure:"admin"` - OIDC OIDCConfig `mapstructure:"oidc"` - Token TokenAuthConfig `mapstructure:"token"` - JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes) + Admin AdminAuthConfig `mapstructure:"admin"` + OIDC OIDCConfig `mapstructure:"oidc"` + Token TokenAuthConfig `mapstructure:"token"` + JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes) + MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth } // AdminAuthConfig contains admin authentication settings @@ -291,6 +292,7 @@ func bindEnvVars() { viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME") viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD") viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY") + viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC") // Token auth config viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 394d8d5..4f6a594 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -894,3 +894,49 @@ func TestIsProduction(t *testing.T) { }) } } + +func TestLoad_MetricsPublic_DefaultsFalse(t *testing.T) { + resetViper(t) + path := writeConfigFile(t, minimalValidYAML) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Auth.MetricsPublic { + t.Errorf("Auth.MetricsPublic = true, want false by default") + } +} + +func TestLoad_MetricsPublic_YAML(t *testing.T) { + resetViper(t) + path := writeConfigFile(t, minimalValidYAML+` +auth: + metrics_public: true +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Auth.MetricsPublic { + t.Errorf("Auth.MetricsPublic = false, want true from YAML") + } +} + +func TestLoad_MetricsPublic_EnvOverridesYAML(t *testing.T) { + resetViper(t) + path := writeConfigFile(t, minimalValidYAML+` +auth: + metrics_public: false +`) + t.Setenv("GARAGE_UI_AUTH_METRICS_PUBLIC", "true") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Auth.MetricsPublic { + t.Errorf("Auth.MetricsPublic = false, want true (env should override YAML)") + } +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 5c64921..6553a1f 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -49,6 +49,16 @@ func SetupRoutes( // Auth configuration endpoint (always accessible, no auth required) app.Get("/auth/config", authHandler.GetAuthConfig) + // Public Prometheus metrics endpoint (no auth), opt-in via auth.metrics_public. + // Registered outside /api/v1 so it bypasses the AuthMiddleware/ResolveSubject + // cascade and the VerifyRouteCoverage fail-closed guard entirely; the + // authenticated /api/v1/monitoring/metrics route is unaffected. Because it is + // registered before the SPA fallback below, Fiber matches it first. + // Protect it at the network layer (NetworkPolicy / trusted scrape network). + if cfg.Auth.MetricsPublic { + app.Get("/metrics", monitoringHandler.GetMetrics) + } + // API v1 group api := app.Group("/api/v1") @@ -333,7 +343,8 @@ func SetupRoutes( if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/auth") || strings.HasPrefix(path, "/health") || - strings.HasPrefix(path, "/docs") { + strings.HasPrefix(path, "/docs") || + path == "/metrics" { logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback") return c.Next() } diff --git a/backend/internal/routes/routes_metrics_test.go b/backend/internal/routes/routes_metrics_test.go new file mode 100644 index 0000000..f436bba --- /dev/null +++ b/backend/internal/routes/routes_metrics_test.go @@ -0,0 +1,93 @@ +package routes + +import ( + "context" + "io" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "Noooste/garage-ui/internal/authz" + "Noooste/garage-ui/internal/config" +) + +// Flag off (default): /metrics is not registered, and the authenticated +// /api/v1/monitoring/metrics route still rejects unauthenticated requests. +func TestRoutes_MetricsPublic_Disabled_NotRegistered(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + // MetricsPublic defaults to false. + }) + + expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404) + expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401) +} + +// Flag on, with admin auth enabled: /metrics serves without credentials, while +// the authenticated /api/v1/monitoring/metrics route still requires auth. +func TestRoutes_MetricsPublic_Enabled_ServesWithoutAuth(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + c.Auth.MetricsPublic = true + }) + f.Admin.GetMetricsFn = func(_ context.Context) (string, error) { + return "garage_metric 1", nil + } + + resp := expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 200) + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "garage_metric") { + t.Errorf("GET /metrics body = %q, want it to contain the metrics text", string(body)) + } + + // The /api/v1 route stays gated; the fail-closed guarantee is intact. + expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401) +} + +// Route coverage must still pass with the flag on: /metrics is outside /api/v1, +// so VerifyRouteCoverage neither requires a Require handler for it nor errors. +func TestRoutes_MetricsPublic_Enabled_RouteCoverageStillPasses(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + c.Auth.MetricsPublic = true + }) + if err := authz.VerifyRouteCoverage(f.App); err != nil { + t.Errorf("VerifyRouteCoverage returned error with metrics_public on: %v", err) + } +} + +// With the metrics flag OFF and the SPA frontend present, GET /metrics must +// return 404 (not the SPA index.html), so a misconfigured Prometheus scrape +// fails loudly instead of silently receiving HTML with a 200 status. +func TestRoutes_MetricsPublic_Disabled_WithSPA_Returns404(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create ./frontend/dist/index.html so the SPA fallback mounts. + if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "frontend", "dist", "index.html"), + []byte("