From 821a564b8402df3797fe4bd981cf47ba07a57051 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Wed, 1 Jul 2026 18:25:20 +0200 Subject: [PATCH 01/19] Support source authentication using client certificates Add a nullable unique `client_certificate_cn` column to the source table, allowing a source to be identified by its TLS client certificate's Subject CN instead of a username/password pair. The existing check constraint is relaxed to accept either identity form, and migration scripts are provided for both MySQL and PostgreSQL. When a request arrives over TLS with a verified client certificate, the source is now resolved from the certificate's Subject CN before falling back to Basic Auth. The 401 error message is extended to mention client certificate auth when TLS is active. TestSourceFromAuthOrAbort covers four scenarios: a matching CN authenticates the correct source, an unknown CN returns 401, an unknown CN falls back to Basic Auth when a matching source exists, and TLS active with no client certificate also falls back to Basic Auth. --- internal/config/runtime.go | 13 +++ internal/config/source.go | 6 +- internal/listener/listener.go | 13 +++ internal/listener/listener_test.go | 86 +++++++++++++++++-- schema/mysql/schema.sql | 5 +- .../upgrades/add-client-certificates.sql | 5 ++ schema/pgsql/schema.sql | 5 +- .../upgrades/add-client-certificates.sql | 5 ++ 8 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 schema/mysql/upgrades/add-client-certificates.sql create mode 100644 schema/pgsql/upgrades/add-client-certificates.sql diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 98915cee..4ec21f0f 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -210,6 +210,19 @@ func (r *RuntimeConfig) GetSourceByUsername(user string) *Source { return nil } +// GetSourceByCN returns a *Source by the given common name. +func (r *RuntimeConfig) GetSourceByCN(cn string) *Source { + r.RLock() + defer r.RUnlock() + for _, src := range r.Sources { + if src.ClientCertificateCN.Valid && src.ClientCertificateCN.String == cn { + return src + } + } + + return nil +} + func (r *RuntimeConfig) fetchFromDatabase(ctx context.Context) error { tx, err := r.db.BeginTxx(ctx, &sql.TxOptions{ Isolation: sql.LevelRepeatableRead, diff --git a/internal/config/source.go b/internal/config/source.go index c2c817ec..d893173a 100644 --- a/internal/config/source.go +++ b/internal/config/source.go @@ -3,12 +3,13 @@ package config import ( "crypto/subtle" "fmt" + "slices" + "sync" + "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/config/baseconf" "go.uber.org/zap/zapcore" "golang.org/x/crypto/bcrypt" - "slices" - "sync" ) // Source entry within the ConfigSet to describe a source. @@ -18,6 +19,7 @@ type Source struct { Type string `db:"type"` Name string `db:"name"` + ClientCertificateCN types.String `db:"client_certificate_cn"` ListenerUsername types.String `db:"listener_username"` ListenerPasswordHash types.String `db:"listener_password_hash"` listenerPassword []byte `db:"-"` diff --git a/internal/listener/listener.go b/internal/listener/listener.go index d722546c..9d982b10 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -302,6 +302,19 @@ func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) return errFunc(fmt.Sprintf("system user %q is not registered as a source username", username)) } } else { + if r.TLS != nil && len(r.TLS.VerifiedChains) > 0 { + if clientCert := r.TLS.VerifiedChains[0][0]; clientCert != nil { + if src := l.runtimeConfig.GetSourceByCN(clientCert.Subject.CommonName); src != nil { + l.logger.Debugw( + "Source is authenticated via a TLS client certificate", + zap.String("source_name", src.Name), + zap.String("common name", clientCert.Subject.CommonName), + ) + return src + } + } + } + l.logger.Debugw("Source is authenticated via HTTP Basic Auth") authUser, authPass, authOk := r.BasicAuth() if !authOk { diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go index d531f70a..9b024a6b 100644 --- a/internal/listener/listener_test.go +++ b/internal/listener/listener_test.go @@ -2,6 +2,9 @@ package listener import ( "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "net/http" "net/http/httptest" "os/user" @@ -17,7 +20,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -func makeTestListener(t *testing.T, useSocket bool) *Listener { +func makeTestListener(t *testing.T, useSocket bool, withCNSrc bool) *Listener { var src *config.Source if useSocket { u, err := user.Current() @@ -39,6 +42,10 @@ func makeTestListener(t *testing.T, useSocket bool) *Listener { rc := &config.RuntimeConfig{} rc.Sources = map[int64]*config.Source{1: src} + if withCNSrc { + rc.Sources[2] = &config.Source{ClientCertificateCN: types.MakeString("icinga-source")} + } + return &Listener{ runtimeConfig: rc, useSocket: useSocket, @@ -54,6 +61,17 @@ func withPeerUsername(req *http.Request, username string) *http.Request { return req.WithContext(context.WithValue(req.Context(), peerUserLookupKey{}, peerUserLookupFunc(setUser))) } +// makeRequestWithClientCert returns a POST request whose TLS state contains a verified client +// certificate with the given common name. +func makeRequestWithClientCert(cn string) *http.Request { + cert := &x509.Certificate{Subject: pkix.Name{CommonName: cn}} + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.TLS = &tls.ConnectionState{ + VerifiedChains: [][]*x509.Certificate{{cert}}, + } + return req +} + func TestSourceFromAuthOrAbort(t *testing.T) { t.Parallel() @@ -63,7 +81,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("ValidCredsMatchingSource", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) src := l.runtimeConfig.Sources[1] username := src.ListenerUsername.String req := withPeerUsername(httptest.NewRequest(http.MethodPost, "/", nil), username) @@ -77,7 +95,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("ValidCredsNoMatchingSource", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) username := l.runtimeConfig.Sources[1].ListenerUsername.String // Replace the source with one that has a different username. l.runtimeConfig.Sources[1] = &config.Source{ @@ -95,7 +113,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("CredsWithUnknownUsername", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) req := withPeerUsername(httptest.NewRequest(http.MethodPost, "/", nil), "unknown User") rw := httptest.NewRecorder() @@ -109,7 +127,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("NoCredsInContext", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) req := httptest.NewRequest(http.MethodPost, "/", nil) rw := httptest.NewRecorder() @@ -126,7 +144,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("CorrectCredentials", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) src := l.runtimeConfig.Sources[1] req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("icingadb", "secret") @@ -140,7 +158,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("WrongPassword", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("icingadb", "wrongpassword") rw := httptest.NewRecorder() @@ -154,7 +172,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("UnknownUsername", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("unknown", "secret") rw := httptest.NewRecorder() @@ -168,7 +186,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("MissingAuthHeader", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) req := httptest.NewRequest(http.MethodPost, "/", nil) rw := httptest.NewRecorder() @@ -178,4 +196,54 @@ func TestSourceFromAuthOrAbort(t *testing.T) { assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) }) }) + + t.Run("Tls", func(t *testing.T) { + t.Parallel() + l := makeTestListener(t, false, true) + + cnSrc := l.runtimeConfig.Sources[2] + basicSrc := l.runtimeConfig.Sources[1] + + t.Run("CertMatchingSource", func(t *testing.T) { + t.Parallel() + + req := makeRequestWithClientCert("icinga-source") + rw := httptest.NewRecorder() + + assert.Same(t, cnSrc, l.sourceFromAuthOrAbort(rw, req)) + }) + + t.Run("CertCNNotMatchingAnySource", func(t *testing.T) { + t.Parallel() + + req := makeRequestWithClientCert("unknown-cn") + rw := httptest.NewRecorder() + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) + assert.Contains(t, rw.Body.String(), "missing or malformed basic auth credentials") + }) + + t.Run("CertNoMatchFallsBackToBasicAuth", func(t *testing.T) { + t.Parallel() + + req := makeRequestWithClientCert("unknown-cn") + req.SetBasicAuth("icingadb", "secret") + rw := httptest.NewRecorder() + + assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) + }) + + t.Run("EmptyVerifiedChains", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.TLS = &tls.ConnectionState{} // TLS active but no client certificate presented + req.SetBasicAuth("icingadb", "secret") + rw := httptest.NewRecorder() + + assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) + }) + }) } diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index f26d85ff..72c2fc12 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -243,12 +243,15 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, + client_certificate_cn varchar(64) DEFAULT NULL, + changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', locked enum('n', 'y') NOT NULL DEFAULT 'n', -- set to 'y' when the source is maintained by an integration CONSTRAINT uk_source_listener_username UNIQUE (listener_username), - CONSTRAINT ck_source_listener_username_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL), + CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn), + CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL), -- 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. diff --git a/schema/mysql/upgrades/add-client-certificates.sql b/schema/mysql/upgrades/add-client-certificates.sql new file mode 100644 index 00000000..f606a632 --- /dev/null +++ b/schema/mysql/upgrades/add-client-certificates.sql @@ -0,0 +1,5 @@ +ALTER TABLE source ADD COLUMN client_certificate_cn varchar(64) DEFAULT NULL AFTER listener_password_hash; +ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn); +ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; +ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted + CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL); diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 359556e7..0e80caa8 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -269,12 +269,15 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, + client_certificate_cn varchar(64) DEFAULT NULL, + changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', locked boolenum NOT NULL DEFAULT 'n', -- set to 'y' when the source is maintained by an integration CONSTRAINT uk_source_listener_username UNIQUE (listener_username), - CONSTRAINT ck_source_listener_username_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL), + CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn), + CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL), -- 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. diff --git a/schema/pgsql/upgrades/add-client-certificates.sql b/schema/pgsql/upgrades/add-client-certificates.sql new file mode 100644 index 00000000..564460c2 --- /dev/null +++ b/schema/pgsql/upgrades/add-client-certificates.sql @@ -0,0 +1,5 @@ +ALTER TABLE source ADD COLUMN client_certificate_cn varchar(64) DEFAULT NULL; +ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn); +ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; +ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted + CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL); From 52d816c9f568617bf8dd79d3d82dc8efa7349b03 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Fri, 3 Jul 2026 17:36:02 +0200 Subject: [PATCH 02/19] Add docs about client certificate auth --- doc/03-Configuration.md | 4 ++++ doc/20-HTTP-API.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index bb597b8f..c49fff65 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -50,6 +50,10 @@ TLS options apply only to the TCP listener. When a Unix socket is configured, Icinga Notifications identifies connecting processes by their OS user, and matches the OS username against the source's configured username. No password or HTTP Basic Auth is involved. +When TLS is enabled, sources can optionally be identified by a TLS client certificate's Subject CN +instead of a username/password pair. To use this, configure a CA via `ca` so that client certificates +can be verified, and set the `client_certificate_cn` field on the source in Icinga Notifications Web. + For YAML configuration, the options are part of the `listener` section. For environment variables, each option is prefixed with `ICINGA_NOTIFICATIONS_LISTENER_`. diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index e78fbf84..746bfc8f 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -15,6 +15,8 @@ Authentication differs by transport: - **TCP:** HTTP Basic Authentication is used; both the source's username and password must match the configured credentials. +- **TCP with TLS:** If the request arrives with a verified TLS client certificate, the source is identified by the + certificate's Subject CN. If no matching source is found, the request falls back to HTTP Basic Authentication. - **Unix socket:** The caller is identified automatically by their OS user. No HTTP Basic Auth or password is involved. From 83b4818f461c6ec2226a48cef4a6e02a74486863 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Tue, 7 Jul 2026 18:04:05 +0200 Subject: [PATCH 03/19] Adjust docs about client cert auth --- doc/20-HTTP-API.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index 746bfc8f..f27f287d 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -15,7 +15,7 @@ Authentication differs by transport: - **TCP:** HTTP Basic Authentication is used; both the source's username and password must match the configured credentials. -- **TCP with TLS:** If the request arrives with a verified TLS client certificate, the source is identified by the +- **TCP with TLS:** If the request arrives with a TLS client certificate signed by the CA, the source is identified by the certificate's Subject CN. If no matching source is found, the request falls back to HTTP Basic Authentication. - **Unix socket:** The caller is identified automatically by their OS user. No HTTP Basic Auth or password is involved. @@ -101,13 +101,26 @@ curl -v -u 'icingadb:insecureinsecure' -H 'X-Icinga-Reject-If-Relations-Incomple EOF ``` -To submit over a Unix socket instead, pass `--unix-socket /run/icinga/icinga-notifications.sock` to curl. +To submit over a **Unix socket** instead, pass `--unix-socket /run/icinga/icinga-notifications.sock` to curl. No credentials are needed; the daemon identifies the caller by their OS user automatically. !!! info curl must be executed as a user which is configured as listener_username of a source. +To submit over **TLS using a client certificate** instead of HTTP Basic Authentication, +pass `--cacert ca.crt --cert client.crt --key client.key` to curl and omit `-u`: + +``` +curl -v --cacert ca.crt --cert client.crt --key client.key -H 'X-Icinga-Reject-If-Relations-Incomplete: true' -d '@-' 'https://localhost:5680/process-event' < Date: Tue, 7 Jul 2026 18:18:05 +0200 Subject: [PATCH 04/19] listener: fix error messages and debug logs --- internal/listener/listener.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 9d982b10..376fa99e 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -275,32 +275,38 @@ func (l *Listener) initSocketServer(server *http.Server) (fn func() error, retEr // If no matching source is found, nil is returned and 401 is written to the response. func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) (src *config.Source) { errFunc := func(errMsg string) *config.Source { + l.logger.Errorw("Error in sourceFromAuthOrAbort", zap.String("error", errMsg)) w.WriteHeader(http.StatusUnauthorized) _, _ = fmt.Fprintln(w, errMsg) return nil } if l.useSocket { - l.logger.Debugw("Source is authenticated via socket connection") value := r.Context().Value(peerUserLookupKey{}) if value == nil { - return errFunc("no value found in context") + return errFunc("unix socket: no value found in context") } lookup, ok := value.(peerUserLookupFunc) if !ok { - return errFunc("no lookup function present") + return errFunc("unix socket: no lookup function present") } username, err := lookup() if err != nil { - return errFunc(err.Error()) + return errFunc("unix socket: " + err.Error()) } src = l.runtimeConfig.GetSourceByUsername(username) if src == nil { - return errFunc(fmt.Sprintf("system user %q is not registered as a source username", username)) + return errFunc(fmt.Sprintf("unix socket: system user %q is not registered as a source username", username)) } + + l.logger.Debugw( + "Source is authenticated via socket connection", + zap.String("source_name", src.Name), + zap.String("username", username), + ) } else { if r.TLS != nil && len(r.TLS.VerifiedChains) > 0 { if clientCert := r.TLS.VerifiedChains[0][0]; clientCert != nil { @@ -308,25 +314,32 @@ func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) l.logger.Debugw( "Source is authenticated via a TLS client certificate", zap.String("source_name", src.Name), - zap.String("common name", clientCert.Subject.CommonName), + zap.String("common_name", clientCert.Subject.CommonName), ) return src } + + return errFunc("tls cert: no matching source found") } } - l.logger.Debugw("Source is authenticated via HTTP Basic Auth") authUser, authPass, authOk := r.BasicAuth() if !authOk { w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) - return errFunc("missing or malformed basic auth credentials") + return errFunc("basic auth: missing or malformed basic auth credentials") } src = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) if src == nil { w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) - return errFunc("expected valid icinga-notifications source basic auth credentials") + return errFunc("basic auth: expected valid icinga-notifications source basic auth credentials") } + + l.logger.Debugw( + "Source is authenticated via HTTP Basic Auth", + zap.String("source_name", src.Name), + zap.String("username", authUser), + ) } return src From ac0edbba9d3b41b566337e38b2aca76005710dee Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Wed, 8 Jul 2026 13:01:19 +0200 Subject: [PATCH 05/19] Add CRL support for TLS client certificate revocation Introduce a crl_file config option that loads a PEM or DER CRL and verifies each connecting client certificate against it on every TLS handshake. The CRL is validated against the configured CA and reloaded automatically whenever the file changes, including atomic replacements via mv. Refactor sourceFromAuthOrAbort to make the TLS certificate and Basic Auth paths mutually exclusive: a request with a verified chain always uses cert auth and never silently falls back to Basic Auth. Empty inner chains and nil leaf certificates are now explicit errors rather than silent fall-throughs. --- go.mod | 1 + go.sum | 2 + internal/daemon/config.go | 45 ++++++++- internal/daemon/crl.go | 143 +++++++++++++++++++++++++++++ internal/listener/listener.go | 99 ++++++++++++-------- internal/listener/listener_test.go | 47 ++++++++-- 6 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 internal/daemon/crl.go diff --git a/go.mod b/go.mod index 6f8b9c69..137f1763 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/creasty/defaults v1.8.0 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 + github.com/fsnotify/fsnotify v1.10.1 github.com/google/uuid v1.6.0 github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77 github.com/jhillyerd/enmime v1.3.0 diff --git a/go.sum b/go.sum index f2fdeeda..0fff284b 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6 github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= diff --git a/internal/daemon/config.go b/internal/daemon/config.go index 84581b6b..fc5a818f 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -1,6 +1,7 @@ package daemon import ( + "context" "crypto/tls" "errors" "fmt" @@ -48,6 +49,7 @@ type Listener struct { DebugPassword string `yaml:"debug_password" env:"DEBUG_PASSWORD"` DebugPasswordFile string `yaml:"debug_password_file" env:"DEBUG_PASSWORD_FILE"` TLSOptions config.TLSCommon `yaml:",inline"` + CRLFile string `yaml:"crl_file" env:"CRL_FILE"` } func (l *Listener) Validate() error { @@ -94,9 +96,48 @@ func (l *Listener) Validate() error { } // GetTlsConfig returns a *[tls.Config] based on the TLS options specified in the Listener configuration. -func (l *Listener) GetTlsConfig() (*tls.Config, error) { +func (l *Listener) GetTlsConfig(ctx context.Context, logger *logging.Logger) (*tls.Config, error) { + //tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} + //return tlsOpts.MakeConfig() + tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} - return tlsOpts.MakeConfig() + tlsConfig, err := tlsOpts.MakeConfig() + if err != nil { + return nil, err + } + + if tlsConfig != nil && l.CRLFile != "" { + caCert, err := parseCACert(l.TLSOptions.Ca) + if err != nil { + return nil, fmt.Errorf("cannot parse CA cert for CRL verification: %w", err) + } + + checker, err := newCRLChecker(l.CRLFile, caCert) + if err != nil { + return nil, fmt.Errorf("cannot load CRL: %w", err) + } + + if err := checker.WatchAndReload(ctx, logger); err != nil { + return nil, err + } + + tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error { + if len(cs.VerifiedChains) == 0 { + return nil // no client cert presented — skip + } + leaf := cs.VerifiedChains[0][0] + revoked, err := checker.isRevoked(leaf.SerialNumber) + if err != nil { + return fmt.Errorf("CRL check failed: %w", err) + } + if revoked { + return fmt.Errorf("client certificate revoked (serial %s)", leaf.SerialNumber) + } + return nil + } + } + + return tlsConfig, nil } type ConfigFile struct { diff --git a/internal/daemon/crl.go b/internal/daemon/crl.go new file mode 100644 index 00000000..b1c215c4 --- /dev/null +++ b/internal/daemon/crl.go @@ -0,0 +1,143 @@ +package daemon + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/icinga/icinga-go-library/logging" + "go.uber.org/zap" +) + +type crlChecker struct { + path string + issuer *x509.Certificate + mu sync.RWMutex + crl *x509.RevocationList +} + +func newCRLChecker(path string, issuer *x509.Certificate) (*crlChecker, error) { + c := &crlChecker{path: path, issuer: issuer} + if err := c.reload(); err != nil { + return nil, err + } + return c, nil +} + +func (c *crlChecker) reload() error { + data, err := os.ReadFile(c.path) + if err != nil { + return fmt.Errorf("cannot read CRL file %q: %w", c.path, err) + } + if block, _ := pem.Decode(data); block != nil { + data = block.Bytes // strip PEM wrapper if present; ParseRevocationList wants DER + } + rl, err := x509.ParseRevocationList(data) + if err != nil { + return fmt.Errorf("cannot parse CRL: %w", err) + } + if err := rl.CheckSignatureFrom(c.issuer); err != nil { + return fmt.Errorf("CRL signature invalid: %w", err) + } + + c.mu.Lock() + c.crl = rl + c.mu.Unlock() + + return nil +} + +func (c *crlChecker) WatchAndReload(ctx context.Context, logger *logging.Logger) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("cannot create watcher: %w", err) + } + if err := watcher.Add(c.path); err != nil { + if err := watcher.Close(); err != nil { + return fmt.Errorf("cannot close watcher: %w", err) + } + return fmt.Errorf("cannot watch CRL file: %w", err) + } + + go func() { + defer func() { + if err := watcher.Close(); err != nil { + logger.Warn("cannot close watcher", zap.Error(err)) + } + }() + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { + // Re-add path on Create/Rename: on Linux, atomic file replacement + // (mv tmp.crl ca.crl) fires Rename on the old inode — the watcher + // loses track of the path without re-adding it. + _ = watcher.Add(c.path) + if err := c.reload(); err != nil { + logger.Warnw("CRL reload failed", zap.Error(err)) + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + logger.Warnw("CRL watcher error", zap.Error(err)) + } + } + }() + + return nil +} + +func (c *crlChecker) isRevoked(serial *big.Int) (bool, error) { + c.mu.RLock() + rl := c.crl + c.mu.RUnlock() + + if time.Now().After(rl.NextUpdate) { + if err := c.reload(); err != nil { + return false, fmt.Errorf("CRL expired and reload failed: %w", err) + } + c.mu.RLock() + rl = c.crl + c.mu.RUnlock() + } + + for _, entry := range rl.RevokedCertificateEntries { + if entry.SerialNumber.Cmp(serial) == 0 { + return true, nil + } + } + return false, nil +} + +func parseCACert(pemOrPath string) (*x509.Certificate, error) { + cleaned := filepath.Clean(pemOrPath) + if cleaned == "" || cleaned == "/" { + return nil, fmt.Errorf("parse CA cert: invalid path") + } + + data, err := os.ReadFile(cleaned) + if err != nil { + // wasn't a path — treat it as raw PEM + data = []byte(pemOrPath) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("cannot decode PEM from CA config") + } + return x509.ParseCertificate(block.Bytes) +} diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 376fa99e..68a6bb2b 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -129,7 +129,7 @@ func (l *Listener) Run(ctx context.Context) error { if l.useSocket { listeningFunc, err = l.initSocketServer(server) } else { - listeningFunc, err = l.initTcpServer(server) + listeningFunc, err = l.initTcpServer(server, ctx) } if err != nil { return err @@ -153,9 +153,9 @@ func (l *Listener) Run(ctx context.Context) error { // initTcpServer configures the given HTTP(S) server for the configured TCP address and returns a function that // starts serving. The caller is responsible for actually calling that function and for graceful shutdown. -func (l *Listener) initTcpServer(server *http.Server) (func() error, error) { +func (l *Listener) initTcpServer(server *http.Server, ctx context.Context) (func() error, error) { conf := daemon.Config().Listener - tlsConfig, err := conf.GetTlsConfig() + tlsConfig, err := conf.GetTlsConfig(ctx, l.logger) if err != nil { return nil, err } @@ -274,32 +274,41 @@ func (l *Listener) initSocketServer(server *http.Server) (fn func() error, retEr // up by the OS username of the connecting process via peer credentials. For TCP connections, HTTP Basic Auth is used. // If no matching source is found, nil is returned and 401 is written to the response. func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) (src *config.Source) { - errFunc := func(errMsg string) *config.Source { + var errMsg string + errFunc := func(httpCode int, sendAuthHeader bool) *config.Source { + if sendAuthHeader { + w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) + } l.logger.Errorw("Error in sourceFromAuthOrAbort", zap.String("error", errMsg)) - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(httpCode) _, _ = fmt.Fprintln(w, errMsg) return nil } if l.useSocket { + errMsg = "unix socket: " value := r.Context().Value(peerUserLookupKey{}) if value == nil { - return errFunc("unix socket: no value found in context") + errMsg += "no client certificate found" + return errFunc(http.StatusUnauthorized, false) } lookup, ok := value.(peerUserLookupFunc) if !ok { - return errFunc("unix socket: no lookup function present") + errMsg += "no lookup function present" + return errFunc(http.StatusUnauthorized, false) } username, err := lookup() if err != nil { - return errFunc("unix socket: " + err.Error()) + errMsg += err.Error() + return errFunc(http.StatusUnauthorized, false) } src = l.runtimeConfig.GetSourceByUsername(username) if src == nil { - return errFunc(fmt.Sprintf("unix socket: system user %q is not registered as a source username", username)) + errMsg += fmt.Sprintf("system user %q is not registered as a source username", username) + return errFunc(http.StatusUnauthorized, false) } l.logger.Debugw( @@ -308,38 +317,56 @@ func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) zap.String("username", username), ) } else { + authUser, authPass, authOk := r.BasicAuth() if r.TLS != nil && len(r.TLS.VerifiedChains) > 0 { - if clientCert := r.TLS.VerifiedChains[0][0]; clientCert != nil { - if src := l.runtimeConfig.GetSourceByCN(clientCert.Subject.CommonName); src != nil { - l.logger.Debugw( - "Source is authenticated via a TLS client certificate", - zap.String("source_name", src.Name), - zap.String("common_name", clientCert.Subject.CommonName), - ) - return src - } - - return errFunc("tls cert: no matching source found") + // if VerifiedChains isn't empty, the request doesn't fall back to basic auth + errMsg = "tls cert: " + if authUser != "" || authPass != "" { + errMsg += " both client certificate and basic auth provided" + return errFunc(http.StatusBadRequest, false) } - } - authUser, authPass, authOk := r.BasicAuth() - if !authOk { - w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) - return errFunc("basic auth: missing or malformed basic auth credentials") - } + if len(r.TLS.VerifiedChains[0]) == 0 { + errMsg += "no verified chain found" + return errFunc(http.StatusUnauthorized, true) + } - src = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) - if src == nil { - w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) - return errFunc("basic auth: expected valid icinga-notifications source basic auth credentials") - } + clientCert := r.TLS.VerifiedChains[0][0] + if clientCert == nil { + errMsg += "no client certificate found" + return errFunc(http.StatusUnauthorized, true) + } - l.logger.Debugw( - "Source is authenticated via HTTP Basic Auth", - zap.String("source_name", src.Name), - zap.String("username", authUser), - ) + src = l.runtimeConfig.GetSourceByCN(clientCert.Subject.CommonName) + if src == nil { + errMsg += "no matching source found" + return errFunc(http.StatusUnauthorized, true) + } + + l.logger.Debugw( + "Source is authenticated via a TLS client certificate", + zap.String("source_name", src.Name), + zap.String("common_name", clientCert.Subject.CommonName), + ) + } else { + errMsg = "basic auth: " + if !authOk { + errMsg += "missing or malformed basic auth credentials" + return errFunc(http.StatusUnauthorized, true) + } + + src = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) + if src == nil { + errMsg += "expected valid icinga-notifications source basic auth credentials" + return errFunc(http.StatusUnauthorized, true) + } + + l.logger.Debugw( + "Source is authenticated via HTTP Basic Auth", + zap.String("source_name", src.Name), + zap.String("username", authUser), + ) + } } return src diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go index 9b024a6b..69b309ff 100644 --- a/internal/listener/listener_test.go +++ b/internal/listener/listener_test.go @@ -222,28 +222,61 @@ func TestSourceFromAuthOrAbort(t *testing.T) { assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) assert.Equal(t, http.StatusUnauthorized, rw.Code) assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) - assert.Contains(t, rw.Body.String(), "missing or malformed basic auth credentials") + assert.Contains(t, rw.Body.String(), "no matching source found") }) - t.Run("CertNoMatchFallsBackToBasicAuth", func(t *testing.T) { + t.Run("NoCertFallsBackToBasicAuth", func(t *testing.T) { t.Parallel() - req := makeRequestWithClientCert("unknown-cn") + req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("icingadb", "secret") rw := httptest.NewRecorder() + req.TLS = &tls.ConnectionState{} // no verified chains + assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) + + req.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{}} // empty verified chains assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) }) - t.Run("EmptyVerifiedChains", func(t *testing.T) { + t.Run("ClientCertAndBasicAuth", func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.TLS = &tls.ConnectionState{} // TLS active but no client certificate presented + req := makeRequestWithClientCert("unknown-cn") req.SetBasicAuth("icingadb", "secret") rw := httptest.NewRecorder() - assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "client certificate and basic auth provided") + }) + + t.Run("EmptyVerifiedChain", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + rw := httptest.NewRecorder() + + req.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{}}} + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) + assert.Contains(t, rw.Body.String(), "no verified chain found") + }) + + t.Run("EmptyClientCertificate", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + rw := httptest.NewRecorder() + + req.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{nil}}} + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) + assert.Contains(t, rw.Body.String(), "no client certificate found") }) }) } From 92c3f22c24a7afccc45acd9d45aa4eb64b545e3a Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Wed, 8 Jul 2026 15:33:11 +0200 Subject: [PATCH 06/19] move CRL-check logic in IGL --- go.mod | 2 +- internal/daemon/config.go | 36 +--------- internal/daemon/crl.go | 143 -------------------------------------- 3 files changed, 4 insertions(+), 177 deletions(-) delete mode 100644 internal/daemon/crl.go diff --git a/go.mod b/go.mod index 137f1763..b8a2a83e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/creasty/defaults v1.8.0 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 - github.com/fsnotify/fsnotify v1.10.1 github.com/google/uuid v1.6.0 github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77 github.com/jhillyerd/enmime v1.3.0 @@ -28,6 +27,7 @@ require ( github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-yaml v1.13.0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect diff --git a/internal/daemon/config.go b/internal/daemon/config.go index fc5a818f..9d7b3dee 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -49,7 +49,6 @@ type Listener struct { DebugPassword string `yaml:"debug_password" env:"DEBUG_PASSWORD"` DebugPasswordFile string `yaml:"debug_password_file" env:"DEBUG_PASSWORD_FILE"` TLSOptions config.TLSCommon `yaml:",inline"` - CRLFile string `yaml:"crl_file" env:"CRL_FILE"` } func (l *Listener) Validate() error { @@ -97,44 +96,15 @@ func (l *Listener) Validate() error { // GetTlsConfig returns a *[tls.Config] based on the TLS options specified in the Listener configuration. func (l *Listener) GetTlsConfig(ctx context.Context, logger *logging.Logger) (*tls.Config, error) { - //tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} - //return tlsOpts.MakeConfig() - tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} tlsConfig, err := tlsOpts.MakeConfig() if err != nil { return nil, err } - if tlsConfig != nil && l.CRLFile != "" { - caCert, err := parseCACert(l.TLSOptions.Ca) - if err != nil { - return nil, fmt.Errorf("cannot parse CA cert for CRL verification: %w", err) - } - - checker, err := newCRLChecker(l.CRLFile, caCert) - if err != nil { - return nil, fmt.Errorf("cannot load CRL: %w", err) - } - - if err := checker.WatchAndReload(ctx, logger); err != nil { - return nil, err - } - - tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error { - if len(cs.VerifiedChains) == 0 { - return nil // no client cert presented — skip - } - leaf := cs.VerifiedChains[0][0] - revoked, err := checker.isRevoked(leaf.SerialNumber) - if err != nil { - return fmt.Errorf("CRL check failed: %w", err) - } - if revoked { - return fmt.Errorf("client certificate revoked (serial %s)", leaf.SerialNumber) - } - return nil - } + err = tlsOpts.ApplyRevocationCheck(ctx, logger.SugaredLogger, tlsConfig) + if err != nil { + return nil, err } return tlsConfig, nil diff --git a/internal/daemon/crl.go b/internal/daemon/crl.go deleted file mode 100644 index b1c215c4..00000000 --- a/internal/daemon/crl.go +++ /dev/null @@ -1,143 +0,0 @@ -package daemon - -import ( - "context" - "crypto/x509" - "encoding/pem" - "fmt" - "math/big" - "os" - "path/filepath" - "sync" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/icinga/icinga-go-library/logging" - "go.uber.org/zap" -) - -type crlChecker struct { - path string - issuer *x509.Certificate - mu sync.RWMutex - crl *x509.RevocationList -} - -func newCRLChecker(path string, issuer *x509.Certificate) (*crlChecker, error) { - c := &crlChecker{path: path, issuer: issuer} - if err := c.reload(); err != nil { - return nil, err - } - return c, nil -} - -func (c *crlChecker) reload() error { - data, err := os.ReadFile(c.path) - if err != nil { - return fmt.Errorf("cannot read CRL file %q: %w", c.path, err) - } - if block, _ := pem.Decode(data); block != nil { - data = block.Bytes // strip PEM wrapper if present; ParseRevocationList wants DER - } - rl, err := x509.ParseRevocationList(data) - if err != nil { - return fmt.Errorf("cannot parse CRL: %w", err) - } - if err := rl.CheckSignatureFrom(c.issuer); err != nil { - return fmt.Errorf("CRL signature invalid: %w", err) - } - - c.mu.Lock() - c.crl = rl - c.mu.Unlock() - - return nil -} - -func (c *crlChecker) WatchAndReload(ctx context.Context, logger *logging.Logger) error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("cannot create watcher: %w", err) - } - if err := watcher.Add(c.path); err != nil { - if err := watcher.Close(); err != nil { - return fmt.Errorf("cannot close watcher: %w", err) - } - return fmt.Errorf("cannot watch CRL file: %w", err) - } - - go func() { - defer func() { - if err := watcher.Close(); err != nil { - logger.Warn("cannot close watcher", zap.Error(err)) - } - }() - - for { - select { - case <-ctx.Done(): - return - case event, ok := <-watcher.Events: - if !ok { - return - } - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { - // Re-add path on Create/Rename: on Linux, atomic file replacement - // (mv tmp.crl ca.crl) fires Rename on the old inode — the watcher - // loses track of the path without re-adding it. - _ = watcher.Add(c.path) - if err := c.reload(); err != nil { - logger.Warnw("CRL reload failed", zap.Error(err)) - } - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - logger.Warnw("CRL watcher error", zap.Error(err)) - } - } - }() - - return nil -} - -func (c *crlChecker) isRevoked(serial *big.Int) (bool, error) { - c.mu.RLock() - rl := c.crl - c.mu.RUnlock() - - if time.Now().After(rl.NextUpdate) { - if err := c.reload(); err != nil { - return false, fmt.Errorf("CRL expired and reload failed: %w", err) - } - c.mu.RLock() - rl = c.crl - c.mu.RUnlock() - } - - for _, entry := range rl.RevokedCertificateEntries { - if entry.SerialNumber.Cmp(serial) == 0 { - return true, nil - } - } - return false, nil -} - -func parseCACert(pemOrPath string) (*x509.Certificate, error) { - cleaned := filepath.Clean(pemOrPath) - if cleaned == "" || cleaned == "/" { - return nil, fmt.Errorf("parse CA cert: invalid path") - } - - data, err := os.ReadFile(cleaned) - if err != nil { - // wasn't a path — treat it as raw PEM - data = []byte(pemOrPath) - } - block, _ := pem.Decode(data) - if block == nil { - return nil, fmt.Errorf("cannot decode PEM from CA config") - } - return x509.ParseCertificate(block.Bytes) -} From 283e3010fc5ff42da1deeed70946ced7eaed655d Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Thu, 9 Jul 2026 10:49:34 +0200 Subject: [PATCH 07/19] listener: adapt to InitRevocationChecking API, document crl_file Replace ApplyRevocationCheck with InitRevocationChecking in GetTlsConfig, returning the CrlChecker to the caller. The listener now calls WatchAndReload explicitly, giving it control over the CRL watcher lifecycle. Also document the crl_file option in config.example.yml. --- config.example.yml | 5 +++++ internal/daemon/config.go | 14 +++++++------- internal/listener/listener.go | 6 +++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/config.example.yml b/config.example.yml index 7db099b7..aa7405c5 100644 --- a/config.example.yml +++ b/config.example.yml @@ -51,6 +51,11 @@ listener: # Required if TLS is enabled to be able to verify client certificate if the client provides one. #ca: /path/to/ca.crt + # Path to a Certificate Revocation List (CRL) file in PEM or DER format to enable client certificate revocation checks. + # When set, client certificates signed by the configured CA are checked against this CRL during the TLS handshake. + # Requires ca to be set. Leave unset (the default) to disable CRL checking. + #crl_file: /path/to/ca.crl + # Connection configuration for the database where Icinga Notifications stores configuration and historical data. # This is also the database used in Icinga Notifications Web to view and work with the data. database: diff --git a/internal/daemon/config.go b/internal/daemon/config.go index 9d7b3dee..48eed8b3 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -1,7 +1,6 @@ package daemon import ( - "context" "crypto/tls" "errors" "fmt" @@ -94,20 +93,21 @@ func (l *Listener) Validate() error { return nil } -// GetTlsConfig returns a *[tls.Config] based on the TLS options specified in the Listener configuration. -func (l *Listener) GetTlsConfig(ctx context.Context, logger *logging.Logger) (*tls.Config, error) { +// GetTlsConfig returns a *[tls.Config] and a *[utils.CrlChecker] based on the TLS options +// specified in the Listener configuration. +func (l *Listener) GetTlsConfig() (*tls.Config, *utils.CrlChecker, error) { tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} tlsConfig, err := tlsOpts.MakeConfig() if err != nil { - return nil, err + return nil, nil, err } - err = tlsOpts.ApplyRevocationCheck(ctx, logger.SugaredLogger, tlsConfig) + crlChecker, err := tlsOpts.InitRevocationChecking(tlsConfig) if err != nil { - return nil, err + return nil, nil, err } - return tlsConfig, nil + return tlsConfig, crlChecker, nil } type ConfigFile struct { diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 68a6bb2b..3e48c7d6 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -155,11 +155,15 @@ func (l *Listener) Run(ctx context.Context) error { // starts serving. The caller is responsible for actually calling that function and for graceful shutdown. func (l *Listener) initTcpServer(server *http.Server, ctx context.Context) (func() error, error) { conf := daemon.Config().Listener - tlsConfig, err := conf.GetTlsConfig(ctx, l.logger) + tlsConfig, crlChecker, err := conf.GetTlsConfig() if err != nil { return nil, err } + if err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger); err != nil { + return nil, err + } + var https string if conf.TLSOptions.Enable { https = "s" From 9fa040e721f0d7b022951c9636ecb9793fc558b4 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Thu, 9 Jul 2026 14:13:52 +0200 Subject: [PATCH 08/19] source: match client cert auth by full subject, not only CN The Common Name alone is not unique across different CAs. Matching on the full X.509 subject string is more correct and robust. Rename the database column from client_certificate_cn (varchar(64)) to client_certificate_subject (text) in both MySQL and PostgreSQL schemas and migration scripts, updating Go code accordingly. Also adds a new XOR constraint to enforce that a source uses either username/password or client certificate auth, but not both. --- internal/config/runtime.go | 6 +++--- internal/config/source.go | 10 +++++----- internal/listener/listener.go | 2 +- internal/listener/listener_test.go | 2 +- schema/mysql/schema.sql | 7 ++++--- schema/mysql/upgrades/add-client-certificates.sql | 8 +++++--- schema/pgsql/schema.sql | 7 ++++--- schema/pgsql/upgrades/add-client-certificates.sql | 8 +++++--- 8 files changed, 28 insertions(+), 22 deletions(-) diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 4ec21f0f..9a93b89c 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -210,12 +210,12 @@ func (r *RuntimeConfig) GetSourceByUsername(user string) *Source { return nil } -// GetSourceByCN returns a *Source by the given common name. -func (r *RuntimeConfig) GetSourceByCN(cn string) *Source { +// GetSourceByClientCertSubject returns a *Source by the given certificate subject. +func (r *RuntimeConfig) GetSourceByClientCertSubject(subject string) *Source { r.RLock() defer r.RUnlock() for _, src := range r.Sources { - if src.ClientCertificateCN.Valid && src.ClientCertificateCN.String == cn { + if src.ClientCertificateSubject.Valid && src.ClientCertificateSubject.String == subject { return src } } diff --git a/internal/config/source.go b/internal/config/source.go index d893173a..36e0c52f 100644 --- a/internal/config/source.go +++ b/internal/config/source.go @@ -19,11 +19,11 @@ type Source struct { Type string `db:"type"` Name string `db:"name"` - ClientCertificateCN types.String `db:"client_certificate_cn"` - ListenerUsername types.String `db:"listener_username"` - ListenerPasswordHash types.String `db:"listener_password_hash"` - listenerPassword []byte `db:"-"` - listenerPasswordMutex sync.Mutex + ClientCertificateSubject types.String `db:"client_certificate_subject"` + ListenerUsername types.String `db:"listener_username"` + ListenerPasswordHash types.String `db:"listener_password_hash"` + listenerPassword []byte `db:"-"` + listenerPasswordMutex sync.Mutex // ruleIDs is a list of rule IDs belonging to this source. // diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 3e48c7d6..58d8e4f8 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -341,7 +341,7 @@ func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) return errFunc(http.StatusUnauthorized, true) } - src = l.runtimeConfig.GetSourceByCN(clientCert.Subject.CommonName) + src = l.runtimeConfig.GetSourceByClientCertSubject(clientCert.Subject.String()) if src == nil { errMsg += "no matching source found" return errFunc(http.StatusUnauthorized, true) diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go index 69b309ff..933ed8f9 100644 --- a/internal/listener/listener_test.go +++ b/internal/listener/listener_test.go @@ -43,7 +43,7 @@ func makeTestListener(t *testing.T, useSocket bool, withCNSrc bool) *Listener { rc.Sources = map[int64]*config.Source{1: src} if withCNSrc { - rc.Sources[2] = &config.Source{ClientCertificateCN: types.MakeString("icinga-source")} + rc.Sources[2] = &config.Source{ClientCertificateSubject: types.MakeString("icinga-source")} } return &Listener{ diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index 72c2fc12..3de6c4f7 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -243,15 +243,16 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, - client_certificate_cn varchar(64) DEFAULT NULL, + client_certificate_subject text DEFAULT NULL, changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', locked enum('n', 'y') NOT NULL DEFAULT 'n', -- set to 'y' when the source is maintained by an integration CONSTRAINT uk_source_listener_username UNIQUE (listener_username), - CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn), - CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL), + CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject), + CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL), + CONSTRAINT ck_source_listener_cert_xor_credentials CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), -- 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. diff --git a/schema/mysql/upgrades/add-client-certificates.sql b/schema/mysql/upgrades/add-client-certificates.sql index f606a632..c13baf64 100644 --- a/schema/mysql/upgrades/add-client-certificates.sql +++ b/schema/mysql/upgrades/add-client-certificates.sql @@ -1,5 +1,7 @@ -ALTER TABLE source ADD COLUMN client_certificate_cn varchar(64) DEFAULT NULL AFTER listener_password_hash; -ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn); +ALTER TABLE source ADD COLUMN client_certificate_subject text DEFAULT NULL AFTER listener_password_hash; +ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted - CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL); + CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL); +ALTER TABLE source ADD CONSTRAINT ck_source_listener_cert_xor_credentials + CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 0e80caa8..0dbe5cac 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -269,15 +269,16 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, - client_certificate_cn varchar(64) DEFAULT NULL, + client_certificate_subject text DEFAULT NULL, changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', locked boolenum NOT NULL DEFAULT 'n', -- set to 'y' when the source is maintained by an integration CONSTRAINT uk_source_listener_username UNIQUE (listener_username), - CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn), - CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL), + CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject), + CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL), + CONSTRAINT ck_source_listener_cert_xor_credentials CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), -- 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. diff --git a/schema/pgsql/upgrades/add-client-certificates.sql b/schema/pgsql/upgrades/add-client-certificates.sql index 564460c2..60f94a9c 100644 --- a/schema/pgsql/upgrades/add-client-certificates.sql +++ b/schema/pgsql/upgrades/add-client-certificates.sql @@ -1,5 +1,7 @@ -ALTER TABLE source ADD COLUMN client_certificate_cn varchar(64) DEFAULT NULL; -ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_cn UNIQUE (client_certificate_cn); +ALTER TABLE source ADD COLUMN client_certificate_subject text DEFAULT NULL; +ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted - CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_cn IS NOT NULL); + CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL); +ALTER TABLE source ADD CONSTRAINT ck_source_listener_cert_xor_credentials + CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), From 1d60d2ba7224e85fcdd8156d95b2cdb8aa9c8cc9 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Thu, 9 Jul 2026 15:46:05 +0200 Subject: [PATCH 09/19] Fix cert subject column type and test subject string format MySQL requires a length prefix for UNIQUE indexes on TEXT columns. Change client_certificate_subject to varchar(4096) in both schemas and upgrade scripts. Also fix the test source subject, which was set to a bare CN value instead of the RFC 2253 format that pkix.Name.String() returns. --- internal/listener/listener_test.go | 2 +- schema/mysql/schema.sql | 2 +- schema/mysql/upgrades/add-client-certificates.sql | 2 +- schema/pgsql/schema.sql | 2 +- schema/pgsql/upgrades/add-client-certificates.sql | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go index 933ed8f9..59f4cc32 100644 --- a/internal/listener/listener_test.go +++ b/internal/listener/listener_test.go @@ -43,7 +43,7 @@ func makeTestListener(t *testing.T, useSocket bool, withCNSrc bool) *Listener { rc.Sources = map[int64]*config.Source{1: src} if withCNSrc { - rc.Sources[2] = &config.Source{ClientCertificateSubject: types.MakeString("icinga-source")} + rc.Sources[2] = &config.Source{ClientCertificateSubject: types.MakeString("CN=icinga-source")} } return &Listener{ diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index 3de6c4f7..4e0bf7a5 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -243,7 +243,7 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, - client_certificate_subject text DEFAULT NULL, + client_certificate_subject varchar(4096) DEFAULT NULL, changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', diff --git a/schema/mysql/upgrades/add-client-certificates.sql b/schema/mysql/upgrades/add-client-certificates.sql index c13baf64..e7297953 100644 --- a/schema/mysql/upgrades/add-client-certificates.sql +++ b/schema/mysql/upgrades/add-client-certificates.sql @@ -1,4 +1,4 @@ -ALTER TABLE source ADD COLUMN client_certificate_subject text DEFAULT NULL AFTER listener_password_hash; +ALTER TABLE source ADD COLUMN client_certificate_subject varchar(4096) DEFAULT NULL AFTER listener_password_hash; ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 0dbe5cac..c68695dc 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -269,7 +269,7 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, - client_certificate_subject text DEFAULT NULL, + client_certificate_subject varchar(4096) DEFAULT NULL, changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', diff --git a/schema/pgsql/upgrades/add-client-certificates.sql b/schema/pgsql/upgrades/add-client-certificates.sql index 60f94a9c..e6256bbf 100644 --- a/schema/pgsql/upgrades/add-client-certificates.sql +++ b/schema/pgsql/upgrades/add-client-certificates.sql @@ -1,4 +1,4 @@ -ALTER TABLE source ADD COLUMN client_certificate_subject text DEFAULT NULL; +ALTER TABLE source ADD COLUMN client_certificate_subject varchar(4096) DEFAULT NULL; ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted From 472cc0d44382e25b3866d82893c37b68d733def3 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Thu, 9 Jul 2026 16:00:25 +0200 Subject: [PATCH 10/19] fix: reduce length of `client_certificate_subject` The max length of a key is 3072 bytes (in mariadb <= 10.3 & mysql <= latest). Utfmb8 is used so the max count for the varchar is 768. --- schema/mysql/schema.sql | 2 +- schema/mysql/upgrades/add-client-certificates.sql | 2 +- schema/pgsql/schema.sql | 2 +- schema/pgsql/upgrades/add-client-certificates.sql | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index 4e0bf7a5..1c483cf4 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -243,7 +243,7 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, - client_certificate_subject varchar(4096) DEFAULT NULL, + client_certificate_subject varchar(768) DEFAULT NULL, changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', diff --git a/schema/mysql/upgrades/add-client-certificates.sql b/schema/mysql/upgrades/add-client-certificates.sql index e7297953..e837f4a6 100644 --- a/schema/mysql/upgrades/add-client-certificates.sql +++ b/schema/mysql/upgrades/add-client-certificates.sql @@ -1,4 +1,4 @@ -ALTER TABLE source ADD COLUMN client_certificate_subject varchar(4096) DEFAULT NULL AFTER listener_password_hash; +ALTER TABLE source ADD COLUMN client_certificate_subject varchar(768) DEFAULT NULL AFTER listener_password_hash; ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index c68695dc..9e0b394f 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -269,7 +269,7 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, - client_certificate_subject varchar(4096) DEFAULT NULL, + client_certificate_subject varchar(768) DEFAULT NULL, changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', diff --git a/schema/pgsql/upgrades/add-client-certificates.sql b/schema/pgsql/upgrades/add-client-certificates.sql index e6256bbf..ffa27239 100644 --- a/schema/pgsql/upgrades/add-client-certificates.sql +++ b/schema/pgsql/upgrades/add-client-certificates.sql @@ -1,4 +1,4 @@ -ALTER TABLE source ADD COLUMN client_certificate_subject varchar(4096) DEFAULT NULL; +ALTER TABLE source ADD COLUMN client_certificate_subject varchar(768) DEFAULT NULL; ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted From 5652b43a8504677e7f53b2cb237845ac83e546b0 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Fri, 10 Jul 2026 17:19:16 +0200 Subject: [PATCH 11/19] fix typos in schema-upgrades --- schema/mysql/upgrades/add-client-certificates.sql | 2 +- schema/pgsql/upgrades/add-client-certificates.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/schema/mysql/upgrades/add-client-certificates.sql b/schema/mysql/upgrades/add-client-certificates.sql index e837f4a6..bd2b6a04 100644 --- a/schema/mysql/upgrades/add-client-certificates.sql +++ b/schema/mysql/upgrades/add-client-certificates.sql @@ -4,4 +4,4 @@ ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL); ALTER TABLE source ADD CONSTRAINT ck_source_listener_cert_xor_credentials - CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), + CHECK (listener_username IS NULL OR client_certificate_subject IS NULL); diff --git a/schema/pgsql/upgrades/add-client-certificates.sql b/schema/pgsql/upgrades/add-client-certificates.sql index ffa27239..f49c5eed 100644 --- a/schema/pgsql/upgrades/add-client-certificates.sql +++ b/schema/pgsql/upgrades/add-client-certificates.sql @@ -4,4 +4,4 @@ ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL); ALTER TABLE source ADD CONSTRAINT ck_source_listener_cert_xor_credentials - CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), + CHECK (listener_username IS NULL OR client_certificate_subject IS NULL); From 43213317a3426236fb77247f404c1c92397098ec Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Fri, 10 Jul 2026 17:32:05 +0200 Subject: [PATCH 12/19] Fix CRL watcher startup and unexpected-exit handling WatchAndReload is a blocking call documented to run in a goroutine; the previous code called it synchronously, making the goroutine that followed it unreachable. The goroutine also called Fatalw unconditionally. Since WatchAndReload returns ctx.Err() on context cancellation, this triggered os.Exit during graceful shutdown. Both issues are fixed by running WatchAndReload exclusively in a goroutine that checks ctx.Err() before acting; on unexpected exit it calls cancel(err) on a WithCancelCause context so Run() shuts down the server gracefully and returns the error to the caller. --- internal/listener/listener.go | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 58d8e4f8..9261e2dd 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -125,11 +125,14 @@ func (l *Listener) Run(ctx context.Context) error { ErrorLog: stdlogger, } + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + var listeningFunc func() error if l.useSocket { listeningFunc, err = l.initSocketServer(server) } else { - listeningFunc, err = l.initTcpServer(server, ctx) + listeningFunc, err = l.initTcpServer(server, ctx, cancel) } if err != nil { return err @@ -142,9 +145,12 @@ func (l *Listener) Run(ctx context.Context) error { select { case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - return server.Shutdown(shutdownCtx) + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + return context.Cause(ctx) case err := <-serverErr: return err @@ -153,16 +159,22 @@ func (l *Listener) Run(ctx context.Context) error { // initTcpServer configures the given HTTP(S) server for the configured TCP address and returns a function that // starts serving. The caller is responsible for actually calling that function and for graceful shutdown. -func (l *Listener) initTcpServer(server *http.Server, ctx context.Context) (func() error, error) { +func (l *Listener) initTcpServer(server *http.Server, ctx context.Context, cancel context.CancelCauseFunc) (func() error, error) { conf := daemon.Config().Listener tlsConfig, crlChecker, err := conf.GetTlsConfig() if err != nil { return nil, err } - if err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger); err != nil { - return nil, err - } + go func() { + err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger) + if ctx.Err() != nil { + // Context was cancelled externally; WatchAndReload returning is expected. + return + } + l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) + cancel(fmt.Errorf("CRL watcher exited: %w", err)) + }() var https string if conf.TLSOptions.Enable { From 461e842c0be53440c0493ade33c248f73505f863 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Tue, 14 Jul 2026 16:04:36 +0200 Subject: [PATCH 13/19] Add readiness check for CRL watching initialization --- internal/listener/listener.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 9261e2dd..8593398e 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -166,8 +166,9 @@ func (l *Listener) initTcpServer(server *http.Server, ctx context.Context, cance return nil, err } + ready := make(chan struct{}) go func() { - err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger) + err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger, ready) if ctx.Err() != nil { // Context was cancelled externally; WatchAndReload returning is expected. return @@ -175,6 +176,7 @@ func (l *Listener) initTcpServer(server *http.Server, ctx context.Context, cance l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) cancel(fmt.Errorf("CRL watcher exited: %w", err)) }() + <-ready var https string if conf.TLSOptions.Enable { From 84ca131502e3da4f0509140451fe77734055eb1c Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Tue, 14 Jul 2026 16:25:08 +0200 Subject: [PATCH 14/19] adjust usage of ServerTLS.MakeConfig() --- internal/daemon/config.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/internal/daemon/config.go b/internal/daemon/config.go index 48eed8b3..13344f44 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -97,12 +97,7 @@ func (l *Listener) Validate() error { // specified in the Listener configuration. func (l *Listener) GetTlsConfig() (*tls.Config, *utils.CrlChecker, error) { tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} - tlsConfig, err := tlsOpts.MakeConfig() - if err != nil { - return nil, nil, err - } - - crlChecker, err := tlsOpts.InitRevocationChecking(tlsConfig) + tlsConfig, crlChecker, err := tlsOpts.MakeConfig() if err != nil { return nil, nil, err } From 553e608be2e6cf42223d9c3f796c292c063a5949 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Tue, 14 Jul 2026 16:25:46 +0200 Subject: [PATCH 15/19] Revert "adjust usage of ServerTLS.MakeConfig()" This reverts commit 0db15ae0b3dcb6c53022f31318754174c6d806c7. --- internal/daemon/config.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/daemon/config.go b/internal/daemon/config.go index 13344f44..48eed8b3 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -97,7 +97,12 @@ func (l *Listener) Validate() error { // specified in the Listener configuration. func (l *Listener) GetTlsConfig() (*tls.Config, *utils.CrlChecker, error) { tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} - tlsConfig, crlChecker, err := tlsOpts.MakeConfig() + tlsConfig, err := tlsOpts.MakeConfig() + if err != nil { + return nil, nil, err + } + + crlChecker, err := tlsOpts.InitRevocationChecking(tlsConfig) if err != nil { return nil, nil, err } From 6f5ce47b3eaabce06a1c8be5ee9bd9a2073582b9 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Tue, 14 Jul 2026 16:18:32 +0200 Subject: [PATCH 16/19] Bump igl to working branch --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index b8a2a83e..451b8708 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 github.com/google/uuid v1.6.0 - github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77 + github.com/icinga/icinga-go-library v0.9.1-0.20260714133932-f2fcca3b7321 github.com/jhillyerd/enmime v1.3.0 github.com/jmoiron/sqlx v1.4.0 github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd diff --git a/go.sum b/go.sum index 0fff284b..de6e785e 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77 h1:YQS63i/R9rHd72SYfNdSDZzgiU4kp1tv3vkdYyf4skM= github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77/go.mod h1:HDw4ODUuK0oineNCDzrJjFBdI0i1F3O2ODbQsiAiZ8k= +github.com/icinga/icinga-go-library v0.9.1-0.20260714133932-f2fcca3b7321 h1:fCRXtKP0gCRCt/YzZ3yhPu3apdJRr98vSnoV4XkCeWY= +github.com/icinga/icinga-go-library v0.9.1-0.20260714133932-f2fcca3b7321/go.mod h1:5xO4CWkWKr6scfle6vRgJTlvsteYfYVz/cRynv/I+ms= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= From d08591076a9bac52348a928d0cd290d6cbc11b01 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Wed, 15 Jul 2026 11:44:13 +0200 Subject: [PATCH 17/19] add nil-check to crlChecker in listener.initTcpServer() --- internal/listener/listener.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 8593398e..eba6ea6c 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -166,17 +166,19 @@ func (l *Listener) initTcpServer(server *http.Server, ctx context.Context, cance return nil, err } - ready := make(chan struct{}) - go func() { - err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger, ready) - if ctx.Err() != nil { - // Context was cancelled externally; WatchAndReload returning is expected. - return - } - l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) - cancel(fmt.Errorf("CRL watcher exited: %w", err)) - }() - <-ready + if crlChecker != nil { + ready := make(chan struct{}) + go func() { + err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger, ready) + if ctx.Err() != nil { + // Context was cancelled externally; WatchAndReload returning is expected. + return + } + l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) + cancel(fmt.Errorf("CRL watcher exited: %w", err)) + }() + <-ready + } var https string if conf.TLSOptions.Enable { From 064ba36fb6abd5be2a9a177abae0a50908b1e312 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Wed, 15 Jul 2026 17:23:05 +0200 Subject: [PATCH 18/19] cleanup: apply review suggestions --- doc/03-Configuration.md | 5 +++-- doc/20-HTTP-API.md | 14 ++++---------- internal/daemon/config.go | 2 +- internal/listener/listener.go | 8 +++----- schema/pgsql/schema.sql | 2 +- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index c49fff65..4f308e78 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -50,9 +50,9 @@ TLS options apply only to the TCP listener. When a Unix socket is configured, Icinga Notifications identifies connecting processes by their OS user, and matches the OS username against the source's configured username. No password or HTTP Basic Auth is involved. -When TLS is enabled, sources can optionally be identified by a TLS client certificate's Subject CN +When TLS is enabled, sources can optionally be identified by a TLS client certificate's Subject instead of a username/password pair. To use this, configure a CA via `ca` so that client certificates -can be verified, and set the `client_certificate_cn` field on the source in Icinga Notifications Web. +can be verified, and set the `client_certificate_subject` field on the source in Icinga Notifications Web. For YAML configuration, the options are part of the `listener` section. For environment variables, each option is prefixed with `ICINGA_NOTIFICATIONS_LISTENER_`. @@ -69,6 +69,7 @@ For environment variables, each option is prefixed with `ICINGA_NOTIFICATIONS_LI | cert | **Optional.** Path to TLS server certificate. Required if `tls` is enabled. | | key | **Optional.** Path to the TLS private key. Required if `tls` is enabled. | | ca | **Optional.** Path to TLS CA cert/bundle to verify client certs. Required if `tls` is enabled. | +| crl_file | **Optional.** Path to CRL file to verify client certificates. Requires `ca` to be set. Defaults to `false`. | !!! important diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index f27f287d..73bdacfe 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -16,7 +16,7 @@ Authentication differs by transport: - **TCP:** HTTP Basic Authentication is used; both the source's username and password must match the configured credentials. - **TCP with TLS:** If the request arrives with a TLS client certificate signed by the CA, the source is identified by the - certificate's Subject CN. If no matching source is found, the request falls back to HTTP Basic Authentication. + certificate's Subject. If no matching source is found, the request falls back to HTTP Basic Authentication. - **Unix socket:** The caller is identified automatically by their OS user. No HTTP Basic Auth or password is involved. @@ -101,25 +101,19 @@ curl -v -u 'icingadb:insecureinsecure' -H 'X-Icinga-Reject-If-Relations-Incomple EOF ``` -To submit over a **Unix socket** instead, pass `--unix-socket /run/icinga/icinga-notifications.sock` to curl. +To submit over a Unix socket instead, pass `--unix-socket /run/icinga/icinga-notifications.sock` to curl. No credentials are needed; the daemon identifies the caller by their OS user automatically. !!! info curl must be executed as a user which is configured as listener_username of a source. -To submit over **TLS using a client certificate** instead of HTTP Basic Authentication, +To submit over TLS using a client certificate instead of HTTP Basic Authentication, pass `--cacert ca.crt --cert client.crt --key client.key` to curl and omit `-u`: -``` -curl -v --cacert ca.crt --cert client.crt --key client.key -H 'X-Icinga-Reject-If-Relations-Incomplete: true' -d '@-' 'https://localhost:5680/process-event' < Date: Wed, 15 Jul 2026 18:18:02 +0200 Subject: [PATCH 19/19] WIP --- internal/listener/listener.go | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 2aac1405..41643b59 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -166,17 +166,15 @@ func (l *Listener) initTcpServer(ctx context.Context, cancel context.CancelCause return nil, err } - if crlChecker != nil { - go func() { - err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger) - if ctx.Err() != nil { - // Context was cancelled externally; WatchAndReload returning is expected. - return - } - l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) - cancel(fmt.Errorf("CRL watcher exited: %w", err)) - }() - } + go func() { + err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger) + if ctx.Err() != nil || err == nil { + // WatchAndReload returning is expected (Context was cancelled externally or noop). + return + } + l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) + cancel(fmt.Errorf("CRL watcher exited: %w", err)) + }() var https string if conf.TLSOptions.Enable {