diff --git a/README.md b/README.md index 23abfa806..cf88c0118 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ If you want to test it locally, see [Docker](#docker). ### Endpoints Endpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are -evaluated on an interval that you define. If any condition fails, the endpoint is considered as unhealthy. +evaluated on an interval that you define. If any condition fails, the endpoint is considered as unhealthy. You can then configure alerts to be triggered when an endpoint is unhealthy once a certain threshold is reached. | Parameter | Description | Default | @@ -417,8 +417,12 @@ the client used to send the request. | `client.proxy-url` | The URL of the proxy to use for the client | `""` | | `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` | | `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` | +| `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.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` | + > 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved > in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. @@ -490,6 +494,22 @@ endpoints: > 📝 Note that Gatus will use the [gcloud default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) within its environment to generate the token. +This example shows you how you cna use the `client.tls` configuration to perform an mTLS query to a backend API: + +```yaml +endpoints: + - name: website + url: "https://your.mtls.protected.app/health" + client: + tls: + certificate-file: /path/to/user_cert.pem + private-key-file: /path/to/user_key.pem + renegotiation: once + conditions: + - "[STATUS] == 200" +``` + +> 📝 Note that if running in a container, you must volume mount the certificate and key into the container. ### Alerting Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each @@ -2059,6 +2079,19 @@ endpoints: - "[STATUS] == 200" ``` +### Proxy client configuration + +You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration. + +```yaml +endpoints: + - name: website + url: "https://twin.sh/health" + client: + proxy-url: http://proxy.example.com:8080 + conditions: + - "[STATUS] == 200" +``` ### How to fix 431 Request Header Fields Too Large error Depending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus, diff --git a/client/client_test.go b/client/client_test.go index 3cb117a96..945dd7852 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "crypto/tls" "io" "net/http" "testing" @@ -290,3 +291,46 @@ func TestQueryWebSocket(t *testing.T) { t.Error("expected an error due to the target not being websocket-friendly") } } + +func TestTlsRenegotiation(t *testing.T) { + tests := []struct { + name string + cfg TLSConfig + expectedConfig tls.RenegotiationSupport + }{ + { + name: "default", + cfg: TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}, + expectedConfig: tls.RenegotiateNever, + }, + { + name: "never", + cfg: TLSConfig{RenegotiationSupport: "never", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}, + expectedConfig: tls.RenegotiateNever, + }, + { + name: "once", + cfg: TLSConfig{RenegotiationSupport: "once", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}, + expectedConfig: tls.RenegotiateOnceAsClient, + }, + { + name: "freely", + cfg: TLSConfig{RenegotiationSupport: "freely", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}, + expectedConfig: tls.RenegotiateFreelyAsClient, + }, + { + name: "not-valid-and-broken", + cfg: TLSConfig{RenegotiationSupport: "invalid", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}, + expectedConfig: tls.RenegotiateNever, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tls := &tls.Config{} + 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 faca45827..79effcee6 100644 --- a/client/config.go +++ b/client/config.go @@ -26,6 +26,7 @@ var ( 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") defaultConfig = Config{ Insecure: false, @@ -72,6 +73,9 @@ type Config struct { // Network (ip, ip4 or ip6) for the ICMP client Network string `yaml:"network"` + + // TLS configuration (optional) + TLS *TLSConfig `yaml:"tls,omitempty"` } // DNSResolverConfig is the parsed configuration from the DNSResolver config string. @@ -94,6 +98,17 @@ type IAPConfig struct { Audience string `yaml:"audience"` // e.g. "toto.apps.googleusercontent.com" } +// TLSConfig is the configuration for mTLS configurations +type TLSConfig struct { + // CertificateFile is the public certificate for TLS in PEM format. + CertificateFile string `yaml:"certificate-file,omitempty"` + + // PrivateKeyFile is the private key file for TLS in PEM format. + PrivateKeyFile string `yaml:"private-key-file,omitempty"` + + RenegotiationSupport string `yaml:"renegotiation,omitempty"` +} + // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary func (c *Config) ValidateAndSetDefaults() error { if c.Timeout < time.Millisecond { @@ -111,6 +126,11 @@ func (c *Config) ValidateAndSetDefaults() error { if c.HasIAPConfig() && !c.IAPConfig.isValid() { return ErrInvalidClientIAPConfig } + if c.HasTlsConfig() { + if err := c.TLS.isValid(); err != nil { + return err + } + } return nil } @@ -156,6 +176,11 @@ func (c *Config) HasIAPConfig() bool { return c.IAPConfig != nil } +// HasTlsConfig returns true if the client has client certificate parameters +func (c *Config) HasTlsConfig() bool { + return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0 +} + // isValid() returns true if the IAP configuration is valid func (c *IAPConfig) isValid() bool { return len(c.Audience) > 0 @@ -166,8 +191,26 @@ 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 +func (t *TLSConfig) isValid() error { + if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 { + _, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile) + if err != nil { + return err + } + return nil + } + return ErrInvalidClientTLSConfig +} + // GetHTTPClient return an HTTP client matching the Config's parameters. func (c *Config) getHTTPClient() *http.Client { + tlsConfig := &tls.Config{ + InsecureSkipVerify: c.Insecure, + } + if c.HasTlsConfig() && c.TLS.isValid() == nil { + tlsConfig = configureTLS(tlsConfig, *c.TLS) + } if c.httpClient == nil { c.httpClient = &http.Client{ Timeout: c.Timeout, @@ -175,9 +218,7 @@ func (c *Config) getHTTPClient() *http.Client { MaxIdleConns: 100, MaxIdleConnsPerHost: 20, Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: c.Insecure, - }, + TLSClientConfig: tlsConfig, }, CheckRedirect: func(req *http.Request, via []*http.Request) error { if c.IgnoreRedirect { @@ -281,3 +322,23 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client { client.Timeout = httpClient.Timeout 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 { + return nil + } + tlsConfig.Certificates = []tls.Certificate{clientTLSCert} + tlsConfig.Renegotiation = tls.RenegotiateNever + + renegotionSupport := map[string]tls.RenegotiationSupport{ + "once": tls.RenegotiateOnceAsClient, + "freely": tls.RenegotiateFreelyAsClient, + "never": tls.RenegotiateNever, + } + if val, ok := renegotionSupport[c.RenegotiationSupport]; ok { + tlsConfig.Renegotiation = val + } + return tlsConfig +} diff --git a/client/config_test.go b/client/config_test.go index 2b040be74..3f6043fb8 100644 --- a/client/config_test.go +++ b/client/config_test.go @@ -106,3 +106,66 @@ func TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) { t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL) } } + +func TestConfig_TlsIsValid(t *testing.T) { + tests := []struct { + name string + cfg *Config + expectedErr bool + }{ + { + name: "good-tls-config", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}}, + expectedErr: false, + }, + { + name: "missing-certificate-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../testdata/cert.key"}}, + expectedErr: true, + }, + { + name: "bad-certificate-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/cert.key"}}, + expectedErr: true, + }, + { + name: "no-certificate-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../testdata/cert.key"}}, + expectedErr: true, + }, + { + name: "missing-private-key-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}}, + expectedErr: true, + }, + { + name: "no-private-key-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: ""}}, + expectedErr: true, + }, + { + name: "bad-private-key-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/badcert.key"}}, + expectedErr: true, + }, + { + name: "bad-certificate-and-private-key-file", + cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/badcert.key"}}, + expectedErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.cfg.TLS.isValid() + if (err != nil) != test.expectedErr { + t.Errorf("expected the existence of an error to be %v, got %v", test.expectedErr, err) + return + } + if !test.expectedErr { + if test.cfg.TLS.isValid() != nil { + t.Error("cfg.TLS.isValid() returned an error even though no error was expected") + } + } + }) + } +}