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: Added support for Docker secrets #690

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
42 changes: 42 additions & 0 deletions .examples/docker-compose-secrets/config/config.yaml
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 41 additions & 0 deletions .examples/docker-compose-secrets/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
1 change: 1 addition & 0 deletions .examples/docker-compose-secrets/postgres_password.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
supersecret
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).


Expand Down
28 changes: 27 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
TwiN marked this conversation as resolved.
Show resolved Hide resolved

func validateConnectivityConfig(config *Config) error {
if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults()
Expand Down
137 changes: 137 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -1616,3 +1617,139 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
})
}
}

func TestExpandingOfFileEnvironmentVariables(t *testing.T) {
secretValue := "http://user:[email protected]"

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())
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you unset the env var after this test using defer? Just to prevent surprises.

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")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

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:[email protected]"
otherSecretValue := "http://user:[email protected]"

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)
Comment on lines +1729 to +1730
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too

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,
)
}
}