mirror of
https://github.com/prymitive/karma
synced 2026-08-28 11:17:28 +00:00
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.
This commit is contained in:
+13
-12
@@ -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)
|
||||
}
|
||||
|
||||
+50
-5
@@ -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
|
||||
|
||||
+236
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user