192 lines
6.1 KiB
Go
192 lines
6.1 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Environment string
|
|
HTTPAddr string
|
|
DatabaseURL string
|
|
EncryptionKey []byte
|
|
PhoneHMACKey []byte
|
|
SessionCookieName string
|
|
SessionSecure bool
|
|
SessionTTL time.Duration
|
|
MigrateOnStart bool
|
|
ShutdownTimeout time.Duration
|
|
DBMaxOpenConns int
|
|
DBMaxIdleConns int
|
|
DBConnMaxIdleTime time.Duration
|
|
DBConnMaxLifetime time.Duration
|
|
MaintenanceInterval time.Duration
|
|
MaintenanceBatchSize int
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
return load(os.LookupEnv)
|
|
}
|
|
|
|
func load(lookup func(string) (string, bool)) (Config, error) {
|
|
get := func(name, fallback string) string {
|
|
if value, ok := lookup(name); ok {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
encryptionKey, err := decodeKey("DATA_ENCRYPTION_KEY_BASE64", get("DATA_ENCRYPTION_KEY_BASE64", ""))
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
phoneHMACKey, err := decodeKey("PHONE_HMAC_KEY_BASE64", get("PHONE_HMAC_KEY_BASE64", ""))
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
sessionTTL, err := time.ParseDuration(get("SESSION_TTL", "12h"))
|
|
if err != nil || sessionTTL < 15*time.Minute || sessionTTL > 7*24*time.Hour {
|
|
return Config{}, errors.New("SESSION_TTL must be a duration between 15m and 168h")
|
|
}
|
|
shutdownTimeout, err := time.ParseDuration(get("SHUTDOWN_TIMEOUT", "10s"))
|
|
if err != nil || shutdownTimeout < time.Second || shutdownTimeout > time.Minute {
|
|
return Config{}, errors.New("SHUTDOWN_TIMEOUT must be a duration between 1s and 1m")
|
|
}
|
|
sessionSecure, err := strconv.ParseBool(get("SESSION_COOKIE_SECURE", "true"))
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("SESSION_COOKIE_SECURE: %w", err)
|
|
}
|
|
migrate, err := strconv.ParseBool(get("MIGRATE_ON_START", "true"))
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("MIGRATE_ON_START: %w", err)
|
|
}
|
|
|
|
environment := get("APP_ENV", "development")
|
|
databaseURL := get("DATABASE_URL", "")
|
|
if databaseURL == "" {
|
|
return Config{}, errors.New("DATABASE_URL is required")
|
|
}
|
|
maxOpen, err := parseIntSetting("DB_MAX_OPEN_CONNS", get("DB_MAX_OPEN_CONNS", "20"), 1, 1000)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
maxIdle, err := parseIntSetting("DB_MAX_IDLE_CONNS", get("DB_MAX_IDLE_CONNS", "5"), 0, 1000)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if maxIdle > maxOpen {
|
|
return Config{}, errors.New("DB_MAX_IDLE_CONNS cannot exceed DB_MAX_OPEN_CONNS")
|
|
}
|
|
connMaxIdleTime, err := parseDurationSetting("DB_CONN_MAX_IDLE_TIME", get("DB_CONN_MAX_IDLE_TIME", "5m"), time.Minute, 24*time.Hour)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
connMaxLifetime, err := parseDurationSetting("DB_CONN_MAX_LIFETIME", get("DB_CONN_MAX_LIFETIME", "30m"), time.Minute, 7*24*time.Hour)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
maintenanceInterval, err := parseDurationSetting("MAINTENANCE_INTERVAL", get("MAINTENANCE_INTERVAL", "1h"), 0, 24*time.Hour)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
maintenanceBatchSize, err := parseIntSetting("MAINTENANCE_BATCH_SIZE", get("MAINTENANCE_BATCH_SIZE", "500"), 1, 10000)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if environment == "production" && !sessionSecure {
|
|
return Config{}, errors.New("SESSION_COOKIE_SECURE must be true in production")
|
|
}
|
|
if environment == "production" && migrate {
|
|
return Config{}, errors.New("MIGRATE_ON_START must be false in production; run migrations as a release job")
|
|
}
|
|
if environment == "production" && !postgresTLSConfigured(databaseURL) {
|
|
return Config{}, errors.New("DATABASE_URL must use PostgreSQL TLS in production")
|
|
}
|
|
if environment == "production" && maintenanceInterval <= 0 {
|
|
return Config{}, errors.New("MAINTENANCE_INTERVAL must be positive in production")
|
|
}
|
|
|
|
cookieName := get("SESSION_COOKIE_NAME", "queue_session")
|
|
if cookieName == "" || strings.ContainsAny(cookieName, "()<>@,;:\\\"/[]?={} \t") {
|
|
return Config{}, errors.New("SESSION_COOKIE_NAME is not a valid cookie name")
|
|
}
|
|
|
|
return Config{
|
|
Environment: environment,
|
|
HTTPAddr: get("HTTP_ADDR", ":8080"),
|
|
DatabaseURL: databaseURL,
|
|
EncryptionKey: encryptionKey,
|
|
PhoneHMACKey: phoneHMACKey,
|
|
SessionCookieName: cookieName,
|
|
SessionSecure: sessionSecure,
|
|
SessionTTL: sessionTTL,
|
|
MigrateOnStart: migrate,
|
|
ShutdownTimeout: shutdownTimeout,
|
|
DBMaxOpenConns: maxOpen,
|
|
DBMaxIdleConns: maxIdle,
|
|
DBConnMaxIdleTime: connMaxIdleTime,
|
|
DBConnMaxLifetime: connMaxLifetime,
|
|
MaintenanceInterval: maintenanceInterval,
|
|
MaintenanceBatchSize: maintenanceBatchSize,
|
|
}, nil
|
|
}
|
|
|
|
func parseIntSetting(name, value string, min, max int) (int, error) {
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil || parsed < min || parsed > max {
|
|
return 0, fmt.Errorf("%s must be an integer between %d and %d", name, min, max)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseDurationSetting(name, value string, min, max time.Duration) (time.Duration, error) {
|
|
parsed, err := time.ParseDuration(strings.TrimSpace(value))
|
|
if err != nil || parsed < min || parsed > max {
|
|
return 0, fmt.Errorf("%s must be a duration between %s and %s", name, min, max)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func postgresTLSConfigured(dsn string) bool {
|
|
value := strings.TrimSpace(dsn)
|
|
if parsed, err := url.Parse(value); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
|
|
return secureSSLMode(parsed.Query().Get("sslmode"))
|
|
}
|
|
for _, field := range strings.Fields(value) {
|
|
if strings.HasPrefix(field, "sslmode=") {
|
|
return secureSSLMode(strings.TrimPrefix(field, "sslmode="))
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func secureSSLMode(mode string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "require", "verify-ca", "verify-full":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func decodeKey(name, value string) ([]byte, error) {
|
|
if value == "" {
|
|
return nil, fmt.Errorf("%s is required", name)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s must be standard base64: %w", name, err)
|
|
}
|
|
if len(decoded) != 32 {
|
|
return nil, fmt.Errorf("%s must decode to exactly 32 bytes", name)
|
|
}
|
|
return decoded, nil
|
|
}
|