diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8adddaeee..fcc6df51a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -610,23 +610,45 @@ sentry: ## Silence form `silenceForm` section allow customizing silence form behavior. +`author:populate_from_header` subsection allows to configure fetching of author +name used on the silence form from the request header. It can be used with +setups where karma is deployed behind authentication proxy that adds some extra +headers with username for all requests received by karma. + Syntax: ```YAML silenceForm: + author: + populate_from_header: + header: string + value_re: string strip: labels: list of strings ``` +- `author:populate_from_header:header` - name of the header to read the username + from +- `author:populate_from_header:value_re` - + [regex](https://golang.org/s/re2syntax) used to extract the username from the + request header. It must include one numbered capturing group, whatever is + matched by that group will be used as the silence form author field. Both + `header` and `value_re` must be set for this feature to work. - `strip:labels` - list of labels to ignore when populating silence form from individual alerts or group of alerts. This allows to create silences matching only unique labels, like `instance` or `host`, ignoring any common labels like `job`. -Example: +Example where `job` label won't be auto populated onto the silence form and +where the `X-Auth` header with value `User foobar` will set the default silence +author to `foobar`. ```YAML silenceForm: + author: + populate_from_header: + header: X-Auth + value_re: ^User (.+)$ strip: labels: - job diff --git a/internal/config/config.go b/internal/config/config.go index e6abcd351..40a23816c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,6 +79,8 @@ func init() { "List of receivers to not display alerts for") pflag.StringSlice("silenceform.strip.labels", []string{}, "List of labels to ignore when auto-filling silence form from alerts") + pflag.String("silenceform.author.populate_from_header.header", "", "Header to read the default silence author from") + pflag.String("silenceform.author.populate_from_header.value_re", "", "Header value regex to read the default silence author") pflag.String("listen.address", "", "IP/Hostname to listen on") pflag.Int("listen.port", 8080, "HTTP port to listen on") @@ -167,6 +169,15 @@ func (config *configSchema) Read() { config.Sentry.Private = v.GetString("sentry.private") config.Sentry.Public = v.GetString("sentry.public") config.SilenceForm.Strip.Labels = v.GetStringSlice("silenceform.strip.labels") + config.SilenceForm.Author.PopulateFromHeader.Header = v.GetString("silenceform.author.populate_from_header.header") + config.SilenceForm.Author.PopulateFromHeader.ValueRegex = v.GetString("silenceform.author.populate_from_header.value_re") + + if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { + _, err = regexp.Compile(config.SilenceForm.Author.PopulateFromHeader.ValueRegex) + if err != nil { + log.Fatalf("Invalid regex for silenceform.author.populate_from_header.value_re: %s", err.Error()) + } + } err = v.UnmarshalKey("alertmanager.servers", &config.Alertmanager.Servers) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2daa5086f..bd8176c4e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,6 +121,10 @@ sentry: private: secret key public: public key silenceForm: + author: + populate_from_header: + header: "" + value_re: "" strip: labels: [] ` @@ -236,3 +240,19 @@ func TestLogValues(t *testing.T) { Config.Read() Config.LogValues() } + +func TestInvalidSilenceFormRegex(t *testing.T) { + resetEnv() + os.Setenv("SILENCEFORM_AUTHOR_POPULATE_FROM_HEADER_VALUE_RE", ".****") + + log.SetLevel(log.PanicLevel) + defer func() { log.StandardLogger().ExitFunc = nil }() + var wasFatal bool + log.StandardLogger().ExitFunc = func(int) { wasFatal = true } + + Config.Read() + + if !wasFatal { + t.Error("Invalid silence form regex didn't cause log.Fatal()") + } +} diff --git a/internal/config/models.go b/internal/config/models.go index 1d3abf0cd..1df0acb00 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -94,6 +94,12 @@ type configSchema struct { Public string } SilenceForm struct { + Author struct { + PopulateFromHeader struct { + Header string `yaml:"header" mapstructure:"header"` + ValueRegex string `yaml:"value_re" mapstructure:"value_re"` + } `yaml:"populate_from_header" mapstructure:"populate_from_header"` + } `yaml:"author" mapstructure:"author"` Strip struct { Labels []string } diff --git a/internal/models/api.go b/internal/models/api.go index 9284d3349..57a835fab 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -270,7 +270,8 @@ type SilenceFormStripSettings struct { } type SilenceFormSettings struct { - Strip SilenceFormStripSettings `json:"strip"` + Strip SilenceFormStripSettings `json:"strip"` + Author string `json:"author"` } // Settings is used to export karma configuration that is used by UI diff --git a/views.go b/views.go index 22ab2ba4e..becb27dc6 100644 --- a/views.go +++ b/views.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "net/http" + "regexp" "sort" "strings" "time" @@ -70,6 +71,21 @@ func populateAPIFilters(matchFilters []filters.FilterT) []models.Filter { return apiFilters } +func authorFromHeader(c *gin.Context, header string, valueRe string) string { + if header == "" || valueRe == "" { + return "" + } + v := c.GetHeader(header) + if v != "" { + r := regexp.MustCompile(valueRe) + matches := r.FindAllStringSubmatch(v, 1) + if len(matches) > 0 && len(matches[0]) > 1 { + return matches[0][1] + } + } + return "" +} + // alerts endpoint, json, JS will query this via AJAX call func alerts(c *gin.Context) { noCache(c) @@ -96,6 +112,7 @@ func alerts(c *gin.Context) { AnnotationsHidden: config.Config.Annotations.Hidden, AnnotationsVisible: config.Config.Annotations.Visible, SilenceForm: models.SilenceFormSettings{ + Author: authorFromHeader(c, config.Config.SilenceForm.Author.PopulateFromHeader.Header, config.Config.SilenceForm.Author.PopulateFromHeader.ValueRegex), Strip: models.SilenceFormStripSettings{ Labels: config.Config.SilenceForm.Strip.Labels, }, @@ -111,7 +128,21 @@ func alerts(c *gin.Context) { data, found := apiCache.Get(cacheKey) if found { - c.Data(http.StatusOK, gin.MIMEJSON, data.([]byte)) + // need to overwrite settings as they can have user specific data + newResp := models.AlertsResponse{} + err := json.Unmarshal(data.([]byte), &newResp) + if err != nil { + log.Error(err.Error()) + panic(err) + } + newResp.Settings = resp.Settings + newResp.Timestamp = string(ts) + newData, err := json.Marshal(&newResp) + if err != nil { + log.Error(err.Error()) + panic(err) + } + c.Data(http.StatusOK, gin.MIMEJSON, newData) logAlertsView(c, "HIT", time.Since(start)) return } diff --git a/views_test.go b/views_test.go index 26a115fc5..cac05156e 100644 --- a/views_test.go +++ b/views_test.go @@ -504,3 +504,83 @@ func TestGzipMiddlewareWithoutAcceptEncoding(t *testing.T) { } } } + +func TestValidateAuthorFromHeaders(t *testing.T) { + type testValidateAuthorFromHeaders struct { + configHeader string + configRegex string + requestHeaderName string + requestHeaderValue string + expectedAuthor string + } + + testCases := []testValidateAuthorFromHeaders{ + { + configHeader: "X-Auth", + configRegex: "^(.*)$", + requestHeaderName: "X-Auth", + requestHeaderValue: "foo", + expectedAuthor: "foo", + }, + { + configHeader: "X-Auth", + configRegex: "^foo(.*)bar$", + requestHeaderName: "X-Auth", + requestHeaderValue: "foo123bar", + expectedAuthor: "123", + }, + { + configHeader: "X-Auth", + configRegex: "^(.*)$", + requestHeaderName: "X-Auth-Not", + requestHeaderValue: "foo", + expectedAuthor: "", + }, + { + configHeader: "", + configRegex: "^(.*)$", + requestHeaderName: "X-Auth", + requestHeaderValue: "foo", + expectedAuthor: "", + }, + { + configHeader: "X-Auth", + configRegex: "", + requestHeaderName: "X-Auth", + requestHeaderValue: "foo", + expectedAuthor: "", + }, + { + configHeader: "X-Auth", + configRegex: "^.*$", + requestHeaderName: "X-Auth", + requestHeaderValue: "foo", + expectedAuthor: "", + }, + } + + mockConfig() + for _, testCase := range testCases { + config.Config.SilenceForm.Author.PopulateFromHeader.Header = testCase.configHeader + config.Config.SilenceForm.Author.PopulateFromHeader.ValueRegex = testCase.configRegex + + r := ginTestEngine() + req := httptest.NewRequest("GET", "/alerts.json", nil) + req.Header.Set(testCase.requestHeaderName, testCase.requestHeaderValue) + + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Errorf("GET /alerts.json returned status %d", resp.Code) + } + ur := models.AlertsResponse{} + body := resp.Body.Bytes() + err := json.Unmarshal(body, &ur) + if err != nil { + t.Errorf("Failed to unmarshal response: %s", err) + } + if ur.Settings.SilenceForm.Author != testCase.expectedAuthor { + t.Errorf("Expected author '%s', got '%s', test case: %+v", testCase.expectedAuthor, ur.Settings.SilenceForm.Author, testCase) + } + } +}