Repository: xenolf/lego (go)

Issue:
Add support for secrets in files
For applications deployed in Docker containers using Docker Swarm - secrets _must_ be stored in files instead of the environment. For reasons largely amounting to the Docker Swarm team's threat model, it seems they will never permit secrets to appear in the environment.

Many Docker images have been implementing the ability to suffix the names of environment variables with `_FILE` to cause the Docker container to read a file to get the value of a secret. For example, the WordPress Docker Image permits `WORDPRESS_DB_PASSWORD` if you wish to put the password in the environment directly, or `WORDPRESS_DB_PASSWORD_FILE` if you wish to have the Docker container read a file to determine the secret.

Lego is used by Traefik and currently my Cloudflare API keys are the only component that I can't populate from a Docker Swarm secret. Given that Traefik is often used as an ingress controller, I think that having support for a `CLOUDFLARE_API_KEY_FILE` et al for the various providers would be a significant value add.

Happy to open a pull request if there's interest in this.

Pull request description:
Add support for reading DNS provider setup from files
This PR adds the ability for lego to read DNS provider settings from files in addition to environment variables. To facilitate this I've continued the pattern that many Docker images follow wherein you can provide the credential using the regular environment variable name, like `CLOUDFLARE_API_KEY`, or you can provide the credential as a file and provide the path to that file by suffixing the normal env var with `_FILE`, for example `CLOUDFLARE_API_KEY_FILE`.

If you're interested, you can see the MySQL docker image doing that [here](https://github.com/docker-library/mysql/blob/2e4f2224f31e101d07c1e6a7c0da8245fde725c9/8.0/docker-entrypoint.sh#L21-L41).

In this implementation, the presence of the _regular_ environment variable will take precedence. This should mean that we minimize the overhead of running this method for folks who are just using the environment variables - which is the only thing that lego currently supports.

I've also standardized on using this for _all_ environment variables for DNS providers so as to not make any assumptions or start any debates about what folks should or should not treat as secrets - with the exception of Google Cloud which was already checking for a file _only_ because that's what Google actually gives you by way of credentials.

closes #509

Patch:
diff --git a/platform/config/env/env.go b/platform/config/env/env.go
index 557d3a73..aaae6eae 100644
--- a/platform/config/env/env.go
+++ b/platform/config/env/env.go
@@ -3,10 +3,13 @@ package env
 import (
 	"errors"
 	"fmt"
+	"io/ioutil"
 	"os"
 	"strconv"
 	"strings"
 	"time"
+
+	"github.com/xenolf/lego/log"
 )
 
 // Get environment variables
