From c453ee35c89a54c61490fe0e072f82f828a92ca2 Mon Sep 17 00:00:00 2001 From: Gleb Smirnov Date: Thu, 21 Feb 2019 21:28:52 +0800 Subject: [PATCH] Add regexp matching of HTTP response headers to the http probe (#419) Signed-off-by: Gleb Smirnov --- CONFIGURATION.md | 26 +++- config/config.go | 52 ++++++-- config/config_test.go | 4 + config/testdata/blackbox-good.yml | 11 ++ config/testdata/invalid-http-header-match.yml | 8 ++ example.yml | 12 +- prober/http.go | 78 +++++++++++- prober/http_test.go | 117 ++++++++++++++++-- 8 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 config/testdata/invalid-http-header-match.yml diff --git a/CONFIGURATION.md b/CONFIGURATION.md index c261c75..7610bd4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -58,14 +58,22 @@ The other placeholders are specified separately. # Probe fails if SSL is not present. [ fail_if_not_ssl: | default = false ] - # Probe fails if response matches regex. - fail_if_matches_regexp: + # Probe fails if response body matches regex. + fail_if_body_matches_regexp: [ - , ... ] - # Probe fails if response does not match regex. - fail_if_not_matches_regexp: + # Probe fails if response body does not match regex. + fail_if_body_not_matches_regexp: [ - , ... ] + # Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches. + fail_if_header_matches: + [ - , ... ] + + # Probe fails if response header does not match regex. For headers with multiple values, fails if *none* match. + fail_if_header_not_matches: + [ - , ... ] + # Configuration for TLS protocol of HTTP probe. tls_config: [ ] @@ -86,7 +94,7 @@ The other placeholders are specified separately. # The IP protocol of the HTTP probe (ip4, ip6). [ preferred_ip_protocol: | default = "ip6" ] - [ ip_protocol_fallback: ] + [ ip_protocol_fallback: | default = true ] # The body of the HTTP request used in probe. body: [ ] @@ -94,6 +102,14 @@ The other placeholders are specified separately. ``` +#### + +```yml +header: , +regexp: , +[ allow_missing: | default = false ] +``` + ### ```yml diff --git a/config/config.go b/config/config.go index afb7ecf..3652f86 100644 --- a/config/config.go +++ b/config/config.go @@ -80,19 +80,27 @@ type Module struct { type HTTPProbe struct { // Defaults to 2xx. - ValidStatusCodes []int `yaml:"valid_status_codes,omitempty"` - ValidHTTPVersions []string `yaml:"valid_http_versions,omitempty"` - IPProtocol string `yaml:"preferred_ip_protocol,omitempty"` - IPProtocolFallback bool `yaml:"ip_protocol_fallback,omitempty"` - NoFollowRedirects bool `yaml:"no_follow_redirects,omitempty"` - FailIfSSL bool `yaml:"fail_if_ssl,omitempty"` - FailIfNotSSL bool `yaml:"fail_if_not_ssl,omitempty"` - Method string `yaml:"method,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - FailIfMatchesRegexp []string `yaml:"fail_if_matches_regexp,omitempty"` - FailIfNotMatchesRegexp []string `yaml:"fail_if_not_matches_regexp,omitempty"` - Body string `yaml:"body,omitempty"` - HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"` + ValidStatusCodes []int `yaml:"valid_status_codes,omitempty"` + ValidHTTPVersions []string `yaml:"valid_http_versions,omitempty"` + IPProtocol string `yaml:"preferred_ip_protocol,omitempty"` + IPProtocolFallback bool `yaml:"ip_protocol_fallback,omitempty"` + NoFollowRedirects bool `yaml:"no_follow_redirects,omitempty"` + FailIfSSL bool `yaml:"fail_if_ssl,omitempty"` + FailIfNotSSL bool `yaml:"fail_if_not_ssl,omitempty"` + Method string `yaml:"method,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + FailIfBodyMatchesRegexp []string `yaml:"fail_if_body_matches_regexp,omitempty"` + FailIfBodyNotMatchesRegexp []string `yaml:"fail_if_body_not_matches_regexp,omitempty"` + FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches,omitempty"` + FailIfHeaderNotMatchesRegexp []HeaderMatch `yaml:"fail_if_header_not_matches,omitempty"` + Body string `yaml:"body,omitempty"` + HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"` +} + +type HeaderMatch struct { + Header string `yaml:"header,omitempty"` + Regexp string `yaml:"regexp,omitempty"` + AllowMissing bool `yaml:"allow_missing,omitempty"` } type QueryResponse struct { @@ -217,3 +225,21 @@ func (s *QueryResponse) UnmarshalYAML(unmarshal func(interface{}) error) error { } return nil } + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (s *HeaderMatch) UnmarshalYAML(unmarshal func(interface{}) error) error { + type plain HeaderMatch + if err := unmarshal((*plain)(s)); err != nil { + return err + } + + if s.Header == "" { + return errors.New("header name must be set for HTTP header matchers") + } + + if s.Regexp == "" { + return errors.New("regexp must be set for HTTP header matchers") + } + + return nil +} diff --git a/config/config_test.go b/config/config_test.go index 94b0248..d7c0719 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -38,6 +38,10 @@ func TestLoadBadConfigs(t *testing.T) { ConfigFile: "testdata/invalid-dns-module.yml", ExpectedError: "error parsing config file: query name must be set for DNS module", }, + { + ConfigFile: "testdata/invalid-http-header-match.yml", + ExpectedError: "error parsing config file: regexp must be set for HTTP header matchers", + }, } for i, test := range tests { err := sc.ReloadConfig(test.ConfigFile) diff --git a/config/testdata/blackbox-good.yml b/config/testdata/blackbox-good.yml index d0a1f0c..8615d3c 100644 --- a/config/testdata/blackbox-good.yml +++ b/config/testdata/blackbox-good.yml @@ -66,3 +66,14 @@ modules: ip_protocol_fallback: false validate_answer_rrs: fail_if_matches_regexp: [test] + http_header_match_origin: + prober: http + timeout: 5s + http: + method: GET + headers: + Origin: example.com + fail_if_header_not_matches: + - header: Access-Control-Allow-Origin + regexp: '(\*|example\.com)' + allow_missing: false diff --git a/config/testdata/invalid-http-header-match.yml b/config/testdata/invalid-http-header-match.yml new file mode 100644 index 0000000..ae56c7d --- /dev/null +++ b/config/testdata/invalid-http-header-match.yml @@ -0,0 +1,8 @@ +modules: + http_headers: + prober: http + timeout: 5s + http: + fail_if_header_not_matches: + - header: Access-Control-Allow-Origin + allow_missing: false diff --git a/example.yml b/example.yml index 39786fb..6c720ff 100644 --- a/example.yml +++ b/example.yml @@ -9,13 +9,21 @@ modules: headers: Host: vhost.example.com Accept-Language: en-US + Origin: example.com no_follow_redirects: false fail_if_ssl: false fail_if_not_ssl: false - fail_if_matches_regexp: + fail_if_body_matches_regexp: - "Could not connect to database" - fail_if_not_matches_regexp: + fail_if_body_not_matches_regexp: - "Download the latest version here" + fail_if_header_matches: # Verifies that no cookies are set + - header: Set-Cookie + allow_missing: true + regexp: '.*' + fail_if_header_not_matches: + - header: Access-Control-Allow-Origin + regexp: '(\*|example\.com)' tls_config: insecure_skip_verify: false preferred_ip_protocol: "ip4" # defaults to "ip6" diff --git a/prober/http.go b/prober/http.go index 9aa6104..1c5ed23 100644 --- a/prober/http.go +++ b/prober/http.go @@ -23,6 +23,7 @@ import ( "net/http" "net/http/cookiejar" "net/http/httptrace" + "net/textproto" "net/url" "regexp" "strconv" @@ -44,7 +45,7 @@ func matchRegularExpressions(reader io.Reader, httpConfig config.HTTPProbe, logg level.Error(logger).Log("msg", "Error reading HTTP body", "err", err) return false } - for _, expression := range httpConfig.FailIfMatchesRegexp { + for _, expression := range httpConfig.FailIfBodyMatchesRegexp { re, err := regexp.Compile(expression) if err != nil { level.Error(logger).Log("msg", "Could not compile regular expression", "regexp", expression, "err", err) @@ -55,7 +56,7 @@ func matchRegularExpressions(reader io.Reader, httpConfig config.HTTPProbe, logg return false } } - for _, expression := range httpConfig.FailIfNotMatchesRegexp { + for _, expression := range httpConfig.FailIfBodyNotMatchesRegexp { re, err := regexp.Compile(expression) if err != nil { level.Error(logger).Log("msg", "Could not compile regular expression", "regexp", expression, "err", err) @@ -69,6 +70,68 @@ func matchRegularExpressions(reader io.Reader, httpConfig config.HTTPProbe, logg return true } +func matchRegularExpressionsOnHeaders(header http.Header, httpConfig config.HTTPProbe, logger log.Logger) bool { + for _, headerMatchSpec := range httpConfig.FailIfHeaderMatchesRegexp { + values := header[textproto.CanonicalMIMEHeaderKey(headerMatchSpec.Header)] + if len(values) == 0 { + if !headerMatchSpec.AllowMissing { + level.Error(logger).Log("msg", "Missing required header", "header", headerMatchSpec.Header) + return false + } else { + continue // No need to match any regex on missing headers. + } + } + + re, err := regexp.Compile(headerMatchSpec.Regexp) + if err != nil { + level.Error(logger).Log("msg", "Could not compile regular expression", "regexp", headerMatchSpec.Regexp, "err", err) + return false + } + + for _, val := range values { + if re.MatchString(val) { + level.Error(logger).Log("msg", "Header matched regular expression", "header", headerMatchSpec.Header, + "regexp", headerMatchSpec.Regexp, "value_count", len(values)) + return false + } + } + } + for _, headerMatchSpec := range httpConfig.FailIfHeaderNotMatchesRegexp { + values := header[textproto.CanonicalMIMEHeaderKey(headerMatchSpec.Header)] + if len(values) == 0 { + if !headerMatchSpec.AllowMissing { + level.Error(logger).Log("msg", "Missing required header", "header", headerMatchSpec.Header) + return false + } else { + continue // No need to match any regex on missing headers. + } + } + + re, err := regexp.Compile(headerMatchSpec.Regexp) + if err != nil { + level.Error(logger).Log("msg", "Could not compile regular expression", "regexp", headerMatchSpec.Regexp, "err", err) + return false + } + + anyHeaderValueMatched := false + + for _, val := range values { + if re.MatchString(val) { + anyHeaderValueMatched = true + break + } + } + + if !anyHeaderValueMatched { + level.Error(logger).Log("msg", "Header did not match regular expression", "header", headerMatchSpec.Header, + "regexp", headerMatchSpec.Regexp, "value_count", len(values)) + return false + } + } + + return true +} + // roundTripTrace holds timings for a single HTTP roundtrip. type roundTripTrace struct { tls bool @@ -320,7 +383,16 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr level.Info(logger).Log("msg", "Invalid HTTP response status code, wanted 2xx", "status_code", resp.StatusCode) } - if success && (len(httpConfig.FailIfMatchesRegexp) > 0 || len(httpConfig.FailIfNotMatchesRegexp) > 0) { + if success && (len(httpConfig.FailIfHeaderMatchesRegexp) > 0 || len(httpConfig.FailIfHeaderNotMatchesRegexp) > 0) { + success = matchRegularExpressionsOnHeaders(resp.Header, httpConfig, logger) + if success { + probeFailedDueToRegex.Set(0) + } else { + probeFailedDueToRegex.Set(1) + } + } + + if success && (len(httpConfig.FailIfBodyMatchesRegexp) > 0 || len(httpConfig.FailIfBodyNotMatchesRegexp) > 0) { success = matchRegularExpressions(resp.Body, httpConfig, logger) if success { probeFailedDueToRegex.Set(0) diff --git a/prober/http_test.go b/prober/http_test.go index a7deaa8..b45c96b 100644 --- a/prober/http_test.go +++ b/prober/http_test.go @@ -234,7 +234,7 @@ func TestFailIfNotSSL(t *testing.T) { checkRegistryResults(expectedResults, mfs, t) } -func TestFailIfMatchesRegexp(t *testing.T) { +func TestFailIfBodyMatchesRegexp(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Bad news: could not connect to database server") })) @@ -245,7 +245,7 @@ func TestFailIfMatchesRegexp(t *testing.T) { testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() result := ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfMatchesRegexp: []string{"could not connect to database"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyMatchesRegexp: []string{"could not connect to database"}}}, registry, log.NewNopLogger()) body := recorder.Body.String() if result { t.Fatalf("Regexp test succeeded unexpectedly, got %s", body) @@ -267,7 +267,7 @@ func TestFailIfMatchesRegexp(t *testing.T) { recorder = httptest.NewRecorder() registry = prometheus.NewRegistry() result = ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfMatchesRegexp: []string{"could not connect to database"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyMatchesRegexp: []string{"could not connect to database"}}}, registry, log.NewNopLogger()) body = recorder.Body.String() if !result { t.Fatalf("Regexp test failed unexpectedly, got %s", body) @@ -291,7 +291,7 @@ func TestFailIfMatchesRegexp(t *testing.T) { recorder = httptest.NewRecorder() registry = prometheus.NewRegistry() result = ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfMatchesRegexp: []string{"could not connect to database", "internal error"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyMatchesRegexp: []string{"could not connect to database", "internal error"}}}, registry, log.NewNopLogger()) body = recorder.Body.String() if result { t.Fatalf("Regexp test succeeded unexpectedly, got %s", body) @@ -305,14 +305,14 @@ func TestFailIfMatchesRegexp(t *testing.T) { recorder = httptest.NewRecorder() registry = prometheus.NewRegistry() result = ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfMatchesRegexp: []string{"could not connect to database", "internal error"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyMatchesRegexp: []string{"could not connect to database", "internal error"}}}, registry, log.NewNopLogger()) body = recorder.Body.String() if !result { t.Fatalf("Regexp test failed unexpectedly, got %s", body) } } -func TestFailIfNotMatchesRegexp(t *testing.T) { +func TestFailIfBodyNotMatchesRegexp(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Bad news: could not connect to database server") })) @@ -323,7 +323,7 @@ func TestFailIfNotMatchesRegexp(t *testing.T) { testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() result := ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfNotMatchesRegexp: []string{"Download the latest version here"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyNotMatchesRegexp: []string{"Download the latest version here"}}}, registry, log.NewNopLogger()) body := recorder.Body.String() if result { t.Fatalf("Regexp test succeeded unexpectedly, got %s", body) @@ -337,7 +337,7 @@ func TestFailIfNotMatchesRegexp(t *testing.T) { recorder = httptest.NewRecorder() registry = prometheus.NewRegistry() result = ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfNotMatchesRegexp: []string{"Download the latest version here"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyNotMatchesRegexp: []string{"Download the latest version here"}}}, registry, log.NewNopLogger()) body = recorder.Body.String() if !result { t.Fatalf("Regexp test failed unexpectedly, got %s", body) @@ -353,7 +353,7 @@ func TestFailIfNotMatchesRegexp(t *testing.T) { recorder = httptest.NewRecorder() registry = prometheus.NewRegistry() result = ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfNotMatchesRegexp: []string{"Download the latest version here", "Copyright 2015"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyNotMatchesRegexp: []string{"Download the latest version here", "Copyright 2015"}}}, registry, log.NewNopLogger()) body = recorder.Body.String() if result { t.Fatalf("Regexp test succeeded unexpectedly, got %s", body) @@ -367,13 +367,110 @@ func TestFailIfNotMatchesRegexp(t *testing.T) { recorder = httptest.NewRecorder() registry = prometheus.NewRegistry() result = ProbeHTTP(testCTX, ts.URL, - config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfNotMatchesRegexp: []string{"Download the latest version here", "Copyright 2015"}}}, registry, log.NewNopLogger()) + config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyNotMatchesRegexp: []string{"Download the latest version here", "Copyright 2015"}}}, registry, log.NewNopLogger()) body = recorder.Body.String() if !result { t.Fatalf("Regexp test failed unexpectedly, got %s", body) } } +func TestFailIfHeaderMatchesRegexp(t *testing.T) { + tests := []struct { + Rule config.HeaderMatch + Values []string + ShouldSucceed bool + }{ + {config.HeaderMatch{"Content-Type", "text/javascript", false}, []string{"text/javascript"}, false}, + {config.HeaderMatch{"Content-Type", "text/javascript", false}, []string{"application/octet-stream"}, true}, + {config.HeaderMatch{"content-type", "text/javascript", false}, []string{"application/octet-stream"}, true}, + {config.HeaderMatch{"Content-Type", ".*", false}, []string{""}, false}, + {config.HeaderMatch{"Content-Type", ".*", false}, []string{}, false}, + {config.HeaderMatch{"Content-Type", ".*", true}, []string{""}, false}, + {config.HeaderMatch{"Content-Type", ".*", true}, []string{}, true}, + {config.HeaderMatch{"Set-Cookie", ".*Domain=\\.example\\.com.*", false}, []string{"gid=1; Expires=Tue, 19-Mar-2019 20:08:29 GMT; Domain=.example.com; Path=/"}, false}, + {config.HeaderMatch{"Set-Cookie", ".*Domain=\\.example\\.com.*", false}, []string{"zz=4; expires=Mon, 01-Jan-1990 00:00:00 GMT; Domain=www.example.com; Path=/", "gid=1; Expires=Tue, 19-Mar-2019 20:08:29 GMT; Domain=.example.com; Path=/"}, false}, + } + + for i, test := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, val := range test.Values { + w.Header().Add(test.Rule.Header, val) + } + })) + defer ts.Close() + registry := prometheus.NewRegistry() + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ProbeHTTP(testCTX, ts.URL, config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfHeaderMatchesRegexp: []config.HeaderMatch{test.Rule}}}, registry, log.NewNopLogger()) + if result != test.ShouldSucceed { + t.Fatalf("Test %d had unexpected result: succeeded: %t, expected: %+v", i, result, test) + } + + mfs, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + expectedResults := map[string]float64{ + "probe_failed_due_to_regex": 1, + } + + if test.ShouldSucceed { + expectedResults["probe_failed_due_to_regex"] = 0 + } + + checkRegistryResults(expectedResults, mfs, t) + } +} + +func TestFailIfHeaderNotMatchesRegexp(t *testing.T) { + tests := []struct { + Rule config.HeaderMatch + Values []string + ShouldSucceed bool + }{ + {config.HeaderMatch{"Content-Type", "text/javascript", false}, []string{"text/javascript"}, true}, + {config.HeaderMatch{"content-type", "text/javascript", false}, []string{"text/javascript"}, true}, + {config.HeaderMatch{"Content-Type", "text/javascript", false}, []string{"application/octet-stream"}, false}, + {config.HeaderMatch{"Content-Type", ".*", false}, []string{""}, true}, + {config.HeaderMatch{"Content-Type", ".*", false}, []string{}, false}, + {config.HeaderMatch{"Content-Type", ".*", true}, []string{}, true}, + {config.HeaderMatch{"Set-Cookie", ".*Domain=\\.example\\.com.*", false}, []string{"zz=4; expires=Mon, 01-Jan-1990 00:00:00 GMT; Domain=www.example.com; Path=/"}, false}, + {config.HeaderMatch{"Set-Cookie", ".*Domain=\\.example\\.com.*", false}, []string{"zz=4; expires=Mon, 01-Jan-1990 00:00:00 GMT; Domain=www.example.com; Path=/", "gid=1; Expires=Tue, 19-Mar-2019 20:08:29 GMT; Domain=.example.com; Path=/"}, true}, + } + + for i, test := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, val := range test.Values { + w.Header().Add(test.Rule.Header, val) + } + })) + defer ts.Close() + registry := prometheus.NewRegistry() + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ProbeHTTP(testCTX, ts.URL, config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfHeaderNotMatchesRegexp: []config.HeaderMatch{test.Rule}}}, registry, log.NewNopLogger()) + if result != test.ShouldSucceed { + t.Fatalf("Test %d had unexpected result: succeeded: %t, expected: %+v", i, result, test) + } + + mfs, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + expectedResults := map[string]float64{ + "probe_failed_due_to_regex": 1, + } + + if test.ShouldSucceed { + expectedResults["probe_failed_due_to_regex"] = 0 + } + + checkRegistryResults(expectedResults, mfs, t) + } +} + func TestHTTPHeaders(t *testing.T) { headers := map[string]string{ "Host": "my-secret-vhost.com", -- 2.25.1