-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add regexp matching of HTTP response headers to the http probe #419
Changes from 5 commits
f6cdd9c
2c2a8ee
aa18657
b5c2f94
4bf57e1
6abb43e
4e794b3
7c95fb3
0ed9994
3527651
e7e7afb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -58,14 +58,30 @@ The other placeholders are specified separately. | |
# Probe fails if SSL is not present. | ||
[ fail_if_not_ssl: <boolean> | default = false ] | ||
|
||
# Probe fails if response matches regex. | ||
# Probe fails if response body matches regex. | ||
fail_if_matches_regexp: | ||
[ - <regex>, ... ] | ||
|
||
# Probe fails if response does not match regex. | ||
# Probe fails if response body does not match regex. | ||
fail_if_not_matches_regexp: | ||
[ - <regex>, ... ] | ||
|
||
# Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches | ||
fail_if_header_matches_regexp: | ||
[ - [ header: <string>, | ||
[ regex: <regex>, ] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indentation seems off here There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, does not look quite good. I got it from the prometheus config documentation, e.g. here when the same level contains both optional and non-optional parameters, then it looks like this (comments stripped): job_name: <job_name>
[ scrape_interval: <duration> | default = <global_config.scrape_interval> ] In here, though, the # Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches.
fail_if_header_matches:
[ - [ header: <string>,
regexp: <regex>,
[ allow_missing: <boolean> | default = false ]
], ...
] Would that be OK? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking around, the only similar one we have is some of pagerduty for the AM. There we break it out as a different type. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another example right in this repo is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For query_response they're all optional though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, extracted only the http header match spec. |
||
[ allow_missing: <boolean> | default = false ] | ||
], ... | ||
] | ||
|
||
# Probe fails if response header does not match regex. For headers with multiple values, fails if *none* match | ||
fail_if_header_not_matches_regexp: | ||
[ - [ header: <string>, | ||
[ regex: <regex>, ] | ||
[ allow_missing: <boolean> | default = false ] | ||
], ... | ||
] | ||
|
||
# Configuration for TLS protocol of HTTP probe. | ||
tls_config: | ||
[ <tls_config> ] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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_matches_regexp,omitempty"` | ||
FailIfBodyNotMatchesRegexp []string `yaml:"fail_if_not_matches_regexp,omitempty"` | ||
FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches_regexp,omitempty"` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn't a string, so I'd remove the _regexp from the name to avoid confusion. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where would the confusion arise from? Is there a convention that properties ending with I could rename the properties so it would be like this: # ...
fail_if_body_matches_regexp:
- 'Thou shall not pass'
fail_if_header_matches:
- header: Host
regexp: example.com It would lose the consistency between property names for header and body, though. Is it worth it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the data structures are different There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
FailIfHeaderNotMatchesRegexp []HeaderMatch `yaml:"fail_if_header_not_matches_regexp,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.AllowMissing && s.Regexp == "" { | ||
return errors.New("regexp must be set for required HTTP headers") | ||
} | ||
|
||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
modules: | ||
http_headers: | ||
prober: http | ||
timeout: 5s | ||
http: | ||
fail_if_header_not_matches_regexp: | ||
- header: Access-Control-Allow-Origin | ||
allow_missing: false |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 := textproto.MIMEHeader(header)[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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "No", and full stop There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed here and in the other place. |
||
} | ||
} | ||
|
||
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 := textproto.MIMEHeader(header)[headerMatchSpec.Header] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This won't canonicalise the key There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed and added tests |
||
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 | ||
|
@@ -173,6 +236,11 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr | |
Help: "Indicates if probe failed due to regex", | ||
}) | ||
|
||
probeFailedDueToHeaders = prometheus.NewGauge(prometheus.GaugeOpts{ | ||
Name: "probe_failed_due_to_headers", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probe_http_ falied_due_regex should already cover this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Thee are several other labels that do not have the EDIT: formatting There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They should be changed, but at least the ssl one is commonly used for alerting so we need to be a little careful so not now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I will not touch the existing labels. But I am not sure if representing the failed header matches with If you do insist, I can reuse There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd suggest reusing, either that or have two more clearly named metrics. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, reused existing metric. Renaming existing ones would be a more dangerous breaking change indeed. |
||
Help: "Indicates if probe failed due to headers", | ||
}) | ||
|
||
probeHTTPLastModified = prometheus.NewGauge(prometheus.GaugeOpts{ | ||
Name: "probe_http_last_modified_timestamp_seconds", | ||
Help: "Returns the Last-Modified HTTP response header in unixtime", | ||
|
@@ -190,6 +258,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr | |
registry.MustRegister(statusCodeGauge) | ||
registry.MustRegister(probeHTTPVersionGauge) | ||
registry.MustRegister(probeFailedDueToRegex) | ||
registry.MustRegister(probeFailedDueToHeaders) | ||
|
||
httpConfig := module.HTTP | ||
|
||
|
@@ -320,7 +389,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 { | ||
probeFailedDueToHeaders.Set(0) | ||
} else { | ||
probeFailedDueToHeaders.Set(1) | ||
} | ||
} | ||
|
||
if success && (len(httpConfig.FailIfBodyMatchesRegexp) > 0 || len(httpConfig.FailIfBodyNotMatchesRegexp) > 0) { | ||
success = matchRegularExpressions(resp.Body, httpConfig, logger) | ||
if success { | ||
probeFailedDueToRegex.Set(0) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Full stop
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added here and in other places