feat: support reading secret env vars from _FILE (#799)

Co-authored-by: Kyle Mendell <ksm@ofkm.us>
This commit is contained in:
Alessandro (Ale) Segala
2025-07-30 18:59:25 +02:00
committed by GitHub
parent d479817b6a
commit 0a3b1c6530
4 changed files with 103 additions and 47 deletions

View File

@@ -6,11 +6,30 @@ import (
"log/slog" "log/slog"
"net/url" "net/url"
"os" "os"
"strings"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
) )
func resolveStringOrFile(directValue string, filePath string, varName string, trim bool) (string, error) {
if directValue != "" {
return directValue, nil
}
if filePath != "" {
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read secret '%s' from file '%s': %w", varName, filePath, err)
}
if trim {
return strings.TrimSpace(string(content)), nil
}
return string(content), nil
}
return "", nil
}
type DbProvider string type DbProvider string
const ( const (
@@ -28,29 +47,31 @@ const (
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"` AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"` DbConnectionString string `env:"DB_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"` DbConnectionStringFile string `env:"DB_CONNECTION_STRING_FILE"`
KeysPath string `env:"KEYS_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysStorage string `env:"KEYS_STORAGE"` KeysPath string `env:"KEYS_PATH"`
EncryptionKey string `env:"ENCRYPTION_KEY"` KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"` EncryptionKey string `env:"ENCRYPTION_KEY"`
Port string `env:"PORT"` EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"`
Host string `env:"HOST"` Port string `env:"PORT"`
UnixSocket string `env:"UNIX_SOCKET"` Host string `env:"HOST"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"` UnixSocket string `env:"UNIX_SOCKET"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` MaxMindLicenseKeyFile string `env:"MAXMIND_LICENSE_KEY_FILE"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
MetricsEnabled bool `env:"METRICS_ENABLED"` LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
TracingEnabled bool `env:"TRACING_ENABLED"` UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
LogJSON bool `env:"LOG_JSON"` MetricsEnabled bool `env:"METRICS_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"` TracingEnabled bool `env:"TRACING_ENABLED"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` LogJSON bool `env:"LOG_JSON"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
} }
var EnvConfig = defaultConfig() var EnvConfig = defaultConfig()
@@ -95,6 +116,29 @@ func parseEnvConfig() error {
return fmt.Errorf("error parsing env config: %w", err) return fmt.Errorf("error parsing env config: %w", err)
} }
// Resolve string/file environment variables
EnvConfig.DbConnectionString, err = resolveStringOrFile(
EnvConfig.DbConnectionString,
EnvConfig.DbConnectionStringFile,
"DB_CONNECTION_STRING",
true,
)
if err != nil {
return err
}
EnvConfig.DbConnectionStringFile = ""
EnvConfig.MaxMindLicenseKey, err = resolveStringOrFile(
EnvConfig.MaxMindLicenseKey,
EnvConfig.MaxMindLicenseKeyFile,
"MAXMIND_LICENSE_KEY",
true,
)
if err != nil {
return err
}
EnvConfig.MaxMindLicenseKeyFile = ""
// Validate the environment variables // Validate the environment variables
switch EnvConfig.DbProvider { switch EnvConfig.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
@@ -122,10 +166,23 @@ func parseEnvConfig() error {
case "": case "":
EnvConfig.KeysStorage = "file" EnvConfig.KeysStorage = "file"
case "database": case "database":
// If KeysStorage is "database", a key must be specified // Resolve encryption key using the same pattern
if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" { encryptionKey, err := resolveStringOrFile(
EnvConfig.EncryptionKey,
EnvConfig.EncryptionKeyFile,
"ENCRYPTION_KEY",
// Do not trim spaces because the file should be interpreted as binary
false,
)
if err != nil {
return err
}
if encryptionKey == "" {
return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database") return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database")
} }
// Update the config with resolved value
EnvConfig.EncryptionKey = encryptionKey
EnvConfig.EncryptionKeyFile = ""
case "file": case "file":
// All good, these are valid values // All good, these are valid values
default: default:

View File

@@ -178,7 +178,7 @@ type AppConfigKeyNotFoundError struct {
} }
func (e AppConfigKeyNotFoundError) Error() string { func (e AppConfigKeyNotFoundError) Error() string {
return fmt.Sprintf("cannot find config key '%s'", e.field) return "cannot find config key '" + e.field + "'"
} }
func (e AppConfigKeyNotFoundError) Is(target error) bool { func (e AppConfigKeyNotFoundError) Is(target error) bool {
@@ -192,7 +192,7 @@ type AppConfigInternalForbiddenError struct {
} }
func (e AppConfigInternalForbiddenError) Error() string { func (e AppConfigInternalForbiddenError) Error() string {
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field) return "field '" + e.field + "' is internal and can't be updated"
} }
func (e AppConfigInternalForbiddenError) Is(target error) bool { func (e AppConfigInternalForbiddenError) Is(target error) bool {

View File

@@ -7,7 +7,6 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"reflect" "reflect"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -412,12 +411,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
field := rt.Field(i) field := rt.Field(i)
// Get the key and internal tag values // Get the key and internal tag values
tagValue := strings.Split(field.Tag.Get("key"), ",") key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
key := tagValue[0]
isInternal := slices.Contains(tagValue, "internal")
// Internal fields are loaded from the database as they can't be set from the environment // Internal fields are loaded from the database as they can't be set from the environment
if isInternal { if attrs == "internal" {
var value string var value string
err := tx.WithContext(ctx). err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}). Model(&model.AppConfigVariable{}).
@@ -436,6 +433,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
value, ok := os.LookupEnv(envVarName) value, ok := os.LookupEnv(envVarName)
if ok { if ok {
rv.Field(i).FieldByName("Value").SetString(value) rv.Field(i).FieldByName("Value").SetString(value)
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).FieldByName("Value").SetString(string(b))
continue
}
} }
} }

View File

@@ -15,7 +15,6 @@ import (
"fmt" "fmt"
"hash" "hash"
"io" "io"
"os"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
@@ -47,26 +46,15 @@ func EncodeJWKBytes(key jwk.Key) ([]byte, error) {
// LoadKeyEncryptionKey loads the key encryption key for JWKs // LoadKeyEncryptionKey loads the key encryption key for JWKs
func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) { func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) {
// Try getting the key from the env var as string // If there's no key, return
kekInput := []byte(envConfig.EncryptionKey) if len(envConfig.EncryptionKey) == 0 {
// If there's nothing in the env, try loading from file
if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" {
kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err)
}
}
// If there's still no key, return
if len(kekInput) == 0 {
return nil, nil return nil, nil
} }
// We need a 256-bit key for encryption with AES-GCM-256 // We need a 256-bit key for encryption with AES-GCM-256
// We use HMAC with SHA3-256 here to derive the key from the one passed as input // We use HMAC with SHA3-256 here to derive the key from the one passed as input
// The key is tied to a specific instance of Pocket ID // The key is tied to a specific instance of Pocket ID
h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput) h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(envConfig.EncryptionKey))
fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek") fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek")
kek = h.Sum(nil) kek = h.Sum(nil)