config

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 15 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 (
	// SMTPStartTLS is submission on 587: connect in clear, then upgrade. The
	// default, because it is what almost every provider documents.
	SMTPStartTLS = "starttls"
	// SMTPImplicit is SMTPS on 465: TLS from the first byte.
	SMTPImplicit = "tls"
	// SMTPNone is no encryption at all, for a relay on the same host or the same
	// private network. Credentials are refused in this mode.
	SMTPNone = "none"
)

TLS modes for the mailer. Three, and no more: the honest set is "the two ways a modern submission server listens, plus a local relay that does not".

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",
	"MFA_SECRET_KEY",
	"DATABASE_URL",
	"SMTP_PASSWORD",
	"FEED_AUTH_TOKEN",
}

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",

	"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",
	"DESTINATION_BLOCK_PRIVATE_IPS": "private, loopback, link-local, carrier-NAT " +
		"and cloud-metadata addresses are refused unconditionally since M30. It " +
		"was an off switch on the one tier that must not have one: the person it " +
		"protects is the visitor whose browser would do the fetching, and they " +
		"are not the person who would be turning it off. Point links at an " +
		"intranet with a hostname that resolves there, not with a literal address",
}

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 the DNS root dot folded and 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.

**A non-default port is kept, deliberately.** SplitHosts compares the app and link hosts through this function, and an instance that serves the dashboard and short links on one name and two ports is split-host — stripping the port unconditionally would collapse it to single-host and take the two trees down to one. HostOnly is the spelling for the other question.

**The trailing dot is folded, and it was not (F72, F88).** "lnk.example.com." is the fully qualified spelling of "lnk.example.com" and names the same host; only storage folded it. `domain.ValidateHostname` drops it before a hostname is written and the unique index is on `lower(hostname)`, so a stored name can never carry one — which made the mismatch entirely request-side, and made every tier that reads a Host header miss together: the split-host router answered its ops-only 404, and the single-host mux served a customer's verified hostname the dashboard, the API and the *default* domain's aliases. It is reachable over HTTPS because SNI carries no trailing dot (RFC 6066), so the handshake completes on the certificate for the folded name and Go passes r.Host through unchanged.

func HostOnly added in v0.2.0

func HostOnly(host string) string

HostOnly is CanonicalHost with any port removed as well.

**The two are different questions and F88 is what happens when one function answers both.** CanonicalHost asks "is this the host this instance was configured with", where the port is part of the configured value and dropping it would merge two deployments into one. HostOnly asks "is this a verified custom hostname", where there is no port to compare against: `domains.hostname` is stored bare, it is validated bare, and the hostname is served on whichever port this instance happens to listen on. Keying the verified-host cache through CanonicalHost meant `Host: go.customer.example:8080` missed a hostname this instance is verified to serve, fell through to the tree behind it, and — on a single-host deployment — was answered by the dashboard, the API and the default domain's aliases.

It is deliberately *wider* than CanonicalHost rather than differently spelled: every host CanonicalHost matches, this matches too. That direction is the safe one. The narrower spelling is what fails silently, because a name normalized out of the set stops being served while every page goes on saying it is verified.

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 may narrow the scheme allowlist and may never widen it:
	// Validate refuses anything outside {http, https}. Non-http(s) schemes are
	// the unappealable tier (M30), and a variable that could add "javascript"
	// back would be an override switch on a tier documented as having none.
	DestSchemes   []string `env:"DESTINATION_SCHEMES" envSeparator:"," envDefault:"http,https"`
	DestMaxLength int      `env:"DESTINATION_MAX_LENGTH" envDefault:"2048"`

	// DestBlocklist is the operator's own host list. Since M30 it seeds the
	// runtime Postgres blocklist at boot rather than being consulted in memory,
	// and it is reconciled on every boot — an entry removed from here is
	// retired, and nothing the owner added through review is touched.
	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 AuditConfig added in v0.2.0

type AuditConfig struct {
	RetentionDays int `env:"AUDIT_RETENTION_DAYS" envDefault:"0"`

	// SizeWarnBytes raises an owner notification once the audit partitions pass
	// it. 5 GB, and **on by default** — which is the asymmetry with
	// RetentionDays above, not an inconsistency (D19).
	//
	// The two defaults protect against opposite failures. Retention defaults to
	// inaction because acting unasked destroys data. The warning defaults to
	// acting because inaction is what leaves the operator uninformed, and
	// keep-forever is only a safe default on an instance nobody configured if
	// that instance is the one being warned. A threshold that had to be
	// switched on would be no threshold at all for exactly the operators who
	// need it.
	//
	// 0 disables it, for an operator who has decided and does not want reminding.
	SizeWarnBytes int64 `env:"AUDIT_SIZE_WARN_BYTES" envDefault:"5368709120"`
}

