config

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package config loads and validates runtime configuration from the environment.

Two properties matter more than the mechanics.

Validation is aggregated rather than fail-on-first. An operator bringing up a self-hosted instance for the first time should see every problem in one run, not discover them one restart at a time.

Secrets are typed as Secret, which refuses to print itself through fmt, slog or JSON. A config dump or a formatted panic cannot leak the database password or the API-key pepper.

Index

Constants

View Source
const EnvPrefix = "LINKCTRL_"

EnvPrefix is prepended to every variable name. POSTGRES_* variables consumed by the Postgres container itself are deliberately outside this prefix.

Variables

View Source
var FileSecretVars = []string{
	"API_KEY_PEPPER",
	"DATABASE_URL",
}

FileSecretVars are the variables that additionally support a _FILE suffix, for Docker and Swarm secrets mounted under /run/secrets.

View Source
var Removed = map[string]string{
	"SECRET_KEY": "nothing was keyed by it. Sessions use random 32-byte tokens " +
		"stored as SHA-256, CSRF is origin-based, and API keys use API_KEY_PEPPER; " +
		"rotating this changed nothing, which is the opposite of what a variable " +
		"with this name promises",
	"SMTP_PASSWORD": "there is no mail feature to authenticate to; it was accepted, " +
		"validated and never read",
	"INGEST_WORKERS": "the ingester runs a single consumer, which is what makes " +
		"batch coalescing work; a worker count would break it",
	"VISITOR_SALT_ROTATION": "visitor salts rotate once per UTC day, which is the " +
		"period the purge window de-identifies against",
	"BOT_FILTER_ENABLED": "bots are always classified and recorded; headline " +
		"figures exclude them in the queries instead",
}

Removed names variables that once existed and no longer do, each with the behaviour that is now fixed.

Kept as data rather than deleted quietly, because silent removal reproduces the defect it is fixing from the other side: the operator still has the line in their .env and still believes it does something. Startup reports these as warnings rather than errors — an upgrade must not refuse to boot over a stale line in a file.

Functions

func CanonicalHost

func CanonicalHost(host string) string

CanonicalHost normalizes a Host header or a URL host for comparison: lowercased, with an explicit default HTTP(S) port removed.

The port matters because the two sides of the comparison come from different places. The configured value is written by an operator ("manage.example.com") and the request value is written by a proxy, which may or may not append ":443". Comparing them raw makes the router's behavior depend on that choice.

func RemovedInUse

func RemovedInUse() []string

RemovedInUse reports removed variables that are still set, ready to log.

Sorted, so the output is stable across runs and diffable in a log.

Types

type AliasConfig

type AliasConfig struct {
	Length              int      `env:"ALIAS_LENGTH" envDefault:"7"`
	MinUserLength       int      `env:"ALIAS_MIN_USER_LENGTH" envDefault:"3"`
	ReservedExtra       []string `env:"ALIAS_RESERVED_EXTRA" envSeparator:","`
	ProfanityFilter     bool     `env:"ALIAS_PROFANITY_FILTER" envDefault:"true"`
	DestSchemes         []string `env:"DESTINATION_SCHEMES" envSeparator:"," envDefault:"http,https"`
	DestMaxLength       int      `env:"DESTINATION_MAX_LENGTH" envDefault:"2048"`
	DestBlockPrivateIPs bool     `env:"DESTINATION_BLOCK_PRIVATE_IPS" envDefault:"true"`
	DestBlocklist       []string `env:"DESTINATION_BLOCKLIST" envSeparator:","`
}

type AnalyticsConfig

type AnalyticsConfig struct {
	RetentionDays int    `env:"ANALYTICS_RETENTION_DAYS" envDefault:"395"`
	GeoIPPath     string `env:"GEOIP_MMDB_PATH"`
}

AnalyticsConfig tunes analytics storage and enrichment.

Salt rotation and bot classification are not configurable, and that is a design decision rather than an omission: the daily rotation is what the purge window de-identifies against, and bots are always classified because the control that matters — keeping them out of headline figures — is in the queries. See Removed.

type AuthConfig

type AuthConfig struct {
	SignupMode         SignupMode    `env:"SIGNUP_MODE" envDefault:"closed"`
	SessionAbsoluteTTL time.Duration `env:"SESSION_ABSOLUTE_TTL" envDefault:"720h"`
	SessionIdleTTL     time.Duration `env:"SESSION_IDLE_TTL" envDefault:"168h"`

	// RFC 9106 recommends at least 19 MiB for the memory-constrained profile;
	// 64 MiB is the comfortable default. Validate enforces the floor, because
	// lowering this is the easiest way to silently weaken password storage.
	Argon2MemoryKiB   uint32 `env:"ARGON2_MEMORY_KIB" envDefault:"65536"`
	Argon2Iterations  uint32 `env:"ARGON2_ITERATIONS" envDefault:"3"`
	Argon2Parallelism uint8  `env:"ARGON2_PARALLELISM" envDefault:"2"`

	LoginRatePerMin  int `env:"LOGIN_RATE_PER_MIN" envDefault:"10"`
	LockoutThreshold int `env:"LOGIN_LOCKOUT_THRESHOLD" envDefault:"5"`
	APIRatePerMin    int `env:"API_RATE_PER_MIN" envDefault:"600"`
}

