Skip to content
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2541,3 +2541,52 @@ go install github.com/TwiN/gatus/v5@latest

### High level design overview
![Gatus diagram](.github/assets/gatus-diagram.jpg)

### Certificate Monitoring

Gatus supports monitoring SSL/TLS certificates for expiration. For endpoints using TLS (including HTTPS and STARTTLS),
Gatus will check the expiration of all certificates in the certificate chain, including:
- The leaf (end-entity) certificate
- Any intermediate certificates
- The root certificate

By default, Gatus checks the expiration of the entire certificate chain. If you only want to check the leaf certificate's expiration,
you can set `disable-full-chain-certificate-expiration-check: true` in your endpoint's client configuration:
You can use the `[CERTIFICATE_EXPIRATION]` placeholder in your conditions to check the expiration time of the leaf certificate:

```
metrics: true

endpoints:
- name: upwork-cert-chain-leaf-only
url: https://upwork.com
interval: 1m
client:
disable-full-chain-certificate-expiration-check: true
conditions:
- "[CERTIFICATE_EXPIRATION] > 48h"
- "[CONNECTED] == true"

- name: gmail-starttls-chain
url: "starttls://smtp.gmail.com:587"
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h"

- name: cloudflare-tls-chain
url: "tls://1.1.1.1:853"
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h"
```

The certificate chain information is also exposed via Prometheus metrics:
- `gatus_results_certificate_expiration_seconds`: Time until leaf certificate expiration
- `gatus_results_certificate_chain_expiration_seconds`: Time until expiration for each certificate in the chain, with labels for subject and issuer

Example PromQL query to alert on any certificate in the chain expiring soon:
```promql
min(gatus_results_certificate_chain_expiration_seconds) by (key, group, name) < 172800 # 48 hours
```
63 changes: 46 additions & 17 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,53 +122,82 @@ func CanCreateSCTPConnection(address string, config *Config) bool {
}
}

// CertificateChainInfo contains information about a TLS certificate chain
type CertificateChainInfo struct {
Connected bool
Chain []*x509.Certificate
}

// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
func CanPerformStartTLS(address string, config *Config) (CertificateChainInfo, error) {
hostAndPort := strings.Split(address, ":")
if len(hostAndPort) != 2 {
return false, nil, errors.New("invalid address for starttls, format must be host:port")
return CertificateChainInfo{}, errors.New("invalid address for starttls, format must be host:port")
}
connection, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return
return CertificateChainInfo{}, err
}
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
if err != nil {
return
return CertificateChainInfo{}, err
}
err = smtpClient.StartTLS(&tls.Config{
InsecureSkipVerify: config.Insecure,
ServerName: hostAndPort[0],
})
if err != nil {
return
return CertificateChainInfo{}, err
}
if state, ok := smtpClient.TLSConnectionState(); ok {
certificate = state.PeerCertificates[0]
} else {
return false, nil, errors.New("could not get TLS connection state")
state, ok := smtpClient.TLSConnectionState()
if !ok {
return CertificateChainInfo{}, errors.New("could not get TLS connection state")
}
return true, certificate, nil

if len(state.PeerCertificates) == 0 {
return CertificateChainInfo{
Connected: true,
Chain: []*x509.Certificate{},
}, nil
}
return newChainFromPeer(state.PeerCertificates, config), nil
}

// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
func CanPerformTLS(address string, config *Config) (CertificateChainInfo, error) {
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
InsecureSkipVerify: config.Insecure,
})
if err != nil {
return
return CertificateChainInfo{}, err
}
defer connection.Close()
verifiedChains := connection.ConnectionState().VerifiedChains
state := connection.ConnectionState()
// If config.Insecure is set to true, verifiedChains will be an empty list []
// We should get the parsed certificates from PeerCertificates, it can't be empty on the client side
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
peerCertificates := connection.ConnectionState().PeerCertificates
return true, peerCertificates[0], nil
if len(state.VerifiedChains) == 0 || len(state.VerifiedChains[0]) == 0 {
return newChainFromPeer(state.PeerCertificates, config), nil
}
return newChainFromVerified(state.VerifiedChains, config), nil
}

func newChainFromVerified(verifiedChains [][]*x509.Certificate, config *Config) CertificateChainInfo {
if config.DisableFullChainCertificateExpirationCheck {
//leaf certificate
return CertificateChainInfo{Connected: true, Chain: []*x509.Certificate{verifiedChains[0][0]}}
}
//full chain
return CertificateChainInfo{Connected: true, Chain: verifiedChains[0]}
}

