// Package config loads runtime configuration from the environment (and // an optional ./.env file) via viper. // // The iron rule of this package: a value that is SET but cannot be // parsed aborts startup. It is never replaced by the default. An // operator who writes PORT=eighty has said something specific and // wrong, and starting anyway on port 8080 turns their mistake into a // silent misconfiguration that only surfaces much later, somewhere // else. Defaults apply to values that are ABSENT, and to nothing else. // // Every parse failure found in one pass is reported together, so a // broken deployment takes one restart to diagnose rather than five. package config import ( "errors" "fmt" "net/url" "path/filepath" "strconv" "strings" "time" "github.com/dustin/go-humanize" "github.com/spf13/viper" "go.uber.org/fx" // spooky action at a distance! // this populates the environment // from a ./.env file automatically // for development configuration. // .env contents should be things like // `PORT=8080` // (without the backticks, of course) _ "github.com/joho/godotenv/autoload" ) // Environment variable names. Bare names, no prefix: this matches the // other services and keeps a compose file readable. const ( EnvPort = "PORT" EnvDataDir = "DATA_DIR" EnvDBPath = "DB_PATH" EnvDebug = "DEBUG" EnvHSTS = "HSTS" EnvBaseURL = "BASE_URL" EnvMaxRequestBody = "MAX_REQUEST_BODY" EnvRequestTimeout = "REQUEST_TIMEOUT" EnvShutdownGrace = "SHUTDOWN_GRACE" EnvSentryDSN = "SENTRY_DSN" EnvSentryEnv = "SENTRY_ENVIRONMENT" EnvMetricsUser = "METRICS_USER" EnvMetricsPassword = "METRICS_PASSWORD" EnvCSRFKey = "CSRF_KEY" ) // Defaults for values that are absent. A value that is present and // unparseable never reaches these. const ( DefaultPort int64 = 8080 DefaultDataDir = "./data" DefaultBaseURL = "http://localhost:8080" DefaultMaxRequestBody int64 = 1 << 20 // 1 MiB DefaultRequestTimeout = 30 * time.Second DefaultShutdownGrace = 15 * time.Second DefaultSentryEnv = "development" ) // Bounds. A value inside the type but outside the range is as // misconfigured as one that does not parse, and fails the same way. const ( minPort int64 = 1 maxPort int64 = 65535 // minRequestBody is a floor below which no useful form submission // fits; maxRequestBody is a ceiling above which the cap is not // doing its job. minRequestBody int64 = 1 << 10 // 1 KiB maxRequestBody int64 = 1 << 26 // 64 MiB minTimeout = 1 * time.Second maxTimeout = 10 * time.Minute // csrfKeyBytes is what gorilla/csrf requires: exactly 32 bytes, // supplied as csrfKeyHexChars hex characters. csrfKeyBytes = 32 csrfKeyHexChars = csrfKeyBytes * 2 ) // ErrInvalidConfig is the sentinel every configuration failure wraps, // so callers can distinguish "the operator got it wrong" from "the // machine is broken" without string matching. var ErrInvalidConfig = errors.New("invalid configuration") // Config is the parsed, validated runtime configuration. Every field // is final by the time New returns: nothing re-reads the environment // later, so there is exactly one moment at which configuration can be // wrong, and it is before the listener opens. type Config struct { Port int DataDir string DBPath string Debug bool HSTS bool BaseURL string MaxRequestBody int64 RequestTimeout time.Duration ShutdownGrace time.Duration SentryDSN string SentryEnvironment string // MetricsUser and MetricsPassword gate /metrics. Both set or // neither: half-set is refused rather than resolved, because // either resolution is dangerous. Treating a missing password as // empty would publish the metrics endpoint to anyone who guesses // the username; treating a missing username as "no auth" would // publish it to everyone, in a deployment whose operator plainly // intended it to be closed. MetricsUser string MetricsPassword string // CSRFKey is exactly 32 bytes. When CSRF_KEY is absent, a random // key is generated at startup and a warning is logged: tokens then // do not survive a restart, which is fine in development and not // fine behind more than one replica. Absent is a default; present // and malformed is a startup failure. CSRFKey []byte CSRFKeyEphemeral bool } // Params defines dependencies for Config. type Params struct { fx.In } // loader parses one environment into a Config, accumulating every // failure instead of stopping at the first, so one restart surfaces the // whole list. type loader struct { v *viper.Viper errs []error } func (l *loader) fail(key, raw, why string) { l.errs = append(l.errs, fmt.Errorf( "%w: %s=%q is %s", ErrInvalidConfig, key, raw, why, )) } // raw returns the trimmed value of key, and whether it was set to // anything. Whitespace-only counts as absent: it is what an empty // compose-file entry produces, and no key here has a meaningful blank // value. func (l *loader) raw(key string) (string, bool) { s := strings.TrimSpace(l.v.GetString(key)) return s, s != "" } func (l *loader) str(key, def string) string { if s, ok := l.raw(key); ok { return s } return def } func (l *loader) integer(key string, def, minVal, maxVal int64) int64 { s, ok := l.raw(key) if !ok { return def } n, err := strconv.ParseInt(s, 10, 64) if err != nil { l.fail(key, s, "not an integer") return def } if n < minVal || n > maxVal { l.fail(key, s, fmt.Sprintf("outside the range %d..%d", minVal, maxVal)) return def } return n } // boolean accepts what strconv.ParseBool accepts (1/t/T/TRUE/true/True // and the false equivalents) and refuses everything else. "yes" is a // parse failure on purpose: guessing at it is how a security header // ends up off in production. func (l *loader) boolean(key string, def bool) bool { s, ok := l.raw(key) if !ok { return def } b, err := strconv.ParseBool(s) if err != nil { l.fail(key, s, "not a boolean (use true or false)") return def } return b } func (l *loader) duration(key string, def time.Duration) time.Duration { s, ok := l.raw(key) if !ok { return def } d, err := time.ParseDuration(s) if err != nil { l.fail(key, s, "not a duration (e.g. 30s, 2m)") return def } if d < minTimeout || d > maxTimeout { l.fail(key, s, fmt.Sprintf("outside the range %s..%s", minTimeout, maxTimeout)) return def } return d } // bytesize accepts both a plain integer and a human size ("1MiB", // "512kB"), which is the form an operator actually writes. func (l *loader) bytesize(key string, def, minVal, maxVal int64) int64 { s, ok := l.raw(key) if !ok { return def } n, err := humanize.ParseBytes(s) if err != nil { l.fail(key, s, "not a byte size (e.g. 1048576, 1MiB, 512kB)") return def } // maxVal and minVal are compile-time constants of this package, // both positive, so these conversions cannot overflow; n is // range-checked before it is narrowed. if n > uint64(maxVal) { //nolint:gosec // see above l.fail(key, s, byteRangeMessage(minVal, maxVal)) return def } sz := int64(n) //nolint:gosec // n was just checked against maxVal, a positive int64. if sz < minVal { l.fail(key, s, byteRangeMessage(minVal, maxVal)) return def } return sz } // byteRangeMessage renders the permitted size range the way an operator // wrote the value they got wrong. func byteRangeMessage(minVal, maxVal int64) string { //nolint:gosec // both are positive compile-time constants of this package. return fmt.Sprintf("outside the range %s..%s", humanize.IBytes(uint64(minVal)), humanize.IBytes(uint64(maxVal))) } // New parses and validates the environment. Returning an error here // aborts fx startup before anything listens, which is the whole point: // there is no partially configured running state to reason about. // //nolint:revive // lc parameter is required by fx even if unused. func New(lc fx.Lifecycle, _ Params) (*Config, error) { v := viper.New() v.AutomaticEnv() return load(v) } // load is New's body against an explicit viper instance, so tests can // drive it with a known environment instead of mutating the process's. func load(v *viper.Viper) (*Config, error) { l := &loader{v: v} c := &Config{} c.Port = int(l.integer(EnvPort, DefaultPort, minPort, maxPort)) c.DataDir = l.str(EnvDataDir, DefaultDataDir) c.Debug = l.boolean(EnvDebug, false) c.BaseURL = l.str(EnvBaseURL, DefaultBaseURL) // HSTS defaults to on unless debugging: pinning a developer's // browser to HTTPS on localhost is a self-inflicted outage that // outlives the process. c.HSTS = l.boolean(EnvHSTS, !c.Debug) c.DBPath = l.str(EnvDBPath, filepath.Join(c.DataDir, "simplexcalc.db")) c.MaxRequestBody = l.bytesize( EnvMaxRequestBody, DefaultMaxRequestBody, minRequestBody, maxRequestBody, ) c.RequestTimeout = l.duration(EnvRequestTimeout, DefaultRequestTimeout) c.ShutdownGrace = l.duration(EnvShutdownGrace, DefaultShutdownGrace) c.SentryDSN = l.str(EnvSentryDSN, "") c.SentryEnvironment = l.str(EnvSentryEnv, DefaultSentryEnv) l.checkSentryDSN(c.SentryDSN) c.MetricsUser = l.str(EnvMetricsUser, "") c.MetricsPassword = l.str(EnvMetricsPassword, "") l.checkMetricsAuth(c) l.loadCSRFKey(c) if len(l.errs) > 0 { return nil, errors.Join(l.errs...) } return c, nil } // checkSentryDSN refuses a DSN that is present and not a URL. An empty // DSN disables Sentry and is not an error; a typo'd one that silently // disabled it would be, since the operator would believe errors were // being reported. func (l *loader) checkSentryDSN(dsn string) { if dsn == "" { return } u, err := url.Parse(dsn) if err != nil || u.Scheme == "" || u.Host == "" { // The DSN embeds a key; report the failure without it. l.errs = append(l.errs, fmt.Errorf( "%w: %s is set but is not a valid DSN URL", ErrInvalidConfig, EnvSentryDSN, )) } } // checkMetricsAuth refuses a half-configured metrics credential. See // the field comment on Config.MetricsUser for why neither resolution // is acceptable. func (l *loader) checkMetricsAuth(c *Config) { switch { case c.MetricsUser == "" && c.MetricsPassword == "": return case c.MetricsUser == "": l.errs = append(l.errs, fmt.Errorf( "%w: %s is set but %s is not; set both or neither", ErrInvalidConfig, EnvMetricsPassword, EnvMetricsUser, )) case c.MetricsPassword == "": l.errs = append(l.errs, fmt.Errorf( "%w: %s is set but %s is not; set both or neither", ErrInvalidConfig, EnvMetricsUser, EnvMetricsPassword, )) } } // loadCSRFKey decodes CSRF_KEY, or marks the config for an ephemeral // key. Generating the random key is deferred to the server, so that // this function stays pure and testable. func (l *loader) loadCSRFKey(c *Config) { s, ok := l.raw(EnvCSRFKey) if !ok { c.CSRFKeyEphemeral = true return } key, err := decodeHex(s) if err != nil { // The value is a secret: say what is wrong with it, never // quote it. l.errs = append(l.errs, fmt.Errorf( "%w: %s is set but is not %d hex characters", ErrInvalidConfig, EnvCSRFKey, csrfKeyHexChars, )) return } c.CSRFKey = key }