diff --git a/.examples/docker-compose-secrets/config/config.yaml b/.examples/docker-compose-secrets/config/config.yaml new file mode 100644 index 000000000..ea579ecfa --- /dev/null +++ b/.examples/docker-compose-secrets/config/config.yaml @@ -0,0 +1,42 @@ +storage: + type: postgres + path: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" + +endpoints: + - name: back-end + group: core + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[CERTIFICATE_EXPIRATION] > 48h" + + - name: monitoring + group: internal + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: nas + group: internal + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: example-dns-query + url: "8.8.8.8" # Address of the DNS server to use + interval: 5m + dns: + query-name: "example.com" + query-type: "A" + conditions: + - "[BODY] == 93.184.216.34" + - "[DNS_RCODE] == NOERROR" + + - name: icmp-ping + url: "icmp://example.org" + interval: 1m + conditions: + - "[CONNECTED] == true" diff --git a/.examples/docker-compose-secrets/docker-compose.yml b/.examples/docker-compose-secrets/docker-compose.yml new file mode 100644 index 000000000..f9b182afa --- /dev/null +++ b/.examples/docker-compose-secrets/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.9" +services: + postgres: + image: postgres + volumes: + - ./data/db:/var/lib/postgresql/data + ports: + - "5432:5432" + secrets: + - postgres_password + environment: + - POSTGRES_DB=gatus + - POSTGRES_USER=username + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password + networks: + - web + + gatus: + image: twinproduction/gatus:latest + restart: always + ports: + - "8080:8080" + secrets: + - postgres_password + environment: + - POSTGRES_USER=username + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password + - POSTGRES_DB=gatus + volumes: + - ./config:/config + networks: + - web + depends_on: + - postgres + +secrets: + postgres_password: + file: ./postgres_password.txt + +networks: + web: diff --git a/.examples/docker-compose-secrets/postgres_password.txt b/.examples/docker-compose-secrets/postgres_password.txt new file mode 100644 index 000000000..b5f907866 --- /dev/null +++ b/.examples/docker-compose-secrets/postgres_password.txt @@ -0,0 +1 @@ +supersecret diff --git a/README.md b/README.md index 249715293..a8a84ff7e 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,13 @@ subdirectories are merged like so: > > See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example. +> Docker secrets are also supported by using environment variables that end in the +> `_FILE` suffix (such as `POSTGRES_PASSWORD_FILE`). In that case, Gatus will +> read the file at the path given by the environment variable and use the +> contents of the file. +> +> See [examples/docker-compose-secrets/config/config.yaml](.examples/docker-compose-secrets/config/config.yaml) for an example. + If you want to test it locally, see [Docker](#docker). diff --git a/config/config.go b/config/config.go index 70f62f2c1..09c98394e 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "io" "io/fs" "log" "os" @@ -223,7 +224,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { // environment variable. This allows Gatus to support literal "$" in the configuration file. yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__")) // Expand environment variables - yamlBytes = []byte(os.ExpandEnv(string(yamlBytes))) + yamlBytes = []byte(os.Expand(string(yamlBytes), expandEnvironmentVariable)) // Replace __GATUS_LITERAL_DOLLAR_SIGN__ with "$" to restore the literal "$" in the configuration file yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$")) // Parse configuration file @@ -263,6 +264,31 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { return } +func expandEnvironmentVariable(name string) string { + secretPathVarName := name + "_FILE" + secretPath, secretIsSet := os.LookupEnv(secretPathVarName) + + if !secretIsSet { + return os.Getenv(name) + } + + secretFile, err := os.Open(secretPath) + if err != nil { + return "" + } + + defer func() { + _ = secretFile.Close() + }() + + secretBytes, err := io.ReadAll(secretFile) + if err != nil { + return "" + } + + return strings.TrimSpace(string(secretBytes)) +} + func validateConnectivityConfig(config *Config) error { if config.Connectivity != nil { return config.Connectivity.ValidateAndSetDefaults() diff --git a/config/config_test.go b/config/config_test.go index ef1068a7d..c031e2729 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "io" "os" "path/filepath" "testing" @@ -1616,3 +1617,139 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { }) } } + +func TestExpandingOfFileEnvironmentVariables(t *testing.T) { + secretValue := "http://user:password@example.com" + + tempFile, err := os.CreateTemp("", "") + if err != nil { + t.Errorf("unable to create temporary file: %s", err.Error()) + } + + t.Cleanup(func() { + _ = os.Remove(tempFile.Name()) + }) + + if _, err := io.WriteString(tempFile, secretValue); err != nil { + t.Errorf("unable to write secret to temporary file: %s", err.Error()) + } + + os.Setenv("GATUS_TestExpandingOfFileEnvironmentVariables_FILE", tempFile.Name()) + t.Cleanup(func() { + defer os.Unsetenv("GATUS_TestExpandingOfFileEnvironmentVariables_FILE") + }) + + config, err := parseAndValidateConfigBytes([]byte(` +endpoints: + - name: website + url: ${GATUS_TestExpandingOfFileEnvironmentVariables} + conditions: + - "[STATUS] == 200" +`)) + if err != nil { + t.Errorf("unable to parse config file: %s", err.Error()) + } + + actualValue := config.Endpoints[0].URL + if actualValue != secretValue { + t.Errorf( + "secret value was not set correctly, expected: '%s' but got '%s'", + secretValue, + actualValue, + ) + } +} + +func TestExpandingOfFileEnvironmentVariablesUnset(t *testing.T) { + config, err := parseAndValidateConfigBytes([]byte(` +endpoints: + - name: website + url: http://${GATUS_TestExpandingOfFileEnvironmentVariablesUnset}localhost:8080 + conditions: + - "[STATUS] == 200" +`)) + if err != nil { + t.Errorf("unable to parse config file: %s", err.Error()) + } + + actualValue := config.Endpoints[0].URL + if actualValue != "http://localhost:8080" { + t.Errorf( + "should default to empty string when variables aren't set, expected: %s but got %s", + "http://localhost:8080", + actualValue, + ) + } +} + +func TestExpandingOfFileEnvironmentVariablesMissingFile(t *testing.T) { + os.Setenv("GATUS_TestExpandingOfFileEnvironmentVariables_FILE", "TestExpandingOfFileEnvironmentVariablesMissingFile.txt") + t.Cleanup(func() { + os.Unsetenv("GATUS_TestExpandingOfFileEnvironmentVariables_FILE") + }) + + config, err := parseAndValidateConfigBytes([]byte(` +endpoints: + - name: website + url: http://${GATUS_TestExpandingOfFileEnvironmentVariablesMissingFile}localhost:8080 + conditions: + - "[STATUS] == 200" +`)) + if err != nil { + t.Errorf("unable to parse config file: %s", err.Error()) + } + + actualValue := config.Endpoints[0].URL + if actualValue != "http://localhost:8080" { + t.Errorf( + "should default to empty string when variables aren't set, expected: %s but got %s", + "http://localhost:8080", + actualValue, + ) + } +} + +func TestExpandingOfFileEnvironmentVariablesSetTwice(t *testing.T) { + secretValue := "http://user:password@example.com" + otherSecretValue := "http://user:hunter123@example.com" + + tempFile, err := os.CreateTemp("", "") + if err != nil { + t.Errorf("unable to create temporary file: %s", err.Error()) + } + + t.Cleanup(func() { + _ = os.Remove(tempFile.Name()) + }) + + if _, err := io.WriteString(tempFile, secretValue); err != nil { + t.Errorf("unable to write secret to temporary file: %s", err.Error()) + } + + os.Setenv("GATUS_TestExpandingOfFileEnvironmentVariables_FILE", tempFile.Name()) + os.Setenv("GATUS_TestExpandingOfFileEnvironmentVariables", otherSecretValue) + t.Cleanup(func() { + os.Unsetenv("GATUS_TestExpandingOfFileEnvironmentVariables_FILE") + os.Unsetenv("GATUS_TestExpandingOfFileEnvironmentVariables") + }) + + config, err := parseAndValidateConfigBytes([]byte(` +endpoints: + - name: website + url: ${GATUS_TestExpandingOfFileEnvironmentVariables} + conditions: + - "[STATUS] == 200" +`)) + if err != nil { + t.Errorf("unable to parse config file: %s", err.Error()) + } + + actualValue := config.Endpoints[0].URL + if actualValue != secretValue { + t.Errorf( + "secret value was not set correctly, expected: '%s' but got '%s'", + secretValue, + actualValue, + ) + } +}