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 }