mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-25 01:13:40 +01:00
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:
parent
1eba430797
commit
926d20d000
42
.examples/docker-compose-secrets/config/config.yaml
Normal file
42
.examples/docker-compose-secrets/config/config.yaml
Normal 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"
|
41
.examples/docker-compose-secrets/docker-compose.yml
Normal file
41
.examples/docker-compose-secrets/docker-compose.yml
Normal 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:
|
1
.examples/docker-compose-secrets/postgres_password.txt
Normal file
1
.examples/docker-compose-secrets/postgres_password.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
supersecret
|
@ -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).
|
||||||
|
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user