Merge pull request #1468 from prymitive/proxy-rewrite-username

feat(backend): use username from credentials for silences
This commit is contained in:
Łukasz Mierzwa
2020-02-26 12:09:54 +00:00
committed by GitHub
6 changed files with 315 additions and 21 deletions

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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()
}

View File

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