AuditConfig is the audit log's retention policy, which is deliberately its own setting rather than a share of the analytics window.

The default is 0 — keep forever — and it is different from the analytics default on purpose. Both choices are a data-loss policy, and they fail in opposite directions: a finite window means an upgrade silently starts deleting history an operator assumed permanent, while keep-forever means unbounded growth. The first failure is invisible and irreversible; the second is visible and recoverable, and linkctrl_audit_log_bytes plus the alert recipe in docs/operations.md are what make it visible. See decisions.md, D5.

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"`

	// InviteTTL is how long an invitation stays redeemable, measured from when
	// it was created (decision D29).
	//
	// A knob rather than a constant, for the reason D5 refused a constant for
	// audit retention: time is the one thing an operator cannot work around
	// without a rebuild. The clock starts at creation and not at delivery,
	// because mail leaves through the outbox on the scheduler's tick (D23) and
	// there is no send moment to start it from — so a slow relay spends the
	// operator's TTL, which is exactly why it is tunable.
	InviteTTL time.Duration `env:"INVITE_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"`

	// UploadRatePerMin bounds how often one address may upload a file (M50.5).
	//
	// **A bucket of its own because an upload is not an API call.** Every other
	// request under `/api/v1` carries a body this product caps at 256 KiB and
	// parses as JSON; an upload carries up to `qr.MaxLogoUploadBytes` and is
	// decoded, which is the one place a request's cost is set by its content
	// rather than by its shape. `API_RATE_PER_MIN` defaults to 600, and 600
	// megabyte uploads a minute is a bandwidth and decoder budget nobody chose
	// by setting a number about JSON.
	//
	// Thirty is what somebody restyling a poster does — upload, look, upload
	// again — with room to spare. It charges the *address* like every other
	// limit here rather than the workspace: the resource being protected is this
	// instance's, and an attacker with one account has as many addresses as they
	// have hosts either way.
	UploadRatePerMin int `env:"UPLOAD_RATE_PER_MIN" envDefault:"30"`
}

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
	Domains   DomainsConfig
	Alias     AliasConfig
	Auth      AuthConfig
	Ingest    IngestConfig
	Analytics AnalyticsConfig
	Audit     AuditConfig
	SMTP      SMTPConfig
	Feed      FeedConfig
	Webhooks  WebhooksConfig
	Shutdown  ShutdownConfig

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

	// MFASecretKey encrypts the TOTP secret at rest (M53).
	//
	// **Its own variable, never the pepper**, which m53.md refuses by name. The
	// pepper is bound to retained API-key rows and rotating it silently
	// invalidates every issued key; sharing it would mean rotating an API-key
	// secret also locks every account out of its second factor, coupling two
	// credential lifecycles that have nothing to do with each other.
	//
	// **Optional, unlike the pepper, and the asymmetry is deliberate.** Unset is
	// an instance with no second factor available, which is exactly what every
	// deployment was before this milestone — making it required would refuse to
	// boot every existing instance on upgrade to buy a feature nobody had asked
	// for. Losing it after accounts have enrolled locks those accounts out of the
	// second factor and no further: recovery codes are SHA-256 and do not involve
	// this key, so an enrolled account signs in with one, disables the factor with
	// another, and enrols again. docs/configuration.md states that chain beside
	// the variable, in the same terms the pepper's consequence is stated in.
	MFASecretKey Secret `env:"MFA_SECRET_KEY,unset"`

	// UpdateCheck is whether this instance may ask, once a day, whether a newer
	// LinkCtrl has been published (M55).
	//
	// **The deployment's half of a two-part switch, and it only ever says no.**
	// The other half is `instance_settings.update_check_enabled`, which is the
	// answer an operator gave when they were asked (D149, D164). The check runs
	// when both allow it: this variable is what an air-gapped or egress-restricted
	// deployment sets to `false` in the place such a deployment configures
	// everything else, and setting it there cannot be undone from a browser by
	// somebody who does not know why the box has no egress.
	//
	// **Default true, and true is permission rather than instruction.** The owner
	// overruled a recommendation of off-by-default on the grounds that the
	// operator is asked and therefore chooses knowingly (D149) — so what this
	// default buys is that the question gets asked, not that the request gets
	// made. The other half starts unanswered and reads as off (D164), which is
	// where an instance upgrading into 0.3.0 sits until an administrator signs in.
	// What the request carries is enumerated in docs/configuration.md beside this
	// variable, and in internal/update's package comment, where a test holds the
	// enumeration to the wire.
	UpdateCheck bool `env:"UPDATE_CHECK" envDefault:"true"`

	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 DomainsConfig added in v0.2.0

