Repository: prometheus/blackbox_exporter
Pull request #823 (2021-09-11 10:17:09)

Add hostname parameter
This is refactoring of https://github.com/prometheus/blackbox_exporter/pull/816
As per [the design doc](https://docs.google.com/document/d/1VwqXi2TOb5KXaZY6Iio7411x64pJao3GusX8MqYsJ2g), the exporter now accept `hostname` parameter, which sets `Host` header for HTTP probes. It also sets TLS server name (if it is not already defined by module configuration) to avoid TLS handshake problems when `target` is an IP address. Setting `Host` with both module configuration and `hostname` parameter results in 400 Bad Request.

anemyte: @roidelapluie please look
imneov: My suggestion is to use the following method:

- Expose as HTTP query param as well: 
```
http://127.0.0.1:9115/probe?target=214.32.12.4&module=http_2xx&HTTP.hostname=prometheus.io
http://127.0.0.1:9115/probe?target=214.32.12.4&module=http_2xx&HTTP.header.field_a=value_a&HTTP.header.field_b=value_b
```
- Expose DNS domain as HTTP query param.
```
http://127.0.0.1:9115/probe?target=8.8.8.8&module=dns&DNS.query_name=prometheus.io
```



In this way, the consistency of the use mode, avoid parameter conflicts, misoperation, and expandability.
anemyte: @roidelapluie I've added fixes, hope it looks better now. Can you please look again?

Patch:
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 431fef7..f96c76a 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -7,6 +7,7 @@ on:
       - "**.go"
       - "scripts/errcheck_excludes.txt"
       - ".github/workflows/golangci-lint.yml"
+      - ".golangci.yml"
   pull_request:
     paths:
       - "go.sum"
@@ -14,6 +15,7 @@ on:
       - "**.go"
       - "scripts/errcheck_excludes.txt"
       - ".github/workflows/golangci-lint.yml"
+      - ".golangci.yml"
 
 jobs:
   golangci:
diff --git a/Makefile.common b/Makefile.common
index 99e8f9f..ed7d182 100644
--- a/Makefile.common
+++ b/Makefile.common
@@ -160,7 +160,7 @@ endif
 update-go-deps:
 	@echo ">> updating Go dependencies"
 	@for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \
-		$(GO) get $$m; \
+		$(GO) get -d $$m; \
 	done
 	GO111MODULE=$(GO111MODULE) $(GO) mod tidy
 ifneq (,$(wildcard vendor))
diff --git a/README.md b/README.md
index 95dee65..6658f7e 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,33 @@ scrape_configs:
         replacement: 127.0.0.1:9115  # The blackbox exporter's real hostname:port.
 ```
 
+HTTP probes can accept an additional `hostname` parameter that will set `Host` header and TLS SNI. This can be especially useful with `dns_sd_config`:
+```yaml
+scrape_configs:
+  - job_name: blackbox_all
+    metrics_path: /probe
+    params:
+      module: [ http_2xx ]  # Look for a HTTP 200 response.
+    dns_sd_configs:
+      - names:
+          - example.com
+          - prometheus.io
+        type: A
+        port: 443
+    relabel_configs:
+      - source_labels: [__address__]
+        target_label: __param_target
+        replacement: https://$1/  # Make probe URL be like https://1.2.3.4:443/
+      - source_labels: [__param_target]
+        target_label: instance
+      - target_label: __address__
+        replacement: 127.0.0.1:9115  # The blackbox exporter's real hostname:port.
+      - source_labels: [__meta_dns_name]
+        target_label: __param_hostname  # Make domain name become 'Host' header for probe requests
+      - source_labels: [__meta_dns_name]
+        target_label: vhost  # and store it in 'vhost' label
+```
+
 ## Permissions
 
 The ICMP probe requires elevated privileges to function:
diff --git a/main.go b/main.go
index 971aed9..689ab80 100644
--- a/main.go
+++ b/main.go
@@ -120,6 +120,15 @@ func probeHandler(w http.ResponseWriter, r *http.Request, c *config.Config, logg
 		return
 	}
 
+	hostname := params.Get("hostname")
+	if module.Prober == "http" && hostname != "" {
+		err = setHTTPHost(hostname, &module)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+	}
+
 	sl := newScrapeLogger(logger, moduleName, target)
 	level.Info(sl).Log("msg", "Beginning probe", "probe", module.Prober, "timeout_seconds", timeoutSeconds)
 
@@ -150,6 +159,23 @@ func probeHandler(w http.ResponseWriter, r *http.Request, c *config.Config, logg
 	h.ServeHTTP(w, r)
 }
 
+func setHTTPHost(hostname string, module *config.Module) error {
+	// By creating a new hashmap and copying values there we
+	// ensure that the initial configuration remain intact.
+	headers := make(map[string]string)
+	if module.HTTP.Headers != nil {
+		for name, value := range module.HTTP.Headers {
+			if strings.Title(name) == "Host" && value != hostname {
+				return fmt.Errorf("host header defined both in module configuration (%s) and with URL-parameter 'hostname' (%s)", value, hostname)
+			}
+			headers[name] = value
+		}
+	}
+	headers["Host"] = hostname
+	module.HTTP.Headers = headers
+	return nil
+}
+
 type scrapeLogger struct {
 	next         log.Logger
 	buffer       bytes.Buffer
diff --git a/prober/http.go b/prober/http.go
index fe73082..156feca 100644
--- a/prober/http.go
+++ b/prober/http.go
@@ -344,6 +344,15 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
 		// If there is no `server_name` in tls_config, use
 		// the hostname of the target.
 		httpClientConfig.TLSConfig.ServerName = targetHost
+
+		// However, if there is a Host header it is better to use
+		// its value instead. This helps avoid TLS handshake error
+		// if targetHost is an IP address.
+		for name, value := range httpConfig.Headers {
+			if strings.Title(name) == "Host" {
+				httpClientConfig.TLSConfig.ServerName = value
+			}
+		}
 	}
 	client, err := pconfig.NewClientFromConfig(httpClientConfig, "http_probe", pconfig.WithKeepAlivesDisabled())
 	if err != nil {


Test patch:
diff --git a/main_test.go b/main_test.go
index ee2d6d8..e7ba5c6 100644
--- a/main_test.go
+++ b/main_test.go
@@ -15,6 +15,7 @@ package main
 
 import (
 	"bytes"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -190,3 +191,68 @@ func TestComputeExternalURL(t *testing.T) {
 		}
 	}
 }
+
+func TestHostnameParam(t *testing.T) {
+	headers := map[string]string{}
+	c := &config.Config{
+		Modules: map[string]config.Module{
+			"http_2xx": config.Module{
+				Prober:  "http",
+				Timeout: 10 * time.Second,
+				HTTP: config.HTTPProbe{
+					Headers:            headers,
+					IPProtocolFallback: true,
+				},
+			},
+		},
+	}
+
+	// check that 'hostname' parameter make its way to Host header
+	hostname := "foo.example.com"
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Host != hostname {
+			t.Errorf("Unexpected Host: expected %q, got %q.", hostname, r.Host)
+		}
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer ts.Close()
+
+	requrl := fmt.Sprintf("?debug=true&hostname=%s&target=%s", hostname, ts.URL)
+
+	req, err := http.NewRequest("GET", requrl, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		probeHandler(w, r, c, log.NewNopLogger(), &resultHistory{})
+	})
+
+	handler.ServeHTTP(rr, req)
+
+	if status := rr.Code; status != http.StatusOK {
+		t.Errorf("probe request handler returned wrong status code: %v, want %v", status, http.StatusOK)
+	}
+
+	// check that ts got the request to perform header check
+	if !strings.Contains(rr.Body.String(), "probe_success 1") {
+		t.Errorf("probe failed, response body: %v", rr.Body.String())
+	}
+
+	// check that host header both in config and in parameter will result in 400
+	c.Modules["http_2xx"].HTTP.Headers["Host"] = hostname + ".something"
+
+	handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		probeHandler(w, r, c, log.NewNopLogger(), &resultHistory{})
+	})
+
+	rr = httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	if status := rr.Code; status != http.StatusBadRequest {
+		t.Errorf("probe request handler returned wrong status code: %v, want %v", status, http.StatusBadRequest)
+	}
+}