// Package config loads runtime settings from the environment. // // Environment variables rather than a config file, for two reasons: the DSN // differs per machine and you are syncing the repo across two, and a file with // credentials in it is one `git add .` away from being public. The bot uses // config.json because its token is the only secret; the backend holds database // credentials and key material. package config import ( "errors" "fmt" "log/slog" "os" "strconv" "strings" "time" ) type Config struct { // Addr is the listen address. Bind to localhost in production and put a // reverse proxy in front for TLS. Addr string // DSN for Postgres. Required. DSN string // MaxConns caps the pgx pool. Postgres forks a backend per connection, so // this is a real resource, not a client-side nicety. MaxConns int32 // RedisAddr enables the shared rate limiter. Empty falls back to an // in-process limiter, which is correct for a single instance and wrong for // several - each would keep its own counters. RedisAddr string RedisDB int // EndUserHeader carries the caller's identifier for whoever triggered the // request, for per-user rate limiting. Empty disables it. EndUserHeader string LogLevel slog.Level LogJSON bool ReadHeaderTimeout time.Duration ShutdownTimeout time.Duration } func Load() (Config, error) { c := Config{ Addr: env("HAMMY_ADDR", "127.0.0.1:8080"), DSN: os.Getenv("HAMMY_DSN"), RedisAddr: os.Getenv("HAMMY_REDIS_ADDR"), EndUserHeader: env("HAMMY_ENDUSER_HEADER", "X-Hammy-User"), ReadHeaderTimeout: 10 * time.Second, ShutdownTimeout: 20 * time.Second, LogJSON: boolEnv("HAMMY_LOG_JSON", false), } if c.DSN == "" { return Config{}, errors.New("config: HAMMY_DSN is not set") } n, err := intEnv("HAMMY_MAX_CONNS", 10) if err != nil { return Config{}, err } c.MaxConns = int32(n) if c.RedisDB, err = intEnv("HAMMY_REDIS_DB", 0); err != nil { return Config{}, err } if c.LogLevel, err = levelEnv("HAMMY_LOG_LEVEL", slog.LevelInfo); err != nil { return Config{}, err } if c.ReadHeaderTimeout, err = durEnv("HAMMY_READ_HEADER_TIMEOUT", c.ReadHeaderTimeout); err != nil { return Config{}, err } if c.ShutdownTimeout, err = durEnv("HAMMY_SHUTDOWN_TIMEOUT", c.ShutdownTimeout); err != nil { return Config{}, err } return c, nil } // Redacted is safe to log at startup. The DSN carries a password. func (c Config) Redacted() string { redis := c.RedisAddr if redis == "" { redis = "(in-process limiter)" } return fmt.Sprintf("addr=%s db=%s redis=%s max_conns=%d level=%s", c.Addr, redactDSN(c.DSN), redis, c.MaxConns, c.LogLevel) } // redactDSN keeps the shape of the connection string and drops the password. func redactDSN(dsn string) string { at := strings.LastIndex(dsn, "@") if at < 0 { return "(set)" } scheme := strings.Index(dsn, "://") if scheme < 0 { return "(set)" } creds := dsn[scheme+3 : at] user := creds if colon := strings.Index(creds, ":"); colon >= 0 { user = creds[:colon] } return dsn[:scheme+3] + user + ":***" + dsn[at:] } func env(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func intEnv(key string, def int) (int, error) { v := os.Getenv(key) if v == "" { return def, nil } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("config: %s: %w", key, err) } return n, nil } func boolEnv(key string, def bool) bool { v := os.Getenv(key) if v == "" { return def } b, err := strconv.ParseBool(v) if err != nil { return def } return b } func durEnv(key string, def time.Duration) (time.Duration, error) { v := os.Getenv(key) if v == "" { return def, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf("config: %s: %w", key, err) } return d, nil } func levelEnv(key string, def slog.Level) (slog.Level, error) { v := os.Getenv(key) if v == "" { return def, nil } var l slog.Level if err := l.UnmarshalText([]byte(v)); err != nil { return 0, fmt.Errorf("config: %s: %w", key, err) } return l, nil }