Support source connection via http over unix sockets#441
Conversation
oxzi
left a comment
There was a problem hiding this comment.
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.
oxzi
left a comment
There was a problem hiding this comment.
Looks good so far, thanks!
Could you please:
- Address the last open comment, #441 (comment).
- Afterward, squash all commits into one, including a descriptive commit message.
Then, it would be good for me :)
18152db to
9f96425
Compare
|
As discussed with @nilmerg, I added a password check for socket authentication if a password is configured. |
|
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. 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 |
yhabteab
left a comment
There was a problem hiding this comment.
Sorry, for hopping in, but I just wanted to submit my pending comments before you push the other changes for #441 (comment).
587d7c8 to
30dff1e
Compare
oxzi
left a comment
There was a problem hiding this comment.
Thanks for the updated PR. I have taken a look at the changed Unix Domain Socket UID lookup and only its implications.
3466681 to
2f4b4e1
Compare
oxzi
left a comment
There was a problem hiding this comment.
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
9247547 to
8fc8ea2
Compare
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.
8fc8ea2 to
e9de006
Compare
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
Listenerconfig block:Validate()is updated so the TCP address only defaults tolocalhost:5680when no socket is configured; settingsocketwithoutaddressstarts the socket listener exclusively. It also removes a stale socket file at the configured path if one already exists.Listener (
internal/listener/listener.go)NewListenergains auseSocket boolparameter.Run()dispatches to eitherrunTCP()(the existing logic, renamed) or the newrunSocket(), which:net.Listen("unix", ...)os.Chownwhensocket_groupis configuredos.Chmodimmediately after bindingAuthentication 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 viauser.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. TheWWW-Authenticateresponse header is omitted on the socket path since browser-style auth prompts are not applicable there.Runtime config (
internal/config/runtime.go)GetSourceFromUsernameis added alongside the existingGetSourceFromCredentials. It matches sources bylistener_usernamewithout 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 theValidate()address-defaulting logic: nothing configured, socket-only, explicit address, both, and invalid/emptysocket_modestringsTestGetSourceFromUsername: known usernames, unknown username, and sources with no configured username not matched by empty stringTestSourceFromAuthOrAbort: socket path: valid peer credentials matching a source username succeed, unknown UID and missing credentials both return 401 withoutWWW-Authenticate; TCP path: correct credentials pass, wrong password and unknown username both return 401 withWWW-Authenticateresolves #412