@@ -15,7 +18,7 @@ func Get(names ...string) (map[string]string, error) {
 
 	var missingEnvVars []string
 	for _, envVar := range names {
-		value := os.Getenv(envVar)
+		value := GetOrFile(envVar)
 		if value == "" {
 			missingEnvVars = append(missingEnvVars, envVar)
 		}
@@ -76,13 +79,13 @@ func GetWithFallback(groups ...[]string) (map[string]string, error) {
 }
 
 func getOneWithFallback(main string, names ...string) (string, string) {
-	value := os.Getenv(main)
+	value := GetOrFile(main)
 	if len(value) > 0 {
 		return value, main
 	}
 
 	for _, name := range names {
-		value := os.Getenv(name)
+		value := GetOrFile(name)
 		if len(value) > 0 {
 			return value, main
 		}
@@ -94,7 +97,7 @@ func getOneWithFallback(main string, names ...string) (string, string) {
 // GetOrDefaultInt returns the given environment variable value as an integer.
 // Returns the default if the envvar cannot be coopered to an int, or is not found.
 func GetOrDefaultInt(envVar string, defaultValue int) int {
-	v, err := strconv.Atoi(os.Getenv(envVar))
+	v, err := strconv.Atoi(GetOrFile(envVar))
 	if err != nil {
 		return defaultValue
 	}
@@ -116,7 +119,7 @@ func GetOrDefaultSecond(envVar string, defaultValue time.Duration) time.Duration
 // GetOrDefaultString returns the given environment variable value as a string.
 // Returns the default if the envvar cannot be find.
 func GetOrDefaultString(envVar string, defaultValue string) string {
-	v := os.Getenv(envVar)
+	v := GetOrFile(envVar)
 	if len(v) == 0 {
 		return defaultValue
 	}
@@ -127,10 +130,34 @@ func GetOrDefaultString(envVar string, defaultValue string) string {
 // GetOrDefaultBool returns the given environment variable value as a boolean.
 // Returns the default if the envvar cannot be coopered to a boolean, or is not found.
 func GetOrDefaultBool(envVar string, defaultValue bool) bool {
-	v, err := strconv.ParseBool(os.Getenv(envVar))
+	v, err := strconv.ParseBool(GetOrFile(envVar))
 	if err != nil {
 		return defaultValue
 	}
 
 	return v
 }
+
+// GetOrFile Attempts to resolve 'key' as an environment variable.
+// Failing that, it will check to see if '<key>_FILE' exists.
+// If so, it will attempt to read from the referenced file to populate a value.
+func GetOrFile(envVar string) string {
+	envVarValue := os.Getenv(envVar)
+	if envVarValue != "" {
+		return envVarValue
+	}
+
+	fileVar := envVar + "_FILE"
+	fileVarValue := os.Getenv(fileVar)
+	if fileVarValue == "" {
+		return envVarValue
+	}
+
+	fileContents, err := ioutil.ReadFile(fileVarValue)
+	if err != nil {
+		log.Printf("Failed to read the file %s (defined by env var %s): %s", fileVarValue, fileVar, err)
+		return ""
+	}
+
+	return string(fileContents)
+}
diff --git a/providers/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go
index f46df26c..41aeb905 100644
--- a/providers/dns/alidns/alidns.go
+++ b/providers/dns/alidns/alidns.go
@@ -5,7 +5,6 @@ package alidns
 import (
 	"errors"
 	"fmt"
-	"os"
 	"strings"
 	"time"
 
@@ -57,7 +56,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	config := NewDefaultConfig()
 	config.APIKey = values["ALICLOUD_ACCESS_KEY"]
 	config.SecretKey = values["ALICLOUD_SECRET_KEY"]
-	config.RegionID = os.Getenv("ALICLOUD_REGION_ID")
+	config.RegionID = env.GetOrFile("ALICLOUD_REGION_ID")
 
 	return NewDNSProviderConfig(config)
 }
diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go
index 5ddbc7af..d3cb76da 100644
--- a/providers/dns/auroradns/auroradns.go
+++ b/providers/dns/auroradns/auroradns.go
@@ -3,7 +3,6 @@ package auroradns
 import (
 	"errors"
 	"fmt"
-	"os"
 	"sync"
 	"time"
 
@@ -53,7 +52,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	}
 
 	config := NewDefaultConfig()
-	config.BaseURL = os.Getenv("AURORA_ENDPOINT")
+	config.BaseURL = env.GetOrFile("AURORA_ENDPOINT")
 	config.UserID = values["AURORA_USER_ID"]
 	config.Key = values["AURORA_KEY"]
 
diff --git a/providers/dns/dnsimple/dnsimple.go b/providers/dns/dnsimple/dnsimple.go
index 876117b4..3b40dc5e 100644
--- a/providers/dns/dnsimple/dnsimple.go
+++ b/providers/dns/dnsimple/dnsimple.go
@@ -5,7 +5,6 @@ package dnsimple
 import (
 	"errors"
 	"fmt"
-	"os"
 	"strconv"
 	"strings"
 	"time"
@@ -45,8 +44,8 @@ type DNSProvider struct {
 // See: https://developer.dnsimple.com/v2/#authentication
 func NewDNSProvider() (*DNSProvider, error) {
 	config := NewDefaultConfig()
-	config.AccessToken = os.Getenv("DNSIMPLE_OAUTH_TOKEN")
-	config.BaseURL = os.Getenv("DNSIMPLE_BASE_URL")
+	config.AccessToken = env.GetOrFile("DNSIMPLE_OAUTH_TOKEN")
+	config.BaseURL = env.GetOrFile("DNSIMPLE_BASE_URL")
 
 	return NewDNSProviderConfig(config)
 }
diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/providers/dns/dnsmadeeasy/dnsmadeeasy.go
index 262a3020..c0f580d5 100644
--- a/providers/dns/dnsmadeeasy/dnsmadeeasy.go
+++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.go
@@ -5,7 +5,6 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"os"
 	"strconv"
 	"strings"
 	"time"
@@ -57,7 +56,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	}
 
 	var baseURL string
-	if sandbox, _ := strconv.ParseBool(os.Getenv("DNSMADEEASY_SANDBOX")); sandbox {
+	if sandbox, _ := strconv.ParseBool(env.GetOrFile("DNSMADEEASY_SANDBOX")); sandbox {
 		baseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0"
 	} else {
 		baseURL = "https://api.dnsmadeeasy.com/V2.0"
diff --git a/providers/dns/exoscale/exoscale.go b/providers/dns/exoscale/exoscale.go
index 339c5ef6..4ffcc6e7 100644
--- a/providers/dns/exoscale/exoscale.go
+++ b/providers/dns/exoscale/exoscale.go
@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"os"
 	"time"
 
 	"github.com/exoscale/egoscale"
@@ -56,7 +55,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	config := NewDefaultConfig()
 	config.APIKey = values["EXOSCALE_API_KEY"]
 	config.APISecret = values["EXOSCALE_API_SECRET"]
-	config.Endpoint = os.Getenv("EXOSCALE_ENDPOINT")
+	config.Endpoint = env.GetOrFile("EXOSCALE_ENDPOINT")
 
 	return NewDNSProviderConfig(config)
 }
diff --git a/providers/dns/lightsail/lightsail.go b/providers/dns/lightsail/lightsail.go
index 7328c076..3b3fe678 100644
--- a/providers/dns/lightsail/lightsail.go
+++ b/providers/dns/lightsail/lightsail.go
@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"math/rand"
-	"os"
 	"time"
 
 	"github.com/aws/aws-sdk-go/aws"
@@ -54,7 +53,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider
 func NewDefaultConfig() *Config {
 	return &Config{
-		DNSZone:            os.Getenv("DNS_ZONE"),
+		DNSZone:            env.GetOrFile("DNS_ZONE"),
 		PropagationTimeout: env.GetOrDefaultSecond("LIGHTSAIL_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond("LIGHTSAIL_POLLING_INTERVAL", acme.DefaultPollingInterval),
 		Region:             env.GetOrDefaultString("LIGHTSAIL_REGION", "us-east-1"),
diff --git a/providers/dns/namedotcom/namedotcom.go b/providers/dns/namedotcom/namedotcom.go
index 20c8b274..3e109886 100644
--- a/providers/dns/namedotcom/namedotcom.go
+++ b/providers/dns/namedotcom/namedotcom.go
@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"os"
 	"strings"
 	"time"
 
@@ -56,7 +55,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	config := NewDefaultConfig()
 	config.Username = values["NAMECOM_USERNAME"]
 	config.APIToken = values["NAMECOM_API_TOKEN"]
-	config.Server = os.Getenv("NAMECOM_SERVER")
+	config.Server = env.GetOrFile("NAMECOM_SERVER")
 
 	return NewDNSProviderConfig(config)
 }
diff --git a/providers/dns/nifcloud/nifcloud.go b/providers/dns/nifcloud/nifcloud.go
index 7e828aa9..6021cd51 100644
--- a/providers/dns/nifcloud/nifcloud.go
+++ b/providers/dns/nifcloud/nifcloud.go
@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"os"
 	"time"
 
 	"github.com/xenolf/lego/acme"
@@ -52,7 +51,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	}
 
 	config := NewDefaultConfig()
-	config.BaseURL = os.Getenv("NIFCLOUD_DNS_ENDPOINT")
+	config.BaseURL = env.GetOrFile("NIFCLOUD_DNS_ENDPOINT")
 	config.AccessKey = values["NIFCLOUD_ACCESS_KEY_ID"]
 	config.SecretKey = values["NIFCLOUD_SECRET_ACCESS_KEY"]
 
diff --git a/providers/dns/rfc2136/rfc2136.go b/providers/dns/rfc2136/rfc2136.go
index 792ee8b0..797b90a8 100644
--- a/providers/dns/rfc2136/rfc2136.go
+++ b/providers/dns/rfc2136/rfc2136.go
@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"net"
-	"os"
 	"strings"
 	"time"
 
@@ -62,8 +61,8 @@ func NewDNSProvider() (*DNSProvider, error) {
 
 	config := NewDefaultConfig()
 	config.Nameserver = values["RFC2136_NAMESERVER"]
-	config.TSIGKey = os.Getenv("RFC2136_TSIG_KEY")
-	config.TSIGSecret = os.Getenv("RFC2136_TSIG_SECRET")
+	config.TSIGKey = env.GetOrFile("RFC2136_TSIG_KEY")
+	config.TSIGSecret = env.GetOrFile("RFC2136_TSIG_SECRET")
 
 	return NewDNSProviderConfig(config)
 }
diff --git a/providers/dns/route53/route53.go b/providers/dns/route53/route53.go
index a1d4a8a3..9416c6a4 100644
--- a/providers/dns/route53/route53.go
+++ b/providers/dns/route53/route53.go
@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"math/rand"
-	"os"
 	"strings"
 	"time"
 
@@ -35,7 +34,7 @@ func NewDefaultConfig() *Config {
 		TTL:                env.GetOrDefaultInt("AWS_TTL", 10),
 		PropagationTimeout: env.GetOrDefaultSecond("AWS_PROPAGATION_TIMEOUT", 2*time.Minute),
 		PollingInterval:    env.GetOrDefaultSecond("AWS_POLLING_INTERVAL", 4*time.Second),
-		HostedZoneID:       os.Getenv("AWS_HOSTED_ZONE_ID"),
+		HostedZoneID:       env.GetOrFile("AWS_HOSTED_ZONE_ID"),
 	}
 }
 
diff --git a/providers/dns/vegadns/vegadns.go b/providers/dns/vegadns/vegadns.go
index 468c2a07..6b4cf7d1 100644
--- a/providers/dns/vegadns/vegadns.go
+++ b/providers/dns/vegadns/vegadns.go
@@ -5,7 +5,6 @@ package vegadns
 import (
 	"errors"
 	"fmt"
-	"os"
 	"strings"
 	"time"
 
@@ -50,8 +49,8 @@ func NewDNSProvider() (*DNSProvider, error) {
 
 	config := NewDefaultConfig()
 	config.BaseURL = values["VEGADNS_URL"]
-	config.APIKey = os.Getenv("SECRET_VEGADNS_KEY")
-	config.APISecret = os.Getenv("SECRET_VEGADNS_SECRET")
+	config.APIKey = env.GetOrFile("SECRET_VEGADNS_KEY")
+	config.APISecret = env.GetOrFile("SECRET_VEGADNS_SECRET")
 
 	return NewDNSProviderConfig(config)
 }


Test patch:
diff --git a/platform/config/env/env_test.go b/platform/config/env/env_test.go
index d32d80fd..3bd5c403 100644
--- a/platform/config/env/env_test.go
+++ b/platform/config/env/env_test.go
@@ -1,6 +1,7 @@
 package env
 
 import (
+	"io/ioutil"
 	"os"
 	"testing"
 	"time"
@@ -273,3 +274,67 @@ func TestGetOrDefaultBool(t *testing.T) {
 		})
 	}
 }
+
+func TestGetOrFile_ReadsEnvVars(t *testing.T) {
+	err := os.Setenv("TEST_LEGO_ENV_VAR", "lego_env")
+	require.NoError(t, err)
+	defer os.Unsetenv("TEST_LEGO_ENV_VAR")
+
+	value := GetOrFile("TEST_LEGO_ENV_VAR")
+
+	assert.Equal(t, "lego_env", value)
+}
+
+func TestGetOrFile_ReadsFiles(t *testing.T) {
+	varEnvFileName := "TEST_LEGO_ENV_VAR_FILE"
+	varEnvName := "TEST_LEGO_ENV_VAR"
+
+	err := os.Unsetenv(varEnvFileName)
+	require.NoError(t, err)
+	err = os.Unsetenv(varEnvName)
+	require.NoError(t, err)
+
+	file, err := ioutil.TempFile("", "lego")
+	require.NoError(t, err)
+	defer os.Remove(file.Name())
+
+	err = ioutil.WriteFile(file.Name(), []byte("lego_file"), 0644)
+	require.NoError(t, err)
+
+	err = os.Setenv(varEnvFileName, file.Name())
+	require.NoError(t, err)
+	defer os.Unsetenv(varEnvFileName)
+
+	value := GetOrFile(varEnvName)
+
+	assert.Equal(t, "lego_file", value)
+}
+
+func TestGetOrFile_PrefersEnvVars(t *testing.T) {
+	varEnvFileName := "TEST_LEGO_ENV_VAR_FILE"
+	varEnvName := "TEST_LEGO_ENV_VAR"
+
+	err := os.Unsetenv(varEnvFileName)
+	require.NoError(t, err)
+	err = os.Unsetenv(varEnvName)
+	require.NoError(t, err)
+
+	file, err := ioutil.TempFile("", "lego")
+	require.NoError(t, err)
+	defer os.Remove(file.Name())
+
+	err = ioutil.WriteFile(file.Name(), []byte("lego_file"), 0644)
+	require.NoError(t, err)
+
+	err = os.Setenv(varEnvFileName, file.Name())
+	require.NoError(t, err)
+	defer os.Unsetenv(varEnvFileName)
+
+	err = os.Setenv(varEnvName, "lego_env")
+	require.NoError(t, err)
+	defer os.Unsetenv(varEnvName)
+
+	value := GetOrFile(varEnvName)
+
+	assert.Equal(t, "lego_env", value)
+}