type Config

type Config struct {
	AppEnv  Environment `env:"APP_ENV" envDefault:"production"`
	BaseURL string      `env:"BASE_URL,required"`

	// AppBaseURL and LinkBaseURL split the instance across two hostnames: the
	// dashboard and API on one, short links on the other. Both default to
	// BaseURL, so leaving them unset is the single-host deployment unchanged.
	//
	// After Load they are always populated, so callers use them rather than
	// deciding for themselves whether the split is configured.
	AppBaseURL  string `env:"APP_BASE_URL"`
	LinkBaseURL string `env:"LINK_BASE_URL"`

	HTTP      HTTPConfig
	Log       LogConfig
	DB        DBConfig
	Redis     RedisConfig
	Redirect  RedirectConfig
	Alias     AliasConfig
	Auth      AuthConfig
	Ingest    IngestConfig
	Analytics AnalyticsConfig
	Shutdown  ShutdownConfig

	APIKeyPepper Secret `env:"API_KEY_PEPPER,required,unset"`

	DocsEnabled    bool `env:"DOCS_ENABLED" envDefault:"true"`
	SecureCookies  bool `env:"SECURE_COOKIES" envDefault:"true"`
	MigrateOnStart bool `env:"MIGRATE_ON_START" envDefault:"true"`

	// TrustedProxies must stay empty unless the app really is behind a proxy.
	// A non-empty value makes the app believe X-Forwarded-For, which is how
	// rate limiting and analytics get spoofed when it is set carelessly.
	TrustedProxies []netip.Prefix `env:"TRUSTED_PROXIES" envSeparator:","`
	// contains filtered or unexported fields
}

func Load

func Load() (Config, error)

Load reads configuration from the environment and validates it.

A .env file is honoured only in development, and only when APP_ENV says so before the file is read. A stray .env on a production host must not be able to change how the service runs.

func Parse

func Parse() (Config, error)

Parse reads configuration from the current environment without consulting a .env file. Tests use it directly.

func (Config) AppBaseURLParsed

func (c Config) AppBaseURLParsed() *url.URL

AppBaseURLParsed returns the origin serving the dashboard and the API.

func (Config) AppOrigin

func (c Config) AppOrigin() string

AppOrigin returns the origin serving the dashboard and the API.

Falls back to BaseURL, so a Config assembled by hand — every test does this rather than going through Load — behaves as a single-host deployment instead of as one with no dashboard origin at all.

func (Config) BaseURLParsed

func (c Config) BaseURLParsed() *url.URL

BaseURLParsed returns the parsed canonical origin.

func (Config) Host

func (c Config) Host() string

Host returns the host short links are served on, which is the default domain when resolving an alias.

func (Config) LinkBaseURLParsed

func (c Config) LinkBaseURLParsed() *url.URL

LinkBaseURLParsed returns the origin serving short links.

func (Config) LinkOrigin

func (c Config) LinkOrigin() string

LinkOrigin returns the origin short links are published under.

func (Config) SplitHosts

func (c Config) SplitHosts() bool

SplitHosts reports whether the dashboard and short links are served on different hostnames.

Compared on host rather than on the whole origin: the routing decision and the cookie boundary are both about the host, and an instance configured with two schemes on one host has neither.

func (Config) Validate

func (c Config) Validate() error

Validate collects every problem rather than returning at the first.

The messages name the variable and say what to do about it. An operator reading them should not need to consult the source.

type DBConfig

type DBConfig struct {
	URL Secret `env:"DATABASE_URL,required,unset"`

	// Two pools. The redirect pool is small, separate, and exists so that a
	// slow analytics query on the application pool cannot starve the hot path
	// of connections. M13 asserts empirically that it does not.
	MaxConns         int32         `env:"DB_MAX_CONNS" envDefault:"20"`
	MinConns         int32         `env:"DB_MIN_CONNS" envDefault:"2"`
	RedirectMaxConns int32         `env:"DB_REDIRECT_MAX_CONNS" envDefault:"6"`
	MaxConnLifetime  time.Duration `env:"DB_MAX_CONN_LIFETIME" envDefault:"1h"`
	MaxConnIdleTime  time.Duration `env:"DB_MAX_CONN_IDLE_TIME" envDefault:"15m"`
	ConnectTimeout   time.Duration `env:"DB_CONNECT_TIMEOUT" envDefault:"10s"`
}

type Environment

type Environment string
const (
	Development Environment = "development"
	Production  Environment = "production"
)

func (Environment) IsProduction

func (e Environment) IsProduction() bool

type HTTPConfig

