feat: Added support for Docker secrets

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.
This commit is contained in:
James Durand 2024-02-27 10:08:24 -06:00
parent 1eba430797
commit 926d20d000
6 changed files with 255 additions and 1 deletions

View File

@ -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"

View File

@ -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:

View File

@ -0,0 +1 @@
supersecret

View File

@ -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. > 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). If you want to test it locally, see [Docker](#docker).

View File

@ -3,6 +3,7 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log"
"os" "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. // environment variable. This allows Gatus to support literal "$" in the configuration file.
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__")) yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__"))
// Expand environment variables // 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 // Replace __GATUS_LITERAL_DOLLAR_SIGN__ with "$" to restore the literal "$" in the configuration file
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$")) yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$"))
// Parse configuration file // Parse configuration file
@ -263,6 +264,31 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
return 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 { func validateConnectivityConfig(config *Config) error {
if config.Connectivity != nil { if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults() return config.Connectivity.ValidateAndSetDefaults()

View File

@ -3,6 +3,7 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"testing" "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,
)
}
}