Skip to content

Support source connection via http over unix sockets#441

Merged
Jan-Schuppik merged 1 commit into
mainfrom
support-http-over-unix-sockets
Jul 3, 2026
Merged

Support source connection via http over unix sockets#441
Jan-Schuppik merged 1 commit into
mainfrom
support-http-over-unix-sockets

Conversation

@Jan-Schuppik

@Jan-Schuppik Jan-Schuppik commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Support HTTP over Unix sockets

Sources running on the same host as Icinga Notifications can now connect via a Unix domain socket instead of (or alongside) TCP. This removes the need to expose a network port for local sources and avoids password verification overhead, as the socket transport itself provides the trust boundary.

Changes

Configuration (internal/daemon/config.go)

Three new fields are added to the Listener config block:

listener:
  socket: /run/icinga-notifications/notifications.sock
  socket_mode: "0660"  # optional, defaults to 0600
  socket_group: icinga  # optional

Validate() is updated so the TCP address only defaults to localhost:5680 when no socket is configured; setting socket without address starts the socket listener exclusively. It also removes a stale socket file at the configured path if one already exists.

Listener (internal/listener/listener.go)

NewListener gains a useSocket bool parameter. Run() dispatches to either runTCP() (the existing logic, renamed) or the new runSocket(), which:

  • Listens on the configured Unix path via net.Listen("unix", ...)
  • Sets group ownership via os.Chown when socket_group is configured
  • Sets permissions via os.Chmod immediately after binding
  • Shuts down gracefully on context cancellation, same as the TCP path

Authentication on the socket path uses OS peer credentials (SO_PEERCRED): the daemon reads the connecting process's UID from the kernel, resolves it to an OS username via user.LookupId, and matches it against the source's configured username; no password or HTTP Basic Auth is involved. The TCP path continues to use full credential verification. The WWW-Authenticate response header is omitted on the socket path since browser-style auth prompts are not applicable there.

Runtime config (internal/config/runtime.go)

GetSourceFromUsername is added alongside the existing GetSourceFromCredentials. It matches sources by listener_username without checking the password hash, and explicitly guards against matching sources that have no username configured (ListenerUsername.Valid == false).

Main (cmd/icinga-notifications/main.go)

Both listeners are started concurrently via errgroup. If only one is configured, only that one runs. Either listener failing propagates the error and cancels the other through the shared context.

Tests

  • TestListenerValidate: covers the Validate() address-defaulting logic: nothing configured, socket-only, explicit address, both, and invalid/empty socket_mode strings
  • TestGetSourceFromUsername: known usernames, unknown username, and sources with no configured username not matched by empty string
  • TestSourceFromAuthOrAbort : socket path: valid peer credentials matching a source username succeed, unknown UID and missing credentials both return 401 without WWW-Authenticate; TCP path: correct credentials pass, wrong password and unknown username both return 401 with WWW-Authenticate

resolves #412

@Jan-Schuppik Jan-Schuppik requested review from oxzi and yhabteab June 16, 2026 09:25
@cla-bot cla-bot Bot added the cla/signed CLA is signed by all contributors of a PR label Jun 16, 2026

@oxzi oxzi left a comment

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.

Thanks for creating this PR. In general, it looks good. Please address each of my comments from a first round of review.

As your PR changes the configuration the documentation in ./doc needs to be updated as well.

Furthermore, please refer the original issue #412 in the PR description.

Comment thread internal/listener/listener.go
Comment thread config.example.yml Outdated
Comment thread internal/daemon/config.go
Comment thread cmd/icinga-notifications/main.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/daemon/config.go Outdated
Comment thread cmd/icinga-notifications/main.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread doc/03-Configuration.md Outdated
Comment thread doc/20-HTTP-API.md Outdated
Comment thread doc/20-HTTP-API.md Outdated
Comment thread doc/20-HTTP-API.md
Comment thread doc/20-HTTP-API.md Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go Outdated
@yhabteab yhabteab marked this pull request as ready for review June 24, 2026 06:54

@oxzi oxzi left a comment

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.

Looks good so far, thanks!

Could you please:

  1. Address the last open comment, #441 (comment).
  2. Afterward, squash all commits into one, including a descriptive commit message.

Then, it would be good for me :)

@Jan-Schuppik Jan-Schuppik force-pushed the support-http-over-unix-sockets branch 2 times, most recently from 18152db to 9f96425 Compare June 24, 2026 14:29
oxzi
oxzi previously approved these changes Jun 24, 2026

@oxzi oxzi left a comment

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.

LGTM, thanks!

@yhabteab: Do you want to take another look?

@Jan-Schuppik

Copy link
Copy Markdown
Contributor Author

