Skip to content

Commit

Permalink
feat: Added support for Docker secrets
Browse files Browse the repository at this point in the history
This adds the ability to use [Docker
secrets](https://docs.docker.com/compose/use-secrets/) in configuration
files. In Docker this is done by creating a secret resource, adding that
secret to the container when you're starting it, and specifying an
environment variable that has the `_FILE` suffix. The environment
variable should point to the file which contains the secret
(`/run/secrets/<secret-name>`).

Typically Docker images are setup so that they will try and find any
environment variables that end in `_FILE` and set new environment
variables with the same name minus the `_FILE` suffix in the running
process. This is beneficial since environment variables that are set by
the user when creating the container are visible to anyone who is able
to run `docker container inspect <container>` on the host. For secrets
this could be really damaging and leak sensitive information. Instead it
is recommended to use Docker secrets.

Because Gatus uses the `scratch` base image I wasn't able to just use a
Bash script to convert the secret file path into a normal environment
variable like many other images do. Instead I opted to just modify the
configuration logic so that it checks the environment variable name and
changes its behavior based on that. This seems to work well enough.

As far as error handling, I opted _not_ to crash the service when it's
unable to read the secret file and instead just pretend its a normal
environment variable and return an empty string. This follows the
conventions of the rest of the configuration handling and leaves the
error reporting to the configuration validation.

I've also updated the readme to mention this feature with a link to an
example.
  • Loading branch information
durandj committed Apr 13, 2024
1 parent 1eba430 commit 926d20d
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 1 deletion.
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))
}

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

0 comments on commit 926d20d

Please sign in to comment.