Initial commit
This commit is contained in:
191
server/internal/config/config.go
Normal file
191
server/internal/config/config.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
}
|
||||
76
server/internal/config/config_test.go
Normal file
76
server/internal/config/config_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAcceptsExactly32ByteKeys(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"DATABASE_URL": "postgres://localhost/test",
|
||||
"DATA_ENCRYPTION_KEY_BASE64": base64.StdEncoding.EncodeToString([]byte(strings.Repeat("a", 32))),
|
||||
"PHONE_HMAC_KEY_BASE64": base64.StdEncoding.EncodeToString([]byte(strings.Repeat("b", 32))),
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
}
|
||||
cfg, err := load(func(key string) (string, bool) { value, ok := values[key]; return value, ok })
|
||||
if err != nil {
|
||||
t.Fatalf("load returned error: %v", err)
|
||||
}
|
||||
if len(cfg.EncryptionKey) != 32 || len(cfg.PhoneHMACKey) != 32 {
|
||||
t.Fatalf("unexpected key lengths: %d, %d", len(cfg.EncryptionKey), len(cfg.PhoneHMACKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsWrongKeyLength(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"DATABASE_URL": "postgres://localhost/test",
|
||||
"DATA_ENCRYPTION_KEY_BASE64": base64.StdEncoding.EncodeToString([]byte(strings.Repeat("a", 31))),
|
||||
"PHONE_HMAC_KEY_BASE64": base64.StdEncoding.EncodeToString([]byte(strings.Repeat("b", 32))),
|
||||
}
|
||||
_, err := load(func(key string) (string, bool) { value, ok := values[key]; return value, ok })
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly 32 bytes") {
|
||||
t.Fatalf("expected exact length error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresSecureCookieInProduction(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 32)))
|
||||
values := map[string]string{
|
||||
"APP_ENV": "production",
|
||||
"DATABASE_URL": "postgres://localhost/test",
|
||||
"DATA_ENCRYPTION_KEY_BASE64": key,
|
||||
"PHONE_HMAC_KEY_BASE64": key,
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
}
|
||||
_, err := load(func(key string) (string, bool) { value, ok := values[key]; return value, ok })
|
||||
if err == nil || !strings.Contains(err.Error(), "must be true") {
|
||||
t.Fatalf("expected production cookie error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresTLSAndReleaseMigrationsInProduction(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 32)))
|
||||
values := map[string]string{
|
||||
"APP_ENV": "production",
|
||||
"DATABASE_URL": "postgres://queue:secret@pg.internal/queue?sslmode=verify-full",
|
||||
"DATA_ENCRYPTION_KEY_BASE64": key,
|
||||
"PHONE_HMAC_KEY_BASE64": key,
|
||||
"SESSION_COOKIE_SECURE": "true",
|
||||
"MIGRATE_ON_START": "false",
|
||||
}
|
||||
if _, err := load(func(key string) (string, bool) { value, ok := values[key]; return value, ok }); err != nil {
|
||||
t.Fatalf("secure production configuration rejected: %v", err)
|
||||
}
|
||||
|
||||
values["DATABASE_URL"] = "postgres://queue:secret@pg.internal/queue?sslmode=disable"
|
||||
if _, err := load(func(key string) (string, bool) { value, ok := values[key]; return value, ok }); err == nil || !strings.Contains(err.Error(), "TLS") {
|
||||
t.Fatalf("expected production database TLS error, got %v", err)
|
||||
}
|
||||
|
||||
values["DATABASE_URL"] = "postgres://queue:secret@pg.internal/queue?sslmode=verify-full"
|
||||
values["MIGRATE_ON_START"] = "true"
|
||||
if _, err := load(func(key string) (string, bool) { value, ok := values[key]; return value, ok }); err == nil || !strings.Contains(err.Error(), "MIGRATE_ON_START") {
|
||||
t.Fatalf("expected production migration mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user