mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat(metrics): add public metrics endpoint configuration and update documentation (#92)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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("<!doctype html><title>spa</title>"), 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user