diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ea69cca9c..55e39ab93 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -44,6 +44,10 @@ alertmanager: uri: string timeout: duration proxy: bool + tls: + ca: string + cert: string + key: string ``` * `interval` - how often alerts should be refreshed, a string in @@ -66,8 +70,20 @@ alertmanager: * `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). + proxied via unsee. This applies to requests made when managing silences via + unsee (creating or expiring silences). +* `tls:ca` - path to CA certificate used to establish TLS connection to this + Alertmanager instance (for URIs using `https://` scheme). If unset or empty + string is set then Go will try to find system CA certificates using well known + paths. +* `tls:cert` - path to a TLS client certificate file to use when establishing + TLS connections to this Alertmanager instance if it requires a TLS client + authentication. + Note that this option requires `tls:key` to be also set. +* `tls:key` - path to a TLS client key file to use when establishing + TLS connections to this Alertmanager instance if it requires a TLS client + authentication. + Note that this option requires `tls:cert` to be also set. Example with two production Alertmanager instances running in HA mode and a staging instance that is also proxied: @@ -88,6 +104,14 @@ alertmanager: uri: https://alertmanager.staging.example.com timeout: 30s proxy: true + tls: + ca: /etc/ssl/staging-ca.crt + - name: protected + uri: https://alertmanager-auth.prod.example.com + timeout: 20s + tls: + cert: /etc/ssl/client.pem + key: /etc/ssl/client.key ``` Defaults: diff --git a/docs/example.yaml b/docs/example.yaml index 043791adb..230fa9891 100644 --- a/docs/example.yaml +++ b/docs/example.yaml @@ -5,6 +5,13 @@ alertmanager: uri: http://localhost:9093 timeout: 10s proxy: true + - name: client-auth + uri: https://localhost:9093 + timeout: 10s + tls: + ca: /etc/ssl/certs/ca-bundle.crt + cert: /etc/unsee/client.pem + key: /etc/unsee/client.key annotations: default: hidden: false diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go index 9bf41f6a6..7f3582cf0 100644 --- a/internal/alertmanager/models.go +++ b/internal/alertmanager/models.go @@ -3,6 +3,7 @@ package alertmanager import ( "encoding/json" "fmt" + "net/http" "path" "sort" "strings" @@ -13,7 +14,7 @@ import ( "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" "github.com/cloudflare/unsee/internal/transform" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" log "github.com/sirupsen/logrus" ) @@ -34,9 +35,12 @@ type Alertmanager struct { RequestTimeout time.Duration `json:"timeout"` Name string `json:"name"` // whenever this instance should be proxied - ProxyRequests bool - // transport instances are specific to URI scheme we collect from - transport transport.Transport + ProxyRequests bool `json:"proxyRequests"` + // reader instances are specific to URI scheme we collect from + reader uri.Reader + // implements how we fetch requests from the Alertmanager, we don't set it + // by default so it's nil and http.DefaultTransport is used + HTTPTransport http.RoundTripper `json:"-"` // lock protects data access while updating lock sync.RWMutex // fields for storing pulled data @@ -53,20 +57,21 @@ func (am *Alertmanager) detectVersion() string { // if everything fails assume Alertmanager is at latest possible version defaultVersion := "999.0.0" - url, err := transport.JoinURL(am.URI, "api/v1/status") + url, err := uri.JoinURL(am.URI, "api/v1/status") if err != nil { log.Errorf("Failed to join url '%s' and path 'api/v1/status': %s", am.URI, err) return defaultVersion } + ver := alertmanagerVersion{} // read raw body from the source - source, err := am.transport.Read(url) - defer source.Close() + source, err := am.reader.Read(url) if err != nil { log.Errorf("[%s] %s request failed: %s", am.Name, url, err) return defaultVersion } + defer source.Close() // decode body as JSON err = json.NewDecoder(source).Decode(&ver) @@ -113,12 +118,12 @@ func (am *Alertmanager) pullSilences(version string) error { start := time.Now() // read raw body from the source - source, err := am.transport.Read(url) - defer source.Close() + source, err := am.reader.Read(url) if err != nil { log.Errorf("[%s] %s request failed: %s", am.Name, url, err) return err } + defer source.Close() // decode body text silences, err := mapper.Decode(source) @@ -172,12 +177,12 @@ func (am *Alertmanager) pullAlerts(version string) error { start := time.Now() // read raw body from the source - source, err := am.transport.Read(url) - defer source.Close() + source, err := am.reader.Read(url) if err != nil { log.Errorf("[%s] %s request failed: %s", am.Name, url, err) return err } + defer source.Close() // decode body text groups, err := mapper.Decode(source) diff --git a/internal/alertmanager/tls.go b/internal/alertmanager/tls.go new file mode 100644 index 000000000..a3cdf080f --- /dev/null +++ b/internal/alertmanager/tls.go @@ -0,0 +1,55 @@ +package alertmanager + +import ( + "crypto/tls" + "crypto/x509" + "io/ioutil" + "net/http" + + log "github.com/sirupsen/logrus" +) + +func configureTLSRootCAs(tlsConfig *tls.Config, caPath string) error { + log.Debugf("Loading TLS CA cert '%s'", caPath) + caCert, err := ioutil.ReadFile(caPath) + if err != nil { + return err + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConfig.RootCAs = caCertPool + return nil +} + +func configureTLSClientCert(tlsConfig *tls.Config, certPath, keyPath string) error { + log.Debugf("Loading TLS cert '%s' and key '%s'", certPath, keyPath) + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + log.Debugf("Failed to load TLS cert and key: %s", err) + return err + } + tlsConfig.Certificates = []tls.Certificate{cert} + tlsConfig.BuildNameToCertificate() + return nil +} + +func NewHTTTPTransport(caPath, certPath, keyPath string) (http.RoundTripper, error) { + tlsConfig := &tls.Config{} + + if caPath != "" { + err := configureTLSRootCAs(tlsConfig, caPath) + if err != nil { + return nil, err + } + } + + if certPath != "" { + err := configureTLSClientCert(tlsConfig, certPath, keyPath) + if err != nil { + return nil, err + } + } + + transport := http.Transport{TLSClientConfig: tlsConfig} + return &transport, nil +} diff --git a/internal/alertmanager/upstream.go b/internal/alertmanager/upstream.go index a67018811..1da5b0def 100644 --- a/internal/alertmanager/upstream.go +++ b/internal/alertmanager/upstream.go @@ -2,26 +2,27 @@ package alertmanager import ( "fmt" + "net/http" "sync" "time" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" log "github.com/sirupsen/logrus" ) // Option allows to pass functional options to NewAlertmanager() -type Option func(am *Alertmanager) +type Option func(am *Alertmanager) error var ( upstreams = map[string]*Alertmanager{} ) // NewAlertmanager creates a new Alertmanager instance -func NewAlertmanager(name, uri string, opts ...Option) (*Alertmanager, error) { +func NewAlertmanager(name, upstreamURI string, opts ...Option) (*Alertmanager, error) { am := &Alertmanager{ - URI: uri, + URI: upstreamURI, RequestTimeout: time.Second * 10, Name: name, lock: sync.RWMutex{}, @@ -38,11 +39,14 @@ func NewAlertmanager(name, uri string, opts ...Option) (*Alertmanager, error) { } for _, opt := range opts { - opt(am) + err := opt(am) + if err != nil { + return nil, err + } } var err error - am.transport, err = transport.NewTransport(am.URI, am.RequestTimeout) + am.reader, err = uri.NewReader(am.URI, am.RequestTimeout, am.HTTPTransport) if err != nil { return am, err } @@ -89,15 +93,26 @@ func GetAlertmanagerByName(name string) *Alertmanager { // WithProxy option can be passed to NewAlertmanager in order to enable request // proxying for unsee clients func WithProxy(proxied bool) Option { - return func(am *Alertmanager) { + return func(am *Alertmanager) error { am.ProxyRequests = proxied + return nil } } // WithRequestTimeout option can be passed to NewAlertmanager in order to set // a custom timeout for Alertmanager upstream requests func WithRequestTimeout(timeout time.Duration) Option { - return func(am *Alertmanager) { + return func(am *Alertmanager) error { am.RequestTimeout = timeout + return nil + } +} + +// WithHTTPTransport option can be passed to NewAlertmanager in order to set +// a custom HTTP transport (http.RoundTripper implementation) +func WithHTTPTransport(httpTransport http.RoundTripper) Option { + return func(am *Alertmanager) error { + am.HTTPTransport = httpTransport + return nil } } diff --git a/internal/alertmanager/version.go b/internal/alertmanager/version.go index 0fd021d68..b001493e0 100644 --- a/internal/alertmanager/version.go +++ b/internal/alertmanager/version.go @@ -1,14 +1,5 @@ package alertmanager -import ( - "encoding/json" - "time" - - "github.com/cloudflare/unsee/internal/transport" - - log "github.com/sirupsen/logrus" -) - // AlertmanagerVersion is what api/v1/status returns, we only use it to check // version, so we skip all other keys (except for status) type alertmanagerVersion struct { @@ -19,42 +10,3 @@ type alertmanagerVersion struct { } `json:"versionInfo"` } `json:"data"` } - -// GetVersion returns version information of the remote Alertmanager endpoint -func GetVersion(uri string, timeout time.Duration) string { - // if everything fails assume Alertmanager is at latest possible version - defaultVersion := "999.0.0" - - url, err := transport.JoinURL(uri, "api/v1/status") - if err != nil { - log.Errorf("Failed to join url '%s' and path 'api/v1/status': %s", uri, err.Error()) - return defaultVersion - } - ver := alertmanagerVersion{} - - t, err := transport.NewTransport(uri, timeout) - if err != nil { - log.Errorf("Unable to get the version information from %s", url) - return defaultVersion - } - - source, err := t.Read(url) - err = json.NewDecoder(source).Decode(&ver) - if err != nil { - log.Errorf("%s request failed: %s", url, err.Error()) - return defaultVersion - } - - if ver.Status != "success" { - log.Errorf("Request to %s returned status %s", url, ver.Status) - return defaultVersion - } - - if ver.Data.VersionInfo.Version == "" { - log.Error("No version information in Alertmanager API") - return defaultVersion - } - - log.Infof("Remote Alertmanager version: %s", ver.Data.VersionInfo.Version) - return ver.Data.VersionInfo.Version -} diff --git a/internal/config/config.go b/internal/config/config.go index ec19231b9..268b3e7c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -173,6 +173,7 @@ func (config *configSchema) LogValues() { Name: s.Name, URI: hideURLPassword(s.URI), Timeout: s.Timeout, + TLS: s.TLS, } servers = append(servers, server) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32e06c37b..dc167385e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,6 +59,10 @@ func testReadConfig(t *testing.T) { uri: http://localhost timeout: 40s proxy: false + tls: + ca: "" + cert: "" + key: "" annotations: default: hidden: true diff --git a/internal/config/models.go b/internal/config/models.go index cee46e9bc..32d663af1 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -7,6 +7,11 @@ type alertmanagerConfig struct { URI string Timeout time.Duration Proxy bool + TLS struct { + CA string + Cert string + Key string + } } type jiraRule struct { diff --git a/internal/mapper/v04/alerts.go b/internal/mapper/v04/alerts.go index becbffa37..efb09d62f 100644 --- a/internal/mapper/v04/alerts.go +++ b/internal/mapper/v04/alerts.go @@ -15,7 +15,7 @@ import ( "github.com/blang/semver" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) type alert struct { @@ -56,7 +56,7 @@ type AlertMapper struct { // AbsoluteURL for alerts API endpoint this mapper supports func (m AlertMapper) AbsoluteURL(baseURI string) (string, error) { - return transport.JoinURL(baseURI, "api/v1/alerts/groups") + return uri.JoinURL(baseURI, "api/v1/alerts/groups") } // IsSupported returns true if given version string is supported diff --git a/internal/mapper/v04/silences.go b/internal/mapper/v04/silences.go index 0362ba4cf..1f8489e7c 100644 --- a/internal/mapper/v04/silences.go +++ b/internal/mapper/v04/silences.go @@ -14,7 +14,7 @@ import ( "github.com/blang/semver" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) // Alertmanager 0.4 silence format @@ -49,7 +49,7 @@ type SilenceMapper struct { // AbsoluteURL for silences API endpoint this mapper supports func (m SilenceMapper) AbsoluteURL(baseURI string) (string, error) { - return transport.JoinURL(baseURI, "api/v1/silences") + return uri.JoinURL(baseURI, "api/v1/silences") } // IsSupported returns true if given version string is supported diff --git a/internal/mapper/v05/alerts.go b/internal/mapper/v05/alerts.go index efc1b1f06..20283cff4 100644 --- a/internal/mapper/v05/alerts.go +++ b/internal/mapper/v05/alerts.go @@ -14,7 +14,7 @@ import ( "github.com/blang/semver" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) type alert struct { @@ -55,7 +55,7 @@ type AlertMapper struct { // AbsoluteURL for alerts API endpoint this mapper supports func (m AlertMapper) AbsoluteURL(baseURI string) (string, error) { - return transport.JoinURL(baseURI, "api/v1/alerts/groups") + return uri.JoinURL(baseURI, "api/v1/alerts/groups") } // IsSupported returns true if given version string is supported diff --git a/internal/mapper/v05/silences.go b/internal/mapper/v05/silences.go index eeac798d8..0740df418 100644 --- a/internal/mapper/v05/silences.go +++ b/internal/mapper/v05/silences.go @@ -13,7 +13,7 @@ import ( "github.com/blang/semver" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) type silence struct { @@ -43,7 +43,7 @@ type SilenceMapper struct { // AbsoluteURL for silences API endpoint this mapper supports func (m SilenceMapper) AbsoluteURL(baseURI string) (string, error) { - return transport.JoinURL(baseURI, "api/v1/silences") + return uri.JoinURL(baseURI, "api/v1/silences") } // IsSupported returns true if given version string is supported diff --git a/internal/mapper/v061/alerts.go b/internal/mapper/v061/alerts.go index 799759603..c2ff0fa18 100644 --- a/internal/mapper/v061/alerts.go +++ b/internal/mapper/v061/alerts.go @@ -15,7 +15,7 @@ import ( "github.com/blang/semver" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) type alert struct { @@ -57,7 +57,7 @@ type AlertMapper struct { // AbsoluteURL for alerts API endpoint this mapper supports func (m AlertMapper) AbsoluteURL(baseURI string) (string, error) { - return transport.JoinURL(baseURI, "api/v1/alerts/groups") + return uri.JoinURL(baseURI, "api/v1/alerts/groups") } // IsSupported returns true if given version string is supported diff --git a/internal/mapper/v062/alerts.go b/internal/mapper/v062/alerts.go index 4d9ba687a..b72f2f5ae 100644 --- a/internal/mapper/v062/alerts.go +++ b/internal/mapper/v062/alerts.go @@ -15,7 +15,7 @@ import ( "github.com/blang/semver" "github.com/cloudflare/unsee/internal/mapper" "github.com/cloudflare/unsee/internal/models" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) type alertStatus struct { @@ -61,7 +61,7 @@ type AlertMapper struct { // AbsoluteURL for alerts API endpoint this mapper supports func (m AlertMapper) AbsoluteURL(baseURI string) (string, error) { - return transport.JoinURL(baseURI, "api/v1/alerts/groups") + return uri.JoinURL(baseURI, "api/v1/alerts/groups") } // IsSupported returns true if given version string is supported diff --git a/internal/transport/transport.go b/internal/transport/transport.go deleted file mode 100644 index e57d4aa55..000000000 --- a/internal/transport/transport.go +++ /dev/null @@ -1,32 +0,0 @@ -package transport - -import ( - "fmt" - "io" - "net/http" - "net/url" - "time" -) - -// Transport reads from a specific URI schema -type Transport interface { - Read(string) (io.ReadCloser, error) -} - -// NewTransport creates an instance of Transport that can handle URI schema -// for the passed uri string -func NewTransport(uri string, timeout time.Duration) (Transport, error) { - u, err := url.Parse(uri) - if err != nil { - return nil, err - } - - switch u.Scheme { - case "http", "https": - return &HTTPTransport{client: http.Client{Timeout: timeout}}, nil - case "file": - return &FileTransport{}, nil - default: - return nil, fmt.Errorf("Unsupported URI scheme '%s' in '%s'", u.Scheme, u) - } -} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go deleted file mode 100644 index 981673f1a..000000000 --- a/internal/transport/transport_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package transport_test - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/cloudflare/unsee/internal/mock" - "github.com/cloudflare/unsee/internal/transport" - - log "github.com/sirupsen/logrus" - httpmock "gopkg.in/jarcoal/httpmock.v1" -) - -type transportTest struct { - uri string - timeout time.Duration - failed bool -} - -var transportTests = []transportTest{ - transportTest{ - uri: "http://localhost/status", - }, - transportTest{ - uri: "http://localhost/404", - failed: true, - }, - transportTest{ - uri: "http://localhost/invalid", - failed: true, - }, - transportTest{ - uri: "https://localhost/status", - }, - transportTest{ - uri: "https://localhost/404", - failed: true, - }, - transportTest{ - uri: "https://localhost/invalid", - failed: true, - }, - transportTest{ - uri: fmt.Sprintf("file://%s", mock.GetAbsoluteMockPath("status", mock.ListAllMocks()[0])), - }, - transportTest{ - uri: "file:///non-existing-file.abcdef", - failed: true, - }, - transportTest{ - uri: "file://transport.go", - failed: true, - }, -} - -type mockStatus struct { - status string - integer int - yes bool - no bool -} - -func TestTransport(t *testing.T) { - log.SetLevel(log.FatalLevel) - httpmock.Activate() - defer httpmock.DeactivateAndReset() - mockJSON := `{ - "response": "success", - "integer": 123, - "yes": true, - "no": false - }` - httpmock.RegisterResponder("GET", "http://localhost/status", httpmock.NewStringResponder(200, mockJSON)) - httpmock.RegisterResponder("GET", "http://localhost/404", httpmock.NewStringResponder(404, "404")) - httpmock.RegisterResponder("GET", "http://localhost/invalid", httpmock.NewStringResponder(200, "bad json}{}")) - httpmock.RegisterResponder("GET", "https://localhost/status", httpmock.NewStringResponder(200, mockJSON)) - httpmock.RegisterResponder("GET", "https://localhost/404", httpmock.NewStringResponder(404, "404")) - httpmock.RegisterResponder("GET", "https://localhost/invalid", httpmock.NewStringResponder(200, "bad json}{}")) - - for _, testCase := range transportTests { - tr, err := transport.NewTransport(testCase.uri, testCase.timeout) - if err != nil { - t.Error(err) - } - - source, err := tr.Read(testCase.uri) - if err != nil { - if !testCase.failed { - t.Errorf("[%s] transport Read() failed with: %s", testCase.uri, err) - } - continue - } - - r := mockStatus{} - err = json.NewDecoder(source).Decode(&r) - source.Close() - - if (err != nil) != testCase.failed { - t.Errorf("[%s] Expected failure: %v, Read() failed: %v, error: %s", testCase.uri, testCase.failed, (err != nil), err) - } - } -} diff --git a/internal/transport/file.go b/internal/uri/file.go similarity index 73% rename from internal/transport/file.go rename to internal/uri/file.go index 6850be4dc..aef474773 100644 --- a/internal/transport/file.go +++ b/internal/uri/file.go @@ -1,4 +1,4 @@ -package transport +package uri import ( "io" @@ -22,11 +22,11 @@ func (fr *fileReader) Close() error { return fr.fd.Close() } -// FileTransport can read data from file:// URIs -type FileTransport struct { +// FileURIReader can read data from file:// URIs +type FileURIReader struct { } -func (t *FileTransport) pathFromURI(uri string) (string, error) { +func (r *FileURIReader) pathFromURI(uri string) (string, error) { u, err := url.Parse(uri) if err != nil { return "", err @@ -45,14 +45,17 @@ func (t *FileTransport) pathFromURI(uri string) (string, error) { return absolutePath, nil } -func (t *FileTransport) Read(uri string) (io.ReadCloser, error) { - filename, err := t.pathFromURI(uri) +func (r *FileURIReader) Read(uri string) (io.ReadCloser, error) { + filename, err := r.pathFromURI(uri) if err != nil { return nil, err } log.Infof("Reading file '%s'", filename) fd, err := os.Open(filename) + if err != nil { + return nil, err + } fr := fileReader{fd: fd} - return &fr, err + return &fr, nil } diff --git a/internal/transport/http.go b/internal/uri/http.go similarity index 72% rename from internal/transport/http.go rename to internal/uri/http.go index a30fa7fca..a4ced4fc0 100644 --- a/internal/transport/http.go +++ b/internal/uri/http.go @@ -1,4 +1,4 @@ -package transport +package uri import ( "compress/gzip" @@ -9,13 +9,13 @@ import ( log "github.com/sirupsen/logrus" ) -// HTTPTransport can read data from http:// and https:// URIs -type HTTPTransport struct { +// HTTPURIReader can read data from http:// and https:// URIs +type HTTPURIReader struct { client http.Client } -func (t *HTTPTransport) Read(uri string) (io.ReadCloser, error) { - log.Infof("GET %s timeout=%s", uri, t.client.Timeout) +func (r *HTTPURIReader) Read(uri string) (io.ReadCloser, error) { + log.Infof("GET %s timeout=%s", uri, r.client.Timeout) request, err := http.NewRequest("GET", uri, nil) if err != nil { @@ -23,7 +23,7 @@ func (t *HTTPTransport) Read(uri string) (io.ReadCloser, error) { } request.Header.Add("Accept-Encoding", "gzip") - resp, err := t.client.Do(request) + resp, err := r.client.Do(request) if err != nil { return nil, err } diff --git a/internal/uri/uri.go b/internal/uri/uri.go new file mode 100644 index 000000000..1c0217e6d --- /dev/null +++ b/internal/uri/uri.go @@ -0,0 +1,36 @@ +package uri + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Reader reads from a specific URI schema +type Reader interface { + Read(string) (io.ReadCloser, error) +} + +// NewReader creates an instance of URIReader that can handle URI schema +// for the passed uri string +func NewReader(uri string, timeout time.Duration, clientTransport http.RoundTripper) (Reader, error) { + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + switch u.Scheme { + case "http", "https": + client := http.Client{ + Timeout: timeout, + Transport: clientTransport, + } + return &HTTPURIReader{client: client}, nil + case "file": + return &FileURIReader{}, nil + default: + return nil, fmt.Errorf("Unsupported URI scheme '%s' in '%s'", u.Scheme, u) + } +} diff --git a/internal/uri/uri_test.go b/internal/uri/uri_test.go new file mode 100644 index 000000000..0e8dfea7e --- /dev/null +++ b/internal/uri/uri_test.go @@ -0,0 +1,163 @@ +package uri_test + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/cloudflare/unsee/internal/mock" + "github.com/cloudflare/unsee/internal/uri" + + log "github.com/sirupsen/logrus" +) + +func getFileSize(path string) int64 { + file, err := os.Open(path) + if err != nil { + log.Fatal(err) + } + fi, err := file.Stat() + if err != nil { + log.Fatal(err) + } + return fi.Size() +} + +type httpTransportTest struct { + timeout time.Duration + useTLS bool + tlsConfig *tls.Config + failed bool +} + +var httpTransportTests = []httpTransportTest{ + { + // plain HTTP request, should work + }, + { + // just enable TLS, will use proper RootCA certs so it should work + useTLS: true, + }, + { + // use empty RootCA pool so we fail on verifying server certificate + useTLS: true, + tlsConfig: &tls.Config{RootCAs: x509.NewCertPool()}, + failed: true, + }, +} + +type fileTransportTest struct { + uri string + failed bool + timeout time.Duration + size int64 +} + +var fileTransportTests = []fileTransportTest{ + fileTransportTest{ + uri: fmt.Sprintf("file://%s", mock.GetAbsoluteMockPath("status", mock.ListAllMocks()[0])), + size: getFileSize(mock.GetAbsoluteMockPath("status", mock.ListAllMocks()[0])), + }, + fileTransportTest{ + uri: "file:///non-existing-file.abcdef", + failed: true, + }, + fileTransportTest{ + uri: "file://uri.go", + size: getFileSize("uri.go"), + failed: true, + }, +} + +func readAll(source io.ReadCloser) int64 { + var readSize int64 + b := make([]byte, 512) + for { + got, err := source.Read(b) + readSize += int64(got) + if err == io.EOF { + break + } + } + return readSize +} + +func TestHTTPReader(t *testing.T) { + log.SetLevel(log.FatalLevel) + + responseBody := "1234" + handler := func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, responseBody) + } + plainTS := httptest.NewServer(http.HandlerFunc(handler)) + defer plainTS.Close() + + tlsTS := httptest.NewTLSServer(http.HandlerFunc(handler)) + + defer tlsTS.Close() + caPool := x509.NewCertPool() + caPool.AddCert(tlsTS.Certificate()) + + for _, testCase := range httpTransportTests { + var amURI string + if testCase.useTLS { + amURI = tlsTS.URL + } else { + amURI = plainTS.URL + } + + tlsConfig := testCase.tlsConfig + if tlsConfig == nil { + tlsConfig = &tls.Config{RootCAs: caPool} + } + + transp, err := uri.NewReader(amURI, testCase.timeout, &http.Transport{TLSClientConfig: tlsConfig}) + if err != nil { + t.Errorf("[%v] failed to create new HTTP transport: %s", testCase, err) + } + + source, err := transp.Read(amURI) + if err != nil { + if !testCase.failed { + t.Errorf("[%v] unexpected failure while creating reader: %s", testCase, err) + } + continue + } + got := readAll(source) + source.Close() + + if got != int64(len(responseBody)+1) { + t.Errorf("[%v] Wrong respone size, got %d, expected %d", testCase, got, len(responseBody)) + } + } +} + +func TestFileReader(t *testing.T) { + //log.SetLevel(log.FatalLevel) + for _, testCase := range fileTransportTests { + transp, err := uri.NewReader(testCase.uri, testCase.timeout, &http.Transport{}) + if err != nil { + t.Errorf("[%v] failed to create new transport: %s", testCase, err) + } + + source, err := transp.Read(testCase.uri) + if err != nil { + if !testCase.failed { + t.Errorf("[%v] unexpected failure while creating reader: %s", testCase, err) + } + continue + } + got := readAll(source) + source.Close() + + if got != testCase.size { + t.Errorf("[%v] Wrong respone size, got %d, expected %d", testCase, got, testCase.size) + } + } +} diff --git a/internal/transport/urls.go b/internal/uri/urls.go similarity index 95% rename from internal/transport/urls.go rename to internal/uri/urls.go index 374e746ba..b9f0b416d 100644 --- a/internal/transport/urls.go +++ b/internal/uri/urls.go @@ -1,4 +1,4 @@ -package transport +package uri import ( "net/url" diff --git a/internal/transport/urls_test.go b/internal/uri/urls_test.go similarity index 85% rename from internal/transport/urls_test.go rename to internal/uri/urls_test.go index 9e3c715ed..9e7f97636 100644 --- a/internal/transport/urls_test.go +++ b/internal/uri/urls_test.go @@ -1,9 +1,9 @@ -package transport_test +package uri_test import ( "testing" - "github.com/cloudflare/unsee/internal/transport" + "github.com/cloudflare/unsee/internal/uri" ) type joinURLTest struct { @@ -32,7 +32,7 @@ var joinURLTests = []joinURLTest{ func TestJoinURL(t *testing.T) { for _, testCase := range joinURLTests { - url, err := transport.JoinURL(testCase.base, testCase.sub) + url, err := uri.JoinURL(testCase.base, testCase.sub) if err != nil { t.Errorf("joinURL(%v, %v) failed: %s", testCase.base, testCase.sub, err.Error()) } diff --git a/main.go b/main.go index 50f8fc2ce..1c21a107d 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "html/template" + "net/http" "path" "strings" "time" @@ -60,7 +61,24 @@ func setupRouter(router *gin.Engine) { func setupUpstreams() { for _, s := range config.Config.Alertmanager.Servers { - am, err := alertmanager.NewAlertmanager(s.Name, s.URI, alertmanager.WithRequestTimeout(s.Timeout), alertmanager.WithProxy(s.Proxy)) + + var httpTransport http.RoundTripper + var err error + // if either TLS root CA or client cert is configured then initialize custom transport where we have this setup + if s.TLS.CA != "" || s.TLS.Cert != "" { + httpTransport, err = alertmanager.NewHTTTPTransport(s.TLS.CA, s.TLS.Cert, s.TLS.Key) + if err != nil { + log.Fatalf("Failed to create HTTP transport for Alertmanager '%s' with URI '%s': %s", s.Name, s.URI, err) + } + } + + am, err := alertmanager.NewAlertmanager( + s.Name, + s.URI, + alertmanager.WithRequestTimeout(s.Timeout), + alertmanager.WithProxy(s.Proxy), + alertmanager.WithHTTPTransport(httpTransport), // we will pass a nil unless TLS.CA or TLS.Cert is set + ) if err != nil { log.Fatalf("Failed to create Alertmanager '%s' with URI '%s': %s", s.Name, s.URI, err) } diff --git a/proxy.go b/proxy.go index e11f83d3d..a8e26d93f 100644 --- a/proxy.go +++ b/proxy.go @@ -37,6 +37,7 @@ func NewAlertmanagerProxy(alertmanager *alertmanager.Alertmanager) (*httputil.Re req.Header.Del("Accept-Encoding") log.Debugf("[%s] Proxy request for %s", alertmanager.Name, req.URL.Path) }, + Transport: alertmanager.HTTPTransport, ModifyResponse: func(resp *http.Response) error { // drop Content-Length header from upstream responses, gzip middleware // will compress those and that could cause a mismatch