type HTTPConfig struct {
	Addr              string        `env:"HTTP_ADDR" envDefault:":8080"`
	MetricsAddr       string        `env:"METRICS_ADDR" envDefault:":9090"`
	ReadHeaderTimeout time.Duration `env:"HTTP_READ_HEADER_TIMEOUT" envDefault:"5s"`
	WriteTimeout      time.Duration `env:"HTTP_WRITE_TIMEOUT" envDefault:"30s"`
	RequestTimeout    time.Duration `env:"HTTP_REQUEST_TIMEOUT" envDefault:"15s"`
	ServerTiming      bool          `env:"SERVER_TIMING" envDefault:"false"`
}

type IngestConfig

type IngestConfig struct {
	QueueSize     int           `env:"INGEST_QUEUE_SIZE" envDefault:"16384"`
	BatchSize     int           `env:"INGEST_BATCH_SIZE" envDefault:"500"`
	FlushInterval time.Duration `env:"INGEST_FLUSH_INTERVAL" envDefault:"250ms"`
}

IngestConfig tunes the click pipeline.

There is deliberately no worker count. One consumer is what makes batch coalescing work — a second would split every batch and interleave the writes — so the knob that used to be here was removed rather than implemented. See Removed.

type LogConfig

type LogConfig struct {
	Level  string `env:"LOG_LEVEL" envDefault:"info"`
	Format string `env:"LOG_FORMAT" envDefault:"json"`
}

type RedirectConfig

type RedirectConfig struct {
	TTL           time.Duration `env:"REDIRECT_TTL" envDefault:"24h"`
	NegativeTTL   time.Duration `env:"REDIRECT_NEGATIVE_TTL" envDefault:"60s"`
	Timeout       time.Duration `env:"REDIRECT_TIMEOUT" envDefault:"250ms"`
	DefaultStatus int           `env:"REDIRECT_DEFAULT_STATUS" envDefault:"302"`
	LogSample     int           `env:"REDIRECT_LOG_SAMPLE" envDefault:"0"`
	NotFoundLimit int           `env:"REDIRECT_404_RATE_LIMIT" envDefault:"60"`
}

type RedisConfig

type RedisConfig struct {
	URL          string        `env:"REDIS_URL" envDefault:"redis://redis:6379/0"`
	DialTimeout  time.Duration `env:"REDIS_DIAL_TIMEOUT" envDefault:"1s"`
	ReadTimeout  time.Duration `env:"REDIS_READ_TIMEOUT" envDefault:"50ms"`
	PoolSize     int           `env:"REDIS_POOL_SIZE" envDefault:"50"`
	CacheEnabled bool          `env:"CACHE_ENABLED" envDefault:"true"`
}

type Secret

type Secret string

Secret is a string that refuses to print itself.

Every obvious way of accidentally disclosing a value is overridden: fmt's %v and %s go through String, structured logging goes through LogValue, json.Marshal goes through MarshalJSON. A config dump, a panic that formats a struct, or a well-meaning slog.Any("config", cfg) therefore cannot leak the database password or the API-key pepper.

Reveal is the only way to read the value, and its name is deliberately awkward so that calls to it stand out in review.

func (Secret) Format

func (s Secret) Format(f fmt.State, verb rune)

Format covers the remaining verbs. Without it, %q on a Secret prints the value, because fmt falls back to the underlying string kind for verbs that Stringer does not handle.

func (Secret) GoString

func (s Secret) GoString() string

GoString covers %#v, which would otherwise print the underlying string.

func (Secret) IsZero

func (s Secret) IsZero() bool

IsZero reports whether the secret is unset, without disclosing it.

func (Secret) Len

func (s Secret) Len() int

Len returns the length of the secret. Useful for validation messages such as "must be at least 32 bytes" that need to say something specific without echoing the value.

func (Secret) LogValue

func (s Secret) LogValue() slog.Value

LogValue makes slog print the redacted form.

func (Secret) MarshalJSON

func (s Secret) MarshalJSON() ([]byte, error)

func (Secret) MarshalText

func (s Secret) MarshalText() ([]byte, error)

MarshalText covers encoders that prefer TextMarshaler, including YAML.

func (Secret) Reveal

func (s Secret) Reveal() string

Reveal returns the underlying value. Call it at the point of use, never to pass a secret into logging or error text.

func (Secret) String

func (s Secret) String() string

func (*Secret) UnmarshalJSON

func (s *Secret) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts a value so that a Secret can be read from a config file, even though the round trip is deliberately lossy.

func (*Secret) UnmarshalText

func (s *Secret) UnmarshalText(b []byte) error

UnmarshalText lets caarlos0/env populate the field from an environment variable.

type ShutdownConfig

type ShutdownConfig struct {
	DrainDelay time.Duration `env:"SHUTDOWN_DRAIN_DELAY" envDefault:"5s"`
	Timeout    time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
}

type SignupMode

type SignupMode string
const (
	SignupClosed SignupMode = "closed"
	SignupInvite SignupMode = "invite"
	SignupOpen   SignupMode = "open"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL