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("spa"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + 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 → no /metrics route registered. + }) + + // SPA fallback is mounted; /metrics must be excluded from it → 404, not + // index.html with 200. + expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404) +} diff --git a/config.example.yaml b/config.example.yaml index 81bc6b1..4d916e0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -34,6 +34,12 @@ auth: # The key is a 64-byte Ed25519 private key jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key + # Expose Prometheus metrics at top-level /metrics WITHOUT authentication. + # Needed for Prometheus to scrape when auth (admin/token/oidc) is enabled. + # WARNING: exposes operational cluster telemetry (no object data or secrets) + # to anyone who can reach the port. Restrict with a NetworkPolicy / firewall. + metrics_public: false # Set to true to serve /metrics unauthenticated + # Admin Authentication (username/password) admin: enabled: false # Set to true to enable admin login diff --git a/helm/garage-ui/README.md b/helm/garage-ui/README.md index 3b93894..d660957 100644 --- a/helm/garage-ui/README.md +++ b/helm/garage-ui/README.md @@ -689,16 +689,17 @@ Enable Prometheus metrics scraping (requires Prometheus Operator): serviceMonitor: enabled: true interval: 30s - path: /api/v1/monitoring/metrics + # /metrics is served only when config.auth.metrics_public is true + path: /metrics labels: prometheus: kube-prometheus ``` ### Metrics Endpoint -The application exposes metrics at: -- Path: `/api/v1/monitoring/metrics` -- Format: Prometheus format (proxies Garage Admin API metrics) +The application exposes Prometheus-format metrics (proxying the Garage Admin API) at: +- `/api/v1/monitoring/metrics`: always registered, requires authentication. +- `/metrics`: top-level, unauthenticated. Served ONLY when `config.auth.metrics_public` is `true`. Use this for Prometheus scraping when authentication is enabled, and restrict access with a NetworkPolicy / trusted scrape network. ### Health Checks diff --git a/helm/garage-ui/values.schema.json b/helm/garage-ui/values.schema.json index 0c757d2..3533f15 100644 --- a/helm/garage-ui/values.schema.json +++ b/helm/garage-ui/values.schema.json @@ -199,6 +199,11 @@ } } }, + "metrics_public": { + "type": "boolean", + "description": "Expose Prometheus metrics at top-level /metrics without authentication (required for scraping when auth is enabled). Restrict access with a NetworkPolicy.", + "default": false + }, "admin": { "type": "object", "description": "Admin authentication settings (username/password)", @@ -846,7 +851,7 @@ "path": { "type": "string", "description": "Metrics endpoint path", - "default": "/api/v1/monitoring/metrics" + "default": "/metrics" }, "labels": { "type": "object", diff --git a/helm/garage-ui/values.yaml b/helm/garage-ui/values.yaml index caea3fe..872a1a7 100644 --- a/helm/garage-ui/values.yaml +++ b/helm/garage-ui/values.yaml @@ -74,6 +74,14 @@ config: name: "" key: "jwt-key.pem" + # Expose Prometheus metrics at top-level /metrics WITHOUT authentication. + # Required for Prometheus to scrape when auth (admin/token/oidc) is enabled, + # since scrapers do not send credentials. Pairs with serviceMonitor below. + # WARNING: exposes operational cluster telemetry (bucket counts, request + # rates, storage sizes) unauthenticated, with no object data or secrets. Restrict + # access with a NetworkPolicy / trusted scrape network. + metrics_public: false + # Admin authentication (username/password) admin: enabled: false @@ -236,7 +244,9 @@ readinessProbe: serviceMonitor: enabled: false interval: 30s - path: /api/v1/monitoring/metrics + # Scrape path. The default /metrics is served only when + # config.auth.metrics_public is true (required when authentication is enabled). + path: /metrics labels: {} # NetworkPolicy