mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-11 07:32:57 +03:00
feat: support reading secret env vars from _FILE (#799)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
This commit is contained in:
committed by
GitHub
parent
d479817b6a
commit
0a3b1c6530
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user