Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): Add support for SNI override #958

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .examples/docker-compose-mtls/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
renegotiation: once
server-name-indication: localhost
3 changes: 2 additions & 1 deletion .examples/docker-compose-mtls/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ services:
- mtls

gatus:
image: twinproduction/gatus:latest
build:
dockerfile: gatus.dockerfile
restart: always
ports:
- "8080:8080"
Expand Down
9 changes: 9 additions & 0 deletions .examples/docker-compose-mtls/gatus.dockerfile
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` |


Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
25 changes: 23 additions & 2 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func TestCanPerformTLS(t *testing.T) {
type args struct {
address string
insecure bool
sni string
}
tests := []struct {
name string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
53 changes: 34 additions & 19 deletions client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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{
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
14 changes: 14 additions & 0 deletions client/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down