Files
chorus/internal/config/config.go
T

276 lines
10 KiB
Go

package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Environment string
const (
Development Environment = "development"
Test Environment = "test"
Production Environment = "production"
)
type Config struct {
Environment Environment
DBDSN string
ListenAddress string
StorageRoot string
SessionKey string
SessionTTL time.Duration
LoginAttempts int
LoginWindow time.Duration
MaxPromptBytes int
MaxImages int
MaxImageBytes int64
MaxUploadBytes int64
MaxImagePixels uint64
HistoryLimit int
WorkerLeaseDuration time.Duration
WorkerPollInterval time.Duration
ProviderHTTPTimeout time.Duration
ProviderMaxResponseBytes int64
ProviderAllowedPorts []uint16
UserRateLimitCapacity uint64
UserRateLimitWindow time.Duration
APIKeyRateLimitCapacity uint64
APIKeyRateLimitWindow time.Duration
ProviderRateLimitCapacity uint64
ProviderRateLimitWindow time.Duration
TestDisableWorker bool
}
func Load() (Config, error) {
return LoadFromLookup(os.LookupEnv)
}
func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) {
return loadFromLookup(lookup, defaultRuntimeSettings())
}
func LoadFile(path string) (Config, error) {
return LoadFileFromLookup(path, os.LookupEnv)
}
func LoadFileFromLookup(path string, lookup func(string) (string, bool)) (Config, error) {
settings, err := loadRuntimeSettings(path)
if err != nil {
return Config{}, err
}
return loadFromLookup(lookup, settings)
}
func loadFromLookup(lookup func(string) (string, bool), defaults runtimeSettings) (Config, error) {
read := func(name string) string {
value, _ := lookup(name)
return strings.TrimSpace(value)
}
environment := Environment(read("CHORUS_ENV"))
if environment == "" {
environment = Development
}
if environment != Development && environment != Test && environment != Production {
return Config{}, fmt.Errorf("CHORUS_ENV must be development, test, or production")
}
cfg := Config{
Environment: environment,
DBDSN: read("CHORUS_DSN"),
ListenAddress: read("CHORUS_LISTEN_ADDRESS"),
StorageRoot: read("CHORUS_STORAGE_ROOT"),
SessionKey: read("CHORUS_SESSION_KEY"),
}
if environment != Production {
if cfg.StorageRoot == "" {
cfg.StorageRoot = "var/storage"
}
if cfg.SessionKey == "" {
generated := make([]byte, 32)
if _, err := rand.Read(generated); err != nil {
return Config{}, fmt.Errorf("generate development session key")
}
cfg.SessionKey = base64.RawURLEncoding.EncodeToString(generated)
}
}
if cfg.ListenAddress == "" {
cfg.ListenAddress = "127.0.0.1:8080"
}
var missing []string
if cfg.DBDSN == "" {
missing = append(missing, "CHORUS_DSN")
}
if environment == Production {
for name, value := range map[string]string{
"CHORUS_SESSION_KEY": cfg.SessionKey,
"CHORUS_STORAGE_ROOT": cfg.StorageRoot,
} {
if value == "" {
missing = append(missing, name)
}
}
for _, name := range []string{"CHORUS_SESSION_TTL_MINUTES", "CHORUS_LOGIN_ATTEMPTS", "CHORUS_LOGIN_WINDOW_SECONDS", "CHORUS_MAX_PROMPT_BYTES", "CHORUS_MAX_IMAGES", "CHORUS_MAX_IMAGE_BYTES", "CHORUS_MAX_UPLOAD_BYTES", "CHORUS_MAX_IMAGE_PIXELS", "CHORUS_HISTORY_LIMIT", "CHORUS_USER_RATE_LIMIT_CAPACITY", "CHORUS_USER_RATE_LIMIT_WINDOW_SECONDS", "CHORUS_API_KEY_RATE_LIMIT_CAPACITY", "CHORUS_API_KEY_RATE_LIMIT_WINDOW_SECONDS", "CHORUS_PROVIDER_RATE_LIMIT_CAPACITY", "CHORUS_PROVIDER_RATE_LIMIT_WINDOW_SECONDS"} {
if read(name) == "" {
missing = append(missing, name)
}
}
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", "))
}
var err error
if cfg.SessionTTL, err = durationConfig(read("CHORUS_SESSION_TTL_MINUTES"), 480, time.Minute); err != nil {
return Config{}, fmt.Errorf("CHORUS_SESSION_TTL_MINUTES is invalid")
}
if cfg.LoginWindow, err = durationConfig(read("CHORUS_LOGIN_WINDOW_SECONDS"), 60, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_LOGIN_WINDOW_SECONDS is invalid")
}
if cfg.LoginAttempts, err = intConfig(read("CHORUS_LOGIN_ATTEMPTS"), 5); err != nil {
return Config{}, fmt.Errorf("CHORUS_LOGIN_ATTEMPTS is invalid")
}
if cfg.MaxPromptBytes, err = intConfig(read("CHORUS_MAX_PROMPT_BYTES"), 8000); err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_PROMPT_BYTES is invalid")
}
if cfg.MaxImages, err = intConfig(read("CHORUS_MAX_IMAGES"), 8); err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGES is invalid")
}
maxImageBytes, err := intConfig(read("CHORUS_MAX_IMAGE_BYTES"), 10<<20)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_BYTES is invalid")
}
cfg.MaxImageBytes = int64(maxImageBytes)
maxUploadBytes, err := intConfig(read("CHORUS_MAX_UPLOAD_BYTES"), 32<<20)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_UPLOAD_BYTES is invalid")
}
cfg.MaxUploadBytes = int64(maxUploadBytes)
maxPixels, err := intConfig(read("CHORUS_MAX_IMAGE_PIXELS"), 40_000_000)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_PIXELS is invalid")
}
cfg.MaxImagePixels = uint64(maxPixels)
if cfg.HistoryLimit, err = intConfig(read("CHORUS_HISTORY_LIMIT"), 50); err != nil {
return Config{}, fmt.Errorf("CHORUS_HISTORY_LIMIT is invalid")
}
if cfg.WorkerLeaseDuration, err = durationConfig(read("CHORUS_WORKER_LEASE_SECONDS"), defaults.Worker.LeaseSeconds, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_WORKER_LEASE_SECONDS is invalid")
}
if cfg.WorkerPollInterval, err = durationConfig(read("CHORUS_WORKER_POLL_MILLISECONDS"), defaults.Worker.PollMilliseconds, time.Millisecond); err != nil {
return Config{}, fmt.Errorf("CHORUS_WORKER_POLL_MILLISECONDS is invalid")
}
if cfg.ProviderHTTPTimeout, err = durationConfig(read("CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS"), defaults.Provider.HTTPTimeoutSeconds, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS is invalid")
}
providerMaxResponseBytes, err := intConfig(read("CHORUS_PROVIDER_MAX_RESPONSE_BYTES"), defaults.Provider.MaxResponseBytes)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_MAX_RESPONSE_BYTES is invalid")
}
cfg.ProviderMaxResponseBytes = int64(providerMaxResponseBytes)
if raw := read("CHORUS_PROVIDER_ALLOWED_PORTS"); raw != "" {
if cfg.ProviderAllowedPorts, err = ParseProviderAllowedPorts(raw); err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_ALLOWED_PORTS is invalid")
}
} else {
cfg.ProviderAllowedPorts = append([]uint16(nil), defaults.Provider.AllowedPorts...)
}
userRateCapacity, err := intConfig(read("CHORUS_USER_RATE_LIMIT_CAPACITY"), 60)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_USER_RATE_LIMIT_CAPACITY is invalid")
}
cfg.UserRateLimitCapacity = uint64(userRateCapacity)
if cfg.UserRateLimitWindow, err = durationConfig(read("CHORUS_USER_RATE_LIMIT_WINDOW_SECONDS"), 60, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_USER_RATE_LIMIT_WINDOW_SECONDS is invalid")
}
apiKeyRateCapacity, err := intConfig(read("CHORUS_API_KEY_RATE_LIMIT_CAPACITY"), 120)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_API_KEY_RATE_LIMIT_CAPACITY is invalid")
}
cfg.APIKeyRateLimitCapacity = uint64(apiKeyRateCapacity)
if cfg.APIKeyRateLimitWindow, err = durationConfig(read("CHORUS_API_KEY_RATE_LIMIT_WINDOW_SECONDS"), 60, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_API_KEY_RATE_LIMIT_WINDOW_SECONDS is invalid")
}
providerRateCapacity, err := intConfig(read("CHORUS_PROVIDER_RATE_LIMIT_CAPACITY"), 60)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_RATE_LIMIT_CAPACITY is invalid")
}
cfg.ProviderRateLimitCapacity = uint64(providerRateCapacity)
if cfg.ProviderRateLimitWindow, err = durationConfig(read("CHORUS_PROVIDER_RATE_LIMIT_WINDOW_SECONDS"), 60, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_RATE_LIMIT_WINDOW_SECONDS is invalid")
}
if value := read("CHORUS_TEST_DISABLE_WORKER"); value != "" {
if cfg.TestDisableWorker, err = strconv.ParseBool(value); err != nil {
return Config{}, fmt.Errorf("CHORUS_TEST_DISABLE_WORKER is invalid")
}
if cfg.TestDisableWorker && environment != Test {
return Config{}, fmt.Errorf("CHORUS_TEST_DISABLE_WORKER is only allowed in test")
}
}
if cfg.MaxUploadBytes < cfg.MaxImageBytes || cfg.MaxImages > 32 || cfg.HistoryLimit > 200 {
return Config{}, fmt.Errorf("portal limits are inconsistent")
}
if cfg.WorkerLeaseDuration <= cfg.ProviderHTTPTimeout+5*time.Second {
return Config{}, fmt.Errorf("worker lease must exceed provider HTTP timeout by more than 5 seconds")
}
return cfg, nil
}
func (c Config) ValidateProduction() error {
if c.Environment != Production {
return nil
}
if c.DBDSN == "" || len(c.SessionKey) < 32 || c.StorageRoot == "" || c.SessionTTL <= 0 || c.MaxImageBytes <= 0 || c.MaxUploadBytes < c.MaxImageBytes || c.WorkerLeaseDuration <= c.ProviderHTTPTimeout+5*time.Second || c.ProviderMaxResponseBytes <= 0 || len(c.ProviderAllowedPorts) == 0 || c.UserRateLimitCapacity == 0 || c.UserRateLimitWindow <= 0 || c.APIKeyRateLimitCapacity == 0 || c.APIKeyRateLimitWindow <= 0 || c.ProviderRateLimitCapacity == 0 || c.ProviderRateLimitWindow <= 0 {
return errors.New("production configuration is incomplete")
}
return nil
}
func ParseProviderAllowedPorts(raw string) ([]uint16, error) {
if strings.TrimSpace(raw) == "" {
return []uint16{80, 443}, nil
}
parts := strings.Split(raw, ",")
ports := make([]uint16, 0, len(parts))
seen := make(map[uint16]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
parsed, err := strconv.ParseUint(value, 10, 16)
if err != nil || parsed == 0 {
return nil, errors.New("invalid provider port list")
}
port := uint16(parsed)
if _, exists := seen[port]; exists {
continue
}
seen[port] = struct{}{}
ports = append(ports, port)
}
return ports, nil
}
func intConfig(value string, fallback int) (int, error) {
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return 0, errors.New("invalid positive integer")
}
return parsed, nil
}
func durationConfig(value string, fallback int, unit time.Duration) (time.Duration, error) {
parsed, err := intConfig(value, fallback)
if err != nil {
return 0, err
}
return time.Duration(parsed) * unit, nil
}