From 0f45b14a72c2766a97a98fee5a6547bfc9075e27 Mon Sep 17 00:00:00 2001 From: Alvar Penning Date: Wed, 22 Nov 2023 10:03:20 +0100 Subject: [PATCH] Multiple Source in Conf, Listener authentication This commit brings two connected changes. First, the YAML configuration might now holds multiple sources. Second, domain knowledge was moved into the listener. Next to some generic error logging refactoring, it checks for each submitted event if it refers a known source, is allowed to be submitted and, as a further change, adds HTTP "Basic" authentication. --- config.example.yml | 20 +++++++++++++- icinga2.conf | 4 +++ internal/daemon/config.go | 40 +++++++++++++++++++++++++++- internal/incident/incident.go | 7 ++++- internal/listener/listener.go | 49 ++++++++++++++++++++++------------- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/config.example.yml b/config.example.yml index 4877f16fd..34a24f49a 100644 --- a/config.example.yml +++ b/config.example.yml @@ -6,7 +6,6 @@ # Set credentials for some debug endpoints provided via HTTP. If not set, these are disabled. #debug-password: "put-something-secret-here" -icingaweb2-url: http://localhost/icingaweb2/ channel-plugin-dir: /usr/libexec/icinga-notifications/channel database: @@ -18,6 +17,25 @@ database: database: icinga_notifications #password: icinga_notifications +# Multiple sources, i.e., Icinga instances, might be defined. +sources: + - # id must be a unique number to identify this source. + id: 1 + # type defines how this source's type. + type: icinga2 + # name of this source. + name: Icinga 2 + + # icingaweb2-url is used to build event URLs to this source's Icinga Web 2. + icingaweb2-url: http://localhost/icingaweb2/ + + # authentication is context-dependant. For + # - submissions through the listener, the user/pass pair is required for incoming requests within the HTTP Basic + # Authentication header. + authentication: + user: icinga + pass: correct horse battery staple + logging: # Default logging level. Can be set to 'fatal', 'error', 'warn', 'info' or 'debug'. # If not set, defaults to 'info'. diff --git a/icinga2.conf b/icinga2.conf index 11bb939fb..f9a6e4b27 100644 --- a/icinga2.conf +++ b/icinga2.conf @@ -8,6 +8,9 @@ if (!globals.contains("IcingaNotificationsEventSourceId")) { // INSERT INTO source (id, type, name) VALUES (1, 'icinga2', 'Icinga 2') const IcingaNotificationsEventSourceId = 1 } +if (!globals.contains("IcingaNotificationsAuth")) { + const IcingaNotificationsAuth = "icinga:correct horse battery staple" +} // urlencode a string loosely based on RFC 3986. // @@ -55,6 +58,7 @@ var baseBody = { (len(macro("$event_severity$")) > 0 || len(macro("$event_type$")) > 0) ? "curl" : "true" }} } + "--user" = { value = IcingaNotificationsAuth } "--fail" = { set_if = true } "--silent" = { set_if = true } "--show-error" = { set_if = true } diff --git a/internal/daemon/config.go b/internal/daemon/config.go index bd4c49832..b498e4e9c 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -2,17 +2,32 @@ package daemon import ( "errors" + "fmt" "github.com/creasty/defaults" "github.com/goccy/go-yaml" icingadbConfig "github.com/icinga/icingadb/pkg/config" "os" ) +// ConfigSource entry for each ConfigFile.Sources to describe a source. +type ConfigSource struct { + Id int64 `yaml:"id"` + Type string `yaml:"type" default:"icinga2"` + Name string `yaml:"name"` + + Icingaweb2URL string `yaml:"icingaweb2-url"` + + Authentication struct { + User string `yaml:"user"` + Pass string `yaml:"pass"` + } `yaml:"authentication"` +} + type ConfigFile struct { Listen string `yaml:"listen" default:"localhost:5680"` DebugPassword string `yaml:"debug-password"` ChannelPluginDir string `yaml:"channel-plugin-dir" default:"/usr/libexec/icinga-notifications/channel"` - Icingaweb2URL string `yaml:"icingaweb2-url"` + Sources []ConfigSource `yaml:"sources"` Database icingadbConfig.Database `yaml:"database"` Logging icingadbConfig.Logging `yaml:"logging"` } @@ -74,5 +89,28 @@ func (c *ConfigFile) Validate() error { return err } + if len(c.Sources) == 0 { + return errors.New("config contains no sources") + } + + sourceIdUnique := make(map[int64]struct{}) + for _, source := range c.Sources { + _, exists := sourceIdUnique[source.Id] + if exists { + return fmt.Errorf("config contains duplicate source for ID %d", source.Id) + } + sourceIdUnique[source.Id] = struct{}{} + } + return nil } + +// GetSource returns the ConfigSource for a requested ID. +func (c *ConfigFile) GetSource(id int64) (ConfigSource, error) { + for _, source := range c.Sources { + if id == source.Id { + return source, nil + } + } + return ConfigSource{}, fmt.Errorf("no source configured for ID %d", id) +} diff --git a/internal/incident/incident.go b/internal/incident/incident.go index c708030a9..3b72438c6 100644 --- a/internal/incident/incident.go +++ b/internal/incident/incident.go @@ -556,7 +556,12 @@ func (i *Incident) notifyContact(contact *recipient.Contact, ev *event.Event, ch i.logger.Infow(fmt.Sprintf("Notify contact %q via %q of type %q", contact.FullName, ch.Name, ch.Type), zap.Int64("channel_id", chID)) - err := ch.Notify(contact, i, ev, daemon.Config().Icingaweb2URL) + source, err := daemon.Config().GetSource(ev.SourceId) + if err != nil { + i.logger.Errorw("Failed to fetch source from config", zap.Int64("source id", ev.SourceId), zap.Error(err)) + return err + } + err = ch.Notify(contact, i, ev, source.Icingaweb2URL) if err != nil { i.logger.Errorw("Failed to send notification via channel plugin", zap.String("type", ch.Type), zap.Error(err)) return err diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 481a24afb..c032cc836 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -79,24 +79,42 @@ func (l *Listener) Run(ctx context.Context) error { } func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { + abort := func(statusCode int, ev event.Event, format string, a ...any) { + msg := fmt.Sprintf(format, a...) + http.Error(w, msg, statusCode) + l.logger.Debugw("Abort listener submitted event processing", zap.String("msg", msg), zap.String("event", ev.String())) + } + if req.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = fmt.Fprintln(w, "POST required") + abort(http.StatusMethodNotAllowed, event.Event{}, "POST required") return } var ev event.Event err := json.NewDecoder(req.Body).Decode(&ev) if err != nil { - w.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprintf(w, "cannot parse JSON body: %v\n", err) + abort(http.StatusBadRequest, event.Event{}, "cannot parse JSON body: %v", err) return } ev.Time = time.Now() + source, err := daemon.Config().GetSource(ev.SourceId) + if err != nil { + abort(http.StatusBadRequest, ev, "ignoring invalid event: unknown source ID %d", ev.SourceId) + return + } + + user, pass, _ := req.BasicAuth() + userOk := subtle.ConstantTimeCompare([]byte(source.Authentication.User), []byte(user)) == 1 + passOk := subtle.ConstantTimeCompare([]byte(source.Authentication.Pass), []byte(pass)) == 1 + if !userOk || !passOk { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="source-%d"`, source.Id)) + abort(http.StatusUnauthorized, ev, "unauthorized for source %d", source.Id) + return + } + if ev.Severity == event.SeverityNone && ev.Type == "" { - w.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprintln(w, "ignoring invalid event: must set 'type' or 'severity'") + abort(http.StatusBadRequest, ev, "ignoring invalid event: must set 'type' or 'severity'") return } @@ -104,8 +122,8 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { if ev.Type == "" { ev.Type = event.TypeState } else if ev.Type != event.TypeState { - w.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprintf(w, "ignoring invalid event: if 'severity' is set, 'type' must not be set or set to %q\n", event.TypeState) + abort(http.StatusBadRequest, ev, + "ignoring invalid event: if 'severity' is set, 'type' must not be set or set to %q", event.TypeState) return } } @@ -113,8 +131,7 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { if ev.Severity == event.SeverityNone { if ev.Type != event.TypeAcknowledgement { // It's neither a state nor an acknowledgement event. - w.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprintf(w, "received not a state/acknowledgement event, ignoring\n") + abort(http.StatusBadRequest, ev, "received not a state/acknowledgement event, ignoring") return } } @@ -123,17 +140,14 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { obj, err := object.FromEvent(ctx, l.db, &ev) if err != nil { l.logger.Errorw("Can't sync object", zap.Error(err)) - - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprintln(w, err.Error()) + abort(http.StatusInternalServerError, ev, err.Error()) return } createIncident := ev.Severity != event.SeverityNone && ev.Severity != event.SeverityOK currentIncident, created, err := incident.GetCurrent(ctx, l.db, obj, l.logs.GetChildLogger("incident"), l.runtimeConfig, createIncident) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprintln(w, err) + abort(http.StatusInternalServerError, ev, err.Error()) return } @@ -159,12 +173,11 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { return } - l.logger.Infof("Processing event") + l.logger.Infow("Processing event", zap.String("event", ev.String())) err = currentIncident.ProcessEvent(ctx, &ev, created) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprintln(w, err) + abort(http.StatusInternalServerError, ev, err.Error()) return }