From be4901bf89cd20c4e4162813a5442857c4d45b89 Mon Sep 17 00:00:00 2001 From: Alvar Penning Date: Wed, 6 Dec 2023 13:27:23 +0100 Subject: [PATCH] Create Event Stream Clients from Source After #132 got merged and each Source's state is now within the database, the Event Stream's configuration could go there, too. This resulted in some level of refactoring as the data flow logic was now reversed at some points. Especially Golang's non-cyclic imports and the omnipresence of the RuntimeConfig made the "hack" of the eventstream.Launcher necessary to not have an importing edge from config to eventstream. --- README.md | 19 +--- cmd/icinga-notifications-daemon/main.go | 21 ++-- config.example.yml | 10 -- internal/config/runtime.go | 25 ++++- internal/config/source.go | 81 +++++++++++--- internal/daemon/config.go | 10 -- internal/eventstream/client.go | 107 ++---------------- internal/eventstream/client_api.go | 4 +- internal/eventstream/launcher.go | 138 ++++++++++++++++++++++++ schema/pgsql/schema.sql | 21 ++-- schema/pgsql/upgrades/022.sql | 22 ++++ 11 files changed, 293 insertions(+), 165 deletions(-) create mode 100644 internal/eventstream/launcher.go create mode 100644 schema/pgsql/upgrades/022.sql diff --git a/README.md b/README.md index 6aceecf0..d07c5cac 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,11 @@ 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, listener_password_hash) -VALUES (1, 'icinga2', 'Icinga 2', '$2y$10$QU8bJ7cpW1SmoVQ/RndX5O2J5L1PJF7NZ2dlIW7Rv3zUEcbUFg3z2'); +INSERT INTO source + (id, type, name, icinga2_base_url, icinga2_auth_user, icinga2_auth_pass, icinga2_insecure_tls) +VALUES + (1, 'icinga2', 'Local Icinga 2', 'https://localhost:5665', 'root', 'icinga', 'y'); ``` -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). - -Currently, there are two ways how notifications get communicated between Icinga 2 and Icinga Notifications. -Please select only one, whereby the first is recommended: - -* Icinga Notifications can pull those from the Icinga 2 API when being configured in the YAML configuration file. - For each `source`, as inserted in the database above, an `icinga2-apis` endpoint must be defined. -* Otherwise, Icinga 2 can push the notifications to the Icinga Notification daemon. - Therefore, you need to copy the [Icinga 2 config](icinga2.conf) to `/etc/icinga2/features-enabled` on your master node(s) and restart the Icinga 2 service. - At the top of this file, you will find multiple configurations options that can be set in `/etc/icinga2/constants.conf`. - There are also Icinga2 `EventCommand` definitions in this file that will automatically match all your **checkables**, which may not work properly if the configuration already uses event commands for something else. Then, you can launch the daemon with the following command. ```go diff --git a/cmd/icinga-notifications-daemon/main.go b/cmd/icinga-notifications-daemon/main.go index 891a279f..d8cbacd8 100644 --- a/cmd/icinga-notifications-daemon/main.go +++ b/cmd/icinga-notifications-daemon/main.go @@ -86,11 +86,20 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - runtimeConfig := config.NewRuntimeConfig(db, logs) + esLauncher := &eventstream.Launcher{ + Ctx: ctx, + Logs: logs, + Db: db, + RuntimeConfig: nil, // Will be set below as it is interconnected.. + } + + runtimeConfig := config.NewRuntimeConfig(esLauncher.Launch, logs, db) if err := runtimeConfig.UpdateFromDatabase(ctx); err != nil { logger.Fatalw("failed to load config from database", zap.Error(err)) } + esLauncher.RuntimeConfig = runtimeConfig + go runtimeConfig.PeriodicUpdates(ctx, 1*time.Second) err = incident.LoadOpenIncidents(ctx, db, logs.GetChildLogger("incident"), runtimeConfig) @@ -98,14 +107,8 @@ func main() { logger.Fatalw("Can't load incidents from database", zap.Error(err)) } - esClients, err := eventstream.NewClientsFromConfig(ctx, logs, db, runtimeConfig, conf) - if err != nil { - logger.Fatalw("cannot prepare Event Stream API Clients form config", zap.Error(err)) - } - for _, esClient := range esClients { - go esClient.Process() - } - + // Wait to load open incidents from the database before either starting Event Stream Clients or starting the Listener. + esLauncher.Ready() if err := listener.NewListener(db, runtimeConfig, logs).Run(ctx); err != nil { logger.Errorw("Listener has finished with an error", zap.Error(err)) } else { diff --git a/config.example.yml b/config.example.yml index 934508da..4877f16f 100644 --- a/config.example.yml +++ b/config.example.yml @@ -9,16 +9,6 @@ icingaweb2-url: http://localhost/icingaweb2/ channel-plugin-dir: /usr/libexec/icinga-notifications/channel -icinga2-apis: - - notifications-event-source-id: 1 - host: https://localhost:5665 - auth-user: root - auth-pass: icinga - # The Icinga 2 API CA must either be in the system's CA store, be passed as - # icinga-ca-file or certificate verification can be disabled. - # icinga-ca-file: /path/to/icinga-ca.crt - # insecure-tls: true - database: type: pgsql host: /run/postgresql diff --git a/internal/config/runtime.go b/internal/config/runtime.go index ad6dd2e3..ec2b9a35 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -25,6 +25,10 @@ type RuntimeConfig struct { // Accessing it requires a lock that is obtained with RLock() and released with RUnlock(). ConfigSet + // EventStreamLaunchFunc is a callback to launch an Event Stream API Client. + // This became necessary due to circular imports, either with the incident or eventstream package. + EventStreamLaunchFunc func(source *Source) + // pending contains changes to config objects that are to be applied to the embedded live config. pending ConfigSet @@ -36,8 +40,18 @@ type RuntimeConfig struct { mu sync.RWMutex } -func NewRuntimeConfig(db *icingadb.DB, logs *logging.Logging) *RuntimeConfig { - return &RuntimeConfig{db: db, logs: logs, logger: logs.GetChildLogger("runtime-updates")} +func NewRuntimeConfig( + esLaunch func(source *Source), + logs *logging.Logging, + db *icingadb.DB, +) *RuntimeConfig { + return &RuntimeConfig{ + EventStreamLaunchFunc: esLaunch, + + logs: logs, + logger: logs.GetChildLogger("runtime-updates"), + db: db, + } } type ConfigSet struct { @@ -167,9 +181,14 @@ func (r *RuntimeConfig) GetSourceFromCredentials(user, pass string, logger *logg return nil } + if !source.ListenerPasswordHash.Valid { + logger.Debugw("Cannot check credentials for source without a listener_password_hash", 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)) + err = bcrypt.CompareHashAndPassword([]byte(source.ListenerPasswordHash.String), []byte(pass)) if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { logger.Debugw("Invalid password for this source", zap.Int64("id", sourceId)) return nil diff --git a/internal/config/source.go b/internal/config/source.go index a851980f..4597fb16 100644 --- a/internal/config/source.go +++ b/internal/config/source.go @@ -2,17 +2,54 @@ package config import ( "context" + "github.com/icinga/icingadb/pkg/types" "github.com/jmoiron/sqlx" "go.uber.org/zap" ) +// SourceTypeIcinga2 represents the "icinga2" Source Type for Event Stream API sources. +const SourceTypeIcinga2 = "icinga2" + // 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"` + ListenerPasswordHash types.String `db:"listener_password_hash"` + + Icinga2BaseURL types.String `db:"icinga2_base_url"` + Icinga2AuthUser types.String `db:"icinga2_auth_user"` + Icinga2AuthPass types.String `db:"icinga2_auth_pass"` + Icinga2CAPem types.String `db:"icinga2_ca_pem"` + Icinga2InsecureTLS types.Bool `db:"icinga2_insecure_tls"` + + // Icinga2SourceConf for Event Stream API sources, only if Source.Type == SourceTypeIcinga2. + Icinga2SourceCancel context.CancelFunc `db:"-" json:"-"` +} + +// fieldEquals checks if this Source's database fields are equal to those of another Source. +func (source *Source) fieldEquals(other *Source) bool { + boolEq := func(a, b types.Bool) bool { return (!a.Valid && !b.Valid) || (a.Bool == b.Bool) } + stringEq := func(a, b types.String) bool { return (!a.Valid && !b.Valid) || (a.String == b.String) } + + return source.ID == other.ID && + source.Type == other.Type && + source.Name == other.Name && + stringEq(source.ListenerPasswordHash, other.ListenerPasswordHash) && + stringEq(source.Icinga2BaseURL, other.Icinga2BaseURL) && + stringEq(source.Icinga2AuthUser, other.Icinga2AuthUser) && + stringEq(source.Icinga2AuthPass, other.Icinga2AuthPass) && + stringEq(source.Icinga2CAPem, other.Icinga2CAPem) && + boolEq(source.Icinga2InsecureTLS, other.Icinga2InsecureTLS) +} + +// stop this Source's worker; currently only Icinga Event Stream API Client. +func (source *Source) stop() { + if source.Type == SourceTypeIcinga2 && source.Icinga2SourceCancel != nil { + source.Icinga2SourceCancel() + source.Icinga2SourceCancel = nil + } } func (r *RuntimeConfig) fetchSources(ctx context.Context, tx *sqlx.Tx) error { @@ -34,12 +71,12 @@ func (r *RuntimeConfig) fetchSources(ctx context.Context, tx *sqlx.Tx) error { 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") + sourceLogger.Error("Ignoring duplicate config for source ID") + continue } + + sourcesById[s.ID] = s + sourceLogger.Debug("loaded source config") } if r.Sources != nil { @@ -62,16 +99,36 @@ func (r *RuntimeConfig) applyPendingSources() { } 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)) + logger := r.logger.With(zap.Int64("id", id)) + currentSource := r.Sources[id] + // Compare the pending source with an optional existing source; instruct the Event Source Client, if necessary. + if pendingSource == nil && currentSource != nil { + logger.Info("Source has been removed") + + currentSource.stop() delete(r.Sources, id) + continue + } else if pendingSource != nil && currentSource != nil { + if currentSource.fieldEquals(pendingSource) { + continue + } + + logger.Info("Source has been updated") + currentSource.stop() + } else if pendingSource != nil && currentSource == nil { + logger.Info("Source has been added") } else { - r.Sources[id] = pendingSource + // Neither an active nor a pending source? + logger.Error("Cannot applying pending configuration: neither an active nor a pending source") + continue + } + + if pendingSource.Type == SourceTypeIcinga2 { + r.EventStreamLaunchFunc(pendingSource) } + + r.Sources[id] = pendingSource } r.pending.Sources = nil diff --git a/internal/daemon/config.go b/internal/daemon/config.go index 9d499f83..bd4c4983 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -8,21 +8,11 @@ import ( "os" ) -type Icinga2ApiConfig struct { - NotificationsEventSourceId int64 `yaml:"notifications-event-source-id"` - Host string `yaml:"host"` - AuthUser string `yaml:"auth-user"` - AuthPass string `yaml:"auth-pass"` - IcingaCaFile string `yaml:"icinga-ca-file"` - InsecureTls bool `yaml:"insecure-tls"` -} - 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"` - Icinga2Apis []Icinga2ApiConfig `yaml:"icinga2-apis"` Database icingadbConfig.Database `yaml:"database"` Logging icingadbConfig.Logging `yaml:"logging"` } diff --git a/internal/eventstream/client.go b/internal/eventstream/client.go index b44eb674..d8e49e0e 100644 --- a/internal/eventstream/client.go +++ b/internal/eventstream/client.go @@ -2,21 +2,11 @@ package eventstream import ( "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "github.com/icinga/icinga-notifications/internal/config" - "github.com/icinga/icinga-notifications/internal/daemon" "github.com/icinga/icinga-notifications/internal/event" - "github.com/icinga/icinga-notifications/internal/incident" - "github.com/icinga/icingadb/pkg/icingadb" - "github.com/icinga/icingadb/pkg/logging" "go.uber.org/zap" "golang.org/x/sync/errgroup" "net/http" "net/url" - "os" "time" ) @@ -39,21 +29,23 @@ type eventMsg struct { // the Client executes a worker within its own goroutine, which dispatches event.Event to the CallbackFn and enforces // order during catching up after (re-)connections. type Client struct { - // ApiHost et al. configure where and how the Icinga 2 API can be reached. - ApiHost string + // ApiBaseURL et al. configure where and how the Icinga 2 API can be reached. + ApiBaseURL string ApiBasicAuthUser string ApiBasicAuthPass string ApiHttpTransport http.Transport - // IcingaNotificationsEventSourceId to be reflected in generated event.Events. - IcingaNotificationsEventSourceId int64 + // EventSourceId to be reflected in generated event.Events. + EventSourceId int64 // IcingaWebRoot points to the Icinga Web 2 endpoint for generated URLs. IcingaWebRoot string // CallbackFn receives generated event.Event objects. CallbackFn func(*event.Event) - // Ctx for all web requests as well as internal wait loops. - Ctx context.Context + // Ctx for all web requests as well as internal wait loops. The CtxCancel can be used to stop this Client. + // Both fields are being populated with a new context from the NewClientFromConfig function. + Ctx context.Context + CtxCancel context.CancelFunc // Logger to log to. Logger *zap.SugaredLogger @@ -63,87 +55,6 @@ type Client struct { catchupPhaseRequest chan struct{} } -// NewClientsFromConfig returns all Clients defined in the conf.ConfigFile. -// -// Those are prepared and just needed to be started by calling their Client.Process method. -func NewClientsFromConfig( - ctx context.Context, - logs *logging.Logging, - db *icingadb.DB, - runtimeConfig *config.RuntimeConfig, - conf *daemon.ConfigFile, -) ([]*Client, error) { - clients := make([]*Client, 0, len(conf.Icinga2Apis)) - - for _, icinga2Api := range conf.Icinga2Apis { - logger := logs.GetChildLogger("eventstream").With(zap.Int64("source-id", icinga2Api.NotificationsEventSourceId)) - client := &Client{ - ApiHost: icinga2Api.Host, - ApiBasicAuthUser: icinga2Api.AuthUser, - ApiBasicAuthPass: icinga2Api.AuthPass, - ApiHttpTransport: http.Transport{ - // Hardened TLS config adjusted to Icinga 2's configuration: - // - https://icinga.com/docs/icinga-2/latest/doc/09-object-types/#objecttype-apilistener - // - https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#security - // - https://ssl-config.mozilla.org/#server=go&config=intermediate - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - CipherSuites: []uint16{ - tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, - tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, - }, - }, - }, - - IcingaNotificationsEventSourceId: icinga2Api.NotificationsEventSourceId, - IcingaWebRoot: conf.Icingaweb2URL, - - CallbackFn: func(ev *event.Event) { - l := logger.With(zap.Stringer("event", ev)) - - err := incident.ProcessEvent(ctx, db, logs, runtimeConfig, ev) - switch { - case errors.Is(err, incident.ErrSuperfluousStateChange): - l.Debugw("Stopped processing event with superfluous state change", zap.Error(err)) - case err != nil: - l.Errorw("Cannot process event", zap.Error(err)) - default: - l.Debug("Successfully processed event over callback") - } - }, - Ctx: ctx, - Logger: logger, - } - - if icinga2Api.IcingaCaFile != "" { - caData, err := os.ReadFile(icinga2Api.IcingaCaFile) - if err != nil { - return nil, fmt.Errorf("cannot read CA file %q for Event Stream ID %d, %w", - icinga2Api.IcingaCaFile, icinga2Api.NotificationsEventSourceId, err) - } - - certPool := x509.NewCertPool() - if !certPool.AppendCertsFromPEM(caData) { - return nil, fmt.Errorf("cannot add custom CA file to CA pool for Event Stream ID %d, %w", - icinga2Api.NotificationsEventSourceId, err) - } - - client.ApiHttpTransport.TLSClientConfig.RootCAs = certPool - } - - if icinga2Api.InsecureTls { - client.ApiHttpTransport.TLSClientConfig.InsecureSkipVerify = true - } - - clients = append(clients, client) - } - return clients, nil -} - // buildCommonEvent creates an event.Event based on Host and (optional) Service attributes to be specified later. // // The new Event's Time will be the current timestamp. @@ -206,7 +117,7 @@ func (client *Client) buildCommonEvent(ctx context.Context, host, service string return &event.Event{ Time: time.Now(), - SourceId: client.IcingaNotificationsEventSourceId, + SourceId: client.EventSourceId, Name: eventName, URL: eventUrl.String(), Tags: eventTags, diff --git a/internal/eventstream/client_api.go b/internal/eventstream/client_api.go index e76cdbb8..e632f8b1 100644 --- a/internal/eventstream/client_api.go +++ b/internal/eventstream/client_api.go @@ -55,7 +55,7 @@ func (client *Client) queryObjectsApi( body io.Reader, headers map[string]string, ) (io.ReadCloser, error) { - apiUrl, err := url.JoinPath(client.ApiHost, urlPaths...) + apiUrl, err := url.JoinPath(client.ApiBaseURL, urlPaths...) if err != nil { return nil, err } @@ -281,7 +281,7 @@ func (e *connectEventStreamReadCloser) Close() error { // // An error will only be returned if reconnecting - retrying the (almost) same thing - will not help. func (client *Client) connectEventStream(esTypes []string) (io.ReadCloser, error) { - apiUrl, err := url.JoinPath(client.ApiHost, "/v1/events") + apiUrl, err := url.JoinPath(client.ApiBaseURL, "/v1/events") if err != nil { return nil, err } diff --git a/internal/eventstream/launcher.go b/internal/eventstream/launcher.go new file mode 100644 index 00000000..a00baab3 --- /dev/null +++ b/internal/eventstream/launcher.go @@ -0,0 +1,138 @@ +package eventstream + +// This file contains the Launcher type to, well, launch new Event Stream Clients through a callback function. + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "github.com/icinga/icinga-notifications/internal/config" + "github.com/icinga/icinga-notifications/internal/daemon" + "github.com/icinga/icinga-notifications/internal/event" + "github.com/icinga/icinga-notifications/internal/incident" + "github.com/icinga/icingadb/pkg/icingadb" + "github.com/icinga/icingadb/pkg/logging" + "go.uber.org/zap" + "net/http" + "sync" +) + +// Launcher allows starting a new Event Stream API Client through a callback from within the config package. +// +// This architecture became kind of necessary to work around circular imports due to the RuntimeConfig's omnipresence. +type Launcher struct { + Ctx context.Context + Logs *logging.Logging + Db *icingadb.DB + RuntimeConfig *config.RuntimeConfig + + mutex sync.Mutex + isReady bool + waitingSources []*config.Source +} + +// Launch either directly launches an Event Stream Client for this Source or enqueues it until the Launcher is Ready. +func (launcher *Launcher) Launch(src *config.Source) { + launcher.mutex.Lock() + defer launcher.mutex.Unlock() + + if !launcher.isReady { + launcher.Logs.GetChildLogger("eventstream"). + With(zap.Int64("source-id", src.ID)). + Debug("Postponing Event Stream Client Launch as Launcher is not ready yet") + launcher.waitingSources = append(launcher.waitingSources, src) + return + } + + launcher.launch(src) +} + +// Ready marks the Launcher as ready and launches all enqueued, postponed Sources. +func (launcher *Launcher) Ready() { + launcher.mutex.Lock() + defer launcher.mutex.Unlock() + + launcher.isReady = true + for _, src := range launcher.waitingSources { + launcher.Logs.GetChildLogger("eventstream"). + With(zap.Int64("source-id", src.ID)). + Debug("Launching postponed Event Stream Client") + launcher.launch(src) + } + launcher.waitingSources = nil +} + +// launch a new Event Stream API Client based on the Icinga2Source configuration. +func (launcher *Launcher) launch(src *config.Source) { + logger := launcher.Logs.GetChildLogger("eventstream").With(zap.Int64("source-id", src.ID)) + + if src.Type != config.SourceTypeIcinga2 || + !src.Icinga2BaseURL.Valid || + !src.Icinga2AuthUser.Valid || + !src.Icinga2AuthPass.Valid { + logger.Error("Source is either not of type icinga2 or not fully populated") + return + } + + subCtx, subCtxCancel := context.WithCancel(launcher.Ctx) + client := &Client{ + ApiBaseURL: src.Icinga2BaseURL.String, + ApiBasicAuthUser: src.Icinga2AuthUser.String, + ApiBasicAuthPass: src.Icinga2AuthPass.String, + ApiHttpTransport: http.Transport{ + // Hardened TLS config adjusted to Icinga 2's configuration: + // - https://icinga.com/docs/icinga-2/latest/doc/09-object-types/#objecttype-apilistener + // - https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#security + // - https://ssl-config.mozilla.org/#server=go&config=intermediate + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + }, + }, + }, + + EventSourceId: src.ID, + IcingaWebRoot: daemon.Config().Icingaweb2URL, + + CallbackFn: func(ev *event.Event) { + l := logger.With(zap.Stringer("event", ev)) + + err := incident.ProcessEvent(subCtx, launcher.Db, launcher.Logs, launcher.RuntimeConfig, ev) + switch { + case errors.Is(err, incident.ErrSuperfluousStateChange): + l.Debugw("Stopped processing event with superfluous state change", zap.Error(err)) + case err != nil: + l.Errorw("Cannot process event", zap.Error(err)) + default: + l.Debug("Successfully processed event over callback") + } + }, + Ctx: subCtx, + CtxCancel: subCtxCancel, + Logger: logger, + } + + if src.Icinga2CAPem.Valid { + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM([]byte(src.Icinga2CAPem.String)) { + logger.Error("Cannot add custom CA file to CA pool") + return + } + + client.ApiHttpTransport.TLSClientConfig.RootCAs = certPool + } + + if src.Icinga2InsecureTLS.Valid && src.Icinga2InsecureTLS.Bool { + client.ApiHttpTransport.TLSClientConfig.InsecureSkipVerify = true + } + + go client.Process() + src.Icinga2SourceCancel = subCtxCancel +} diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index a5abddf0..5bd4f7d5 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -124,21 +124,30 @@ CREATE TABLE schedule_member ( CREATE TABLE source ( id bigserial, + -- The type "icinga2" is special and requires (at least some of) the icinga2_ prefixed columns. type text NOT NULL, name text NOT NULL, -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example -- the Icinga DB environment ID for Icinga 2 sources - -- listener_password_hash is required to limit API access for incoming connections to the Listener. The username is - -- "source-${id}", allowing an early verification before having to parse the POSTed event. - -- - -- This behavior might change in the future to become "type"-dependable. - listener_password_hash text NOT NULL, + -- The column listener_password_hash is type-dependent. + -- If type is not "icinga2", listener_password_hash is required to limit API access for incoming connections + -- to the Listener. The username will be "source-${id}", allowing early verification. + listener_password_hash text, + + -- Following columns are for the "icinga2" type. + -- At least icinga2_base_url, icinga2_auth_user, and icinga2_auth_pass are required - see CHECK below. + icinga2_base_url text, + icinga2_auth_user text, + icinga2_auth_pass text, + icinga2_ca_pem text, + icinga2_insecure_tls boolenum NOT NULL DEFAULT 'n', -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures -- that listener_password_hash can only be populated with bcrypt hashes. -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend - CHECK (listener_password_hash LIKE '$2y$%'), + CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2y$%'), + CHECK (type != 'icinga2' OR (icinga2_base_url IS NOT NULL AND icinga2_auth_user IS NOT NULL AND icinga2_auth_pass IS NOT NULL)), CONSTRAINT pk_source PRIMARY KEY (id) ); diff --git a/schema/pgsql/upgrades/022.sql b/schema/pgsql/upgrades/022.sql new file mode 100644 index 00000000..3b26a161 --- /dev/null +++ b/schema/pgsql/upgrades/022.sql @@ -0,0 +1,22 @@ +ALTER TABLE source + ALTER COLUMN listener_password_hash DROP NOT NULL, + + ADD COLUMN icinga2_base_url text, + ADD COLUMN icinga2_auth_user text, + ADD COLUMN icinga2_auth_pass text, + ADD COLUMN icinga2_ca_pem text, + ADD COLUMN icinga2_insecure_tls boolenum NOT NULL DEFAULT 'n', + + DROP CONSTRAINT source_listener_password_hash_check; + +-- NOTE: Change those defaults as they most likely don't work with your installation! +UPDATE source + SET icinga2_base_url = 'https://localhost:5665/', + icinga2_auth_user = 'root', + icinga2_auth_pass = 'icinga', + icinga2_insecure_tls = 'y' + WHERE type = 'icinga2'; + +ALTER TABLE source + ADD CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2y$%'), + ADD CHECK (type != 'icinga2' OR (icinga2_base_url IS NOT NULL AND icinga2_auth_user IS NOT NULL AND icinga2_auth_pass IS NOT NULL));