Adding web.external-url and web.route-prefix flags (#515)
authorSven Nebel <nebel.sven@gmail.com>
Wed, 11 Sep 2019 12:36:17 +0000 (13:36 +0100)
committerBrian Brazil <brian.brazil@robustperception.io>
Wed, 11 Sep 2019 12:36:17 +0000 (13:36 +0100)
Signed-off-by: Sven Nebel <nebel.sven@gmail.com>
go.mod
main.go
main_test.go

diff --git a/go.mod b/go.mod
index bfae6ec0aeafa55f6a8158bc5fa39b116c5b6d44..b60028b4a0ab904329d6c429e76807e2f88d28b2 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
        github.com/go-kit/kit v0.8.0
        github.com/kr/pretty v0.1.0 // indirect
        github.com/miekg/dns v1.1.14
-       github.com/pkg/errors v0.8.1 // indirect
+       github.com/pkg/errors v0.8.1
        github.com/prometheus/client_golang v1.0.0
        github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90
        github.com/prometheus/common v0.6.0
diff --git a/main.go b/main.go
index e4f9b75f400f6fbafc0b33bd6886f9bd8395525a..3d959710e97802d73070895b0697d0b9e1780c68 100644 (file)
--- a/main.go
+++ b/main.go
@@ -18,16 +18,21 @@ import (
        "context"
        "fmt"
        "html"
+       "net"
        "net/http"
        _ "net/http/pprof"
+       "net/url"
        "os"
        "os/signal"
+       "path"
        "strconv"
+       "strings"
        "syscall"
        "time"
 
        "github.com/go-kit/kit/log"
        "github.com/go-kit/kit/log/level"
+       "github.com/pkg/errors"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/prometheus/client_golang/prometheus/promhttp"
        "github.com/prometheus/common/expfmt"
@@ -51,6 +56,8 @@ var (
        timeoutOffset = kingpin.Flag("timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64()
        configCheck   = kingpin.Flag("config.check", "If true validate the config file and then exit.").Default().Bool()
        historyLimit  = kingpin.Flag("history.limit", "The maximum amount of items to keep in the history.").Default("100").Uint()
+       externalURL   = kingpin.Flag("web.external-url", "The URL under which Blackbox exporter is externally reachable (for example, if Blackbox exporter is served via a reverse proxy). Used for generating relative and absolute links back to Blackbox exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Blackbox exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("<url>").String()
+       routePrefix   = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").String()
 
        Probers = map[string]prober.ProbeFn{
                "http": prober.ProbeHTTP,
@@ -217,6 +224,28 @@ func run() int {
 
        level.Info(logger).Log("msg", "Loaded config file")
 
+       // Infer or set Blackbox exporter externalURL
+       beURL, err := computeExternalURL(*externalURL, *listenAddress)
+       if err != nil {
+               level.Error(logger).Log("msg", "failed to determine external URL", "err", err)
+               return 1
+       }
+       level.Debug(logger).Log("externalURL", beURL.String())
+
+       // Default -web.route-prefix to path of -web.external-url.
+       if *routePrefix == "" {
+               *routePrefix = beURL.Path
+       }
+
+       // routePrefix must always be at least '/'.
+       *routePrefix = "/" + strings.Trim(*routePrefix, "/")
+       // routePrefix requires path to have trailing "/" in order
+       // for browsers to interpret the path-relative path correctly, instead of stripping it.
+       if *routePrefix != "/" {
+               *routePrefix = *routePrefix + "/"
+       }
+       level.Debug(logger).Log("routePrefix", *routePrefix)
+
        hup := make(chan os.Signal, 1)
        reloadCh := make(chan chan error)
        signal.Notify(hup, syscall.SIGHUP)
@@ -241,7 +270,19 @@ func run() int {
                }
        }()
 
-       http.HandleFunc("/-/reload",
+       // Match Prometheus behaviour and redirect over externalURL for root path only
+       // if routePrefix is different than "/"
+       if *routePrefix != "/" {
+               http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+                       if r.URL.Path != "/" {
+                               http.NotFound(w, r)
+                               return
+                       }
+                       http.Redirect(w, r, beURL.String(), http.StatusFound)
+               })
+       }
+
+       http.HandleFunc(path.Join(*routePrefix, "/-/reload"),
                func(w http.ResponseWriter, r *http.Request) {
                        if r.Method != "POST" {
                                w.WriteHeader(http.StatusMethodNotAllowed)
@@ -255,23 +296,23 @@ func run() int {
                                http.Error(w, fmt.Sprintf("failed to reload config: %s", err), http.StatusInternalServerError)
                        }
                })
-       http.Handle("/metrics", promhttp.Handler())
-       http.HandleFunc("/probe", func(w http.ResponseWriter, r *http.Request) {
+       http.Handle(path.Join(*routePrefix, "/metrics"), promhttp.Handler())
+       http.HandleFunc(path.Join(*routePrefix, "/probe"), func(w http.ResponseWriter, r *http.Request) {
                sc.Lock()
                conf := sc.C
                sc.Unlock()
                probeHandler(w, r, conf, logger, rh)
        })
-       http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+       http.HandleFunc(*routePrefix, func(w http.ResponseWriter, r *http.Request) {
                w.Header().Set("Content-Type", "text/html")
                w.Write([]byte(`<html>
     <head><title>Blackbox Exporter</title></head>
     <body>
     <h1>Blackbox Exporter</h1>
-    <p><a href="/probe?target=prometheus.io&module=http_2xx">Probe prometheus.io for http_2xx</a></p>
-    <p><a href="/probe?target=prometheus.io&module=http_2xx&debug=true">Debug probe prometheus.io for http_2xx</a></p>
-    <p><a href="/metrics">Metrics</a></p>
-    <p><a href="/config">Configuration</a></p>
+    <p><a href="probe?target=prometheus.io&module=http_2xx">Probe prometheus.io for http_2xx</a></p>
+    <p><a href="probe?target=prometheus.io&module=http_2xx&debug=true">Debug probe prometheus.io for http_2xx</a></p>
+    <p><a href="metrics">Metrics</a></p>
+    <p><a href="config">Configuration</a></p>
     <h2>Recent Probes</h2>
     <table border='1'><tr><th>Module</th><th>Target</th><th>Result</th><th>Debug</th>`))
 
@@ -283,7 +324,7 @@ func run() int {
                        if !r.success {
                                success = "<strong>Failure</strong>"
                        }
-                       fmt.Fprintf(w, "<tr><td>%s</td><td>%s</td><td>%s</td><td><a href='logs?id=%d'>Logs</a></td></td>",
+                       fmt.Fprintf(w, "<tr><td>%s</td><td>%s</td><td>%s</td><td><a href='"+path.Join(*routePrefix, "/logs")+"?id=%d'>Logs</a></td></td>",
                                html.EscapeString(r.moduleName), html.EscapeString(r.target), success, r.id)
                }
 
@@ -291,7 +332,7 @@ func run() int {
     </html>`))
        })
 
-       http.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
+       http.HandleFunc(path.Join(*routePrefix, "/logs"), func(w http.ResponseWriter, r *http.Request) {
                id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
                if err != nil {
                        http.Error(w, "Invalid probe id", 500)
@@ -306,7 +347,7 @@ func run() int {
                w.Write([]byte(result.debugOutput))
        })
 
-       http.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
+       http.HandleFunc(path.Join(*routePrefix, "/config"), func(w http.ResponseWriter, r *http.Request) {
                sc.RLock()
                c, err := yaml.Marshal(sc.C)
                sc.RUnlock()
@@ -366,3 +407,41 @@ func getTimeout(r *http.Request, module config.Module, offset float64) (timeoutS
 
        return timeoutSeconds, nil
 }
+
+func startsOrEndsWithQuote(s string) bool {
+       return strings.HasPrefix(s, "\"") || strings.HasPrefix(s, "'") ||
+               strings.HasSuffix(s, "\"") || strings.HasSuffix(s, "'")
+}
+
+// computeExternalURL computes a sanitized external URL from a raw input. It infers unset
+// URL parts from the OS and the given listen address.
+func computeExternalURL(u, listenAddr string) (*url.URL, error) {
+       if u == "" {
+               hostname, err := os.Hostname()
+               if err != nil {
+                       return nil, err
+               }
+               _, port, err := net.SplitHostPort(listenAddr)
+               if err != nil {
+                       return nil, err
+               }
+               u = fmt.Sprintf("http://%s:%s/", hostname, port)
+       }
+
+       if startsOrEndsWithQuote(u) {
+               return nil, errors.New("URL must not begin or end with quotes")
+       }
+
+       eu, err := url.Parse(u)
+       if err != nil {
+               return nil, err
+       }
+
+       ppref := strings.TrimRight(eu.Path, "/")
+       if ppref != "" && !strings.HasPrefix(ppref, "/") {
+               ppref = "/" + ppref
+       }
+       eu.Path = ppref
+
+       return eu, nil
+}
index c2341ec1194452e6aed7e40c7956d05a8ed5fe26..f9d536be09a9367ab293e582a7d030a5a474a1fe 100644 (file)
@@ -137,3 +137,56 @@ func TestTimeoutIsSetCorrectly(t *testing.T) {
                }
        }
 }
+
+func TestComputeExternalURL(t *testing.T) {
+       tests := []struct {
+               input string
+               valid bool
+       }{
+               {
+                       input: "",
+                       valid: true,
+               },
+               {
+                       input: "http://proxy.com/prometheus",
+                       valid: true,
+               },
+               {
+                       input: "'https://url/prometheus'",
+                       valid: false,
+               },
+               {
+                       input: "'relative/path/with/quotes'",
+                       valid: false,
+               },
+               {
+                       input: "http://alertmanager.company.com",
+                       valid: true,
+               },
+               {
+                       input: "https://double--dash.de",
+                       valid: true,
+               },
+               {
+                       input: "'http://starts/with/quote",
+                       valid: false,
+               },
+               {
+                       input: "ends/with/quote\"",
+                       valid: false,
+               },
+       }
+
+       for _, test := range tests {
+               _, err := computeExternalURL(test.input, "0.0.0.0:9090")
+               if test.valid {
+                       if err != nil {
+                               t.Errorf("unexpected error %v", err)
+                       }
+               } else {
+                       if err == nil {
+                               t.Errorf("expected error computing %s got none", test.input)
+                       }
+               }
+       }
+}