func newChainFromPeer(peerCertificates []*x509.Certificate, config *Config) CertificateChainInfo {
if config.DisableFullChainCertificateExpirationCheck {
//leaf certificate
return CertificateChainInfo{Connected: true, Chain: []*x509.Certificate{peerCertificates[0]}}
}
return true, verifiedChains[0][0], nil
//full chain
return CertificateChainInfo{Connected: true, Chain: peerCertificates}
}

// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
Expand Down
123 changes: 113 additions & 10 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ package client

import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"io"
"math/big"
"net"
"net/http"
"net/netip"
"runtime"
"testing"
"time"

Expand Down Expand Up @@ -95,10 +101,13 @@ func TestPing(t *testing.T) {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on failure should've been 0")
// Skip IPv6 ping tests on Windows as they require elevated privileges
if runtime.GOOS != "windows" {
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test fails on Windows system. So might be a good idea to skip?

}
}
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip4"}); success {
Expand Down Expand Up @@ -154,13 +163,13 @@ func TestCanPerformStartTLS(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
info, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
if info.Connected != tt.wantConnected {
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", info.Connected, tt.wantConnected)
}
})
}
Expand Down Expand Up @@ -223,13 +232,13 @@ func TestCanPerformTLS(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
info, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
if info.Connected != tt.wantConnected {
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", info.Connected, tt.wantConnected)
}
})
}
Expand Down Expand Up @@ -509,3 +518,97 @@ func TestCheckSSHBanner(t *testing.T) {
})

}

func TestCanPerformTLS_WithDisableFullChainCheck(t *testing.T) {
rootKey, _ := rsa.GenerateKey(rand.Reader, 2048)
rootTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
}
rootCert, _ := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
root, _ := x509.ParseCertificate(rootCert)

intermediateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
intermediateTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
NotBefore: time.Now(),
NotAfter: time.Now().Add(36 * time.Hour),
IsCA: true,
}
intermediateCert, _ := x509.CreateCertificate(rand.Reader, intermediateTemplate, root, &intermediateKey.PublicKey, rootKey)
intermediate, _ := x509.ParseCertificate(intermediateCert)

leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(3),
NotBefore: time.Now(),
NotAfter: time.Now().Add(48 * time.Hour),
IsCA: false,
DNSNames: []string{"localhost"},
}
leafCert, _ := x509.CreateCertificate(rand.Reader, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey)
leaf, _ := x509.ParseCertificate(leafCert)

serverCert := tls.Certificate{
Certificate: [][]byte{leafCert, intermediateCert, rootCert},
PrivateKey: leafKey,
}

server := &http.Server{
Addr: "localhost:0",
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{serverCert},
},
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}),
}

ln, err := tls.Listen("tcp", server.Addr, server.TLSConfig)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go server.Serve(ln)

_, port, _ := net.SplitHostPort(ln.Addr().String())
serverAddr := "localhost:" + port

config := &Config{
DisableFullChainCertificateExpirationCheck: true,
Insecure: true,
}
info, err := CanPerformTLS(serverAddr, config)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if len(info.Chain) != 1 {
t.Errorf("Expected only leaf certificate, got %d certificates", len(info.Chain))
}
if info.Chain[0].SerialNumber.Cmp(leaf.SerialNumber) != 0 {
t.Error("Expected leaf certificate, got different certificate")
}

config = &Config{
DisableFullChainCertificateExpirationCheck: false,
Insecure: true,
}
info, err = CanPerformTLS(serverAddr, config)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if len(info.Chain) < 3 {
t.Errorf("Expected full certificate chain (root, intermediate, leaf), got %d certificates", len(info.Chain))
}
if info.Chain[0].SerialNumber.Cmp(leaf.SerialNumber) != 0 {
t.Error("Expected root certificate, got different certificate")
}
if info.Chain[1].SerialNumber.Cmp(intermediate.SerialNumber) != 0 {
t.Error("Expected intermediate certificate, got different certificate")
}
if info.Chain[2].SerialNumber.Cmp(root.SerialNumber) != 0 {
t.Error("Expected leaf certificate, got different certificate")
}
}
7 changes: 6 additions & 1 deletion client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ var (
Insecure: false,
IgnoreRedirect: false,
Timeout: defaultTimeout,
Network: "ip",
DisableFullChainCertificateExpirationCheck: false,
Network: "ip",
}
)

Expand Down Expand Up @@ -69,6 +70,10 @@ type Config struct {
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`

// DisableFullChainCertificateExpirationCheck determines whether to only check the leaf certificate expiration (true)
// or verify the full certificate chain expiration (false, default).
DisableFullChainCertificateExpirationCheck bool `yaml:"disable-full-chain-certificate-expiration-check"`

httpClient *http.Client

// Network (ip, ip4 or ip6) for the ICMP client
Expand Down
Loading