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) +}