diff --git a/changes/issue-2322-authd-metrics b/changes/issue-2322-authd-metrics new file mode 100644 index 0000000000..06e68b0405 --- /dev/null +++ b/changes/issue-2322-authd-metrics @@ -0,0 +1 @@ +* Add HTTP Basic Auth to Fleet's `/metrics` endpoint. (If credentials are not set, the `/metrics` endpoint is disabled.) diff --git a/cmd/fleet/main.go b/cmd/fleet/main.go index 3164b17f49..8c171069c8 100644 --- a/cmd/fleet/main.go +++ b/cmd/fleet/main.go @@ -38,7 +38,6 @@ func initFatal(err error, message string) { } func createRootCmd() *cobra.Command { - // rootCmd represents the base command when called without any subcommands rootCmd := &cobra.Command{ Use: "fleet", @@ -62,4 +61,11 @@ func applyDevFlags(cfg *config.FleetConfig) { cfg.Mysql.Username = "fleet" cfg.Mysql.Database = "fleet" cfg.Mysql.Password = "insecure" + + if cfg.Prometheus.BasicAuth.Username == "" { + cfg.Prometheus.BasicAuth.Username = "fleet" + } + if cfg.Prometheus.BasicAuth.Password == "" { + cfg.Prometheus.BasicAuth.Password = "insecure" + } } diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 3484f5bb45..298d225581 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/sha256" + "crypto/subtle" "crypto/tls" "database/sql/driver" "errors" @@ -394,7 +396,7 @@ the way that the Fleet server works. var apiHandler, frontendHandler http.Handler { - frontendHandler = service.InstrumentHandler("get_frontend", service.ServeFrontend(config.Server.URLPrefix, httpLogger)) + frontendHandler = service.PrometheusMetricsHandler("get_frontend", service.ServeFrontend(config.Server.URLPrefix, httpLogger)) apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore) setupRequired, err := svc.SetupRequired(context.Background()) @@ -436,10 +438,17 @@ the way that the Fleet server works. eh := errorstore.NewHandler(ctx, redisPool, logger, config.Logging.ErrorRetentionPeriod) rootMux := http.NewServeMux() - rootMux.Handle("/healthz", service.InstrumentHandler("healthz", health.Handler(httpLogger, healthCheckers))) - rootMux.Handle("/version", service.InstrumentHandler("version", version.Handler())) - rootMux.Handle("/assets/", service.InstrumentHandler("static_assets", service.ServeStaticAssets("/assets/"))) - rootMux.Handle("/metrics", service.InstrumentHandler("metrics", promhttp.Handler())) + rootMux.Handle("/healthz", service.PrometheusMetricsHandler("healthz", health.Handler(httpLogger, healthCheckers))) + rootMux.Handle("/version", service.PrometheusMetricsHandler("version", version.Handler())) + rootMux.Handle("/assets/", service.PrometheusMetricsHandler("static_assets", service.ServeStaticAssets("/assets/"))) + + if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" { + metricsHandler := basicAuthHandler(config.Prometheus.BasicAuth.Username, config.Prometheus.BasicAuth.Password, service.PrometheusMetricsHandler("metrics", promhttp.Handler())) + rootMux.Handle("/metrics", metricsHandler) + } else { + level.Info(logger).Log("msg", "metrics endpoint disabled (http basic auth credentials not set)") + } + rootMux.Handle("/api/", apiHandler) rootMux.Handle("/", frontendHandler) rootMux.Handle("/debug/", service.MakeDebugHandler(svc, config, logger, eh, ds)) @@ -564,6 +573,32 @@ the way that the Fleet server works. return serveCmd } +// basicAuthHandler wraps the given handler behind HTTP Basic Auth. +func basicAuthHandler(username, password string, next http.Handler) http.HandlerFunc { + hashFn := func(s string) []byte { + h := sha256.Sum256([]byte(s)) + return h[:] + } + expectedUsernameHash := hashFn(username) + expectedPasswordHash := hashFn(password) + + return func(w http.ResponseWriter, r *http.Request) { + recvUsername, recvPassword, ok := r.BasicAuth() + if ok { + usernameMatch := subtle.ConstantTimeCompare(hashFn(recvUsername), expectedUsernameHash) == 1 + passwordMatch := subtle.ConstantTimeCompare(hashFn(recvPassword), expectedPasswordHash) == 1 + + if usernameMatch && passwordMatch { + next.ServeHTTP(w, r) + return + } + } + + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } +} + const ( lockKeyLeader = "leader" lockKeyVulnerabilities = "vulnerabilities" diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index cb78db724e..a5f161ea5f 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -435,3 +435,73 @@ func TestCronWebhooksIntervalChange(t *testing.T) { t.Fatal("timeout: interval change did not trigger lock call") } } + +func TestBasicAuthHandler(t *testing.T) { + for _, tc := range []struct { + name string + username string + password string + passes bool + noBasicAuthSet bool + }{ + { + name: "good-credentials", + username: "foo", + password: "bar", + passes: true, + }, + { + name: "empty-credentials", + username: "", + password: "", + passes: false, + }, + { + name: "no-basic-auth-set", + username: "", + password: "", + noBasicAuthSet: true, + passes: false, + }, + { + name: "wrong-username", + username: "foo1", + password: "bar", + passes: false, + }, + { + name: "wrong-password", + username: "foo", + password: "bar1", + passes: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + pass := false + h := basicAuthHandler("foo", "bar", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pass = true + w.WriteHeader(http.StatusOK) + })) + + r, err := http.NewRequest("GET", "", nil) + require.NoError(t, err) + + if !tc.noBasicAuthSet { + r.SetBasicAuth(tc.username, tc.password) + } + + var w httptest.ResponseRecorder + h.ServeHTTP(&w, r) + + if pass != tc.passes { + t.Fatal("unexpected pass") + } + + expStatusCode := http.StatusUnauthorized + if pass { + expStatusCode = http.StatusOK + } + require.Equal(t, w.Result().StatusCode, expStatusCode) + }) + } +} diff --git a/docs/Deploying/Configuration.md b/docs/Deploying/Configuration.md index cd8d29e735..852e0a5843 100644 --- a/docs/Deploying/Configuration.md +++ b/docs/Deploying/Configuration.md @@ -2436,3 +2436,35 @@ If set then `fleet serve` will capture errors and panics and push them to Sentry ``` + +#### Prometheus + +##### basic_auth.username + +Username to use for HTTP Basic Auth on the `/metrics` endpoint. +If not set then the prometheus `/metrics` endpoint is disabled. + +- Default value: `""` +- Environment variable: `FLEET_PROMETHEUS_BASIC_AUTH_USERNAME` +- Config file format: + + ```yaml + prometheus: + basic_auth: + username: "foo" + ``` + +##### basic_auth.password + +Password to use for HTTP Basic Auth on the `/metrics` endpoint. +If not set then the prometheus `/metrics` endpoint is disabled. + +- Default value: `""` +- Environment variable: `FLEET_PROMETHEUS_BASIC_AUTH_PASSWORD` +- Config file format: + + ```yaml + prometheus: + basic_auth: + password: "bar" + ``` diff --git a/server/config/config.go b/server/config/config.go index 8ac90bdaab..8850e7f16f 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -237,6 +237,20 @@ type GeoIPConfig struct { DatabasePath string `json:"database_path" yaml:"database_path"` } +// PrometheusConfig holds the configuration for Fleet's prometheus metrics. +type PrometheusConfig struct { + // BasicAuth is the HTTP Basic BasicAuth configuration. + BasicAuth HTTPBasicAuthConfig `json:"basic_auth" yaml:"basic_auth"` +} + +// HTTPBasicAuthConfig holds configuration for HTTP Basic Auth. +type HTTPBasicAuthConfig struct { + // Username is the HTTP Basic Auth username. + Username string `json:"username" yaml:"username"` + // Password is the HTTP Basic Auth password. + Password string `json:"password" yaml:"password"` +} + // FleetConfig stores the application configuration. Each subcategory is // broken up into it's own struct, defined above. When editing any of these // structs, Manager.addConfigs and Manager.LoadConfig should be @@ -263,6 +277,7 @@ type FleetConfig struct { Upgrades UpgradesConfig Sentry SentryConfig GeoIP GeoIPConfig + Prometheus PrometheusConfig } type TLS struct { @@ -562,6 +577,10 @@ func (man Manager) addConfigs() { // GeoIP man.addConfigString("geoip.database_path", "", "path to mmdb file") + + // Prometheus + man.addConfigString("prometheus.basic_auth.username", "", "Prometheus username for HTTP Basic Auth") + man.addConfigString("prometheus.basic_auth.password", "", "Prometheus password for HTTP Basic Auth") } // LoadConfig will load the config variables into a fully initialized @@ -745,6 +764,12 @@ func (man Manager) LoadConfig() FleetConfig { GeoIP: GeoIPConfig{ DatabasePath: man.getConfigString("geoip.database_path"), }, + Prometheus: PrometheusConfig{ + BasicAuth: HTTPBasicAuthConfig{ + Username: man.getConfigString("prometheus.basic_auth.username"), + Password: man.getConfigString("prometheus.basic_auth.password"), + }, + }, } } @@ -937,7 +962,7 @@ func (man Manager) loadConfigFile() { os.Exit(1) } - fmt.Println("Using config file: ", man.viper.ConfigFileUsed()) + fmt.Println("Using config file:", man.viper.ConfigFileUsed()) } // TestConfig returns a barebones configuration suitable for use in tests. diff --git a/server/service/handler.go b/server/service/handler.go index 0c36d5b90a..cc75372a0d 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -80,7 +80,6 @@ func MakeHandler(svc fleet.Service, config config.FleetConfig, logger kitlog.Log fleetAPIOptions := []kithttp.ServerOption{ kithttp.ServerBefore( kithttp.PopulateRequestContext, // populate the request context with common fields - setRequestsContexts(svc), ), kithttp.ServerErrorHandler(&errorHandler{logger}), @@ -124,10 +123,10 @@ func publicIP(handler http.Handler) http.Handler { }) } -// InstrumentHandler wraps the provided handler with prometheus metrics +// PrometheusMetricsHandler wraps the provided handler with prometheus metrics // middleware and returns the resulting handler that should be mounted for that // route. -func InstrumentHandler(name string, handler http.Handler) http.Handler { +func PrometheusMetricsHandler(name string, handler http.Handler) http.Handler { reg := prometheus.DefaultRegisterer registerOrExisting := func(coll prometheus.Collector) prometheus.Collector { if err := reg.Register(coll); err != nil { @@ -198,7 +197,7 @@ func InstrumentHandler(name string, handler http.Handler) http.Handler { // addMetrics decorates each handler with prometheus instrumentation func addMetrics(r *mux.Router) { walkFn := func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { - route.Handler(InstrumentHandler(route.GetName(), route.GetHandler())) + route.Handler(PrometheusMetricsHandler(route.GetName(), route.GetHandler())) return nil } r.Walk(walkFn) @@ -213,7 +212,6 @@ var ( func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetConfig, logger kitlog.Logger, limitStore throttled.GCRAStore, opts []kithttp.ServerOption, ) { - apiVersions := []string{"v1", "2022-04"} // user-authenticated endpoints diff --git a/tools/app/prometheus.yml b/tools/app/prometheus.yml index ba920e0a3d..7b07381822 100644 --- a/tools/app/prometheus.yml +++ b/tools/app/prometheus.yml @@ -6,3 +6,6 @@ scrape_configs: - targets: ['host.docker.internal:8080'] tls_config: insecure_skip_verify: true + basic_auth: + username: fleet + password: insecure