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.
This commit is contained in:
Łukasz Mierzwa
2018-01-03 16:17:58 -08:00
parent 4667b9e34c
commit b6dd993c2b
11 changed files with 230 additions and 16 deletions
+9 -1
View File
@@ -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:
+4 -2
View File
@@ -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]+
+1 -1
View File
@@ -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)
}
}
+22 -1
View File
@@ -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,
+10 -9
View File
@@ -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,
+1
View File
@@ -53,6 +53,7 @@ func testReadConfig(t *testing.T) {
- name: default
uri: http://localhost
timeout: 40s
proxy: false
annotations:
default:
hidden: true
+1
View File
@@ -6,6 +6,7 @@ type alertmanagerConfig struct {
Name string
URI string
Timeout time.Duration
Proxy bool
}
type jiraRule struct {
+1 -1
View File
@@ -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)
+4 -1
View File
@@ -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)
+56
View File
@@ -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
}
+121
View File
@@ -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)
}
}
}