mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-24 17:04:42 +01:00
feat(web): Support TLS encryption (#322)
* Basic setup to serve HTTPS * Correctly handle the case of missing TLS configs * Documenting TLS * Refactor TLS configuration setup * Add TLS Encryption section again to README * Extending TOC in README * Moving TLS settings to subsection of web settings * Adding tests for config/web * Add test for handling TLS * Rename some variables as suggested * Corrected error formatting * Update test module import * Polishing the readme file * Error handling for TLSConfig() --------- Co-authored-by: TwiN <twin@linux.com>
This commit is contained in:
parent
0bd0c1fd15
commit
a05daeda2e
28
README.md
28
README.md
@ -67,8 +67,9 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Setting a default alert](#setting-a-default-alert)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Security](#security)
|
||||
- [Basic](#basic)
|
||||
- [Basic Authentication](#basic-authentication)
|
||||
- [OIDC](#oidc)
|
||||
- [TLS Encryption](#tls-encryption)
|
||||
- [Metrics](#metrics)
|
||||
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
|
||||
- [Deployment](#deployment)
|
||||
@ -87,7 +88,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
|
||||
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
|
||||
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
|
||||
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)>
|
||||
- [Monitoring domain expiration](#monitoring-domain-expiration)
|
||||
- [disable-monitoring-lock](#disable-monitoring-lock)
|
||||
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
|
||||
@ -228,6 +229,8 @@ If you want to test it locally, see [Docker](#docker).
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` |
|
||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||
@ -1054,7 +1057,7 @@ As a result, the `[ALERT_TRIGGERED_OR_RESOLVED]` in the body of first example of
|
||||
|
||||
#### Setting a default alert
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------------|:------------------------------------------------------------------------------|:--------|
|
||||
|:---------------------------------------------|:------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.*.default-alert.enabled` | Whether to enable the alert | N/A |
|
||||
| `alerting.*.default-alert.failure-threshold` | Number of failures in a row needed before triggering the alert | N/A |
|
||||
| `alerting.*.default-alert.success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | N/A |
|
||||
@ -1176,13 +1179,13 @@ maintenance:
|
||||
|
||||
### Security
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:-----------------------------|:--------------|
|
||||
|:-----------------|:-----------------------------|:--------|
|
||||
| `security` | Security configuration | `{}` |
|
||||
| `security.basic` | HTTP Basic configuration | `{}` |
|
||||
| `security.oidc` | OpenID Connect configuration | `{}` |
|
||||
|
||||
|
||||
#### Basic
|
||||
#### Basic Authentication
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------|:-----------------------------------------------------------------------------------|:--------------|
|
||||
| `security.basic` | HTTP Basic configuration | `{}` |
|
||||
@ -1226,6 +1229,17 @@ security:
|
||||
|
||||
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
|
||||
|
||||
### TLS Encryption
|
||||
Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided.
|
||||
The example below shows an example configuration which makes gatus respond on port 4443 to HTTPS requests.
|
||||
|
||||
```yaml
|
||||
web:
|
||||
port: 4443
|
||||
tls:
|
||||
certificate-file: "server.crt"
|
||||
private-key-file: "server.key"
|
||||
```
|
||||
|
||||
### Metrics
|
||||
To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
|
||||
@ -1254,7 +1268,7 @@ there are known issues with this feature. If you'd like to provide some feedback
|
||||
Use at your own risk.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:---------------------------------------------|:---------------|
|
||||
|:-----------------------------------|:---------------------------------------------|:--------------|
|
||||
| `remote` | Remote configuration | `{}` |
|
||||
| `remote.instances` | List of remote instances | Required `[]` |
|
||||
| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` |
|
||||
@ -1387,7 +1401,7 @@ simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
|
||||
|
||||
### Default timeouts
|
||||
| Endpoint type | Timeout |
|
||||
|:---------------|:--------|
|
||||
|:--------------|:--------|
|
||||
| HTTP | 10s |
|
||||
| TCP | 10s |
|
||||
| ICMP | 10s |
|
||||
|
@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
@ -21,6 +22,21 @@ type Config struct {
|
||||
|
||||
// Port to listen on (default to 8080 specified by DefaultPort)
|
||||
Port int `yaml:"port"`
|
||||
|
||||
// TLS configuration
|
||||
Tls TLSConfig `yaml:"tls"`
|
||||
|
||||
tlsConfig *tls.Config
|
||||
tlsConfigError error
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
|
||||
// Optional public certificate for TLS in PEM format.
|
||||
CertificateFile string `yaml:"certificate-file,omitempty"`
|
||||
|
||||
// Optional private key file for TLS in PEM format.
|
||||
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
|
||||
}
|
||||
|
||||
// GetDefaultConfig returns a Config struct with the default values
|
||||
@ -40,6 +56,11 @@ func (web *Config) ValidateAndSetDefaults() error {
|
||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
||||
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
|
||||
}
|
||||
// Try to load the TLS certificates
|
||||
_, err := web.TLSConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tls config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -47,3 +68,19 @@ func (web *Config) ValidateAndSetDefaults() error {
|
||||
func (web *Config) SocketAddress() string {
|
||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||
}
|
||||
|
||||
// TLSConfig returns a tls.Config object for serving over an encrypted channel
|
||||
func (web *Config) TLSConfig() (*tls.Config, error) {
|
||||
if web.tlsConfig == nil && len(web.Tls.CertificateFile) > 0 && len(web.Tls.PrivateKeyFile) > 0 {
|
||||
web.loadTLSConfig()
|
||||
}
|
||||
return web.tlsConfig, web.tlsConfigError
|
||||
}
|
||||
|
||||
func (web *Config) loadTLSConfig() {
|
||||
cer, err := tls.LoadX509KeyPair(web.Tls.CertificateFile, web.Tls.PrivateKeyFile)
|
||||
if err != nil {
|
||||
web.tlsConfigError = err
|
||||
}
|
||||
web.tlsConfig = &tls.Config{Certificates: []tls.Certificate{cer}}
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestGetDefaultConfig(t *testing.T) {
|
||||
@ -12,6 +14,9 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
if defaultConfig.Address != DefaultAddress {
|
||||
t.Error("expected default config to have the default address")
|
||||
}
|
||||
if defaultConfig.Tls != (TLSConfig{}) {
|
||||
t.Error("expected default config to have TLS disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
@ -63,3 +68,43 @@ func TestConfig_SocketAddress(t *testing.T) {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_TLSConfig(t *testing.T) {
|
||||
privateKeyPath, publicKeyPath := test.UnsafeSelfSignedCertificates(t.TempDir())
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "including TLS",
|
||||
cfg: &Config{Tls: (TLSConfig{CertificateFile: publicKeyPath, PrivateKeyFile: privateKeyPath})},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "TLS with missing crt file",
|
||||
cfg: &Config{Tls: (TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: privateKeyPath})},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "TLS with missing key file",
|
||||
cfg: &Config{Tls: (TLSConfig{CertificateFile: publicKeyPath, PrivateKeyFile: "doesnotexist"})},
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
cfg, err := scenario.cfg.TLSConfig()
|
||||
if (err != nil) != scenario.expectedErr {
|
||||
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
|
||||
return
|
||||
}
|
||||
if !scenario.expectedErr {
|
||||
if cfg == nil {
|
||||
t.Error("TLS configuration was not correctly loaded although no error was returned")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -24,8 +24,14 @@ func Handle(cfg *config.Config) {
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
router = handler.DevelopmentCORS(router)
|
||||
}
|
||||
tlsConfig, err := cfg.Web.TLSConfig()
|
||||
if err != nil {
|
||||
panic(err) // Should be unreachable, because the config is validated before
|
||||
}
|
||||
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
|
||||
TLSConfig: tlsConfig,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
@ -35,8 +41,12 @@ func Handle(cfg *config.Config) {
|
||||
if os.Getenv("ROUTER_TEST") == "true" {
|
||||
return
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
log.Println("[controller][Handle]", server.ListenAndServeTLS("", ""))
|
||||
} else {
|
||||
log.Println("[controller][Handle]", server.ListenAndServe())
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops the server
|
||||
func Shutdown() {
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestHandle(t *testing.T) {
|
||||
@ -45,6 +46,41 @@ func TestHandle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTls(t *testing.T) {
|
||||
privateKeyPath, publicKeyPath := test.UnsafeSelfSignedCertificates(t.TempDir())
|
||||
cfg := &config.Config{
|
||||
Web: &web.Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: rand.Intn(65534),
|
||||
Tls: (web.TLSConfig{CertificateFile: publicKeyPath, PrivateKeyFile: privateKeyPath}),
|
||||
},
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = os.Setenv("ROUTER_TEST", "true")
|
||||
_ = os.Setenv("ENVIRONMENT", "dev")
|
||||
defer os.Clearenv()
|
||||
Handle(cfg)
|
||||
defer Shutdown()
|
||||
request, _ := http.NewRequest("GET", "/health", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected GET /health to return status code 200")
|
||||
}
|
||||
if server == nil {
|
||||
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdown(t *testing.T) {
|
||||
// Pretend that we called controller.Handle(), which initializes the server variable
|
||||
server = &http.Server{}
|
||||
|
72
test/tls.go
Normal file
72
test/tls.go
Normal file
@ -0,0 +1,72 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UnsafeSelfSignedCertificates creates a pair of test certificates in the given test folder
|
||||
func UnsafeSelfSignedCertificates(testfolder string) (privateKeyPath string, publicKeyPath string) {
|
||||
privateKeyPath = fmt.Sprintf("%s/cert.key", testfolder)
|
||||
publicKeyPath = fmt.Sprintf("%s/cert.pem", testfolder)
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generatekey: %v", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1234),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Gatus test"},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour * 24),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certOut, err := os.Create(publicKeyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open cert.pem for writing: %v", err)
|
||||
}
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
||||
log.Fatalf("Failed to write data to cert.pem: %v", err)
|
||||
}
|
||||
if err := certOut.Close(); err != nil {
|
||||
log.Fatalf("Error closing cert.pem: %v", err)
|
||||
}
|
||||
|
||||
keyOut, err := os.OpenFile(privateKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open %s for writing: %v", privateKeyPath, err)
|
||||
}
|
||||
privBytes, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to marshal private key: %v", err)
|
||||
}
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
|
||||
log.Fatalf("Failed to write data to key.pem: %v", err)
|
||||
}
|
||||
if err := keyOut.Close(); err != nil {
|
||||
log.Fatalf("Error closing key.pem: %v", err)
|
||||
}
|
||||
log.Print("wrote key.pem\n")
|
||||
|
||||
return
|
||||
}
|
Loading…
Reference in New Issue
Block a user