As discussed with @nilmerg, I added a password check for socket authentication if a password is configured.

@oxzi

oxzi commented Jun 25, 2026

Copy link
Copy Markdown
Member

As we just discussed using system user names as a kind of authentication, as other Unix domain socket-based daemons have been doing for ages, and I said this would be easy with confidence, I just wrote a small PoC, as it is actually not very straightforward in Go.

The following diff on top of your PR branch extracts the peer's credentials, and logs them.

Source lookup request from Unix domain socket   {"creds": "&{Pid:37 Uid:1000 Gid:1000}"}
Source lookup request from Unix domain socket   {"creds": "&{Pid:40 Uid:23 Gid:0}"}
diff --git a/internal/listener/listener.go b/internal/listener/listener.go
index a289fbb..3904d89 100644
--- a/internal/listener/listener.go
+++ b/internal/listener/listener.go
@@ -25,6 +25,7 @@ import (
        "github.com/icinga/icinga-notifications/internal/incident"
        "github.com/icinga/icinga-notifications/internal/object"
        "go.uber.org/zap"
+       "golang.org/x/sys/unix"
 )

 // responseWriter is a wrapper around [http.ResponseWriter] that captures the status code written to the response.
@@ -43,6 +44,9 @@ func (rw *responseWriter) WriteHeader(status int) {
        rw.ResponseWriter.WriteHeader(status)
 }

+// listenerUnixDomainSocketCreds is the context key to retrieve the Unix Domain Socket credentials.
+type listenerUnixDomainSocketCreds struct{}
+
 type Listener struct {
        db            *database.DB
        logger        *logging.Logger
@@ -235,6 +239,13 @@ func (l *Listener) initSocketServer(server *http.Server) (fn func() error, retEr
                return nil, fmt.Errorf("cannot set permissions on unix socket %q: %w", path, err)
        }

+       server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
+               // TODO: Check errors and types :^)
+               f, _ := c.(*net.UnixConn).File()
+               creds, _ := unix.GetsockoptUcred(int(f.Fd()), unix.SOL_SOCKET, unix.SO_PEERCRED)
+               return context.WithValue(ctx, listenerUnixDomainSocketCreds{}, creds)
+       }
+
        l.logger.Infof("Starting listener on unix socket %s", path)

        return func() error { return server.Serve(listener) }, nil
