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));