type DomainsConfig struct {
	// VerifyInterval is how often the leader re-checks every registered
	// hostname. One hour: the point of the cadence is to make a single failure
	// weak evidence and a sustained one strong, and at this rate a domain must
	// fail twenty-four consecutive checks before serving stops. Zero disables
	// the job entirely, which leaves verification on-demand only.
	VerifyInterval time.Duration `env:"DOMAIN_VERIFY_INTERVAL" envDefault:"1h"`
	// VerifyGrace is how long a *serving* hostname keeps serving after its first
	// failed check. Twenty-four hours: long enough that somebody woken by the
	// notification has a working day to fix their DNS, short enough that the
	// window is stated in the runbook as "one day" rather than as a calculation.
	// It is never zero — an unset or zero value takes the default, because a
	// zero window would turn one resolver hiccup into an outage.
	VerifyGrace time.Duration `env:"DOMAIN_VERIFY_GRACE" envDefault:"24h"`
	// VerifyDNSTimeout bounds one TXT lookup. A nameserver that accepts a query
	// and never answers must cost this and not the whole pass.
	VerifyDNSTimeout time.Duration `env:"DOMAIN_VERIFY_DNS_TIMEOUT" envDefault:"5s"`
	// VerifyBatch caps how many hostnames one pass checks, oldest check first.
	// A bound rather than a limit anybody is expected to reach: it is what keeps
	// an instance with ten thousand registrations from turning one job run into
	// ten thousand DNS queries.
	VerifyBatch int `env:"DOMAIN_VERIFY_BATCH" envDefault:"500"`
}

DomainsConfig is custom-domain verification (M40).

**Every value here is operator-visible on purpose, and decision D70 is why.** The grace window decides how long an instance keeps serving a hostname whose DNS its owner may no longer control, and a number with that consequence belongs in configuration and in the deployment runbook rather than in a constant somebody has to read the source to find.

type Environment

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

func (Environment) IsProduction

func (e Environment) IsProduction() bool

type FeedConfig added in v0.2.0

type FeedConfig struct {
	// URL is the endpoint, and the switch. Empty means no feed, no client, and
	// no code path that sends a destination anywhere.
	URL string `env:"FEED_URL"`
	// Name is the third party in words — "Google Safe Browsing", "urlscan.io" —
	// as the disclosure page and the docs print it.
	Name string `env:"FEED_NAME"`

	// Method is GET or POST. POST by default, which is what most reputation
	// APIs take and which keeps the destination out of the feed's access log
	// query string.
	Method string `env:"FEED_METHOD" envDefault:"POST"`
	// Param names the field carrying the destination: a query parameter on GET,
	// a JSON key on POST.
	Param string `env:"FEED_PARAM" envDefault:"url"`
	// VerdictField is the dotted path into the JSON response holding the
	// answer, e.g. "data.malicious".
	VerdictField string `env:"FEED_VERDICT_FIELD" envDefault:"blocked"`

	// AuthHeader and AuthToken authenticate to the feed. The header is only
	// sent when the token is set.
	AuthHeader string `env:"FEED_AUTH_HEADER" envDefault:"Authorization"`
	AuthToken  Secret `env:"FEED_AUTH_TOKEN,unset"`

	// Timeout bounds one check. Spent inside a link creation somebody is
	// waiting on, so it is small: two seconds is long enough for a healthy API
	// on another continent and short enough that a sick one is not felt as the
	// dashboard being broken.
	Timeout time.Duration `env:"FEED_TIMEOUT" envDefault:"2s"`
}

FeedConfig is the optional third-party reputation feed (M32). Off unless URL is set, and off is the default.

This is the only setting in this file whose default is chosen by a promise rather than by an engineering trade. Every other blocking decision this product makes is local — a compiled host list, a Postgres table, heuristics that read a URL's own text. Answering *is this destination malicious* means sending the destination to somebody else's server, which is a deliberate exception to Plan.md's "no destination leaves the box uninvited" and is why switching it on costs an operator a named feed rather than a boolean.

FeedName is required alongside FeedURL for the same reason: the disclosure this feature ships names the third party, and a disclosure that cannot is not one. See docs/build-notes/decisions.md, D40.

func (FeedConfig) Enabled added in v0.2.0

func (f FeedConfig) Enabled() bool

