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