Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync Source and Authenticate Listener #132

Merged
merged 1 commit into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ It is required that you have created a new database and imported the [schema](sc

Additionally, it also requires you to manually insert items into the **source** table before starting the daemon.
```sql
INSERT INTO source (id, type, name) VALUES (1, 'icinga2', 'Icinga 2')
INSERT INTO source (id, type, name, listener_password_hash)
VALUES (1, 'icinga2', 'Icinga 2', '$2y$10$QU8bJ7cpW1SmoVQ/RndX5O2J5L1PJF7NZ2dlIW7Rv3zUEcbUFg3z2');
```
The `listener_password_hash` is a [PHP `password_hash`](https://www.php.net/manual/en/function.password-hash.php) with the `PASSWORD_DEFAULT` algorithm, currently bcrypt.
In the example above, this is "correct horse battery staple".
This mimics Icinga Web 2's behavior, as stated in [its documentation](https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend).

Then, you can launch the daemon with the following command.
```go
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/ssgreg/journald v1.0.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,12 @@ go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -71,7 +74,10 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
Expand Down
13 changes: 4 additions & 9 deletions icinga2.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ if (!globals.contains("IcingaNotificationsProcessEventUrl")) {
if (!globals.contains("IcingaNotificationsIcingaWebUrl")) {
const IcingaNotificationsIcingaWebUrl = "http://localhost/icingaweb2"
}
if (!globals.contains("IcingaNotificationsEventSourceId")) {
// INSERT INTO source (id, type, name) VALUES (1, 'icinga2', 'Icinga 2')
const IcingaNotificationsEventSourceId = 1
if (!globals.contains("IcingaNotificationsAuth")) {
// INSERT INTO source (id, type, name, listener_password_hash) VALUES (1, 'icinga2', 'Icinga 2', '$2y$10$QU8bJ7cpW1SmoVQ/RndX5O2J5L1PJF7NZ2dlIW7Rv3zUEcbUFg3z2')
const IcingaNotificationsAuth = "source-1:correct horse battery staple"
}

// urlencode a string loosely based on RFC 3986.
Expand Down Expand Up @@ -55,6 +55,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 All @@ -72,7 +73,6 @@ var hostBody = baseBody + {
args.username = macro("$event_author$")
args.message = macro("$event_message$")
args.url = IcingaNotificationsIcingaWebUrl + "/icingadb/host?name=" + urlencode(macro("$host.name$"))
args.source_id = macro("$event_source_id$")

var type = macro("$event_type$")
if (len(type) > 0) {
Expand Down Expand Up @@ -113,7 +113,6 @@ object NotificationCommand "icinga-notifications-host" use(hostBody, hostExtraTa
event_message = "$notification.comment$"
event_object_name = "$host.display_name$"
event_extra_tags = hostExtraTags
event_source_id = IcingaNotificationsEventSourceId
}

vars.event_type = {{
Expand Down Expand Up @@ -144,7 +143,6 @@ object EventCommand "icinga-notifications-host-events" use(hostBody, hostExtraTa
event_message = "$host.output$"
event_object_name = "$host.display_name$"
event_extra_tags = hostExtraTags
event_source_id = IcingaNotificationsEventSourceId
}

vars.event_severity = {{
Expand Down Expand Up @@ -179,7 +177,6 @@ var serviceBody = baseBody + {
args.username = macro("$event_author$")
args.message = macro("$event_message$")
args.url = IcingaNotificationsIcingaWebUrl + "/icingadb/service?name=" + urlencode(macro("$service.name$")) + "&host.name=" + urlencode(macro("$service.host.name$"))
args.source_id = macro("$event_source_id$")

var type = macro("$event_type$")
if (len(type) > 0) {
Expand Down Expand Up @@ -225,7 +222,6 @@ object NotificationCommand "icinga-notifications-service" use(serviceBody, servi
event_message = "$notification.comment$"
event_object_name = "$host.display_name$: $service.display_name$"
event_extra_tags = serviceExtraTags
event_source_id = IcingaNotificationsEventSourceId
}

vars.event_type = {{
Expand Down Expand Up @@ -266,7 +262,6 @@ object EventCommand "icinga-notifications-service-events" use(serviceBody, servi
event_message = "$service.output$"
event_object_name = "$host.display_name$: $service.display_name$"
event_extra_tags = serviceExtraTags
event_source_id = IcingaNotificationsEventSourceId
}

vars.event_severity = {{
Expand Down
46 changes: 46 additions & 0 deletions internal/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"context"
"database/sql"
"errors"
"github.com/icinga/icinga-notifications/internal/channel"
"github.com/icinga/icinga-notifications/internal/recipient"
"github.com/icinga/icinga-notifications/internal/rule"
Expand All @@ -11,6 +12,9 @@ import (
"github.com/icinga/icingadb/pkg/logging"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"strconv"
"strings"
"sync"
"time"
)
Expand Down Expand Up @@ -44,6 +48,7 @@ type ConfigSet struct {
TimePeriods map[int64]*timeperiod.TimePeriod
Schedules map[int64]*recipient.Schedule
Rules map[int64]*rule.Rule
Sources map[int64]*Source
}

func (r *RuntimeConfig) UpdateFromDatabase(ctx context.Context) error {
Expand Down Expand Up @@ -137,6 +142,45 @@ func (r *RuntimeConfig) GetContact(username string) *recipient.Contact {
return nil
}

// GetSourceFromCredentials verifies a credential pair against known Sources.
//
// This method returns either a *Source or a nil pointer and logs the cause to the given logger. This is in almost all
// cases a debug logging message, except when something server-side is wrong, e.g., the hash is invalid.
func (r *RuntimeConfig) GetSourceFromCredentials(user, pass string, logger *logging.Logger) *Source {
r.RLock()
defer r.RUnlock()

sourceIdRaw, sourceIdOk := strings.CutPrefix(user, "source-")
if !sourceIdOk {
logger.Debugw("Cannot extract source ID from HTTP basic auth username", zap.String("user-input", user))
return nil
}
sourceId, err := strconv.ParseInt(sourceIdRaw, 10, 64)
if err != nil {
logger.Debugw("Cannot convert extracted source Id to int", zap.String("user-input", user), zap.Error(err))
return nil
}

source, ok := r.Sources[sourceId]
if !ok {
logger.Debugw("Cannot check credentials for unknown source ID", zap.Int64("id", sourceId))
return nil
}

// If either PHP's PASSWORD_DEFAULT changes or Icinga Web 2 starts using something else, e.g., Argon2id, this will
// return a descriptive error as the identifier does no longer match the bcrypt "$2y$".
err = bcrypt.CompareHashAndPassword([]byte(source.ListenerPasswordHash), []byte(pass))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
logger.Debugw("Invalid password for this source", zap.Int64("id", sourceId))
return nil
} else if err != nil {
logger.Errorw("Failed to verify password for this source", zap.Int64("id", sourceId), zap.Error(err))
return nil
}

return source
}

func (r *RuntimeConfig) fetchFromDatabase(ctx context.Context) error {
r.logger.Debug("fetching configuration from database")
start := time.Now()
Expand All @@ -162,6 +206,7 @@ func (r *RuntimeConfig) fetchFromDatabase(ctx context.Context) error {
r.fetchTimePeriods,
r.fetchSchedules,
r.fetchRules,
r.fetchSources,
}
for _, f := range updateFuncs {
if err := f(ctx, tx); err != nil {
Expand All @@ -188,6 +233,7 @@ func (r *RuntimeConfig) applyPending() {
r.applyPendingTimePeriods()
r.applyPendingSchedules()
r.applyPendingRules()
r.applyPendingSources()

r.logger.Debugw("applied pending configuration", zap.Duration("took", time.Since(start)))
}
78 changes: 78 additions & 0 deletions internal/config/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package config

import (
"context"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)

// Source entry within the ConfigSet to describe a source.
type Source struct {
ID int64 `db:"id"`
Type string `db:"type"`
Name string `db:"name"`

ListenerPasswordHash string `db:"listener_password_hash"`
}

func (r *RuntimeConfig) fetchSources(ctx context.Context, tx *sqlx.Tx) error {
var sourcePtr *Source
stmt := r.db.BuildSelectStmt(sourcePtr, sourcePtr)
r.logger.Debugf("Executing query %q", stmt)

var sources []*Source
if err := tx.SelectContext(ctx, &sources, stmt); err != nil {
r.logger.Errorln(err)
return err
}

sourcesById := make(map[int64]*Source)
for _, s := range sources {
sourceLogger := r.logger.With(
zap.Int64("id", s.ID),
zap.String("name", s.Name),
zap.String("type", s.Type),
)
if sourcesById[s.ID] != nil {
sourceLogger.Warnw("ignoring duplicate config for source ID")
} else {
sourcesById[s.ID] = s

sourceLogger.Debugw("loaded source config")
}
}

if r.Sources != nil {
// mark no longer existing sources for deletion
for id := range r.Sources {
if _, ok := sourcesById[id]; !ok {
sourcesById[id] = nil
}
}
}

r.pending.Sources = sourcesById

return nil
}

func (r *RuntimeConfig) applyPendingSources() {
if r.Sources == nil {
r.Sources = make(map[int64]*Source)
}

for id, pendingSource := range r.pending.Sources {
if pendingSource == nil {
r.logger.Infow("Source has been removed",
zap.Int64("id", r.Sources[id].ID),
zap.String("name", r.Sources[id].Name),
zap.String("type", r.Sources[id].Type))

delete(r.Sources, id)
} else {
r.Sources[id] = pendingSource
}
}

r.pending.Sources = nil
}
11 changes: 8 additions & 3 deletions internal/event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ import (
"time"
)

// Event received of a specified Type for internal processing.
//
// The JSON struct tags are being used to unmarshal a JSON representation received from the listener.Listener. Some
// fields are being omitted as they are only allowed to be populated from within icinga-notifications. Currently, there
// is no Event being marshalled into its JSON representation.
type Event struct {
Time time.Time
SourceId int64 `json:"source_id"`
Time time.Time `json:"-"`
SourceId int64 `json:"-"`

Name string `json:"name"`
URL string `json:"url"`
Expand All @@ -25,7 +30,7 @@ type Event struct {
Username string `json:"username"`
Message string `json:"message"`

ID int64
ID int64 `json:"-"`
}

const (
Expand Down
Loading