Enabled reports whether a feed is configured. The one question every consumer asks, so it is a method rather than a comparison repeated in four places.

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"`
	// PasswordLimit caps guesses at a password link, per minute, per address
	// *and* per alias (M35, D54). Twenty rather than the login limit's number:
	// a person who has been handed a link and its password types it once, and a
	// legitimate visitor never approaches this. Zero disables it, which on a
	// public instance means a link password is only as strong as the wordlist
	// somebody is willing to run.
	PasswordLimit int `env:"LINK_PASSWORD_RATE_LIMIT" envDefault:"20"`
}

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"`

	// InvalidateBudget is the total an edit will wait for the cache to be
	// invalidated, across every retry rather than per attempt. The retry loop
	// used to spend ReadTimeout three times over, so raising ReadTimeout
	// tripled the worst case an operator saw on a form submission; this is the
	// one number that bounds it. D26.
	InvalidateBudget time.Duration `env:"REDIS_INVALIDATE_BUDGET" envDefault:"250ms"`

	// SubscriberReadTimeout is how long the cache-invalidation subscriber will
	// sit in one read before it makes Redis prove the subscription is still
	// delivering. It is not ReadTimeout and cannot be: on the hot path a
	// timeout means the cache failed, while here it usually means nobody has
	// edited a link, which is the ordinary state of a healthy instance. F30,
	// D42.
	SubscriberReadTimeout time.Duration `env:"REDIS_SUBSCRIBER_READ_TIMEOUT" envDefault:"30s"`

	PoolSize     int  `env:"REDIS_POOL_SIZE" envDefault:"50"`
	CacheEnabled bool `env:"CACHE_ENABLED" envDefault:"true"`
}

type SMTPConfig added in v0.2.0

type SMTPConfig struct {
	// Host is the switch. Empty means no mailer, which is the default and the
	// state every consumer must degrade to.
	Host string `env:"SMTP_HOST"`
	Port int    `env:"SMTP_PORT" envDefault:"587"`

	// Username and Password authenticate with PLAIN. Both or neither.
	Username string `env:"SMTP_USERNAME"`
	Password Secret `env:"SMTP_PASSWORD,unset"`

	// From is the envelope sender and the From header. Required once Host is
	// set: a message with no sender is refused by most receivers, and finding
	// that out from a bounce is worse than finding it out at boot.
	From string `env:"SMTP_FROM"`

	TLS string `env:"SMTP_TLS" envDefault:"starttls"`

	// Timeout bounds one delivery attempt end to end — dial, handshake, DATA.
	// A hung relay must not hold the scheduler.
	Timeout time.Duration `env:"SMTP_TIMEOUT" envDefault:"10s"`
}

SMTPConfig is the optional mailer. Off unless Host is set.

The surface is deliberately small. TLS modes and auth mechanisms are where a mail configuration turns into a compatibility matrix, so this ships the set it can honestly claim — STARTTLS, implicit TLS, or nothing, with PLAIN auth over an encrypted connection — and documents the rest as unsupported rather than implying it works and failing at the first send.

func (SMTPConfig) Addr added in v0.2.0

func (s SMTPConfig) Addr() string

Addr is the host:port to dial.

func (SMTPConfig) Enabled added in v0.2.0

func (s SMTPConfig) Enabled() bool

Enabled reports whether a mailer is configured. The one question every consumer asks, so it is a method rather than a comparison repeated five times.

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"
)

type WebhooksConfig added in v0.2.0

type WebhooksConfig struct {
	// Timeout bounds one delivery attempt end to end: connect, write, read. Ten
	// seconds is long enough for a receiver that does real work before answering
	// and short enough that a batch of twenty slow ones fits well inside the
	// job's own bound.
	Timeout time.Duration `env:"WEBHOOK_TIMEOUT" envDefault:"10s"`
	// RetentionDays is how long a delivered or abandoned delivery row is kept.
	// Thirty days, matching the mail outbox, because the two are the same kind
	// of record: what was attempted and what happened, not an archive.
	//
	// Never zero. Zero means "keep forever" elsewhere in this file (audit
	// retention, D5), and a table that grows by one row per link write per
	// webhook with no window is the growth problem that convention exists to
	// make visible rather than to permit here. Validate refuses it.
	RetentionDays int `env:"WEBHOOK_RETENTION_DAYS" envDefault:"30"`
}

WebhooksConfig is outbound webhook delivery (M42).

Two numbers, both operator-visible for the reason D70 made the domain verification numbers visible: each has a consequence somebody deploying this has to be able to see and change. The timeout decides how long one unresponsive receiver holds a delivery slot, and the retention window decides how long the delivery log — one row per link write per enabled webhook — is kept before it is pruned.

The **attempt count is not here**, and that is deliberate rather than an omission. It is `webhook.MaxAttempts`, six, and it is documented in docs/usage.md: unlike the two below, changing it changes what a *receiver* experiences — how long a delivery can arrive late — which is a contract with somebody who does not read this instance's environment. An operator who wants a different one is asking for a different contract, and should say so in a release rather than in a variable.

Jump to

Keyboard shortcuts

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