From b6dd993c2bfa39c81aaa8391286879ab9d3e7095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 6 Dec 2017 22:46:21 -0800 Subject: [PATCH] Add support for proxying user connection to Alertmanager Fixes #190. With this feature unsee can be configured to proxy requests to selected Alertmanager instances, if it's enabled unsee silence form will send a request via unsee rather than directly. This allows users to manage silences in environments where they have access to unsee but not to Alertmanager. Only silences endpoints on Alertmanager API are proxied. --- docs/CONFIGURATION.md | 10 ++- docs/example.yaml | 6 +- internal/alertmanager/dedup_test.go | 2 +- internal/alertmanager/models.go | 23 +++++- internal/alertmanager/upstream.go | 19 ++--- internal/config/config_test.go | 1 + internal/config/models.go | 1 + internal/filters/filter_test.go | 2 +- main.go | 5 +- proxy.go | 56 +++++++++++++ proxy_test.go | 121 ++++++++++++++++++++++++++++ 11 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 proxy.go create mode 100644 proxy_test.go diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 30c9a45ed..da6912a00 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -49,6 +49,7 @@ alertmanager: - name: string uri: string timeout: duration + proxy: bool ``` * `interval` - how often alerts should be refreshed, a string in @@ -70,8 +71,12 @@ alertmanager: of unsee with `make run`. * `timeout` - timeout for requests send to this Alertmanager server, a string in [time.Duration](https://golang.org/pkg/time/#ParseDuration) format. +* `proxy` - if enabled requests from user browsers to this Alertmanager will be + proxied via unsee. This applies to requests made when managing + silences via unsee (creating or expiring silences). -Example: +Example with two production Alertmanager instances running in HA mode and a +staging instance that is also proxied: ```yaml alertmanager: @@ -80,12 +85,15 @@ alertmanager: - name: production1 uri: https://alertmanager1.prod.example.com timeout: 20s + proxy: false - name: production2 uri: https://alertmanager2.prod.example.com timeout: 20s + proxy: false - name: staging uri: https://alertmanager.staging.example.com timeout: 30s + proxy: true ``` Defaults: diff --git a/docs/example.yaml b/docs/example.yaml index 01b19dc45..043791adb 100644 --- a/docs/example.yaml +++ b/docs/example.yaml @@ -1,9 +1,10 @@ alertmanager: interval: 60s servers: - - name: mock - uri: file://internal/mock/0.11.0 + - name: local + uri: http://localhost:9093 timeout: 10s + proxy: true annotations: default: hidden: false @@ -29,6 +30,7 @@ listen: port: 8080 prefix: / log: + config: false level: info jira: - regex: DEVOPS-[0-9]+ diff --git a/internal/alertmanager/dedup_test.go b/internal/alertmanager/dedup_test.go index 039f9ec9e..f102660c2 100644 --- a/internal/alertmanager/dedup_test.go +++ b/internal/alertmanager/dedup_test.go @@ -16,7 +16,7 @@ import ( func init() { log.SetLevel(log.ErrorLevel) for i, uri := range mock.ListAllMockURIs() { - alertmanager.NewAlertmanager(fmt.Sprintf("dedup-mock-%d", i), uri, time.Second) + alertmanager.NewAlertmanager(fmt.Sprintf("dedup-mock-%d", i), uri, time.Second, false) } } diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go index 344eb0e19..809ba0edc 100644 --- a/internal/alertmanager/models.go +++ b/internal/alertmanager/models.go @@ -2,10 +2,13 @@ package alertmanager import ( "fmt" + "path" "sort" + "strings" "sync" "time" + "github.com/cloudflare/unsee/internal/config" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" "github.com/cloudflare/unsee/internal/transform" @@ -29,6 +32,8 @@ type Alertmanager struct { URI string `json:"uri"` Timeout time.Duration `json:"timeout"` Name string `json:"name"` + // whenever this instance should be proxied + ProxyRequests bool // lock protects data access while updating lock sync.RWMutex // fields for storing pulled data @@ -107,6 +112,22 @@ func (am *Alertmanager) pullSilences(version string) error { return nil } +// this is the URI of this Alertmanager we put in JSON reponse +// it's either real full URI or a proxy relative URI +func (am *Alertmanager) publicURI() string { + if am.ProxyRequests { + sub := fmt.Sprintf("/proxy/alertmanager/%s", am.Name) + uri := path.Join(config.Config.Listen.Prefix, sub) + if strings.HasSuffix(sub, "/") { + // if sub path had trailing slash then add it here, since path.Join will + // skip it + return uri + "/" + } + return uri + } + return am.URI +} + func (am *Alertmanager) pullAlerts(version string) error { mapper, err := mapper.GetAlertMapper(version) if err != nil { @@ -163,7 +184,7 @@ func (am *Alertmanager) pullAlerts(version string) error { alert.Alertmanager = []models.AlertmanagerInstance{ models.AlertmanagerInstance{ Name: am.Name, - URI: am.URI, + URI: am.publicURI(), State: alert.State, StartsAt: alert.StartsAt, EndsAt: alert.EndsAt, diff --git a/internal/alertmanager/upstream.go b/internal/alertmanager/upstream.go index 0423d1d2d..e6b025772 100644 --- a/internal/alertmanager/upstream.go +++ b/internal/alertmanager/upstream.go @@ -15,7 +15,7 @@ var ( ) // NewAlertmanager creates a new Alertmanager instance -func NewAlertmanager(name, uri string, timeout time.Duration) error { +func NewAlertmanager(name, uri string, timeout time.Duration, proxyRequests bool) error { if _, found := upstreams[name]; found { return fmt.Errorf("Alertmanager upstream '%s' already exist", name) } @@ -27,14 +27,15 @@ func NewAlertmanager(name, uri string, timeout time.Duration) error { } upstreams[name] = &Alertmanager{ - URI: uri, - Timeout: timeout, - Name: name, - lock: sync.RWMutex{}, - alertGroups: []models.AlertGroup{}, - silences: map[string]models.Silence{}, - colors: models.LabelsColorMap{}, - autocomplete: []models.Autocomplete{}, + URI: uri, + Timeout: timeout, + Name: name, + ProxyRequests: proxyRequests, + lock: sync.RWMutex{}, + alertGroups: []models.AlertGroup{}, + silences: map[string]models.Silence{}, + colors: models.LabelsColorMap{}, + autocomplete: []models.Autocomplete{}, metrics: alertmanagerMetrics{ errors: map[string]float64{ labelValueErrorsAlerts: 0, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b0608c636..ffdafe7a9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -53,6 +53,7 @@ func testReadConfig(t *testing.T) { - name: default uri: http://localhost timeout: 40s + proxy: false annotations: default: hidden: true diff --git a/internal/config/models.go b/internal/config/models.go index aeaab158a..cee46e9bc 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -6,6 +6,7 @@ type alertmanagerConfig struct { Name string URI string Timeout time.Duration + Proxy bool } type jiraRule struct { diff --git a/internal/filters/filter_test.go b/internal/filters/filter_test.go index 9f39d8225..819608218 100644 --- a/internal/filters/filter_test.go +++ b/internal/filters/filter_test.go @@ -485,7 +485,7 @@ var tests = []filterTest{ func TestFilters(t *testing.T) { log.SetLevel(log.ErrorLevel) - err := alertmanager.NewAlertmanager("test", "http://localhost", time.Second) + err := alertmanager.NewAlertmanager("test", "http://localhost", time.Second, false) am := alertmanager.GetAlertmanagerByName("test") if err != nil { t.Error(err) diff --git a/main.go b/main.go index 20e31e5eb..056e57ffe 100644 --- a/main.go +++ b/main.go @@ -60,7 +60,7 @@ func setupRouter(router *gin.Engine) { func setupUpstreams() { for _, s := range config.Config.Alertmanager.Servers { - err := alertmanager.NewAlertmanager(s.Name, s.URI, s.Timeout) + err := alertmanager.NewAlertmanager(s.Name, s.URI, s.Timeout, s.Proxy) if err != nil { log.Fatalf("Failed to configure Alertmanager '%s' with URI '%s': %s", s.Name, s.URI, err) } @@ -151,6 +151,9 @@ func main() { } setupRouter(router) + for _, am := range alertmanager.GetAlertmanagers() { + setupRouterProxyHandlers(router, am) + } listen := fmt.Sprintf("%s:%d", config.Config.Listen.Address, config.Config.Listen.Port) log.Infof("Listening on %s", listen) err := router.Run(listen) diff --git a/proxy.go b/proxy.go new file mode 100644 index 000000000..bc56adf80 --- /dev/null +++ b/proxy.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/cloudflare/unsee/internal/alertmanager" + "github.com/cloudflare/unsee/internal/config" + "github.com/gin-gonic/gin" + + log "github.com/sirupsen/logrus" +) + +func proxyPathPrefix(name string) string { + return fmt.Sprintf("%sproxy/alertmanager/%s", config.Config.Listen.Prefix, name) +} + +// NewAlertmanagerProxy creates a proxy instance for given alertmanager instance +func NewAlertmanagerProxy(alertmanager *alertmanager.Alertmanager) (*httputil.ReverseProxy, error) { + upstreamURL, err := url.Parse(alertmanager.URI) + if err != nil { + return nil, err + } + proxy := httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = upstreamURL.Scheme + req.URL.Host = upstreamURL.Host + req.URL.Path = strings.TrimPrefix(req.URL.Path, proxyPathPrefix(alertmanager.Name)) + // drop Accept-Encoding header so we always get uncompressed reponses from + // upstream, there's a gzip middleware that's global so we don't want it + // to gzip twice + req.Header.Del("Accept-Encoding") + log.Debugf("[%s] Proxy request for %s", alertmanager.Name, req.URL.Path) + }, + ModifyResponse: func(resp *http.Response) error { + // drop Content-Length header from upstream responses, gzip middleware + // will compress those and that could cause a mismatch + resp.Header.Del("Content-Length") + return nil + }, + } + return &proxy, nil +} + +func setupRouterProxyHandlers(router *gin.Engine, alertmanager *alertmanager.Alertmanager) error { + proxy, err := NewAlertmanagerProxy(alertmanager) + if err != nil { + return err + } + router.POST(fmt.Sprintf("%s/api/v1/silences", proxyPathPrefix(alertmanager.Name)), gin.WrapH(proxy)) + router.DELETE(fmt.Sprintf("%s/api/v1/silence/*id", proxyPathPrefix(alertmanager.Name)), gin.WrapH(proxy)) + return nil +} diff --git a/proxy_test.go b/proxy_test.go new file mode 100644 index 000000000..e40c97b0c --- /dev/null +++ b/proxy_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cloudflare/unsee/internal/alertmanager" + + httpmock "gopkg.in/jarcoal/httpmock.v1" +) + +// httptest.NewRecorder() doesn't implement http.CloseNotifier +type closeNotifyingRecorder struct { + *httptest.ResponseRecorder + closed chan bool +} + +func newCloseNotifyingRecorder() *closeNotifyingRecorder { + return &closeNotifyingRecorder{ + httptest.NewRecorder(), + make(chan bool, 1), + } +} + +func (c *closeNotifyingRecorder) close() { + c.closed <- true +} + +func (c *closeNotifyingRecorder) CloseNotify() <-chan bool { + return c.closed +} + +type proxyTest struct { + method string + localPath string + upstreamURI string + code int + response string +} + +var proxyTests = []proxyTest{ + // valid alertmanager and methods + proxyTest{ + method: "POST", + localPath: "/proxy/alertmanager/dummy/api/v1/silences", + upstreamURI: "http://localhost:9093/api/v1/silences", + code: 200, + response: "{\"status\":\"success\",\"data\":{\"silenceId\":\"d8a61ca8-ee2e-4076-999f-276f1e986bf3\"}}", + }, + proxyTest{ + method: "DELETE", + localPath: "/proxy/alertmanager/dummy/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3", + upstreamURI: "http://localhost:9093/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3", + code: 200, + response: "{\"status\":\"success\"}", + }, + // invalid alertmanager name + proxyTest{ + method: "POST", + localPath: "/proxy/alertmanager/INVALID/api/v1/silences", + upstreamURI: "", + code: 404, + response: "404 page not found", + }, + proxyTest{ + method: "DELETE", + localPath: "/proxy/alertmanager/INVALID/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3", + upstreamURI: "http://localhost:9093/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3", + code: 404, + response: "404 page not found", + }, + // valid alertmanager name, but invalid method + proxyTest{ + method: "GET", + localPath: "/proxy/alertmanager/dummy/api/v1/silences", + upstreamURI: "", + code: 404, + response: "404 page not found", + }, + proxyTest{ + method: "GET", + localPath: "/proxy/alertmanager/dummy/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3", + upstreamURI: "http://localhost:9093/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3", + code: 404, + response: "404 page not found", + }, +} + +func TestProxy(t *testing.T) { + r := ginTestEngine() + setupRouterProxyHandlers(r, &alertmanager.Alertmanager{ + URI: "http://localhost:9093", + Timeout: time.Second * 5, + Name: "dummy", + ProxyRequests: true, + }) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + for _, testCase := range proxyTests { + httpmock.Reset() + if testCase.upstreamURI != "" { + httpmock.RegisterResponder(testCase.method, testCase.upstreamURI, httpmock.NewStringResponder(testCase.code, testCase.response)) + } + req, _ := http.NewRequest(testCase.method, testCase.localPath, nil) + resp := newCloseNotifyingRecorder() + r.ServeHTTP(resp, req) + if resp.Code != testCase.code { + t.Errorf("%s %s proxied to %s returned status %d while %d was expected", + testCase.method, testCase.localPath, testCase.upstreamURI, resp.Code, testCase.code) + } + body := resp.Body.String() + if body != testCase.response { + t.Errorf("%s %s proxied to %s returned content '%s' while '%s' was expected", + testCase.method, testCase.localPath, testCase.upstreamURI, body, testCase.response) + } + } +}