diff --git a/README.md b/README.md index 2d985bcc..a0b9e31d 100644 --- a/README.md +++ b/README.md @@ -102,19 +102,23 @@ Note that you can also add environment variables in the configuration file (i.e. | `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved | `false` | | `services[].alerts[].description` | Description of the alert. Will be included in the alert sent | `""` | | `alerting` | Configuration for alerting | `{}` | -| `alerting.slack` | Configuration for alerts of type `slack` | `""` | +| `alerting.slack` | Configuration for alerts of type `slack` | `{}` | | `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` | -| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `""` | +| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` | | `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key. | Required `""` | -| `alerting.twilio` | Settings for alerts of type `twilio` | `""` | +| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` | | `alerting.twilio.sid` | Twilio account SID | Required `""` | | `alerting.twilio.token` | Twilio auth token | Required `""` | | `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` | | `alerting.twilio.to` | Number to send twilio alerts to | Required `""` | -| `alerting.custom` | Configuration for custom actions on failure or alerts | `""` | +| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` | | `alerting.custom.url` | Custom alerting request url | Required `""` | | `alerting.custom.body` | Custom alerting request body. | `""` | | `alerting.custom.headers` | Custom alerting request headers | `{}` | +| `security` | Security configuration | `{}` | +| `security.basic` | Basic authentication security configuration | `{}` | +| `security.basic.username` | Username for Basic authentication | Required `""` | +| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication | Required `""` | ### Conditions @@ -410,3 +414,17 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `services[].body`, `s **NOTE**: `[CONNECTED] == true` does not guarantee that the service itself is healthy - it only guarantees that there's something at the given address listening to the given port, and that a connection to that address was successfully established. + + +### Basic authentication + +You can require Basic authentication by leveraging the `security.basic` configuration: + +```yaml +security: + basic: + username: "john.doe" + password-sha512: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22" +``` + +The example above will require that you authenticate with the username `john.doe` as well as the password `hunter2`. diff --git a/config/config.go b/config/config.go index 09e0bfcb..6d0a0540 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "github.com/TwinProduction/gatus/alerting" "github.com/TwinProduction/gatus/alerting/provider" "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/security" "gopkg.in/yaml.v2" "io/ioutil" "log" @@ -18,16 +19,18 @@ const ( ) var ( - ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service") - ErrConfigFileNotFound = errors.New("configuration file not found") - ErrConfigNotLoaded = errors.New("configuration is nil") - config *Config + ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service") + ErrConfigFileNotFound = errors.New("configuration file not found") + ErrConfigNotLoaded = errors.New("configuration is nil") + ErrInvalidSecurityConfig = errors.New("invalid security configuration") + config *Config ) // Config is the main configuration structure type Config struct { Metrics bool `yaml:"metrics"` Debug bool `yaml:"debug"` + Security *security.Config `yaml:"security"` Alerting *alerting.Config `yaml:"alerting"` Services []*core.Service `yaml:"services"` } @@ -83,6 +86,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { err = ErrNoServiceInConfig } else { validateAlertingConfig(config) + validateSecurityConfig(config) validateServicesConfig(config) } return @@ -98,6 +102,20 @@ func validateServicesConfig(config *Config) { log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services)) } +func validateSecurityConfig(config *Config) { + if config.Security != nil { + if config.Security.IsValid() { + if config.Debug { + log.Printf("[config][validateSecurityConfig] Basic security configuration has been validated") + } + } else { + // If there was an attempt to configure security, then it must mean that some confidential or private + // data are exposed. As a result, we'll force a panic because it's better to be safe than sorry. + panic(ErrInvalidSecurityConfig) + } + } +} + func validateAlertingConfig(config *Config) { if config.Alerting == nil { log.Printf("[config][validateAlertingConfig] Alerting is not configured") diff --git a/config/config_test.go b/config/config_test.go index 20f9e95c..d4d76982 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "github.com/TwinProduction/gatus/core" "testing" "time" @@ -217,3 +218,56 @@ services: t.Fatal("PagerDuty alerting config should've been invalid") } } + +func TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) { + defer func() { recover() }() + _, _ = parseAndValidateConfigBytes([]byte(` +security: + basic: + username: "admin" + password-sha512: "invalid-sha512-hash" +services: + - name: twinnation + url: https://twinnation.org/actuator/health + conditions: + - "[STATUS] == 200" +`)) + t.Error("Function should've panicked") +} + +func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) { + const expectedUsername = "admin" + const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22" + config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` +security: + basic: + username: "%s" + password-sha512: "%s" +services: + - name: twinnation + url: https://twinnation.org/actuator/health + conditions: + - "[STATUS] == 200" +`, expectedUsername, expectedPasswordHash))) + if err != nil { + t.Error("No error should've been returned") + } + if config == nil { + t.Fatal("Config shouldn't have been nil") + } + if config.Security == nil { + t.Fatal("config.Security shouldn't have been nil") + } + if !config.Security.IsValid() { + t.Error("Security config should've been valid") + } + if config.Security.Basic == nil { + t.Fatal("config.Security.Basic shouldn't have been nil") + } + if config.Security.Basic.Username != expectedUsername { + t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username) + } + if config.Security.Basic.PasswordSha512Hash != expectedPasswordHash { + t.Errorf("config.Security.Basic.PasswordSha512Hash should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash) + } +} diff --git a/main.go b/main.go index 34a81229..86c6d726 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/security" "github.com/TwinProduction/gatus/watchdog" "github.com/prometheus/client_golang/prometheus/promhttp" "log" @@ -23,7 +24,11 @@ var ( func main() { cfg := loadConfiguration() - http.HandleFunc("/api/v1/results", serviceResultsHandler) + resultsHandler := serviceResultsHandler + if cfg.Security != nil && cfg.Security.IsValid() { + resultsHandler = security.Handler(serviceResultsHandler, cfg.Security) + } + http.HandleFunc("/api/v1/results", resultsHandler) http.HandleFunc("/health", healthHandler) http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static")))) if cfg.Metrics { diff --git a/security/handler.go b/security/handler.go new file mode 100644 index 00000000..251a9cef --- /dev/null +++ b/security/handler.go @@ -0,0 +1,18 @@ +package security + +import ( + "net/http" +) + +func Handler(handler http.HandlerFunc, security *Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + usernameEntered, passwordEntered, ok := r.BasicAuth() + if !ok || usernameEntered != security.Basic.Username || Sha512(passwordEntered) != security.Basic.PasswordSha512Hash { + w.Header().Set("WWW-Authenticate", "Basic") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + return + } + handler(w, r) + } +} diff --git a/security/handler_test.go b/security/handler_test.go new file mode 100644 index 00000000..2a131cc1 --- /dev/null +++ b/security/handler_test.go @@ -0,0 +1,58 @@ +package security + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func mockHandler(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(200) +} + +func TestHandlerWhenNotAuthenticated(t *testing.T) { + handler := Handler(mockHandler, &Config{&BasicConfig{ + Username: "john.doe", + PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22", + }}) + request, _ := http.NewRequest("GET", "/api/v1/results", nil) + responseRecorder := httptest.NewRecorder() + + handler.ServeHTTP(responseRecorder, request) + + if responseRecorder.Code != http.StatusUnauthorized { + t.Error("Expected code to be 401, but was", responseRecorder.Code) + } +} + +func TestHandlerWhenAuthenticated(t *testing.T) { + handler := Handler(mockHandler, &Config{&BasicConfig{ + Username: "john.doe", + PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22", + }}) + request, _ := http.NewRequest("GET", "/api/v1/results", nil) + request.SetBasicAuth("john.doe", "hunter2") + responseRecorder := httptest.NewRecorder() + + handler.ServeHTTP(responseRecorder, request) + + if responseRecorder.Code != http.StatusOK { + t.Error("Expected code to be 200, but was", responseRecorder.Code) + } +} + +func TestHandlerWhenAuthenticatedWithBadCredentials(t *testing.T) { + handler := Handler(mockHandler, &Config{&BasicConfig{ + Username: "john.doe", + PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22", + }}) + request, _ := http.NewRequest("GET", "/api/v1/results", nil) + request.SetBasicAuth("john.doe", "bad-password") + responseRecorder := httptest.NewRecorder() + + handler.ServeHTTP(responseRecorder, request) + + if responseRecorder.Code != http.StatusUnauthorized { + t.Error("Expected code to be 401, but was", responseRecorder.Code) + } +} diff --git a/security/security.go b/security/security.go new file mode 100644 index 00000000..29b5340c --- /dev/null +++ b/security/security.go @@ -0,0 +1,18 @@ +package security + +type Config struct { + Basic *BasicConfig `yaml:"basic"` +} + +func (c *Config) IsValid() bool { + return c.Basic != nil && c.Basic.IsValid() +} + +type BasicConfig struct { + Username string `yaml:"username"` + PasswordSha512Hash string `yaml:"password-sha512"` +} + +func (c *BasicConfig) IsValid() bool { + return len(c.Username) > 0 && len(c.PasswordSha512Hash) == 128 +} diff --git a/security/security_test.go b/security/security_test.go new file mode 100644 index 00000000..67d4e796 --- /dev/null +++ b/security/security_test.go @@ -0,0 +1,23 @@ +package security + +import "testing" + +func TestBasicConfig_IsValid(t *testing.T) { + basicConfig := &BasicConfig{ + Username: "admin", + PasswordSha512Hash: Sha512("test"), + } + if !basicConfig.IsValid() { + t.Error("basicConfig should've been valid") + } +} + +func TestBasicConfig_IsValidWhenPasswordIsInvalid(t *testing.T) { + basicConfig := &BasicConfig{ + Username: "admin", + PasswordSha512Hash: "", + } + if basicConfig.IsValid() { + t.Error("basicConfig shouldn't have been valid") + } +} diff --git a/security/sha512.go b/security/sha512.go new file mode 100644 index 00000000..fffcf1b8 --- /dev/null +++ b/security/sha512.go @@ -0,0 +1,12 @@ +package security + +import ( + "crypto/sha512" + "fmt" +) + +func Sha512(s string) string { + hash := sha512.New() + hash.Write([]byte(s)) + return fmt.Sprintf("%x", hash.Sum(nil)) +} diff --git a/security/sha512_test.go b/security/sha512_test.go new file mode 100644 index 00000000..23e0f8d4 --- /dev/null +++ b/security/sha512_test.go @@ -0,0 +1,12 @@ +package security + +import "testing" + +func TestSha512(t *testing.T) { + input := "password" + expectedHash := "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86" + hash := Sha512(input) + if hash != expectedHash { + t.Errorf("Expected hash to be '%s', but was '%s'", expectedHash, hash) + } +}