Skip to content

Commit

Permalink
Multiple Source in Conf, Listener authentication
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
oxzi committed Nov 23, 2023
1 parent e4839e8 commit 0f45b14
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 21 deletions.
20 changes: 19 additions & 1 deletion config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'.
Expand Down
4 changes: 4 additions & 0 deletions icinga2.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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 }
Expand Down
40 changes: 39 additions & 1 deletion internal/daemon/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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)
}
7 changes: 6 additions & 1 deletion internal/incident/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 31 additions & 18 deletions internal/listener/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,42 +79,59 @@ 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
}

if ev.Severity != event.SeverityNone {
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
}
}

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
}
}
Expand All @@ -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
}

Expand All @@ -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
}

Expand Down

0 comments on commit 0f45b14

Please sign in to comment.