diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d0bfec..98bd0508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ ## master / unreleased +### Added +- Multi-target scraping via `/probe` endpoint with optional auth modules (compatible with postgres_exporter style) #1063 + BREAKING CHANGES: +* [CHANGE] Set `--es.uri` by default to empty string #1063 + The flag `--es.data_stream` has been renamed to `--collector.data-stream`. The flag `--es.ilm` has been renamed to `--collector.ilm`. diff --git a/README.md b/README.md index f813534d..08bd9851 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ elasticsearch_exporter --help | Argument | Introduced in Version | Description | Default | | ----------------------- | --------------------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ----------- | | collector.clustersettings| 1.6.0 | If true, query stats for cluster settings (As of v1.6.0, this flag has replaced "es.cluster_settings"). | false | -| es.uri | 1.0.2 | Address (host and port) of the Elasticsearch node we should connect to. This could be a local node (`localhost:9200`, for instance), or the address of a remote Elasticsearch server. When basic auth is needed, specify as: `://:@:`. E.G., `http://admin:pass@localhost:9200`. Special characters in the user credentials need to be URL-encoded. | | +| es.uri | 1.0.2 | Address (host and port) of the Elasticsearch node we should connect to **when running in single-target mode**. Leave empty (the default) when you want to run the exporter only as a multi-target `/probe` endpoint. When basic auth is needed, specify as: `://:@:`. E.G., `http://admin:pass@localhost:9200`. Special characters in the user credentials need to be URL-encoded. | "" | | es.all | 1.0.2 | If true, query stats for all nodes in the cluster, rather than just the node we connect to. | false | | es.indices | 1.0.2 | If true, query stats for all indices in the cluster. | false | | es.indices_settings | 1.0.4rc1 | If true, query settings stats for all indices in the cluster. | false | @@ -77,6 +77,7 @@ elasticsearch_exporter --help | web.telemetry-path | 1.0.2 | Path under which to expose metrics. | /metrics | | aws.region | 1.5.0 | Region for AWS elasticsearch | | | aws.role-arn | 1.6.0 | Role ARN of an IAM role to assume. | | +| config.file | 2.0.0 | Path to a YAML configuration file that defines `auth_modules:` used by the `/probe` multi-target endpoint. Leave unset when not using multi-target mode. | | | version | 1.0.2 | Show version info on stdout and exit. | | Commandline parameters start with a single `-` for versions less than `1.1.0rc1`. @@ -113,6 +114,67 @@ Further Information - [Defining Roles](https://www.elastic.co/guide/en/elastic-stack-overview/7.3/defining-roles.html) - [Privileges](https://www.elastic.co/guide/en/elastic-stack-overview/7.3/security-privileges.html) +### Multi-Target Scraping (beta) + +From v2.X the exporter exposes `/probe` allowing one running instance to scrape many clusters. + +Supported `auth_module` types: + +| type | YAML fields | Injected into request | +| ---------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `userpass` | `userpass.username`, `userpass.password`, optional `options:` map | Sets HTTP basic-auth header, appends `options` as query parameters | +| `apikey` | `apikey:` Base64 API-Key string, optional `options:` map | Adds `Authorization: ApiKey …` header, appends `options` | +| `aws` | `aws.region`, optional `aws.role_arn`, optional `options:` map | Uses AWS SigV4 signing transport for HTTP(S) requests, appends `options` | +| `tls` | `tls.ca_file`, `tls.cert_file`, `tls.key_file` | Uses client certificate authentication via TLS; cannot be mixed with other auth types | + +Example config: + +```yaml +# exporter-config.yml +auth_modules: + prod_basic: + type: userpass + userpass: + username: metrics + password: s3cr3t + + staging_key: + type: apikey + apikey: "bXk6YXBpa2V5Ig==" # base64 id:key + options: + sslmode: disable +``` + +Run exporter: + +```bash +./elasticsearch_exporter --config.file=exporter-config.yml +``` + +Prometheus scrape_config: + +```yaml +- job_name: es + metrics_path: /probe + params: + auth_module: [staging_key] + static_configs: + - targets: ["https://es-stage:9200"] + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: exporter:9114 +``` + +Notes: +- `/metrics` serves a single, process-wide registry and is intended for single-target mode. +- `/probe` creates a fresh registry per scrape for the given `target` allowing multi-target scraping. +- Any `options:` under an auth module will be appended as URL query parameters to the target URL. +- The `tls` auth module (client certificate authentication) is intended for self‑managed Elasticsearch/OpenSearch deployments. Amazon OpenSearch Service typically authenticates at the domain edge with IAM/SigV4 and does not support client certificate authentication; use the `aws` auth module instead when scraping Amazon OpenSearch Service domains. + ### Metrics | Name | Type | Cardinality | Help | diff --git a/collector/indices.go b/collector/indices.go index dd7e7274..fdad33f5 100644 --- a/collector/indices.go +++ b/collector/indices.go @@ -19,6 +19,7 @@ import ( "log/slog" "net/http" "net/url" + "path" "sort" "strconv" @@ -620,13 +621,28 @@ func (i *Indices) fetchAndDecodeIndexStats(ctx context.Context) (indexStatsRespo return isr, nil } -// getCluserName returns the name of the cluster from the clusterinfo -// if the clusterinfo is nil, it returns "unknown_cluster" -// TODO(@sysadmind): this should be removed once we have a better way to handle clusterinfo +// getClusterName returns the cluster name. If no clusterinfo retriever is +// attached (e.g. /probe mode) it performs a lightweight call to the root +// endpoint once and caches the result. func (i *Indices) getClusterName() string { - if i.lastClusterInfo != nil { + if i.lastClusterInfo != nil && i.lastClusterInfo.ClusterName != "unknown_cluster" { return i.lastClusterInfo.ClusterName } + u := *i.url + u.Path = path.Join(u.Path, "/") + resp, err := i.client.Get(u.String()) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + var root struct { + ClusterName string `json:"cluster_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&root); err == nil && root.ClusterName != "" { + i.lastClusterInfo = &clusterinfo.Response{ClusterName: root.ClusterName} + return root.ClusterName + } + } + } return "unknown_cluster" } diff --git a/collector/shards.go b/collector/shards.go index 136ea671..351680ca 100644 --- a/collector/shards.go +++ b/collector/shards.go @@ -64,23 +64,50 @@ type nodeShardMetric struct { Labels labels } +// fetchClusterNameOnce performs a single request to the root endpoint to obtain the cluster name. +func fetchClusterNameOnce(s *Shards) string { + if s.lastClusterInfo != nil && s.lastClusterInfo.ClusterName != "unknown_cluster" { + return s.lastClusterInfo.ClusterName + } + u := *s.url + u.Path = path.Join(u.Path, "/") + resp, err := s.client.Get(u.String()) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + var root struct { + ClusterName string `json:"cluster_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&root); err == nil && root.ClusterName != "" { + s.lastClusterInfo = &clusterinfo.Response{ClusterName: root.ClusterName} + return root.ClusterName + } + } + } + return "unknown_cluster" +} + // NewShards defines Shards Prometheus metrics func NewShards(logger *slog.Logger, client *http.Client, url *url.URL) *Shards { + var shardPtr *Shards nodeLabels := labels{ keys: func(...string) []string { return []string{"node", "cluster"} }, - values: func(lastClusterinfo *clusterinfo.Response, s ...string) []string { + values: func(lastClusterinfo *clusterinfo.Response, base ...string) []string { if lastClusterinfo != nil { - return append(s, lastClusterinfo.ClusterName) + return append(base, lastClusterinfo.ClusterName) } - // this shouldn't happen, as the clusterinfo Retriever has a blocking - // Run method. It blocks until the first clusterinfo call has succeeded - return append(s, "unknown_cluster") + if shardPtr != nil { + return append(base, fetchClusterNameOnce(shardPtr)) + } + return append(base, "unknown_cluster") }, } shards := &Shards{ + // will assign later + logger: logger, client: client, url: url, @@ -123,6 +150,7 @@ func NewShards(logger *slog.Logger, client *http.Client, url *url.URL) *Shards { logger.Debug("exiting cluster info receive loop") }() + shardPtr = shards return shards } diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..2137cc5e --- /dev/null +++ b/config/config.go @@ -0,0 +1,137 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "os" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Config represents the YAML configuration file structure. +type Config struct { + AuthModules map[string]AuthModule `yaml:"auth_modules"` +} + +type AuthModule struct { + Type string `yaml:"type"` + UserPass *UserPassConfig `yaml:"userpass,omitempty"` + APIKey string `yaml:"apikey,omitempty"` + AWS *AWSConfig `yaml:"aws,omitempty"` + TLS *TLSConfig `yaml:"tls,omitempty"` + Options map[string]string `yaml:"options,omitempty"` +} + +// AWSConfig contains settings for SigV4 authentication. +type AWSConfig struct { + Region string `yaml:"region,omitempty"` + RoleARN string `yaml:"role_arn,omitempty"` +} + +// TLSConfig allows per-target TLS options. +type TLSConfig struct { + CAFile string `yaml:"ca_file,omitempty"` + CertFile string `yaml:"cert_file,omitempty"` + KeyFile string `yaml:"key_file,omitempty"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify,omitempty"` +} + +type UserPassConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// validate ensures every auth module has the required fields according to its type. +func (c *Config) validate() error { + for name, am := range c.AuthModules { + // Validate fields based on auth type + switch strings.ToLower(am.Type) { + case "userpass": + if am.UserPass == nil || am.UserPass.Username == "" || am.UserPass.Password == "" { + return fmt.Errorf("auth_module %s type userpass requires username and password", name) + } + case "apikey": + if am.APIKey == "" { + return fmt.Errorf("auth_module %s type apikey requires apikey", name) + } + case "aws": + // No strict validation: region can come from environment/defaults; role_arn is optional. + case "tls": + // TLS auth type means client certificate authentication only (no other auth) + if am.TLS == nil { + return fmt.Errorf("auth_module %s type tls requires tls configuration section", name) + } + if am.TLS.CertFile == "" || am.TLS.KeyFile == "" { + return fmt.Errorf("auth_module %s type tls requires cert_file and key_file for client certificate authentication", name) + } + // Validate that other auth fields are not set when using TLS auth type + if am.UserPass != nil { + return fmt.Errorf("auth_module %s type tls cannot have userpass configuration", name) + } + if am.APIKey != "" { + return fmt.Errorf("auth_module %s type tls cannot have apikey", name) + } + if am.AWS != nil { + return fmt.Errorf("auth_module %s type tls cannot have aws configuration", name) + } + default: + return fmt.Errorf("auth_module %s has unsupported type %s", name, am.Type) + } + + // Validate TLS configuration (optional for all auth types, provides transport security) + if am.TLS != nil { + // For cert-based auth (type: tls), cert and key are required + // For other auth types, TLS config is optional and used for transport security + if strings.ToLower(am.Type) != "tls" { + // For non-TLS auth types, if cert/key are provided, both must be present + if (am.TLS.CertFile != "") != (am.TLS.KeyFile != "") { + return fmt.Errorf("auth_module %s: if providing client certificate, both cert_file and key_file must be specified", name) + } + } + + // Validate file accessibility + for fileType, path := range map[string]string{ + "ca_file": am.TLS.CAFile, + "cert_file": am.TLS.CertFile, + "key_file": am.TLS.KeyFile, + } { + if path == "" { + continue + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("auth_module %s: %s '%s' not accessible: %w", name, fileType, path, err) + } + } + } + } + return nil +} + +// LoadConfig reads, parses, and validates the YAML config file. +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if err := cfg.validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 00000000..f5147db6 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,183 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "os" + "testing" +) + +func mustTempFile(t *testing.T) string { + f, err := os.CreateTemp(t.TempDir(), "pem-*.crt") + if err != nil { + t.Fatalf("temp file: %v", err) + } + f.Close() + // Ensure temp file is removed even if created outside of test's TempDir semantics change + path := f.Name() + t.Cleanup(func() { _ = os.Remove(path) }) + return path +} + +// ---------------------------- Positive cases ---------------------------- +func TestLoadConfigPositiveVariants(t *testing.T) { + ca := mustTempFile(t) + cert := mustTempFile(t) + key := mustTempFile(t) + + positive := []struct { + name string + yaml string + }{{ + "userpass", + `auth_modules: + basic: + type: userpass + userpass: + username: u + password: p`, + }, { + "userpass-with-tls", + `auth_modules: + basic: + type: userpass + userpass: + username: u + password: p + tls: + ca_file: ` + ca + ` + insecure_skip_verify: true`, + }, { + "apikey", + `auth_modules: + key: + type: apikey + apikey: ZXhhbXBsZQ==`, + }, { + "apikey-with-tls", + `auth_modules: + key: + type: apikey + apikey: ZXhhbXBsZQ== + tls: + ca_file: ` + ca + ` + cert_file: ` + cert + ` + key_file: ` + key + ``, + }, { + "aws-with-tls", + `auth_modules: + awsmod: + type: aws + aws: + region: us-east-1 + tls: + insecure_skip_verify: true`, + }, { + "tls-only", + `auth_modules: + pki: + type: tls + tls: + ca_file: ` + ca + ` + cert_file: ` + cert + ` + key_file: ` + key + ``, + }} + + for _, c := range positive { + tmp, _ := os.CreateTemp(t.TempDir(), "cfg-*.yml") + _, _ = tmp.WriteString(c.yaml) + _ = tmp.Close() + t.Cleanup(func() { _ = os.Remove(tmp.Name()) }) + if _, err := LoadConfig(tmp.Name()); err != nil { + t.Fatalf("%s: expected success, got %v", c.name, err) + } + } +} + +// ---------------------------- Negative cases ---------------------------- +func TestLoadConfigNegativeVariants(t *testing.T) { + cert := mustTempFile(t) + key := mustTempFile(t) + + negative := []struct { + name string + yaml string + }{{ + "userpassMissingPassword", + `auth_modules: + bad: + type: userpass + userpass: {username: u}`, + }, { + "tlsMissingCert", + `auth_modules: + bad: + type: tls + tls: {key_file: ` + key + `}`, + }, { + "tlsMissingKey", + `auth_modules: + bad: + type: tls + tls: {cert_file: ` + cert + `}`, + }, { + "tlsMissingConfig", + `auth_modules: + bad: + type: tls`, + }, { + "tlsWithUserpass", + `auth_modules: + bad: + type: tls + tls: {cert_file: ` + cert + `, key_file: ` + key + `} + userpass: {username: u, password: p}`, + }, { + "tlsWithAPIKey", + `auth_modules: + bad: + type: tls + tls: {cert_file: ` + cert + `, key_file: ` + key + `} + apikey: ZXhhbXBsZQ==`, + }, { + "tlsWithAWS", + `auth_modules: + bad: + type: tls + tls: {cert_file: ` + cert + `, key_file: ` + key + `} + aws: {region: us-east-1}`, + }, { + "tlsIncompleteCert", + `auth_modules: + bad: + type: apikey + apikey: ZXhhbXBsZQ== + tls: {cert_file: ` + cert + `}`, + }, { + "unsupportedType", + `auth_modules: + bad: + type: foobar`, + }} + + for _, c := range negative { + tmp, _ := os.CreateTemp(t.TempDir(), "cfg-*.yml") + _, _ = tmp.WriteString(c.yaml) + _ = tmp.Close() + t.Cleanup(func() { _ = os.Remove(tmp.Name()) }) + if _, err := LoadConfig(tmp.Name()); err == nil { + t.Fatalf("%s: expected validation error, got none", c.name) + } + } +} diff --git a/examples/auth_modules.yml b/examples/auth_modules.yml new file mode 100644 index 00000000..7603aa8c --- /dev/null +++ b/examples/auth_modules.yml @@ -0,0 +1,55 @@ +# Example exporter-config.yml demonstrating multiple auth modules +# Each module can be referenced with ?auth_module= in /probe requests. + +auth_modules: + ########################################################################### + # 1. Simple basic-auth over HTTPS # + ########################################################################### + prod_basic: + type: userpass + userpass: + username: metrics + password: s3cr3t + # extra URL query parameters are appended to the target DSN + options: + sslmode: disable # becomes ?sslmode=disable + + ########################################################################### + # 2. Read-only account for staging cluster # + ########################################################################### + staging_ro: + type: userpass + userpass: + username: readonly + password: changeme + + ########################################################################### + # 3. API-Key authentication # + ########################################################################### + prod_key: + type: apikey + apikey: BASE64-ENCODED-KEY== + + ########################################################################### + # 5. AWS SigV4 signing with optional TLS settings # + ########################################################################### + aws_sigv4: + type: aws + aws: + region: us-east-1 + # role_arn is optional + # Optional TLS configuration for transport security + tls: + ca_file: /etc/ssl/ca.pem + insecure_skip_verify: false + + ########################################################################### + # 6. Client certificate authentication only (no username/password) # + ########################################################################### + pki_mtls: + type: tls # This auth type uses ONLY client certificates for authentication + tls: + ca_file: /etc/ssl/pki/ca.pem # Optional: CA for server verification + cert_file: /etc/ssl/pki/client.pem # Required: Client certificate for auth + key_file: /etc/ssl/pki/client-key.pem # Required: Client private key for auth + insecure_skip_verify: false # Optional: Skip server cert validation diff --git a/examples/example-prometheus.yml b/examples/example-prometheus.yml new file mode 100644 index 00000000..f63be37a --- /dev/null +++ b/examples/example-prometheus.yml @@ -0,0 +1,33 @@ +scrape_configs: + - job_name: es-multi + metrics_path: /probe + # Default parameters for all scrapes in this job. + # Can be overridden by labels on a per-target basis. + params: + auth_module: [prod_key] + static_configs: + # This is a target group. All targets here will use the default 'prod_key' auth_module. + - targets: + - https://es-prod-1:9200 + - https://es-prod-2:9200 + # This is another target group. + - targets: + - https://es-stage:9200 + # The __param_ prefix on a label causes it to be added as a URL parameter. + # This will override the default auth_module for this target. + labels: + __param_auth_module: staging_basic + relabel_configs: + # The following relabeling rules are applied to every target. + + # 1. The special label __address__ (the target address) is saved as the 'target' URL parameter. + - source_labels: [__address__] + target_label: __param_target + + # 2. The 'target' parameter is used as the 'instance' label for the scraped metrics. + - source_labels: [__param_target] + target_label: instance + + # 3. The scrape address is rewritten to point to the exporter. + - target_label: __address__ + replacement: exporter:9114 # host:port of the single exporter diff --git a/go.mod b/go.mod index 60081bdf..057c72f6 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/prometheus-community/elasticsearch_exporter -go 1.23.0 - -toolchain go1.24.1 +go 1.24.1 require ( github.com/alecthomas/kingpin/v2 v2.4.0 @@ -15,6 +13,7 @@ require ( github.com/prometheus/client_golang v1.23.0 github.com/prometheus/common v0.65.0 github.com/prometheus/exporter-toolkit v0.14.0 + go.yaml.in/yaml/v3 v3.0.4 ) require ( diff --git a/go.sum b/go.sum index 6b4e31de..0a857670 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= diff --git a/main.go b/main.go index a8405f45..c739c28d 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ import ( webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" "github.com/prometheus-community/elasticsearch_exporter/collector" + "github.com/prometheus-community/elasticsearch_exporter/config" "github.com/prometheus-community/elasticsearch_exporter/pkg/clusterinfo" "github.com/prometheus-community/elasticsearch_exporter/pkg/roundtripper" ) @@ -60,7 +61,7 @@ func main() { toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9114") esURI = kingpin.Flag("es.uri", "HTTP API address of an Elasticsearch node."). - Default("http://localhost:9200").String() + Default("").String() esTimeout = kingpin.Flag("es.timeout", "Timeout for trying to get stats from Elasticsearch."). Default("5s").Duration() @@ -109,6 +110,7 @@ func main() { awsRoleArn = kingpin.Flag("aws.role-arn", "Role ARN of an IAM role to assume."). Default("").String() + configFile = kingpin.Flag("config.file", "Path to YAML configuration file.").Default("").String() ) promslogConfig := &promslog.Config{} @@ -117,6 +119,18 @@ func main() { kingpin.CommandLine.HelpFlag.Short('h') kingpin.Parse() + // Load optional YAML config + var cfg *config.Config + if *configFile != "" { + var cfgErr error + cfg, cfgErr = config.LoadConfig(*configFile) + if cfgErr != nil { + // At this stage logger not yet created; fallback to stderr + fmt.Fprintf(os.Stderr, "failed to load config file: %v\n", cfgErr) + os.Exit(1) + } + } + var w io.Writer switch strings.ToLower(*logOutput) { case "stderr": @@ -129,116 +143,124 @@ func main() { promslogConfig.Writer = w logger := promslog.New(promslogConfig) - esURL, err := url.Parse(*esURI) - if err != nil { - logger.Error("failed to parse es.uri", "err", err) - os.Exit(1) - } + // version metric + prometheus.MustRegister(versioncollector.NewCollector(name)) - esUsername := os.Getenv("ES_USERNAME") - esPassword := os.Getenv("ES_PASSWORD") + // Create a context that is cancelled on SIGKILL or SIGINT. + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) + defer cancel() - if esUsername != "" && esPassword != "" { - esURL.User = url.UserPassword(esUsername, esPassword) - } + if *esURI != "" { + esURL, err := url.Parse(*esURI) + if err != nil { + logger.Error("failed to parse es.uri", "err", err) + os.Exit(1) + } - // returns nil if not provided and falls back to simple TCP. - tlsConfig := createTLSConfig(*esCA, *esClientCert, *esClientPrivateKey, *esInsecureSkipVerify) + esUsername := os.Getenv("ES_USERNAME") + esPassword := os.Getenv("ES_PASSWORD") - var httpTransport http.RoundTripper + if esUsername != "" && esPassword != "" { + esURL.User = url.UserPassword(esUsername, esPassword) + } - httpTransport = &http.Transport{ - TLSClientConfig: tlsConfig, - Proxy: http.ProxyFromEnvironment, - } + // returns nil if not provided and falls back to simple TCP. + tlsConfig := createTLSConfig(*esCA, *esClientCert, *esClientPrivateKey, *esInsecureSkipVerify) - esAPIKey := os.Getenv("ES_API_KEY") + var httpTransport http.RoundTripper - if esAPIKey != "" { - httpTransport = &transportWithAPIKey{ - underlyingTransport: httpTransport, - apiKey: esAPIKey, + httpTransport = &http.Transport{ + TLSClientConfig: tlsConfig, + Proxy: http.ProxyFromEnvironment, } - } - httpClient := &http.Client{ - Timeout: *esTimeout, - Transport: httpTransport, - } + esAPIKey := os.Getenv("ES_API_KEY") - if *awsRegion != "" { - httpClient.Transport, err = roundtripper.NewAWSSigningTransport(httpTransport, *awsRegion, *awsRoleArn, logger) - if err != nil { - logger.Error("failed to create AWS transport", "err", err) - os.Exit(1) + if esAPIKey != "" { + httpTransport = &transportWithAPIKey{ + underlyingTransport: httpTransport, + apiKey: esAPIKey, + } } - } - // version metric - prometheus.MustRegister(versioncollector.NewCollector(name)) + httpClient := &http.Client{ + Timeout: *esTimeout, + Transport: httpTransport, + } - // create the exporter - exporter, err := collector.NewElasticsearchCollector( - logger, - []string{}, - collector.WithElasticsearchURL(esURL), - collector.WithHTTPClient(httpClient), - ) - if err != nil { - logger.Error("failed to create Elasticsearch collector", "err", err) - os.Exit(1) - } - prometheus.MustRegister(exporter) - - // TODO(@sysadmind): Remove this when we have a better way to get the cluster name to down stream collectors. - // cluster info retriever - clusterInfoRetriever := clusterinfo.New(logger, httpClient, esURL, *esClusterInfoInterval) - - prometheus.MustRegister(collector.NewClusterHealth(logger, httpClient, esURL)) - prometheus.MustRegister(collector.NewNodes(logger, httpClient, esURL, *esAllNodes, *esNode)) - - if *esExportIndices || *esExportShards { - sC := collector.NewShards(logger, httpClient, esURL) - prometheus.MustRegister(sC) - iC := collector.NewIndices(logger, httpClient, esURL, *esExportShards, *esExportIndexAliases) - prometheus.MustRegister(iC) - if registerErr := clusterInfoRetriever.RegisterConsumer(iC); registerErr != nil { - logger.Error("failed to register indices collector in cluster info") - os.Exit(1) + if *awsRegion != "" { + var err error + httpClient.Transport, err = roundtripper.NewAWSSigningTransport(httpTransport, *awsRegion, *awsRoleArn, logger) + if err != nil { + logger.Error("failed to create AWS transport", "err", err) + os.Exit(1) + } } - if registerErr := clusterInfoRetriever.RegisterConsumer(sC); registerErr != nil { - logger.Error("failed to register shards collector in cluster info") + + // create the exporter + exporter, err := collector.NewElasticsearchCollector( + logger, + []string{}, + collector.WithElasticsearchURL(esURL), + collector.WithHTTPClient(httpClient), + ) + if err != nil { + logger.Error("failed to create Elasticsearch collector", "err", err) os.Exit(1) } - } + prometheus.MustRegister(exporter) + + // TODO(@sysadmind): Remove this when we have a better way to get the cluster name to down stream collectors. + // cluster info retriever + clusterInfoRetriever := clusterinfo.New(logger, httpClient, esURL, *esClusterInfoInterval) + + prometheus.MustRegister(collector.NewClusterHealth(logger, httpClient, esURL)) + prometheus.MustRegister(collector.NewNodes(logger, httpClient, esURL, *esAllNodes, *esNode)) + + if *esExportIndices || *esExportShards { + sC := collector.NewShards(logger, httpClient, esURL) + prometheus.MustRegister(sC) + iC := collector.NewIndices(logger, httpClient, esURL, *esExportShards, *esExportIndexAliases) + prometheus.MustRegister(iC) + if registerErr := clusterInfoRetriever.RegisterConsumer(iC); registerErr != nil { + logger.Error("failed to register indices collector in cluster info") + os.Exit(1) + } + if registerErr := clusterInfoRetriever.RegisterConsumer(sC); registerErr != nil { + logger.Error("failed to register shards collector in cluster info") + os.Exit(1) + } + } - if *esExportIndicesSettings { - prometheus.MustRegister(collector.NewIndicesSettings(logger, httpClient, esURL)) - } + if *esExportIndicesSettings { + prometheus.MustRegister(collector.NewIndicesSettings(logger, httpClient, esURL)) + } - if *esExportIndicesMappings { - prometheus.MustRegister(collector.NewIndicesMappings(logger, httpClient, esURL)) - } + if *esExportIndicesMappings { + prometheus.MustRegister(collector.NewIndicesMappings(logger, httpClient, esURL)) + } - // Create a context that is cancelled on SIGKILL or SIGINT. - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) - defer cancel() + // start the cluster info retriever + switch runErr := clusterInfoRetriever.Run(ctx); runErr { + case nil: + logger.Info("started cluster info retriever", "interval", (*esClusterInfoInterval).String()) + case clusterinfo.ErrInitialCallTimeout: + logger.Info("initial cluster info call timed out") + default: + logger.Error("failed to run cluster info retriever", "err", runErr) + os.Exit(1) + } - // start the cluster info retriever - switch runErr := clusterInfoRetriever.Run(ctx); runErr { - case nil: - logger.Info("started cluster info retriever", "interval", (*esClusterInfoInterval).String()) - case clusterinfo.ErrInitialCallTimeout: - logger.Info("initial cluster info call timed out") - default: - logger.Error("failed to run cluster info retriever", "err", err) - os.Exit(1) + // register cluster info retriever as prometheus collector + prometheus.MustRegister(clusterInfoRetriever) } - // register cluster info retriever as prometheus collector - prometheus.MustRegister(clusterInfoRetriever) + http.HandleFunc(*metricsPath, func(w http.ResponseWriter, r *http.Request) { + // /metrics endpoint is reserved for single-target mode only. + // For per-scrape overrides use the dedicated /probe endpoint. + promhttp.Handler().ServeHTTP(w, r) + }) - http.Handle(*metricsPath, promhttp.Handler()) if *metricsPath != "/" && *metricsPath != "" { landingConfig := web.LandingConfig{ Name: "Elasticsearch Exporter", @@ -264,9 +286,129 @@ func main() { http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) }) + // probe endpoint + http.HandleFunc("/probe", func(w http.ResponseWriter, r *http.Request) { + origQuery := r.URL.Query() + targetStr, am, valErr := validateProbeParams(cfg, origQuery) + if valErr != nil { + http.Error(w, valErr.Error(), http.StatusBadRequest) + return + } + targetURL, _ := url.Parse(targetStr) + if am != nil { + // Apply userpass credentials only if the module type is explicitly set to userpass. + if strings.EqualFold(am.Type, "userpass") && am.UserPass != nil { + targetURL.User = url.UserPassword(am.UserPass.Username, am.UserPass.Password) + } + if len(am.Options) > 0 { + q := targetURL.Query() + for k, v := range am.Options { + q.Set(k, v) + } + targetURL.RawQuery = q.Encode() + } + } + + // Build a dedicated HTTP client for this probe request (reuse TLS opts, timeout, etc.). + pemCA := *esCA + pemCert := *esClientCert + pemKey := *esClientPrivateKey + insecure := *esInsecureSkipVerify + + // Apply TLS configuration from auth module if provided (for transport security) + // This matches single-target behavior where TLS settings are always applied + if am != nil && am.TLS != nil { + // Override with module-specific TLS settings + if am.TLS.CAFile != "" { + pemCA = am.TLS.CAFile + } + if am.TLS.CertFile != "" { + pemCert = am.TLS.CertFile + } + if am.TLS.KeyFile != "" { + pemKey = am.TLS.KeyFile + } + if am.TLS.InsecureSkipVerify { + insecure = true + } + } + tlsCfg := createTLSConfig(pemCA, pemCert, pemKey, insecure) + var transport http.RoundTripper = &http.Transport{ + TLSClientConfig: tlsCfg, + Proxy: http.ProxyFromEnvironment, + } + + // inject authentication based on auth_module type + if am != nil { + switch strings.ToLower(am.Type) { + case "apikey": + if am.APIKey != "" { + transport = &transportWithAPIKey{ + underlyingTransport: transport, + apiKey: am.APIKey, + } + } + case "aws": + var region string + if am.AWS.Region != "" { + region = am.AWS.Region + } + var err error + transport, err = roundtripper.NewAWSSigningTransport(transport, region, am.AWS.RoleARN, logger) + if err != nil { + http.Error(w, "failed to create AWS signing transport", http.StatusInternalServerError) + return + } + case "tls": + // No additional auth wrapper needed - client certificates in TLS config handle authentication + case "userpass": + // Already handled above by setting targetURL.User + } + } + probeClient := &http.Client{ + Timeout: *esTimeout, + Transport: transport, + } + + reg := prometheus.NewRegistry() + + // version metric + reg.MustRegister(versioncollector.NewCollector(name)) + + // Core exporter collector + exp, err := collector.NewElasticsearchCollector( + logger, + []string{}, + collector.WithElasticsearchURL(targetURL), + collector.WithHTTPClient(probeClient), + ) + if err != nil { + http.Error(w, "failed to create exporter", http.StatusInternalServerError) + return + } + reg.MustRegister(exp) + // Basic additional collectors – reuse global CLI flags + reg.MustRegister(collector.NewClusterHealth(logger, probeClient, targetURL)) + reg.MustRegister(collector.NewNodes(logger, probeClient, targetURL, *esAllNodes, *esNode)) + if *esExportIndices || *esExportShards { + shardsC := collector.NewShards(logger, probeClient, targetURL) + indicesC := collector.NewIndices(logger, probeClient, targetURL, *esExportShards, *esExportIndexAliases) + reg.MustRegister(shardsC) + reg.MustRegister(indicesC) + } + if *esExportIndicesSettings { + reg.MustRegister(collector.NewIndicesSettings(logger, probeClient, targetURL)) + } + if *esExportIndicesMappings { + reg.MustRegister(collector.NewIndicesMappings(logger, probeClient, targetURL)) + } + + promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(w, r) + }) + server := &http.Server{} go func() { - if err = web.ListenAndServe(server, toolkitFlags, logger); err != nil { + if err := web.ListenAndServe(server, toolkitFlags, logger); err != nil { logger.Error("http server quit", "err", err) os.Exit(1) } diff --git a/pkg/roundtripper/roundtripper.go b/pkg/roundtripper/roundtripper.go index 4c4dd026..8f1cfd3f 100644 --- a/pkg/roundtripper/roundtripper.go +++ b/pkg/roundtripper/roundtripper.go @@ -42,7 +42,12 @@ type AWSSigningTransport struct { } func NewAWSSigningTransport(transport http.RoundTripper, region string, roleArn string, log *slog.Logger) (*AWSSigningTransport, error) { - cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(region)) + // Only set region explicitly when provided; otherwise allow env/IMDS resolution + var opts []func(*config.LoadOptions) error + if region != "" { + opts = append(opts, config.WithRegion(region)) + } + cfg, err := config.LoadDefaultConfig(context.Background(), opts...) if err != nil { log.Error("failed to load aws default config", "err", err) return nil, err diff --git a/probe.go b/probe.go new file mode 100644 index 00000000..2b999604 --- /dev/null +++ b/probe.go @@ -0,0 +1,78 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "net/url" + "strings" + + "github.com/prometheus-community/elasticsearch_exporter/config" +) + +var ( + errMissingTarget = errors.New("missing target parameter") + errInvalidTarget = errors.New("invalid target parameter") + errModuleNotFound = errors.New("auth_module not found") + errUnsupportedModule = errors.New("unsupported auth_module type") +) + +// validateProbeParams performs upfront validation of the query parameters. +// It returns the target string (as given), the resolved AuthModule (optional), or an error. +func validateProbeParams(cfg *config.Config, q url.Values) (string, *config.AuthModule, error) { + target := q.Get("target") + if target == "" { + return "", nil, errMissingTarget + } + + // If the target does not contain an URL scheme, default to http. + // This allows users to pass "host:port" without the "http://" prefix. + if !strings.Contains(target, "://") { + target = "http://" + target + } + + u, err := url.Parse(target) + if err != nil { + return "", nil, errInvalidTarget + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", nil, errInvalidTarget + } + + modu := q.Get("auth_module") + if modu == "" { + return target, nil, nil // no auth module requested + } + if cfg == nil { + return "", nil, errModuleNotFound + } + am, ok := cfg.AuthModules[modu] + if !ok { + return "", nil, errModuleNotFound + } + switch strings.ToLower(am.Type) { + case "userpass": + return target, &am, nil + case "apikey": + return target, &am, nil + case "aws": + // Accept module even if region omitted; environment resolver can provide it. + return target, &am, nil + case "tls": + // TLS auth type is valid; detailed TLS validation is performed during config load. + return target, &am, nil + default: + return "", nil, errUnsupportedModule + } +} diff --git a/probe_test.go b/probe_test.go new file mode 100644 index 00000000..a2cc3bbf --- /dev/null +++ b/probe_test.go @@ -0,0 +1,126 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "net/url" + "testing" + + "github.com/prometheus-community/elasticsearch_exporter/config" +) + +func TestValidateProbeParams(t *testing.T) { + cfg := &config.Config{AuthModules: map[string]config.AuthModule{}} + // missing target + _, _, err := validateProbeParams(cfg, url.Values{}) + if err != errMissingTarget { + t.Fatalf("expected missing target error, got %v", err) + } + + // invalid target + vals := url.Values{} + vals.Set("target", "http://[::1") + _, _, err = validateProbeParams(cfg, vals) + if err == nil { + t.Fatalf("expected invalid target error") + } + + // invalid scheme + vals = url.Values{} + vals.Set("target", "ftp://example.com") + _, _, err = validateProbeParams(cfg, vals) + if err == nil { + t.Fatalf("expected invalid target error for unsupported scheme") + } + + // unknown module + vals = url.Values{} + vals.Set("target", "http://localhost:9200") + vals.Set("auth_module", "foo") + _, _, err = validateProbeParams(cfg, vals) + if err != errModuleNotFound { + t.Fatalf("expected module not found error, got %v", err) + } + + // good path (userpass) + cfg.AuthModules["foo"] = config.AuthModule{Type: "userpass", UserPass: &config.UserPassConfig{Username: "u", Password: "p"}} + vals = url.Values{} + vals.Set("target", "http://localhost:9200") + vals.Set("auth_module", "foo") + tgt, am, err := validateProbeParams(cfg, vals) + if err != nil || am == nil || tgt == "" { + t.Fatalf("expected success, got err=%v", err) + } + + // good path (apikey) with both userpass and apikey set - apikey should be accepted + cfg.AuthModules["api"] = config.AuthModule{ + Type: "apikey", + APIKey: "mysecret", + UserPass: &config.UserPassConfig{Username: "u", Password: "p"}, + } + vals = url.Values{} + vals.Set("target", "http://localhost:9200") + vals.Set("auth_module", "api") + _, am, err = validateProbeParams(cfg, vals) + if err != nil { + t.Fatalf("expected success for apikey module, got err=%v", err) + } + if am == nil || am.Type != "apikey" { + t.Fatalf("expected apikey module, got %+v", am) + } + if am.APIKey != "mysecret" { + t.Fatalf("unexpected apikey value: %s", am.APIKey) + } + + // good path (aws) + cfg.AuthModules["awsmod"] = config.AuthModule{ + Type: "aws", + AWS: &config.AWSConfig{ + Region: "us-east-1", + RoleARN: "arn:aws:iam::123456789012:role/metrics", + }, + } + vals = url.Values{} + vals.Set("target", "http://localhost:9200") + vals.Set("auth_module", "awsmod") + _, am, err = validateProbeParams(cfg, vals) + if err != nil { + t.Fatalf("expected success for aws module, got err=%v", err) + } + if am == nil || am.Type != "aws" { + t.Fatalf("expected aws module, got %+v", am) + } + if am.AWS == nil || am.AWS.Region != "us-east-1" { + t.Fatalf("unexpected aws config: %+v", am.AWS) + } + + // invalid path (aws with empty region - rejected at config load; simulate here by passing nil cfg lookup) + // No additional test needed as config.LoadConfig enforces region. + + // good path (tls) + cfg.AuthModules["mtls"] = config.AuthModule{ + Type: "tls", + TLS: &config.TLSConfig{CAFile: "/dev/null", CertFile: "/dev/null", KeyFile: "/dev/null"}, + } + vals = url.Values{} + vals.Set("target", "http://localhost:9200") + vals.Set("auth_module", "mtls") + _, am, err = validateProbeParams(cfg, vals) + if err != nil { + t.Fatalf("expected success for tls module, got err=%v", err) + } + if am == nil || am.Type != "tls" { + t.Fatalf("expected tls module, got %+v", am) + } +}