diff --git a/.examples/docker-compose-mtls/config/config.yaml b/.examples/docker-compose-mtls/config/config.yaml index 3fb44a9e39..4aaab2d966 100644 --- a/.examples/docker-compose-mtls/config/config.yaml +++ b/.examples/docker-compose-mtls/config/config.yaml @@ -6,8 +6,9 @@ endpoints: - "[STATUS] == 200" client: # mtls - insecure: true + insecure: false tls: certificate-file: /certs/client.crt private-key-file: /certs/client.key - renegotiation: once \ No newline at end of file + renegotiation: once + server-name-indication: localhost diff --git a/.examples/docker-compose-mtls/docker-compose.yml b/.examples/docker-compose-mtls/docker-compose.yml index 9ea21f504d..923e40f11e 100644 --- a/.examples/docker-compose-mtls/docker-compose.yml +++ b/.examples/docker-compose-mtls/docker-compose.yml @@ -11,7 +11,8 @@ services: - mtls gatus: - image: twinproduction/gatus:latest + build: + dockerfile: gatus.dockerfile restart: always ports: - "8080:8080" diff --git a/.examples/docker-compose-mtls/gatus.dockerfile b/.examples/docker-compose-mtls/gatus.dockerfile new file mode 100644 index 0000000000..b20b26b052 --- /dev/null +++ b/.examples/docker-compose-mtls/gatus.dockerfile @@ -0,0 +1,9 @@ +# Generate ca-certificates file using sample certificates +FROM alpine as builder +RUN apk --update add ca-certificates +COPY certs/server/ca.crt /usr/local/share/ca-certificates/docker-compose-mtls.crt +RUN cat /usr/local/share/ca-certificates/docker-compose-mtls.crt >> /etc/ssl/certs/ca-certificates.crt + +# Add CA cert to gatus +FROM twinproduction/gatus:latest +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt diff --git a/README.md b/README.md index 416c0c1876..19e269c9ec 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,7 @@ the client used to send the request. | `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` | | `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` | | `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` | +| `client.tls.server-name-indication` | Override default SNI hostname in secure TLS connections. | `""` | | `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` | @@ -511,15 +512,21 @@ endpoints: - name: website url: "https://your.mtls.protected.app/health" client: + insecure: false tls: certificate-file: /path/to/user_cert.pem private-key-file: /path/to/user_key.pem renegotiation: once + server-name-indication: your.mtls.app conditions: - "[STATUS] == 200" ``` -> 📝 Note that if running in a container, you must volume mount the certificate and key into the container. +> 📝 Note: +> - If running in a container, you must volume mount the certificate and key into the container. +> - You must provide neither or both certificate and private key. You cannot provide one without the other. +> - If `client.insecure` is true, server name will not be validated regardless of whether `client.server-name-indication` is set or not +> - If you leave `client.server-name-indication` unset, the SNI set in the client hello will be sourced from `endpoints[].url` if applicable (i.e. left unset for IP). ### Alerting Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each diff --git a/client/client.go b/client/client.go index a7bb18da13..5e89c567aa 100644 --- a/client/client.go +++ b/client/client.go @@ -149,9 +149,13 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi // 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) { - connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{ + tlsConfig := &tls.Config{ InsecureSkipVerify: config.Insecure, - }) + } + if config.HasTLSConfig() && config.TLS.isValid() == nil { + tlsConfig = ConfigureTLS(tlsConfig, *config.TLS) + } + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, tlsConfig) if err != nil { return } diff --git a/client/client_test.go b/client/client_test.go index 8f3a2bd74d..79adba61c3 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -169,6 +169,7 @@ func TestCanPerformTLS(t *testing.T) { type args struct { address string insecure bool + sni string } tests := []struct { name string @@ -218,11 +219,31 @@ func TestCanPerformTLS(t *testing.T) { wantConnected: false, wantErr: true, }, + { + name: "valid tls with different sni", + args: args{ + insecure: false, + address: "example.com:443", + sni: "example.net", + }, + wantConnected: true, + wantErr: false, + }, + { + name: "valid tls with wrong sni", + args: args{ + insecure: false, + address: "example.com:443", + sni: "wrong.sni", + }, + wantConnected: false, + wantErr: true, + }, } 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}) + connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, TLS: &TLSConfig{ServerNameIndication: tt.args.sni}}) if (err != nil) != tt.wantErr { t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr) return @@ -347,7 +368,7 @@ func TestTlsRenegotiation(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { tls := &tls.Config{} - tlsConfig := configureTLS(tls, test.cfg) + tlsConfig := ConfigureTLS(tls, test.cfg) if tlsConfig.Renegotiation != test.expectedConfig { t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation) } diff --git a/client/config.go b/client/config.go index d095ae45bb..19c65c1fd5 100644 --- a/client/config.go +++ b/client/config.go @@ -22,11 +22,11 @@ const ( ) var ( - ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}") - ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port") - ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)") - ErrInvalidClientIAPConfig = errors.New("invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)") - ErrInvalidClientTLSConfig = errors.New("invalid TLS configuration: certificate-file and private-key-file must be specified") + ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}") + ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port") + ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)") + ErrInvalidClientIAPConfig = errors.New("invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)") + ErrInvalidClientCertificatesConfig = errors.New("invalid TLS client certificates configuration: both certificate-file and private-key-file must be specified") defaultConfig = Config{ Insecure: false, @@ -107,6 +107,9 @@ type TLSConfig struct { PrivateKeyFile string `yaml:"private-key-file,omitempty"` RenegotiationSupport string `yaml:"renegotiation,omitempty"` + + // Override default SNI behaviour + ServerNameIndication string `yaml:"server-name-indication,omitempty"` } // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary @@ -176,9 +179,9 @@ func (c *Config) HasIAPConfig() bool { return c.IAPConfig != nil } -// HasTLSConfig returns true if the client has client certificate parameters +// HasTLSConfig returns true if the client has a TLS config func (c *Config) HasTLSConfig() bool { - return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0 + return c.TLS != nil } // isValid() returns true if the IAP configuration is valid @@ -191,16 +194,23 @@ func (c *OAuth2Config) isValid() bool { return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0 } -// isValid() returns nil if the client tls certificates are valid, otherwise returns an error +// HasClientCertificates returns true if the client has client certificate parameters in the TLS config +func (c *TLSConfig) HasClientCertificates() bool { + return len(c.CertificateFile) > 0 && len(c.PrivateKeyFile) > 0 +} + +// isValid() returns nil if the client tls configuration is valid (including certificate validation if provided), otherwise returns an error func (t *TLSConfig) isValid() error { - if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 { + if (len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) <= 0) || (len(t.PrivateKeyFile) > 0 && len(t.CertificateFile) <= 0) { + return ErrInvalidClientCertificatesConfig + } + if t.HasClientCertificates() { _, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile) if err != nil { return err } - return nil } - return ErrInvalidClientTLSConfig + return nil } // getHTTPClient return an HTTP client matching the Config's parameters. @@ -209,7 +219,7 @@ func (c *Config) getHTTPClient() *http.Client { InsecureSkipVerify: c.Insecure, } if c.HasTLSConfig() && c.TLS.isValid() == nil { - tlsConfig = configureTLS(tlsConfig, *c.TLS) + tlsConfig = ConfigureTLS(tlsConfig, *c.TLS) } if c.httpClient == nil { c.httpClient = &http.Client{ @@ -322,14 +332,16 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client { return client } -// configureTLS returns a TLS Config that will enable mTLS -func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config { - clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile) - if err != nil { - logr.Errorf("[client.configureTLS] Failed to load certificate: %s", err.Error()) - return nil +// configureTLS returns a TLS Config that can enable mTLS, set renegotiation and SNI +func ConfigureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config { + if c.HasClientCertificates() { + clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile) + if err != nil { + logr.Errorf("[client.ConfigureTLS] Failed to load certificate: %s", err.Error()) + return nil + } + tlsConfig.Certificates = []tls.Certificate{clientTLSCert} } - tlsConfig.Certificates = []tls.Certificate{clientTLSCert} tlsConfig.Renegotiation = tls.RenegotiateNever renegotiationSupport := map[string]tls.RenegotiationSupport{ "once": tls.RenegotiateOnceAsClient, @@ -339,5 +351,8 @@ func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config { if val, ok := renegotiationSupport[c.RenegotiationSupport]; ok { tlsConfig.Renegotiation = val } + if len(c.ServerNameIndication) > 0 { + tlsConfig.ServerName = c.ServerNameIndication + } return tlsConfig } diff --git a/client/config_test.go b/client/config_test.go index 3f6043fb8f..65c7f0a6bf 100644 --- a/client/config_test.go +++ b/client/config_test.go @@ -107,6 +107,20 @@ func TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) { } } +func TestConfig_getHTTPClient_withServerNameIndication(t *testing.T) { + cfg := &Config{TLS: &TLSConfig{ + CertificateFile: "../testdata/cert.pem", + PrivateKeyFile: "../testdata/cert.key", + ServerNameIndication: "sni", + }} + cfg.ValidateAndSetDefaults() + client := cfg.getHTTPClient() + transport := client.Transport.(*http.Transport) + if transport.TLSClientConfig.ServerName != "sni" { + t.Errorf("expected Config.TLS.ServerNameIndication set to \"sni\" to cause the HTTP client to use \"sni\" as server name in TLS config") + } +} + func TestConfig_TlsIsValid(t *testing.T) { tests := []struct { name string