@@ -243,6 +254,13 @@ func (l *Listener) initSocketServer(server *http.Server) (fn func() error, retEr
 // sourceFromAuthOrAbort extracts a *config.Source from the HTTP Basic Auth. If the credentials are wrong, (nil, false) is
 // returned and 401 was written back to the response writer.
 func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) (*config.Source, bool) {
+       if l.useSocket {
+               // TODO: Check type and existence
+               creds := r.Context().Value(listenerUnixDomainSocketCreds{}).(*unix.Ucred)
+               l.logger.Infow("Source lookup request from Unix domain socket",
+                       zap.String("creds", fmt.Sprintf("%+v", creds)))
+       }
+
        if authUser, authPass, authOk := r.BasicAuth(); authOk {
                if l.useSocket {
                        src := l.runtimeConfig.GetSourceFromUsername(authUser, l.logger)

As @julianbrost mentioned, there is also .SyscallConn, which might be safer in case of vanishing fds.

@yhabteab yhabteab left a comment

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.

Sorry, for hopping in, but I just wanted to submit my pending comments before you push the other changes for #441 (comment).

Comment thread internal/config/runtime.go Outdated
Comment thread internal/config/runtime_test.go Outdated
Comment thread internal/config/runtime_test.go Outdated
Comment thread internal/daemon/config.go Outdated
Comment thread internal/daemon/config_test.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go
Comment thread internal/listener/listener_test.go Outdated
Comment thread internal/listener/listener_test.go Outdated
Comment thread internal/listener/listener_test.go Outdated
@yhabteab yhabteab added this to the 1.0 milestone Jun 30, 2026
@Jan-Schuppik Jan-Schuppik requested review from oxzi and yhabteab June 30, 2026 14:15
@Jan-Schuppik Jan-Schuppik force-pushed the support-http-over-unix-sockets branch 5 times, most recently from 587d7c8 to 30dff1e Compare July 2, 2026 08:31

@oxzi oxzi left a comment

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.

Thanks for the updated PR. I have taken a look at the changed Unix Domain Socket UID lookup and only its implications.

Comment thread internal/listener/socket_creds_darwin.go Outdated
Comment thread internal/listener/socket_creds_darwin.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go
Comment thread internal/daemon/config.go
Comment thread internal/listener/listener.go Outdated
Comment thread doc/03-Configuration.md Outdated
Comment thread doc/03-Configuration.md Outdated
Comment thread doc/03-Configuration.md Outdated
Comment thread doc/20-HTTP-API.md Outdated

@oxzi oxzi left a comment

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.

Only small things left. In general, I have successfully tested the changes.

Click here for lots of curl!

Two socket connection, once with a user name registered at a source (auth okay, error due to #455) and once with an unknown user (auth error).

$ doas -u icingadb curl -v -H 'X-Icinga-Reject-If-Relations-Incomplete: true' -d '@-' --unix-socket /tmp/sock 'http://localhost:5680/process-event' <<EOF
...
EOF
*   Trying /tmp/sock:0...
* Established connection to /tmp/sock (/tmp/sock port 0) from  port 0
* using HTTP/1.x
> POST /process-event HTTP/1.1
> Host: localhost:5680
> User-Agent: curl/8.21.0
> Accept: */*
> X-Icinga-Reject-If-Relations-Incomplete: true
> Content-Length: 970
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 970 bytes
< HTTP/1.1 400 Bad Request
< Content-Type: text/plain; charset=utf-8
< Server: icinga-notifications/0.2.0-g6745724
< X-Content-Type-Options: nosniff
< Date: Fri, 03 Jul 2026 09:45:04 GMT
< Content-Length: 65
<
invalid event: at least one of 'incident' or 'muted' must be set
* Connection #0 to host /tmp/sock:0 left intact
$ doas -u kaffeemaschine curl -v -H 'X-Icinga-Reject-If-Relations-Incomplete: true' -d '@-' --unix-socket /tmp/sock 'http://localhost:5680/process-event' <<EOF
...
EOF
*   Trying /tmp/sock:0...
* Established connection to /tmp/sock (/tmp/sock port 0) from  port 0
* using HTTP/1.x
> POST /process-event HTTP/1.1
> Host: localhost:5680
> User-Agent: curl/8.21.0
> Accept: */*
> X-Icinga-Reject-If-Relations-Incomplete: true
> Content-Length: 970
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 970 bytes
< HTTP/1.1 401 Unauthorized
< Server: icinga-notifications/0.2.0-g6745724
< Date: Fri, 03 Jul 2026 09:51:07 GMT
< Content-Length: 80
< Content-Type: text/plain; charset=utf-8
<
expected valid unix-user whose username is equal to an existing source username
* Connection #0 to host /tmp/sock:0 left intact

Comment thread internal/listener/listener.go
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go Outdated
Comment thread internal/listener/listener.go
Comment thread internal/listener/listener.go Outdated
Comment thread internal/daemon/config.go Outdated
Comment thread internal/listener/listener.go
Comment thread internal/listener/listener.go Outdated
@Jan-Schuppik Jan-Schuppik force-pushed the support-http-over-unix-sockets branch 2 times, most recently from 9247547 to 8fc8ea2 Compare July 3, 2026 14:37
@Jan-Schuppik Jan-Schuppik requested a review from oxzi July 3, 2026 14:41
The daemon can now serve its HTTP API over a Unix domain socket in
addition to, or instead of, the existing TCP listener. Both listeners
run concurrently; a failure in either propagates cancellation to the
other.

Sources connecting via the socket are authenticated by OS peer
credentials (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS): the
daemon resolves the connecting process's UID to an OS username and
matches it against the source's configured username. No password is
needed. TCP connections continue to use HTTP Basic Auth unchanged.
Debug endpoints always require the password regardless of transport.

Three new `listener` config keys control the socket: `socket` sets
the path; `socket_mode` sets the file permissions as an octal string
(default `0660`); `socket_group` optionally assigns an OS group to
the socket file. Socket path checks, mode validation, and group GID
resolution are performed during config validation at startup.
Platform-specific peer credential implementations live in
socket_creds_linux.go and socket_creds_darwin.go.

Documentation is updated in doc/03-Configuration.md and
doc/20-HTTP-API.md with transport-specific auth notes, a security
warning admonition, and a curl Unix socket example.
@Jan-Schuppik Jan-Schuppik force-pushed the support-http-over-unix-sockets branch from 8fc8ea2 to e9de006 Compare July 3, 2026 14:48

@oxzi oxzi left a comment

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.

🛳️

@Jan-Schuppik Jan-Schuppik enabled auto-merge July 3, 2026 14:54
@Jan-Schuppik Jan-Schuppik merged commit ba90242 into main Jul 3, 2026
26 checks passed
@Jan-Schuppik Jan-Schuppik deleted the support-http-over-unix-sockets branch July 3, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla/signed CLA is signed by all contributors of a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for HTTP over unix sockets as transport to deliver events

4 participants