Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions doc/03-Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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_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_`.

Expand All @@ -65,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

Expand Down
9 changes: 9 additions & 0 deletions doc/20-HTTP-API.md
Comment thread
oxzi marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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 TLS client certificate signed by the CA, the source is identified by the
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.

Expand Down Expand Up @@ -106,6 +108,13 @@ No credentials are needed; the daemon identifies the caller by their OS user aut

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`:

!!! info

The daemon identifies the source by the certificate's Subject. If no matching source is found, the request is rejected.

## Incidents

The `/incidents` Icinga Notifications HTTP API endpoint allows sources to query and modify a list of open incidents
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -38,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=
Expand Down
13 changes: 13 additions & 0 deletions internal/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,19 @@ func (r *RuntimeConfig) GetSourceByUsername(user string) *Source {
return nil
}

// 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.ClientCertificateSubject.Valid && src.ClientCertificateSubject.String == subject {
return src
}
}

return nil
}

func (r *RuntimeConfig) fetchFromDatabase(ctx context.Context) error {
tx, err := r.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
Expand Down
14 changes: 8 additions & 6 deletions internal/config/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,10 +19,11 @@ type Source struct {
Type string `db:"type"`
Name string `db:"name"`

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.
//
Expand Down
17 changes: 14 additions & 3 deletions internal/daemon/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,21 @@
return nil
}

// GetTlsConfig returns a *[tls.Config] based on the TLS options specified in the Listener configuration.
func (l *Listener) GetTlsConfig() (*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, *config.CrlChecker, error) {

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / go

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 11

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 9.6

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:10.5)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 15

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 13

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mysql:5.7)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 10

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 14

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:11.2)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:11.1)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:latest)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mysql:8.0)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL latest

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / PostgreSQL 12

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mysql:latest)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:10)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:10.6)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mariadb:11.0)

undefined: config.CrlChecker

Check failure on line 98 in internal/daemon/config.go

View workflow job for this annotation

GitHub Actions / mysql (mysql:8)

undefined: config.CrlChecker
tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)}
return 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
}

return tlsConfig, crlChecker, nil
}

type ConfigFile struct {
Expand Down
113 changes: 91 additions & 22 deletions internal/listener/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
listeningFunc, err = l.initTcpServer(ctx, cancel, server)
}
if err != nil {
return err
Expand All @@ -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
Expand All @@ -153,13 +159,23 @@ 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(ctx context.Context, cancel context.CancelCauseFunc, server *http.Server) (func() error, error) {
conf := daemon.Config().Listener
tlsConfig, err := conf.GetTlsConfig()
tlsConfig, crlChecker, err := conf.GetTlsConfig()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just saw that this was already addressed in d085910 shortly before I sent this review. Nevertheless, please make the API less dangerous: In the IGL, return an error if InitRevocationChecking was called when no CRL was configured. Here, ensure that GetTlsConfig only calls InitRevocationChecking if a CRL was configured. If you need any help or have questions, just ask :)

if err != nil {
return nil, 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 {
https = "s"
Expand Down Expand Up @@ -274,45 +290,98 @@ 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 {
w.WriteHeader(http.StatusUnauthorized)
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(httpCode)
_, _ = fmt.Fprintln(w, errMsg)
return nil
}

if l.useSocket {
l.logger.Debugw("Source is authenticated via socket connection")
errMsg = "unix socket: "
value := r.Context().Value(peerUserLookupKey{})
if value == nil {
return errFunc("no value found in context")
errMsg += "no client certificate found"
return errFunc(http.StatusUnauthorized, false)
}

lookup, ok := value.(peerUserLookupFunc)
if !ok {
return errFunc("no lookup function present")
errMsg += "no lookup function present"
return errFunc(http.StatusUnauthorized, false)
}

username, err := lookup()
if err != nil {
return errFunc(err.Error())
errMsg += err.Error()
return errFunc(http.StatusUnauthorized, false)
}

src = l.runtimeConfig.GetSourceByUsername(username)
if src == nil {
return errFunc(fmt.Sprintf("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(
"Source is authenticated via socket connection",
zap.String("source_name", src.Name),
zap.String("username", username),
)
} else {
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")
}
if r.TLS != nil && len(r.TLS.VerifiedChains) > 0 {
// 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)
}

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")
if len(r.TLS.VerifiedChains[0]) == 0 {
errMsg += "no verified chain found"
return errFunc(http.StatusUnauthorized, true)
}

clientCert := r.TLS.VerifiedChains[0][0]
if clientCert == nil {
errMsg += "no client certificate found"
return errFunc(http.StatusUnauthorized, true)
}

src = l.runtimeConfig.GetSourceByClientCertSubject(clientCert.Subject.String())
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),
)
}
}

Expand Down
Loading
Loading