The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
391 lines
11 KiB
Go
391 lines
11 KiB
Go
// 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
|
|
}
|