diff --git a/README.md b/README.md index 6814961ac..6c654d3bb 100644 --- a/README.md +++ b/README.md @@ -126,14 +126,23 @@ Alertmanager version. ## Security -The karma process doesn't send any API request to the Alertmanager that could -modify alerts or silence state, but it does provide a web interface that allows -a user to send such requests directly to the Alertmanager API. -If you wish to deploy karma as a read-only tool please ensure that: +karma doesn't in any way alter alerts in any Alertmanager instance it collects +data from. This is true for both the backend and the web UI. +The web UI allows to manage silences by sending requests to Alertmanager +instances, this can be done directly (browser to Alertmanager API) or by +proxying such requests via karma backend (browser to karma backend to +Alertmanager API) if `proxy` mode is enabled in karma config. + +If you wish to deploy karma as a read-only tool without giving users any ability +to modify data in Alertmanager instance, then please ensure that: - the karma process is able to connect to the Alertmanager API - read-only users are able to connect to the karma web interface - read-only users are NOT able to connect to the Alertmanager API +- `readonly` is set to `true` in + [alertmanager:servers](/docs/CONFIGURATION.md#alertmanagers) config section + for all alertmanager instances, this options will disable any UI elements that + could trigger updates (like silence management) ## Metrics diff --git a/cmd/karma/alerts.go b/cmd/karma/alerts.go index 2d08528e8..1f797c7ea 100644 --- a/cmd/karma/alerts.go +++ b/cmd/karma/alerts.go @@ -112,6 +112,7 @@ func getUpstreams() models.AlertmanagerAPISummary { Name: upstream.Name, URI: upstream.InternalURI(), PublicURI: upstream.PublicURI(), + ReadOnly: upstream.ReadOnly, Headers: map[string]string{}, Error: upstream.Error(), Version: upstream.Version(), diff --git a/cmd/karma/main.go b/cmd/karma/main.go index 2dad1a294..77a839004 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -120,6 +120,10 @@ func setupMetrics(router *gin.Engine) { func setupUpstreams() error { for _, s := range config.Config.Alertmanager.Servers { + if s.Proxy && s.ReadOnly { + return fmt.Errorf("Failed to create Alertmanager '%s' with URI '%s': cannot use proxy and readonly mode at the same time", s.Name, uri.SanitizeURI(s.URI)) + } + 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 @@ -136,6 +140,7 @@ func setupUpstreams() error { alertmanager.WithExternalURI(s.ExternalURI), alertmanager.WithRequestTimeout(s.Timeout), alertmanager.WithProxy(s.Proxy), + alertmanager.WithReadOnly(s.ReadOnly), alertmanager.WithHTTPTransport(httpTransport), // we will pass a nil unless TLS.CA or TLS.Cert is set alertmanager.WithHTTPHeaders(s.Headers), ) diff --git a/cmd/karma/testdata/proxy-with-readonly.txt b/cmd/karma/testdata/proxy-with-readonly.txt new file mode 100644 index 000000000..152b104b8 --- /dev/null +++ b/cmd/karma/testdata/proxy-with-readonly.txt @@ -0,0 +1,12 @@ +# Check if proxy mode is set correctly +karma.bin-should-fail --log.format=text --log.config=true --config.file=karma.yaml --check-config +! stdout . +stderr 'msg="Failed to create Alertmanager ''failed'' with URI ''http://localhost'': cannot use proxy and readonly mode at the same time"' + +-- karma.yaml -- +alertmanager: + servers: + - name: failed + uri: http://localhost + proxy: true + readonly: true diff --git a/cmd/karma/testdata/proxy.txt b/cmd/karma/testdata/proxy.txt new file mode 100644 index 000000000..1857fc00d --- /dev/null +++ b/cmd/karma/testdata/proxy.txt @@ -0,0 +1,13 @@ +# Check if proxy mode is set correctly +karma.bin-should-work --log.format=text --log.config=true --config.file=karma.yaml --check-config +! stdout . +stderr 'msg=" proxy: true"' +stderr 'msg="\[proxied\] Configured Alertmanager source at http://localhost \(proxied: true\, readonly: false\)"' +! stderr 'level=error' + +-- karma.yaml -- +alertmanager: + servers: + - name: proxied + uri: http://localhost + proxy: true diff --git a/cmd/karma/testdata/readonly.txt b/cmd/karma/testdata/readonly.txt new file mode 100644 index 000000000..312d104fe --- /dev/null +++ b/cmd/karma/testdata/readonly.txt @@ -0,0 +1,11 @@ +# Check if readonly mode is set correctly +karma.bin-should-work --log.format=text --log.config=true --config.file=karma.yaml --check-config +! stdout . +stderr 'msg="\[readonly\] Configured Alertmanager source at http://localhost \(proxied: false\, readonly: true\)"' + +-- karma.yaml -- +alertmanager: + servers: + - name: readonly + uri: http://localhost + readonly: true diff --git a/cmd/karma/testdata/simple_config.txt b/cmd/karma/testdata/simple_config.txt index 144dc4870..9f05889c0 100644 --- a/cmd/karma/testdata/simple_config.txt +++ b/cmd/karma/testdata/simple_config.txt @@ -2,4 +2,4 @@ karma.bin-should-work --log.format=text --log.config=false --alertmanager.uri=http://localhost ! stdout . stderr 'msg="Using simple config with a single Alertmanager server"' -stderr 'msg="\[default\] Configured Alertmanager source at http://localhost \(proxied: false\)"' +stderr 'msg="\[default\] Configured Alertmanager source at http://localhost \(proxied: false\, readonly: false\)"' diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a7e51592a..59187f3a3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -42,6 +42,7 @@ alertmanager: external_uri: string timeout: duration proxy: bool + readonly: bool tls: ca: string cert: string @@ -81,7 +82,10 @@ alertmanager: - `proxy` - if enabled requests from user browsers to this Alertmanager will be proxied via karma. This applies to requests made when managing silences via karma (creating or expiring silences). - THis option cannot be used when `external_uri` is set. + This option cannot be used when `readonly` is enabled. +- `readonly` - set this Alertmanager upstream to a read only mode. This will + disallow silence creation or editing. + This option cannot be used when `proxy` is enabled. - `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 @@ -181,6 +185,19 @@ Breakdown of all combination of settings: |-|-|-| | `http://localhost:123` | Karma internal URI | `http://example.com` | +1. ReadOnly mode is enabled: + + ```YAML + uri: http://localhost:123 + readonly: true + ``` + + Karma would use those URIs for: + + | Backend | Silence management | Silence links | + |-|-|-| + | `http://localhost:123` | Disabled | `http://localhost:123` | + Example with two production Alertmanager instances running in HA mode and a staging instance that is also proxied and requires a custom auth header: diff --git a/docs/example.yaml b/docs/example.yaml index 86d89936e..bd1e8e94b 100644 --- a/docs/example.yaml +++ b/docs/example.yaml @@ -5,6 +5,7 @@ alertmanager: uri: http://localhost:9093 timeout: 10s proxy: true + readonly: false headers: X-Auth-Test: some-token-or-other-string - name: client-auth diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go index b9b2db984..8126607e3 100644 --- a/internal/alertmanager/models.go +++ b/internal/alertmanager/models.go @@ -39,6 +39,7 @@ type Alertmanager struct { Name string `json:"name"` // whenever this instance should be proxied ProxyRequests bool `json:"proxyRequests"` + ReadOnly bool `json:"readonly"` // 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 diff --git a/internal/alertmanager/upstream.go b/internal/alertmanager/upstream.go index b877be0e6..5ac04afd6 100644 --- a/internal/alertmanager/upstream.go +++ b/internal/alertmanager/upstream.go @@ -73,7 +73,7 @@ func RegisterAlertmanager(am *Alertmanager) error { } } upstreams[am.Name] = am - log.Infof("[%s] Configured Alertmanager source at %s (proxied: %v)", am.Name, uri.SanitizeURI(am.URI), am.ProxyRequests) + log.Infof("[%s] Configured Alertmanager source at %s (proxied: %v, readonly: %v)", am.Name, uri.SanitizeURI(am.URI), am.ProxyRequests, am.ReadOnly) return nil } @@ -117,6 +117,15 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +// WithReadOnly option can be passed to NewAlertmanager in order to configure +// it as read-only, in that mode it doesn't allow creating silences via Proxy +func WithReadOnly(readonly bool) Option { + return func(am *Alertmanager) error { + am.ReadOnly = readonly + return nil + } +} + // WithHTTPHeaders option can be passed to NewAlertManager in order to set // a map of headers that will be passed with every request func WithHTTPHeaders(headers map[string]string) Option { diff --git a/internal/alertmanager/upstream_test.go b/internal/alertmanager/upstream_test.go new file mode 100644 index 000000000..0fea9359c --- /dev/null +++ b/internal/alertmanager/upstream_test.go @@ -0,0 +1,88 @@ +package alertmanager + +import ( + "net/http" + "testing" + "time" + + "github.com/prymitive/karma/internal/config" +) + +type testCase struct { + config config.AlertmanagerConfig +} + +var testCases = []testCase{ + { + config: config.AlertmanagerConfig{ + Name: "name", + URI: "http://localhost:9093", + ExternalURI: "http://localhost:9093", + Timeout: time.Second * 30, + Proxy: false, + ReadOnly: false, + Headers: map[string]string{}, + }, + }, + { + config: config.AlertmanagerConfig{ + Name: "proxy", + URI: "http://localhost:9094", + ExternalURI: "http://localhost:9094", + Timeout: time.Second * 30, + Proxy: true, + ReadOnly: false, + Headers: map[string]string{}, + }, + }, + { + config: config.AlertmanagerConfig{ + Name: "name", + URI: "http://localhost:9095", + ExternalURI: "http://localhost:9095", + Timeout: time.Second * 30, + Proxy: false, + ReadOnly: true, + Headers: map[string]string{}, + }, + }, +} + +func TestOptions(t *testing.T) { + for _, tc := range testCases { + var httpTransport http.RoundTripper + var err error + if tc.config.TLS.CA != "" || tc.config.TLS.Cert != "" || tc.config.TLS.InsecureSkipVerify { + httpTransport, err = NewHTTPTransport(tc.config.TLS.CA, tc.config.TLS.Cert, tc.config.TLS.Key, tc.config.TLS.InsecureSkipVerify) + if err != nil { + t.Errorf("Failed to create HTTP transport for Alertmanager '%s' with URI '%s': %s", tc.config.Name, tc.config.URI, err) + } + } + + am, err := NewAlertmanager( + tc.config.Name, + tc.config.URI, + WithExternalURI(tc.config.ExternalURI), + WithRequestTimeout(tc.config.Timeout), + WithProxy(tc.config.Proxy), + WithReadOnly(tc.config.ReadOnly), + WithHTTPTransport(httpTransport), // we will pass a nil unless TLS.CA or TLS.Cert is set + WithHTTPHeaders(tc.config.Headers), + ) + if err != nil { + t.Errorf("Failed to create Alertmanager '%s' with URI '%s': %s", tc.config.Name, tc.config.URI, err) + } + if am.Name != tc.config.Name { + t.Errorf("AlertmanagerConfig with name '%s' returned Alertmanager with name '%s'", tc.config.Name, am.Name) + } + if am.URI != tc.config.URI { + t.Errorf("AlertmanagerConfig with name '%s' and URI '%s' returned Alertmanager with URI '%s'", tc.config.Name, tc.config.URI, am.URI) + } + if am.ProxyRequests != tc.config.Proxy { + t.Errorf("AlertmanagerConfig with name '%s' and proxy '%v' returned Alertmanager with proxy '%v'", tc.config.Name, tc.config.Proxy, am.ProxyRequests) + } + if am.ReadOnly != tc.config.ReadOnly { + t.Errorf("AlertmanagerConfig with name '%s' and readonly '%v' returned Alertmanager with readonly '%v'", tc.config.Name, tc.config.ReadOnly, am.ReadOnly) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 03339c8ef..b12fd1cab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,8 @@ func init() { "Timeout for requests sent to the Alertmanager server (only used with simplified config)") pflag.Bool("alertmanager.proxy", false, "Proxy all client requests to Alertmanager via karma (only used with simplified config)") + pflag.Bool("alertmanager.readonly", false, + "Enable read-only mode that disable silence management (only used with simplified config)") pflag.String("karma.name", "karma", "Name for the karma instance") @@ -156,7 +158,7 @@ func (config *configSchema) Read() { log.Fatal(err) } - config.Alertmanager.Servers = []alertmanagerConfig{} + config.Alertmanager.Servers = []AlertmanagerConfig{} config.Alertmanager.Interval = v.GetDuration("alertmanager.interval") config.AlertAcknowledgement.Enabled = v.GetBool("alertAcknowledgement.enabled") config.AlertAcknowledgement.Author = v.GetString("alertAcknowledgement.author") @@ -282,13 +284,14 @@ func (config *configSchema) Read() { // accept single Alertmanager server from flag/env if nothing is set yet if len(config.Alertmanager.Servers) == 0 && v.GetString("alertmanager.uri") != "" { log.Info("Using simple config with a single Alertmanager server") - config.Alertmanager.Servers = []alertmanagerConfig{ + config.Alertmanager.Servers = []AlertmanagerConfig{ { Name: v.GetString("alertmanager.name"), URI: v.GetString("alertmanager.uri"), ExternalURI: v.GetString("alertmanager.external_uri"), Timeout: v.GetDuration("alertmanager.timeout"), Proxy: v.GetBool("alertmanager.proxy"), + ReadOnly: v.GetBool("alertmanager.readonly"), Headers: make(map[string]string), }, } @@ -301,15 +304,16 @@ func (config *configSchema) LogValues() { cfg := configSchema(*config) // replace passwords in Alertmanager URIs with 'xxx' - servers := []alertmanagerConfig{} + servers := []AlertmanagerConfig{} for _, s := range cfg.Alertmanager.Servers { - server := alertmanagerConfig{ + server := AlertmanagerConfig{ Name: s.Name, URI: uri.SanitizeURI(s.URI), ExternalURI: uri.SanitizeURI(s.ExternalURI), Timeout: s.Timeout, TLS: s.TLS, Proxy: s.Proxy, + ReadOnly: s.ReadOnly, Headers: s.Headers, } servers = append(servers, server) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a5f2fe2de..cdbe818a4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -63,6 +63,7 @@ func testReadConfig(t *testing.T) { external_uri: http://example.com timeout: 40s proxy: false + readonly: false tls: ca: "" cert: "" diff --git a/internal/config/models.go b/internal/config/models.go index caab853ab..e7ffdf6fb 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -5,12 +5,13 @@ import ( "time" ) -type alertmanagerConfig struct { +type AlertmanagerConfig struct { Name string URI string ExternalURI string `yaml:"external_uri" mapstructure:"external_uri"` Timeout time.Duration Proxy bool + ReadOnly bool `yaml:"readonly" mapstructure:"readonly"` TLS struct { CA string Cert string @@ -37,7 +38,7 @@ type CustomLabelColors map[string][]CustomLabelColor type configSchema struct { Alertmanager struct { Interval time.Duration - Servers []alertmanagerConfig + Servers []AlertmanagerConfig } AlertAcknowledgement struct { Enabled bool diff --git a/internal/models/alertmanager.go b/internal/models/alertmanager.go index e25d40e2b..00f9e69f5 100644 --- a/internal/models/alertmanager.go +++ b/internal/models/alertmanager.go @@ -29,6 +29,7 @@ type AlertmanagerAPIStatus struct { URI string `json:"uri"` // this is the Alertmanager URI used for links in the browser PublicURI string `json:"publicURI"` + ReadOnly bool `json:"readonly"` Headers map[string]string `json:"headers"` Error string `json:"error"` Version string `json:"version"` diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js index 17a1fbd82..4f6839bc5 100644 --- a/ui/src/Components/AlertAck/index.js +++ b/ui/src/Components/AlertAck/index.js @@ -192,7 +192,7 @@ const AlertAck = observer( .filter(([amName, alertCount]) => alertCount > 0) .map(([amName, _]) => amName); const clusters = Object.entries( - alertStore.data.upstreams.clusters + alertStore.data.clustersWithoutReadOnly ).filter(([clusterName, clusterMembers]) => alertmanagers.some(m => clusterMembers.includes(m)) ); diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index 9512f7dd8..98b33af45 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -35,6 +35,7 @@ beforeEach(() => { name: "default", uri: "http://localhost", publicURI: "http://example.com", + readonly: false, headers: { foo: "bar" }, error: "", version: "0.15.0", @@ -287,6 +288,7 @@ describe("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -297,6 +299,7 @@ describe("", () => { name: "fallback", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -334,6 +337,7 @@ describe("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.16.2", @@ -344,6 +348,7 @@ describe("", () => { name: "fallback", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.16.2", @@ -381,6 +386,7 @@ describe("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", diff --git a/ui/src/Components/ManagedSilence/DeleteSilence.js b/ui/src/Components/ManagedSilence/DeleteSilence.js index 20589f1db..ecac4df05 100644 --- a/ui/src/Components/ManagedSilence/DeleteSilence.js +++ b/ui/src/Components/ManagedSilence/DeleteSilence.js @@ -113,6 +113,7 @@ const DeleteSilenceModalContent = observer( getAlertmanager = () => this.props.alertStore.data.upstreams.instances .filter(u => u.cluster === this.props.cluster) + .filter(u => u.readonly === false) .slice(0, 1)[0]; parseAlertmanagerResponse = response => { @@ -296,11 +297,16 @@ const DeleteSilence = observer( onModalExit } = this.props; + const members = alertStore.data.getClusterAlertmanagersWithoutReadOnly( + cluster + ); + return (