From 2ab6892c858d62d81641217708bcfbc308551096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Tue, 25 Feb 2020 22:32:26 +0000 Subject: [PATCH] feat(backend): use username from credentials for silences When authentication is enabled and proxy mode is on then ensure all silences are created with the username from credentials, rather than what the user sends. --- cmd/karma/main.go | 25 ++-- cmd/karma/proxy.go | 55 ++++++- cmd/karma/proxy_test.go | 240 ++++++++++++++++++++++++++++++- internal/mapper/mapper.go | 1 + internal/mapper/v017/api.go | 11 ++ internal/mapper/v017/silences.go | 4 + 6 files changed, 315 insertions(+), 21 deletions(-) diff --git a/cmd/karma/main.go b/cmd/karma/main.go index 1c9d2607a..26d1c426d 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -47,6 +47,8 @@ var ( staticBuildFileSystem = newBinaryFileSystem("ui/build") staticSrcFileSystem = newBinaryFileSystem("ui/src") + + protectedEndpoints *gin.RouterGroup ) func getViewURL(sub string) string { @@ -117,10 +119,9 @@ func setupRouter(router *gin.Engine) { ExposeHeaders: []string{"Content-Length"}, })) - var protected *gin.RouterGroup if config.Config.Authentication.Header.Name != "" { config.Config.Authentication.Enabled = true - protected = router.Group(getViewURL("/"), + protectedEndpoints = router.Group(getViewURL("/"), headerAuth(config.Config.Authentication.Header.Name, config.Config.Authentication.Header.ValueRegex)) } else if len(config.Config.Authentication.BasicAuth.Users) > 0 { config.Config.Authentication.Enabled = true @@ -128,22 +129,22 @@ func setupRouter(router *gin.Engine) { for _, u := range config.Config.Authentication.BasicAuth.Users { users[u.Username] = u.Password } - protected = router.Group(getViewURL("/"), gin.BasicAuth(users)) + protectedEndpoints = router.Group(getViewURL("/"), gin.BasicAuth(users)) } else { - protected = router.Group(getViewURL("/")) + protectedEndpoints = router.Group(getViewURL("/")) } router.GET(getViewURL("/health"), pong) - protected.GET("/", index) - protected.GET("/alerts.json", alerts) - protected.GET("/autocomplete.json", autocomplete) - protected.GET("/labelNames.json", knownLabelNames) - protected.GET("/labelValues.json", knownLabelValues) - protected.GET("/silences.json", silences) + protectedEndpoints.GET("/", index) + protectedEndpoints.GET("/alerts.json", alerts) + protectedEndpoints.GET("/autocomplete.json", autocomplete) + protectedEndpoints.GET("/labelNames.json", knownLabelNames) + protectedEndpoints.GET("/labelValues.json", knownLabelValues) + protectedEndpoints.GET("/silences.json", silences) - protected.GET("/custom.css", customCSS) - protected.GET("/custom.js", customJS) + protectedEndpoints.GET("/custom.css", customCSS) + protectedEndpoints.GET("/custom.js", customJS) router.NoRoute(notFound) } diff --git a/cmd/karma/proxy.go b/cmd/karma/proxy.go index b3587abef..e00a91b51 100644 --- a/cmd/karma/proxy.go +++ b/cmd/karma/proxy.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "fmt" + "io/ioutil" "net/http" "net/http/httputil" "net/url" @@ -10,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/prymitive/karma/internal/alertmanager" "github.com/prymitive/karma/internal/config" + "github.com/prymitive/karma/internal/mapper" log "github.com/sirupsen/logrus" ) @@ -23,7 +26,7 @@ func proxyPathPrefix(name string) string { } func proxyPath(name, path string) string { - return fmt.Sprintf("%s%s", proxyPathPrefix(name), path) + return fmt.Sprintf("/proxy/alertmanager/%s%s", name, path) } // NewAlertmanagerProxy creates a proxy instance for given alertmanager instance @@ -56,7 +59,7 @@ func NewAlertmanagerProxy(alertmanager *alertmanager.Alertmanager) (*httputil.Re req.URL.Path = strings.TrimSuffix(upstreamURL.Path, "/") + req.URL.Path } - log.Debugf("[%s] Proxy request for %s", alertmanager.Name, req.URL.Path) + log.Debugf("[%s] Forwarding request for %s to %s", alertmanager.Name, req.RequestURI, req.URL.String()) }, Transport: alertmanager.HTTPTransport, ModifyResponse: func(resp *http.Response) error { @@ -69,15 +72,57 @@ func NewAlertmanagerProxy(alertmanager *alertmanager.Alertmanager) (*httputil.Re return &proxy, nil } +func handlePostRequest(alertmanager *alertmanager.Alertmanager, h http.Handler) gin.HandlerFunc { + return func(c *gin.Context) { + log.Debugf("[%s] Proxy request %s", alertmanager.Name, c.Request.RequestURI) + if config.Config.Authentication.Enabled { + body, err := ioutil.ReadAll(c.Request.Body) + c.Request.Body.Close() + if err != nil { + log.Errorf("[%s] proxy request '%s %s' body close failed: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + ver := alertmanager.Version() + if ver == "" { + ver = "999.0" + } + + username := c.MustGet(gin.AuthUserKey).(string) + m, err := mapper.GetSilenceMapper(ver) + if err != nil { + log.Errorf("[%s] proxy request '%s %s' error: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + newBody, err := m.RewriteUsername(body, username) + if err != nil { + log.Errorf("[%s] proxy request '%s %s' silence body rewrite error: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(newBody)) + c.Request.ContentLength = int64(len(newBody)) + c.Request.Header.Set("Content-Length", fmt.Sprintf("%d", c.Request.ContentLength)) + } + + h.ServeHTTP(c.Writer, c.Request) + } +} + func setupRouterProxyHandlers(router *gin.Engine, alertmanager *alertmanager.Alertmanager) error { proxy, err := NewAlertmanagerProxy(alertmanager) if err != nil { return err } - router.POST( + + protectedEndpoints.POST( proxyPath(alertmanager.Name, "/api/v2/silences"), - gin.WrapH(http.StripPrefix(proxyPathPrefix(alertmanager.Name), proxy))) - router.DELETE( + handlePostRequest(alertmanager, http.StripPrefix(proxyPathPrefix(alertmanager.Name), proxy))) + protectedEndpoints.DELETE( proxyPath(alertmanager.Name, "/api/v2/silence/*id"), gin.WrapH(http.StripPrefix(proxyPathPrefix(alertmanager.Name), proxy))) return nil diff --git a/cmd/karma/proxy_test.go b/cmd/karma/proxy_test.go index b66a2dbb7..9005b736f 100644 --- a/cmd/karma/proxy_test.go +++ b/cmd/karma/proxy_test.go @@ -1,15 +1,22 @@ package main import ( + "bytes" + "fmt" + "io/ioutil" "net/http" "net/http/httptest" "testing" "time" + cache "github.com/patrickmn/go-cache" "github.com/prymitive/karma/internal/alertmanager" "github.com/prymitive/karma/internal/config" + "github.com/prymitive/karma/internal/mock" + log "github.com/sirupsen/logrus" "github.com/jarcoal/httpmock" + "github.com/pmezard/go-difflib/difflib" ) // httptest.NewRecorder() doesn't implement http.CloseNotifier @@ -271,11 +278,10 @@ func TestProxyToSubURIAlertmanager(t *testing.T) { } for _, testCase := range proxyTests { - t.Run(testCase.alertmanagerURI, func(t *testing.T) { + t.Run(fmt.Sprintf("prefix=%s|uri=%s", testCase.listenPrefix, testCase.alertmanagerURI), func(t *testing.T) { httpmock.Reset() - r := ginTestEngine() - config.Config.Listen.Prefix = testCase.listenPrefix + r := ginTestEngine() am, err := alertmanager.NewAlertmanager( "suburi", @@ -299,7 +305,233 @@ func TestProxyToSubURIAlertmanager(t *testing.T) { resp := newCloseNotifyingRecorder() r.ServeHTTP(resp, req) if resp.Code != 200 { - t.Errorf("Got response code %d instead of 200", resp.Code) + t.Errorf("Got response code %d instead of 200 for %s", resp.Code, testCase.requestURI) + } + }) + } +} + +func TestProxyUserRewrite(t *testing.T) { + type proxyTest struct { + name string + + headerName string + headerRe string + basicAuthUsers []config.AuthenticationUser + requestHeaders map[string]string + requestBasicAuthUser string + requestBasicAuthPassword string + + frontednRequestBody string + proxyRequestBody string + responseCode int + } + + proxyTests := []proxyTest{ + { + name: "no-auth, no-op", + responseCode: 200, + frontednRequestBody: `{ +"comment": "comment", +"createdBy": "username", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ + { "isRegex": false, "name": "alertname", "value": "Fake Alert" }, + { "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}`, + proxyRequestBody: `{ +"comment": "comment", +"createdBy": "username", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ + { "isRegex": false, "name": "alertname", "value": "Fake Alert" }, + { "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}`, + }, + { + name: "basicAuth, correct credentials, invalid JSON", + responseCode: 500, + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobar", + frontednRequestBody: `{XXX`, + }, + { + name: "basicAuth, missing credentials", + responseCode: 401, + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + }, + { + name: "basicAuth, correct credentials, fixed username", + responseCode: 200, + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobar", + frontednRequestBody: `{ +"comment": "comment", +"createdBy": "username", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ + { "isRegex": false, "name": "alertname", "value": "Fake Alert" }, + { "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}`, + proxyRequestBody: `{"comment":"comment","createdBy":"john","endsAt":"2000-02-01T00:02:03.000Z","matchers":[{"isRegex":false,"name":"alertname","value":"Fake Alert"},{"isRegex":true,"name":"foo","value":"(bar|baz)"}],"startsAt":"2000-02-01T00:00:00.000Z"}`, + }, + { + name: "basicAuth, correct credentials, fixed username, silence ID", + responseCode: 200, + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobar", + frontednRequestBody: `{ +"id": "1234567890", +"comment": "comment", +"createdBy": "username", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ + { "isRegex": false, "name": "alertname", "value": "Fake Alert" }, + { "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}`, + proxyRequestBody: `{"id":"1234567890","comment":"comment","createdBy":"john","endsAt":"2000-02-01T00:02:03.000Z","matchers":[{"isRegex":false,"name":"alertname","value":"Fake Alert"},{"isRegex":true,"name":"foo","value":"(bar|baz)"}],"startsAt":"2000-02-01T00:00:00.000Z"}`, + }, + + { + name: "header auth, missing header", + responseCode: 401, + headerName: "X-Auth", + headerRe: "(.+)", + }, + { + name: "header auth, invalid header", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "xxx", + }, + responseCode: 401, + }, + { + name: "header auth, correct credentials, fixed username", + responseCode: 200, + headerName: "X-Auth", + headerRe: "(.+)", + requestHeaders: map[string]string{ + "X-Auth": "john", + }, + frontednRequestBody: `{ +"comment": "comment", +"createdBy": "username", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ + { "isRegex": false, "name": "alertname", "value": "Fake Alert" }, + { "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}`, + proxyRequestBody: `{"comment":"comment","createdBy":"john","endsAt":"2000-02-01T00:02:03.000Z","matchers":[{"isRegex":false,"name":"alertname","value":"Fake Alert"},{"isRegex":true,"name":"foo","value":"(bar|baz)"}],"startsAt":"2000-02-01T00:00:00.000Z"}`, + }, + { + name: "basicAuth, correct credentials, fixed username, silence ID", + responseCode: 200, + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "Username john", + }, + frontednRequestBody: `{ +"id": "1234567890", +"comment": "comment", +"createdBy": "username", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ + { "isRegex": false, "name": "alertname", "value": "Fake Alert" }, + { "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}`, + proxyRequestBody: `{"id":"1234567890","comment":"comment","createdBy":"john","endsAt":"2000-02-01T00:02:03.000Z","matchers":[{"isRegex":false,"name":"alertname","value":"Fake Alert"},{"isRegex":true,"name":"foo","value":"(bar|baz)"}],"startsAt":"2000-02-01T00:00:00.000Z"}`, + }, + } + + for _, testCase := range proxyTests { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + log.SetLevel(log.FatalLevel) + t.Run(testCase.name, func(t *testing.T) { + for _, version := range mock.ListAllMocks() { + t.Logf("Testing alerts using mock files from Alertmanager %s", version) + + config.Config.Listen.Prefix = "/" + config.Config.Authentication.Header.Name = testCase.headerName + config.Config.Authentication.Header.ValueRegex = testCase.headerRe + config.Config.Authentication.BasicAuth.Users = testCase.basicAuthUsers + r := ginTestEngine() + + am, err := alertmanager.NewAlertmanager( + "proxyAuth", + "http://localhost", + alertmanager.WithRequestTimeout(time.Second*5), + alertmanager.WithProxy(true), + ) + if err != nil { + t.Error(err) + } + err = setupRouterProxyHandlers(r, am) + if err != nil { + t.Errorf("Failed to setup proxy for Alertmanager %s: %s", am.Name, err) + } + + apiCache = cache.New(cache.NoExpiration, 10*time.Second) + httpmock.Reset() + mock.RegisterURL("http://localhost/metrics", version, "metrics") + mock.RegisterURL("http://localhost/api/v2/status", version, "api/v2/status") + mock.RegisterURL("http://localhost/api/v2/silences", version, "api/v2/silences") + mock.RegisterURL("http://localhost/api/v2/alerts/groups", version, "api/v2/alerts/groups") + _ = am.Pull() + + httpmock.RegisterResponder("POST", "http://localhost/api/v2/silences", func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + return httpmock.NewBytesResponse(200, body), nil + }) + + req := httptest.NewRequest("POST", "/proxy/alertmanager/proxyAuth/api/v2/silences", ioutil.NopCloser(bytes.NewBufferString(testCase.frontednRequestBody))) + for k, v := range testCase.requestHeaders { + req.Header.Set(k, v) + } + req.SetBasicAuth(testCase.requestBasicAuthUser, testCase.requestBasicAuthPassword) + + resp := newCloseNotifyingRecorder() + r.ServeHTTP(resp, req) + if resp.Code != testCase.responseCode { + t.Errorf("Got response code %d instead of %d", resp.Code, testCase.responseCode) + } + + gotBody, _ := ioutil.ReadAll(resp.Body) + if string(gotBody) != testCase.proxyRequestBody { + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(testCase.proxyRequestBody), + B: difflib.SplitLines(string(gotBody)), + FromFile: "Expected", + ToFile: "Response", + Context: 3, + } + text, err := difflib.GetUnifiedDiffString(diff) + if err != nil { + t.Error(err) + } + t.Errorf("Body mismatch:\n%s", text) + } } }) } diff --git a/internal/mapper/mapper.go b/internal/mapper/mapper.go index 241d138a3..a6933d6ff 100644 --- a/internal/mapper/mapper.go +++ b/internal/mapper/mapper.go @@ -30,6 +30,7 @@ type AlertMapper interface { type SilenceMapper interface { Mapper Collect(string, map[string]string, time.Duration, http.RoundTripper) ([]models.Silence, error) + RewriteUsername([]byte, string) ([]byte, error) } // StatusMapper handles mapping Alertmanager status information containing cluster config diff --git a/internal/mapper/v017/api.go b/internal/mapper/v017/api.go index 7612e229d..5174bce5d 100644 --- a/internal/mapper/v017/api.go +++ b/internal/mapper/v017/api.go @@ -14,6 +14,7 @@ import ( "github.com/prymitive/karma/internal/mapper/v017/client/alertgroup" "github.com/prymitive/karma/internal/mapper/v017/client/general" "github.com/prymitive/karma/internal/mapper/v017/client/silence" + ammodels "github.com/prymitive/karma/internal/mapper/v017/models" "github.com/prymitive/karma/internal/models" ) @@ -119,3 +120,13 @@ func status(c *client.Alertmanager, timeout time.Duration) (models.AlertmanagerS return ret, nil } + +func rewriteSilenceUsername(body []byte, username string) ([]byte, error) { + s := ammodels.PostableSilence{} + err := s.UnmarshalBinary(body) + if err != nil { + return nil, err + } + s.CreatedBy = &username + return s.MarshalBinary() +} diff --git a/internal/mapper/v017/silences.go b/internal/mapper/v017/silences.go index 4482d0fc2..1ffba7640 100644 --- a/internal/mapper/v017/silences.go +++ b/internal/mapper/v017/silences.go @@ -27,3 +27,7 @@ func (m SilenceMapper) Collect(uri string, headers map[string]string, timeout ti c := newClient(uri, headers, httpTransport) return silences(c, timeout) } + +func (m SilenceMapper) RewriteUsername(body []byte, username string) ([]byte, error) { + return rewriteSilenceUsername(body, username) +}