Files
pocket-id/backend/internal/common/env_config.go
T
ItalyPaleAle fadb1a5552 feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When set to "embedded" (the default), Pocket ID starts the runtime inside its own process.

Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime.

Because when using a remote runtime, it's likewise not possible to enforce a single instance of Pocket ID is running at once, the env vars currently have the `EXPERIMENTAL_` prefix, are **undocumented**, and show a warning if used.

Notes:

- Connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE, and optionally (but recommended) FRANCIS_CA.
- When connecting to a remote runtime, exporting Pocket ID data does not include the actor state, which will need to be backed up and restored separately
2026-09-19 23:38:18 -07:00

599 lines
20 KiB
Go

package common
import (
"bytes"
"errors"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"github.com/caarlos0/env/v11"
sloggin "github.com/gin-contrib/slog"
_ "github.com/joho/godotenv/autoload"
)
type (
AppEnv string
DbProvider string
TrustProxyConfig []string
DismissSQLiteStorageWarningConfig bool
)
const (
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
TracerName = "github.com/pocket-id/pocket-id/backend/tracing"
// MeterName should be passed to otel.Meter when create custom metrics.
MeterName = "github.com/pocket-id/pocket-id/backend/metrics"
// dismissSQLiteStorageWarningPhrase is the exact phrase DISMISS_SQLITE_STORAGE_WARNING must be set to to suppress the warning (matched case-insensitive)
dismissSQLiteStorageWarningPhrase = "i accept the risks"
)
const (
AppEnvProduction AppEnv = "production"
AppEnvDevelopment AppEnv = "development"
AppEnvTest AppEnv = "test"
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "data/pocket-id.db"
defaultFsUploadPath string = "data/uploads"
AppUrl string = "http://localhost:1411"
// FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process
FrancisHostEmbedded string = "embedded"
// francisHostPSKMinLength is the min length for the host bootstrap pre-shared key for Francis
francisHostPSKMinLength int = 16
)
type EnvConfigSchema struct {
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy TrustProxyConfig `env:"TRUST_PROXY"`
ProxyProtocol TrustProxyConfig `env:"PROXY_PROTOCOL"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
AllowInsecureCallbackURLs bool `env:"ALLOW_INSECURE_CALLBACK_URLS"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY" options:"file"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
SystemdSocket bool `env:"SYSTEMD_SOCKET"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
// TLS cert and key need special treatment with fsnotify, so we aren't using `options:"file"`
TLSCert string `env:"TLS_CERT"`
TLSKey string `env:"TLS_KEY"`
TLSCertFile string `env:"TLS_CERT_FILE"`
TLSKeyFile string `env:"TLS_KEY_FILE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
ActorsPort string `env:"ACTORS_PORT"`
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
// FrancisHost selects where the Francis actor runtime lives
// When set to "embedded" (the default), Pocket ID runs the runtime inside its own process
// Any other value is the address (or a comma-separated list of addresses) of a standalone Francis runtime to connect to
FrancisHost string `env:"EXPERIMENTAL_FRANCIS_HOST" options:"toLower"`
// FrancisHostPSK is the pre-shared key Pocket ID presents to a standalone Francis runtime when joining the cluster
// It must match the "bootstrap.hostPSK" value in the runtime's own configuration
// One and only one of FrancisHostPSK and FrancisHostJWTFile must be set when FrancisHost is pointing at a standalone runtime
FrancisHostPSK []byte `env:"EXPERIMENTAL_FRANCIS_HOST_PSK" options:"file"`
// FrancisHostJWTFile is the path to a file holding the bearer token Pocket ID presents to a standalone Francis runtime
// One and only one of FrancisHostPSK and FrancisHostJWTFile must be set when FrancisHost is pointing at a standalone runtime
// Note: Unlike the other "_FILE" variables this one keeps the path rather than the contents: the file is re-read on every connection to the runtime, so a rotated token (such as a Kubernetes projected service account token) is picked up without restarting Pocket ID
FrancisHostJWTFile string `env:"EXPERIMENTAL_FRANCIS_HOST_JWT_FILE"`
// FrancisCA is the PEM-encoded cluster CA of a standalone Francis runtime, which Pocket ID pins before its first connection
// Leaving it empty makes Pocket ID trust the certificate the runtime presents on the first connection, which is vulnerable to an attacker intercepting that connection
FrancisCA []byte `env:"EXPERIMENTAL_FRANCIS_CA" options:"file"`
// HAEnabled turns on high-availability mode, allowing more than one replica of Pocket ID to run against the same database at once
// It is intentionally not bound to an environment variable while HA support is still being completed
// TODO: Add env var when HA mode is ready
HAEnabled bool
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
LogJSON bool `env:"LOG_JSON"`
// LogQueryArgs includes the values of SQL query parameters in traces and in the query logs printed when LogLevel is "debug"
// Note that these may can contain sensitive data
LogQueryArgs bool `env:"LOG_QUERY_ARGS"`
// This is true when DISMISS_SQLITE_STORAGE_WARNING is the exact confirmation phrase set in the constant above
// Note: this is omitted from the general list of environment variables in the docs, and documented only in the SQLite-specific section
DismissSQLiteStorageWarning DismissSQLiteStorageWarningConfig `env:"DISMISS_SQLITE_STORAGE_WARNING"`
/*** Internal properties ***/
// francisAddresses contains the runtime addresses parsed out of FrancisHost, and is empty when the actor runtime is embedded
// It is automatically derived from FrancisHost
francisAddresses []string
}
var EnvConfig = defaultConfig()
func init() {
err := parseEnvConfig()
if err != nil {
slog.Error("Configuration error", slog.Any("error", err))
os.Exit(1)
}
}
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AllowInsecureCallbackURLs: true, // TODO: Default to false in major v3
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
ActorsPort: "1414",
ActorsHost: "0.0.0.0",
FrancisHost: FrancisHostEmbedded,
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
}
func parseEnvConfig() error {
parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeFor[[]byte](): func(value string) (any, error) {
return []byte(value), nil
},
}
err := env.ParseWithOptions(&EnvConfig, env.Options{
FuncMap: parsers,
})
if err != nil {
return fmt.Errorf("error parsing env config: %w", err)
}
err = prepareEnvConfig(&EnvConfig)
if err != nil {
return fmt.Errorf("error preparing env config: %w", err)
}
return nil
}
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error {
if shouldSkipEnvValidation(os.Args) {
return nil
}
_, err := sloggin.ParseLevel(config.LogLevel)
if err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
// Check required properties
if len(config.EncryptionKey) < 16 {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
if len(config.ProxyProtocol) > 0 && config.UnixSocket != "" {
return errors.New("PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive")
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("when set, STATIC_API_KEY must be at least 16 characters long")
}
// Prepare the DB config
prepareDbConfig(config)
// Resolve where the Francis actor runtime lives, which decides whether Pocket ID starts an embedded one
err = prepareFrancisConfig(config)
if err != nil {
return err
}
// Show a warning if using a standalone Francis runtime as it's currently experimental and meant for development only
// TODO: Remove when HA mode is ready
if !config.HasEmbeddedFrancisRuntime() {
slog.Warn("🚨🚨🚨 CONNECTING TO A STANDALONE FRANCIS RUNTIME IS EXPERIMENTAL AND MEANT FOR DEVELOPMENT ONLY 🚨🚨🚨")
}
// Validate other required options
err = validateAppURLs(config)
if err != nil {
return err
}
err = validateFileBackend(config)
if err != nil {
return err
}
err = validateLocalIPv6Ranges(config.LocalIPv6Ranges)
if err != nil {
return err
}
err = validateTLSConfig(config)
if err != nil {
return err
}
return nil
}
func prepareDbConfig(config *EnvConfigSchema) {
switch {
case config.DbConnectionString == "":
config.DbProvider = DbProviderSqlite
config.DbConnectionString = defaultSqliteConnString
case strings.HasPrefix(config.DbConnectionString, "postgres://") || strings.HasPrefix(config.DbConnectionString, "postgresql://"):
config.DbProvider = DbProviderPostgres
default:
config.DbProvider = DbProviderSqlite
}
}
// prepareFrancisConfig resolves FRANCIS_HOST into the list of standalone runtime addresses Pocket ID connects to
// An empty value or the "embedded" constant keeps the actor runtime inside the Pocket ID process, and leaves the address list empty
func prepareFrancisConfig(config *EnvConfigSchema) error {
config.francisAddresses = nil
value := strings.TrimSpace(config.FrancisHost)
if value == "" || value == FrancisHostEmbedded {
return nil
}
// Any other value is one address, or a comma-separated list of addresses, of the standalone runtime replicas
// Pocket ID dials them directly rather than resolving a service record, so each one must carry an explicit port
parts := strings.Split(value, ",")
addresses := make([]string, 0, len(parts))
for _, address := range parts {
address = strings.TrimSpace(address)
if address == "" {
continue
}
_, port, err := net.SplitHostPort(address)
if err != nil || port == "" {
return fmt.Errorf("invalid address '%s' in FRANCIS_HOST: addresses must be in the 'host:port' format", address)
}
addresses = append(addresses, address)
}
if len(addresses) == 0 {
return errors.New("FRANCIS_HOST does not contain any address")
}
// Credentials have to match the runtime's byte-for-byte, and reading one from a file (including a container secret) usually leaves a trailing newline behind, so surrounding whitespace is never meaningful here
config.FrancisHostPSK = bytes.TrimSpace(config.FrancisHostPSK)
err := validateFrancisBootstrap(config)
if err != nil {
return err
}
config.francisAddresses = addresses
return nil
}
// validateFrancisBootstrap checks the credential Pocket ID presents when joining a standalone Francis runtime
// The runtime admits a host through exactly one bootstrap method, so configuring none or more than one is a configuration error rather than something to resolve by picking a winner
func validateFrancisBootstrap(config *EnvConfigSchema) error {
configured := make([]string, 0, 2)
if len(config.FrancisHostPSK) > 0 {
configured = append(configured, "FRANCIS_HOST_PSK")
}
if config.FrancisHostJWTFile != "" {
configured = append(configured, "FRANCIS_HOST_JWT_FILE")
}
switch len(configured) {
case 1:
// Exactly one method, which is what the runtime expects
case 0:
return errors.New("one of FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE is required when FRANCIS_HOST points to a standalone Francis runtime")
default:
return fmt.Errorf("only one host bootstrap method may be configured, but %s are both set", strings.Join(configured, " and "))
}
// Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup
if len(config.FrancisHostPSK) > 0 && len(config.FrancisHostPSK) < francisHostPSKMinLength {
return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength)
}
// A token read on every connection is useless if the file is not there when Pocket ID starts
if config.FrancisHostJWTFile != "" {
_, err := os.Stat(config.FrancisHostJWTFile)
if err != nil {
return fmt.Errorf("FRANCIS_HOST_JWT_FILE not found: %w", err)
}
}
return nil
}
// HasEmbeddedFrancisRuntime returns true when Pocket ID runs the Francis actor runtime inside its own process, which is the case unless FRANCIS_HOST points to a standalone runtime
func (c *EnvConfigSchema) HasEmbeddedFrancisRuntime() bool {
return len(c.francisAddresses) == 0
}
// FrancisAddresses returns the value of francisAddresses
func (c *EnvConfigSchema) FrancisAddresses() []string {
return c.francisAddresses
}
func validateAppURLs(config *EnvConfigSchema) error {
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
return err
}
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
return nil
}
return validateURLWithoutPath(config.InternalAppURL, "INTERNAL_APP_URL")
}
func validateURLWithoutPath(rawURL, envName string) error {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("%s is not a valid URL", envName)
}
if parsedURL.Path != "" {
return fmt.Errorf("%s must not contain a path", envName)
}
return nil
}
func validateFileBackend(config *EnvConfigSchema) error {
switch config.FileBackend {
case "s3", "database":
return nil
case "", "filesystem":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
}
return nil
default:
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
}
}
func validateLocalIPv6Ranges(localIPv6Ranges string) error {
ranges := strings.SplitSeq(localIPv6Ranges, ",")
for rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
if err := validateLocalIPv6Range(rangeStr); err != nil {
return err
}
}
return nil
}
func validateLocalIPv6Range(rangeStr string) error {
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
}
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
return nil
}
func validateTLSConfig(config *EnvConfigSchema) error {
inlineConfigured := config.TLSCert != "" || config.TLSKey != ""
fileConfigured := config.TLSCertFile != "" || config.TLSKeyFile != ""
if inlineConfigured && fileConfigured {
return errors.New("TLS_CERT and TLS_KEY cannot be combined with TLS_CERT_FILE or TLS_KEY_FILE")
}
switch {
case config.TLSCert != "" && config.TLSKey == "":
return errors.New("TLS_KEY must be set when TLS_CERT is set")
case config.TLSCert == "" && config.TLSKey != "":
return errors.New("TLS_CERT must be set when TLS_KEY is set")
case config.TLSCertFile != "" && config.TLSKeyFile == "":
return errors.New("TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
case config.TLSCertFile == "" && config.TLSKeyFile != "":
return errors.New("TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
}
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
if _, err := os.Stat(config.TLSCertFile); err != nil {
return fmt.Errorf("TLS_CERT_FILE not found: %w", err)
}
}
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
if _, err := os.Stat(config.TLSKeyFile); err != nil {
return fmt.Errorf("TLS_KEY_FILE not found: %w", err)
}
}
return nil
}
func shouldSkipEnvValidation(args []string) bool {
for _, arg := range args[1:] {
switch arg {
case "-h", "--help", "help", "version":
return true
}
}
return false
}
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options")
options := strings.SplitSeq(optionsTag, ",")
for option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "file":
err := resolveFileBasedEnvVariable(field, fieldType)
if err != nil {
return err
}
case "trimTrailingSlash":
if field.Kind() == reflect.String {
field.SetString(strings.TrimRight(field.String(), "/"))
}
}
}
}
return nil
}
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return nil
}
// Only process fields with the "env" tag, ignoring any option that follows the name of the variable
envVarName, _, _ := strings.Cut(fieldType.Tag.Get("env"), ",")
if envVarName == "" {
return nil
}
// Load the value from the file referenced by the "_FILE" variable, keeping the value parsed from the environment if that variable is not set
if isString {
value, ok, err := LoadStringEnvVarFromFile(envVarName)
if err != nil || !ok {
return err
}
field.SetString(value)
return nil
}
value, ok, err := LoadEnvVarFromFile(envVarName)
if err != nil || !ok {
return err
}
field.SetBytes(value)
return nil
}
func (a AppEnv) IsProduction() bool {
return a == AppEnvProduction
}
func (a AppEnv) IsTest() bool {
return a == AppEnvTest
}
func (config *DismissSQLiteStorageWarningConfig) UnmarshalText(text []byte) error {
// Make lowercase, then replace all - and _ with spaces
value := strings.ToLower(strings.TrimSpace(string(text)))
value = strings.ReplaceAll(value, "_", " ")
value = strings.ReplaceAll(value, "-", " ")
*config = DismissSQLiteStorageWarningConfig(value == dismissSQLiteStorageWarningPhrase)
return nil
}
func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
value := strings.TrimSpace(string(text))
// Support boolean values for completely enabling or disabling trust proxy
enabled, err := strconv.ParseBool(value)
if err == nil {
if enabled {
*config = TrustProxyConfig{"0.0.0.0/0", "::/0"}
} else {
*config = nil
}
return nil
}
// Normalize and validate each explicit proxy before the server starts
proxies := strings.Split(value, ",")
for i, proxy := range proxies {
proxy = strings.TrimSpace(proxy)
if net.ParseIP(proxy) == nil {
if _, _, err := net.ParseCIDR(proxy); err != nil {
return fmt.Errorf("invalid proxy IP address or CIDR %q", proxy)
}
}
proxies[i] = proxy
}
*config = proxies
return nil
}