Support source authentication using client certificates#454
Support source authentication using client certificates#454Jan-Schuppik wants to merge 14 commits into
Conversation
462a210 to
6301707
Compare
|
This PR still misses documentation. Both that this feature is available as well as how to use it. |
b4f2250 to
86afe26
Compare
There was a problem hiding this comment.
First and foremost: I have successfully tested the PR with a demo CA setup, as one can read in the collapsed details block below.
Toggle demo run details
Create demo CA and certificates:
$ # Create CA
$ openssl genpkey -algorithm rsa -out ca.key
$ openssl req -new -x509 -days 1 -key ca.key -sha512 -out ca.crt
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:DE
State or Province Name (full name) [Some-State]:Franconia
Locality Name (eg, city) []:Nuremberg
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Icinga GmbH
Organizational Unit Name (eg, section) []:Icinga Notifications Manufactory
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:
$ # Create server cert
$ openssl genpkey -algorithm rsa -out server.key
$ openssl req -new -key server.key -out server.csr
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:DE
State or Province Name (full name) [Some-State]:Franconia
Locality Name (eg, city) []:Nuremberg
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Icinga GmbH
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:example.com
Email Address []:
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:
$ openssl x509 -req -days 1 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
Certificate request self-signature ok
subject=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, CN=example.com
$ # Generate client cert
$ cat client_cert.ext
keyUsage = digitalSignature
extendedKeyUsage = clientAuth
$ openssl genpkey -algorithm rsa -out client.key
$ openssl req -new -key client.key -out client.csr
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:DE
State or Province Name (full name) [Some-State]:Franconia
Locality Name (eg, city) []:Nuremberg
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Icinga GmbH
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:icingadb
Email Address []:
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:
$ openssl x509 -req -days 1 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -extfile client_cert.ext -out client.crt
Certificate request self-signature ok
subject=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, CN=icingadb
$ # Cleanup and status
$ rm *.csr
$ for crt in *.crt; do echo ">>> $crt"; openssl x509 -in "$crt" -noout -issuer -subject; done
>>> ca.crt
issuer=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, OU=Icinga Notifications Manufactory
subject=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, OU=Icinga Notifications Manufactory
>>> client.crt
issuer=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, OU=Icinga Notifications Manufactory
subject=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, CN=icingadb
>>> server.crt
issuer=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, OU=Icinga Notifications Manufactory
subject=C=DE, ST=Franconia, L=Nuremberg, O=Icinga GmbH, CN=example.com
Monkey-patched Icinga Notifications Dockerfile to add necessary server certs and CA:
diff --git a/Dockerfile b/Dockerfile
index f04a576..fe2a9ff 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,11 +15,16 @@ FROM docker.io/library/alpine
COPY --from=build /target /
+COPY server.crt /
+COPY server.key /
+COPY ca.crt /
+
RUN apk add tzdata
ARG username=notifications
RUN addgroup -g 1000 $username
RUN adduser -u 1000 -H -D -G $username $username
+RUN chown notifications:notifications /ca.crt /server.crt /server.key
USER $username
EXPOSE 5680Launch container with additional environment variables:
ICINGA_NOTIFICATIONS_LISTENER_TLS: 1
ICINGA_NOTIFICATIONS_LISTENER_CERT: /server.crt
ICINGA_NOTIFICATIONS_LISTENER_KEY: /server.key
ICINGA_NOTIFICATIONS_LISTENER_CA: /ca.crt
Manually populate source.client_certificate_cn as Web is not there yet:
mysql> select * from source;
+----+----------+----------+-------------------+--------------------------------------------------------------+-----------------------+---------------+---------+--------+
| id | type | name | listener_username | listener_password_hash | client_certificate_cn | changed_at | deleted | locked |
+----+----------+----------+-------------------+--------------------------------------------------------------+-----------------------+---------------+---------+--------+
| 1 | icingadb | icingadb | icingadb | $2y$12$SpHOoIV7/9bO.tM4W/YyVuAgN56H25kfAIGdZnyw2dl2.afuVRj2G | NULL | 1783326692179 | n | n |
+----+----------+----------+-------------------+--------------------------------------------------------------+-----------------------+---------------+---------+--------+
1 row in set (0.001 sec)
mysql> update source set client_certificate_cn = 'icingadb', changed_at = 1783326692180;
Query OK, 1 row affected (0.006 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Finally, curl it:
$ curl -v --resolve example.com:5680:127.0.0.1 --cacert ca.crt --cert client.crt --key client.key -H 'X-Icinga-Reject-If-Relations-Incomplete: true' -d '@-' 'https://example.com:5680/process-event' <<EOF
{
"name": "dummy-809: random fortune",
"url": "http://localhost/icingaweb2/icingadb/service?name=random%20fortune&host.name=dummy-809",
"tags": {
"host": "dummy-809",
"service": "random fortune"
},
"type": "state",
"severity": "crit",
"message": "Something went somewhere very wrong.",
"complete_relations": [
"host",
"services",
"hostgroups",
"servicegroups"
],
"relations": {
"host": {
"name": "dummy-809",
"display_name": "My Dummy Host",
"vars": {
"os": "linux"
}
},
"services": [
{
"name": "random fortune",
"display_name": "Random Fortune Service",
"vars": {
"env": "production",
"team": "devops"
}
}
],
"hostgroups": [
{
"name": "linux-servers",
"display_name": "Linux Servers"
}
],
"servicegroups": [
{
"name": "production-services",
"display_name": "Production Services"
}
]
}
}
EOF
* Added example.com:5680:127.0.0.1 to DNS cache
* Hostname example.com was found in DNS cache
* Host example.com:5680 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
* Trying 127.0.0.1:5680...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* SSL Trust Anchors:
* CAfile: ca.crt
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519MLKEM768 / RSASSA-PSS
* ALPN: server accepted h2
* Server certificate:
* subject: C=DE; ST=Franconia; L=Nuremberg; O=Icinga GmbH; CN=example.com
* start date: Jul 6 08:10:06 2026 GMT
* expire date: Jul 7 08:10:06 2026 GMT
* issuer: C=DE; ST=Franconia; L=Nuremberg; O=Icinga GmbH; OU=Icinga Notifications Manufactory
* Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* Certificate level 1: Public key type RSA (2048/112 Bits/secBits), signed using sha512WithRSAEncryption
* common name: example.com (matched)
* OpenSSL verify result: 0
* SSL certificate verified via OpenSSL.
* Established connection to example.com (127.0.0.1 port 5680) from 127.0.0.1 port 50302
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://example.com:5680/process-event
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: example.com:5680]
* [HTTP/2] [1] [:path: /process-event]
* [HTTP/2] [1] [user-agent: curl/8.20.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [x-icinga-reject-if-relations-incomplete: true]
* [HTTP/2] [1] [content-length: 970]
* [HTTP/2] [1] [content-type: application/x-www-form-urlencoded]
> POST /process-event HTTP/2
> Host: example.com:5680
> User-Agent: curl/8.20.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
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/2 400
< content-type: text/plain; charset=utf-8
< server: icinga-notifications/0.2.0-g1863867-dirty
< x-content-type-options: nosniff
< content-length: 65
< date: Mon, 06 Jul 2026 08:46:46 GMT
<
invalid event: at least one of 'incident' or 'muted' must be set
* Connection #0 to host example.com:5680 left intact
Verify it in the logs:
$ docker logs icinga-dev-notifications-1 2>&1 | grep 'Source is authenticated'
2026-07-06T08:46:46.615Z DEBUG listener Source is authenticated via a TLS client certificate {"source_name": "icingadb", "common name": "icingadb"}
(As the testing was quite excessive, I will urge for integration tests, similar to those for Icinga DB.)
Furthermore, I have added some small comments. Please address those.
With the reworked authentication flow, I have now suggested a few small changes to increase debugability in the Listener.sourceFromAuthOrAbort method. As there are now multiple branches, this would enable an administrator to trace authentication failures and successes.
In the end, please squash your commits. Then, this PR would be mergable for me :)
| - **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. |
There was a problem hiding this comment.
| - **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. | |
| - **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. |
| l.logger.Debugw( | ||
| "Source is authenticated via a TLS client certificate", | ||
| zap.String("source_name", src.Name), | ||
| zap.String("common name", clientCert.Subject.CommonName), |
There was a problem hiding this comment.
| zap.String("common name", clientCert.Subject.CommonName), | |
| zap.String("common_name", clientCert.Subject.CommonName), |
There was a problem hiding this comment.
While this is not part of this PR, could you please move this debug message down to the success case where src is set (currently line 299) and add keys similar to those of this PR's success debug message: source_name and username.
There was a problem hiding this comment.
Please add a debug log with the error as a zap field here.
| } | ||
| } | ||
|
|
||
| l.logger.Debugw("Source is authenticated via HTTP Basic Auth") |
There was a problem hiding this comment.
There was a problem hiding this comment.
Please prefix each error message passed to errFunc with the current branch: unix socket, tls cert, or basic auth.
| return errFunc("unix socket: no value found in context") |
There was a problem hiding this comment.
Please also add a comment about curl(1) flags for client certificates after the comment about --unix-socket. Feel free to take inspiration from the shell listing in my main review comment.
3cdb5f1 to
e8857f7
Compare
06c8c0c to
a836d34
Compare
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.
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.
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.
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.
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.
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.
748d2d5 to
6b652ad
Compare
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.
This PR adds support for authenticating sources via TLS client certificates. A source can now be identified by its certificate's Subject as an alternative to username/password Basic Auth, with automatic fallback to Basic Auth when no certificate is found.
Changes
Test plan
Depends on:
resolves #411