// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package config reads config.toml into a typed value, applies the // command-line overrides, and validates it. The commented template it // ships, config.toml.example, is the file an operator copies. // // There is no configuration map: every key has a field, the decoder // rejects a key whose TOML type does not match its field, and a key the // decoder does not know is ignored, so a file written for a newer release // still loads. package config import ( "errors" "fmt" "log/slog" "net/netip" "net/url" "os" "path/filepath" "strings" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/volumen/internal/fediverse" "sourcedock.dev/petrbalvin/volumen/internal/password" ) // DefaultPath is the system-wide configuration file location. const DefaultPath = "/etc/volumen/config.toml" // UserConfigPath returns the per-user configuration file location, // resolved from XDG_CONFIG_HOME or ~/.config. It is "" when neither can // be named. func UserConfigPath() string { if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { return filepath.Join(d, "volumen", "config.toml") } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".config", "volumen", "config.toml") } // ResolveConfigPath picks the configuration file `serve` reads when // --config was not given: the system path if it exists, otherwise the // per-user path if it exists, otherwise the system path, which does not // exist and so loads the built-in defaults. A plain `volumen serve` with // no file anywhere therefore runs on per-user state paths. func ResolveConfigPath() string { if _, err := os.Stat(DefaultPath); err == nil { return DefaultPath } if p := UserConfigPath(); p != "" { if _, err := os.Stat(p); err == nil { return p } } return DefaultPath } // UserStatePaths returns the per-user content and users file paths used // as the defaults when no configuration file exists, so the server can // run and write its state under the user's home without root. They are // resolved from XDG_DATA_HOME or ~/.local/share; ok is false when the // platform cannot name one. func UserStatePaths() (contentDir, usersFile string, ok bool) { base := os.Getenv("XDG_DATA_HOME") if base == "" { home, err := os.UserHomeDir() if err != nil { return "", "", false } base = filepath.Join(home, ".local", "share") } dir := filepath.Join(base, "volumen") return filepath.Join(dir, "posts"), filepath.Join(dir, "users.toml"), true } // The built-in defaults, applied to every key the file leaves out. const ( DefaultHost = "::" DefaultPort = 9091 DefaultContentDir = "/var/lib/volumen/posts" DefaultUsersFile = "/var/lib/volumen/users.toml" DefaultSiteTitle = "Volumen" DefaultSiteDescription = "Powered by Volumen." DefaultBaseURL = "https://example.com" DefaultLanguage = "en" DefaultAuthor = "Anonymous" DefaultSessionTTL = 86400 DefaultMinPasswordLength = 10 DefaultMaxPasswordLength = 1024 DefaultMaxUploadBytes = 10 * 1024 * 1024 DefaultAPIRateLimit = 60 DefaultAPIRateLimitWindow = 60 DefaultRevisionLimit = 10 DefaultSchedulerInterval = 300 // MaxSessionTTL bounds [admin].session_ttl so that it cannot overflow // a time.Duration when converted to seconds, and so that a session // cannot outlive a year. MaxSessionTTL = 365 * 24 * 60 * 60 // MaxRateLimitWindow bounds [api].rate_limit_window for the same // reason. MaxRateLimitWindow = 24 * 60 * 60 // MaxUploadBytesCeiling bounds [admin].max_upload_bytes, so that a // mistyped value cannot be read into memory in one piece. MaxUploadBytesCeiling = 1 << 30 // PortUnset is the Overrides.Port sentinel meaning "do not override". PortUnset = -1 EnvProduction = "production" EnvDevelopment = "development" LogFormatText = "text" LogFormatJSON = "json" ) // ConfigError reports a configuration value the program refuses to run // with. Validate returns it, and the caller prints it and exits. type ConfigError struct { msg string } func (e *ConfigError) Error() string { return e.msg } func errorf(format string, args ...any) *ConfigError { return &ConfigError{msg: fmt.Sprintf(format, args...)} } // Server is the [server] table. type Server struct { Host string `toml:"host"` Port int `toml:"port"` Env string `toml:"env"` TrustProxy bool `toml:"trust_proxy"` // TrustedProxies lists the addresses whose X-Forwarded-For may be // believed, as addresses or CIDR prefixes. An empty list means the // header is never read and the connection address is always used; // list the proxy so its clients each rate-limit under their own // address. TrustedProxies []string `toml:"trusted_proxies"` CookieSecure bool `toml:"cookie_secure"` LogFormat string `toml:"log_format"` } // Site is the [site] table. type Site struct { Title string `toml:"title"` Description string `toml:"description"` BaseURL string `toml:"base_url"` Language string `toml:"language"` Author string `toml:"author"` FediverseCreator string `toml:"fediverse_creator"` } // Admin is the [admin] table. type Admin struct { SessionKey string `toml:"session_key"` SessionTTL int `toml:"session_ttl"` MinPasswordLength int `toml:"min_password_length"` MaxPasswordLength int `toml:"max_password_length"` MaxUploadBytes int `toml:"max_upload_bytes"` } // API is the [api] table. type API struct { RateLimit int `toml:"rate_limit"` RateLimitWindow int `toml:"rate_limit_window"` } // Scheduler is the [scheduler] table. type Scheduler struct { Enabled bool `toml:"enabled"` Interval int `toml:"interval"` } // Webhook is one [[webhooks]] entry. type Webhook struct { URL string `toml:"url"` Secret string `toml:"secret"` Events []string `toml:"events"` // Enabled is a pointer so that an omitted key means enabled: a hook // written without the key is one the operator wants delivered, and // only an explicit false turns it off. Enabled *bool `toml:"enabled"` } // Delivers reports whether the hook is on. func (w Webhook) Delivers() bool { return w.Enabled == nil || *w.Enabled } // Config is the merged configuration: the built-in defaults with the file // decoded over them and the command-line overrides applied. type Config struct { Server Server `toml:"server"` Site Site `toml:"site"` Admin Admin `toml:"admin"` API API `toml:"api"` Scheduler Scheduler `toml:"scheduler"` ContentDir string `toml:"content_dir"` UsersFile string `toml:"users_file"` RevisionLimit int `toml:"revision_limit"` AuditLog string `toml:"audit_log"` Webhooks []Webhook `toml:"webhooks"` } // Overrides carries the command-line overrides of the `serve` subcommand. // An empty string means unset; Port uses PortUnset rather than zero, // because port 0 is a value a caller could mean to set. type Overrides struct { Host string Port int ContentDir string UsersFile string } // Defaults returns the built-in configuration. func Defaults() *Config { return &Config{ Server: Server{ Host: DefaultHost, Port: DefaultPort, Env: EnvDevelopment, LogFormat: LogFormatText, }, Site: Site{ Title: DefaultSiteTitle, Description: DefaultSiteDescription, BaseURL: DefaultBaseURL, Language: DefaultLanguage, Author: DefaultAuthor, }, Admin: Admin{ SessionTTL: DefaultSessionTTL, MinPasswordLength: DefaultMinPasswordLength, MaxPasswordLength: DefaultMaxPasswordLength, MaxUploadBytes: DefaultMaxUploadBytes, }, API: API{ RateLimit: DefaultAPIRateLimit, RateLimitWindow: DefaultAPIRateLimitWindow, }, Scheduler: Scheduler{ Interval: DefaultSchedulerInterval, }, ContentDir: DefaultContentDir, UsersFile: DefaultUsersFile, RevisionLimit: DefaultRevisionLimit, } } // Load reads path, decodes it over the built-in defaults, applies the // overrides, and returns the configuration. A missing file is not an // error: the defaults are used and one line says so. With no file, the // data paths move under the user's home so a server started without any // configuration can still write its state and run the first-run wizard; // an explicit --content or --users-file override wins over that. func Load(path string, ov Overrides) (*Config, error) { cfg := Defaults() raw, err := os.ReadFile(path) switch { case err == nil: if err := decode(raw, cfg); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } case errors.Is(err, os.ErrNotExist): if content, users, ok := UserStatePaths(); ok { cfg.ContentDir = content cfg.UsersFile = users slog.Info("volumen: configuration file not found, using built-in defaults", "path", path, "example", "config.toml.example", "content_dir", content) } else { slog.Info("volumen: configuration file not found, using built-in defaults", "path", path, "example", "config.toml.example") } default: return nil, fmt.Errorf("read config %s: %w", path, err) } cfg.apply(ov) return cfg, nil } // decode fills cfg from a TOML document, and reports a root key that a // table header swallowed: TOML puts a key written below [site] inside // that table, where nothing reads it. func decode(raw []byte, cfg *Config) error { tree, err := interpres.ParseMap(raw) if err != nil { return err } for name, value := range tree { // Only tables can swallow a root key; [[webhooks]] is an array // of tables and parses as a slice, so the type check skips it. sub, ok := value.(map[string]any) if !ok { continue } for _, key := range foldedKeys { if _, present := sub[key]; present { return fmt.Errorf( "%s is written below the [%s] header, so it belongs to that table; move it above the first [table] header", key, name) } } } return interpres.Unmarshal(raw, cfg) } // foldedKeys are the keys that sit at the root of the document and are // silently captured by a preceding table header if they are written below // one. var foldedKeys = []string{"content_dir", "users_file", "revision_limit", "audit_log"} func (c *Config) apply(ov Overrides) { if ov.Host != "" { c.Server.Host = ov.Host } if ov.Port != PortUnset { c.Server.Port = ov.Port } if ov.ContentDir != "" { c.ContentDir = ov.ContentDir } if ov.UsersFile != "" { c.UsersFile = ov.UsersFile } } // TemplatesFile returns the templates.toml path, next to users_file. func (c *Config) TemplatesFile() string { return filepath.Join(filepath.Dir(c.UsersFile), "templates.toml") } // TokensFile returns the tokens.toml path, next to users_file. func (c *Config) TokensFile() string { return filepath.Join(filepath.Dir(c.UsersFile), "tokens.toml") } // IsProduction reports whether the environment label is production. func (c *Config) IsProduction() bool { return c.Server.Env == EnvProduction } // ListenAddr returns the address the server binds, as host:port. func (c *Config) ListenAddr() (netip.AddrPort, error) { addr, err := netip.ParseAddr(c.Server.Host) if err != nil { return netip.AddrPort{}, errorf("[server].host must be an IP address (got %q)", c.Server.Host) } return netip.AddrPortFrom(addr, uint16(c.Server.Port)), nil } // TrustedProxyPrefixes parses [server].trusted_proxies. An entry may be a // single address, which is read as a /32 or /128 prefix. func (c *Config) TrustedProxyPrefixes() ([]netip.Prefix, error) { out := make([]netip.Prefix, 0, len(c.Server.TrustedProxies)) for _, entry := range c.Server.TrustedProxies { if prefix, err := netip.ParsePrefix(entry); err == nil { out = append(out, prefix.Masked()) continue } addr, err := netip.ParseAddr(entry) if err != nil { return nil, errorf("[server].trusted_proxies entry %q is not an address or a CIDR prefix", entry) } out = append(out, netip.PrefixFrom(addr, addr.BitLen())) } return out, nil } // Validate checks the configuration and returns a *ConfigError naming the // first problem. A key the decoder could not read is already an error by // then, so this covers the values a wrong type cannot catch. func (c *Config) Validate() error { if c.Server.Host == "" { return errorf("[server].host must be a non-empty string") } if _, err := netip.ParseAddr(c.Server.Host); err != nil { return errorf("[server].host must be an IP address (got %q)", c.Server.Host) } if c.Server.Port < 1 || c.Server.Port > 65535 { return errorf("[server].port must be an integer in 1..65535 (got %d)", c.Server.Port) } if c.Server.Env != EnvDevelopment && c.Server.Env != EnvProduction { return errorf("[server].env must be one of [development production] (got %q)", c.Server.Env) } if c.Server.LogFormat != LogFormatText && c.Server.LogFormat != LogFormatJSON { return errorf("[server].log_format must be one of [text json] (got %q)", c.Server.LogFormat) } if _, err := c.TrustedProxyPrefixes(); err != nil { return err } if c.Server.TrustProxy && !c.IsProduction() { slog.Warn("config: [server].trust_proxy is on outside production; " + "only a proxy you control must be able to reach the listener") } if c.RevisionLimit < 0 { return errorf("revision_limit must be >= 0 (got %d)", c.RevisionLimit) } if c.Admin.SessionTTL <= 0 { return errorf("[admin].session_ttl must be > 0 (got %d)", c.Admin.SessionTTL) } if c.Admin.SessionTTL > MaxSessionTTL { return errorf("[admin].session_ttl must be <= %d seconds (got %d)", MaxSessionTTL, c.Admin.SessionTTL) } if c.Admin.MinPasswordLength < 1 || c.Admin.MinPasswordLength > c.Admin.MaxPasswordLength { return errorf("[admin].min_password_length must be >= 1 and <= max_password_length") } // The hashing layer refuses anything longer whatever this value // says; catching it here turns a password-change 500 into a startup // error the operator can read. if c.Admin.MaxPasswordLength > password.MaxPasswordLength { return errorf("[admin].max_password_length must be <= %d (got %d)", password.MaxPasswordLength, c.Admin.MaxPasswordLength) } if c.Admin.MaxUploadBytes <= 0 { return errorf("[admin].max_upload_bytes must be > 0") } if c.Admin.MaxUploadBytes > MaxUploadBytesCeiling { return errorf("[admin].max_upload_bytes must be <= %d (got %d)", MaxUploadBytesCeiling, c.Admin.MaxUploadBytes) } if c.API.RateLimit < 0 { return errorf("[api].rate_limit must be >= 0 (got %d)", c.API.RateLimit) } if c.API.RateLimit > 0 { if c.API.RateLimitWindow <= 0 { return errorf("[api].rate_limit_window must be > 0 when rate limiting is enabled") } if c.API.RateLimitWindow > MaxRateLimitWindow { return errorf("[api].rate_limit_window must be <= %d seconds (got %d)", MaxRateLimitWindow, c.API.RateLimitWindow) } } if c.Scheduler.Enabled && c.Scheduler.Interval < 1 { return errorf("[scheduler].interval must be >= 1 second (got %d)", c.Scheduler.Interval) } for _, hook := range c.Webhooks { parsed, err := url.Parse(hook.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return errorf("[[webhooks]].url must be an http(s) URL (got %q)", hook.URL) } } if c.Site.BaseURL == "" { return errorf("[site].base_url must be a non-empty string") } parsed, err := url.Parse(c.Site.BaseURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return errorf("[site].base_url must be an absolute URL (got %q)", c.Site.BaseURL) } if c.Site.FediverseCreator != "" && !fediverse.Valid(c.Site.FediverseCreator) { return errorf("[site].fediverse_creator must look like @user@host when set") } if err := probeWritable(c.ContentDir, "[content_dir]"); err != nil { return err } if err := probeWritable(c.UsersFile, "[users_file]"); err != nil { return err } return nil } // probeWritable reports whether the directory holding target can be // written to. It never creates anything: a read-only command validates a // configuration, and turning a mistyped path into a directory would make // the mistake harder to see. The directory itself is created when the // first post or account is written. func probeWritable(target, label string) error { parent := filepath.Dir(target) probeDir := nearestExisting(parent) probe, err := os.CreateTemp(probeDir, ".volumen-write-probe-*") if err != nil { return errorf("%s is not writable at %q: %v", label, target, err) } name := probe.Name() probe.Close() os.Remove(name) return nil } // nearestExisting returns the closest existing ancestor of path, which is // where writability is probed. func nearestExisting(path string) string { for dir := path; ; { if info, err := os.Stat(dir); err == nil && info.IsDir() { return dir } parent := filepath.Dir(dir) if parent == dir { return dir } dir = parent } } // TemplatesDir returns the directory that holds templates.toml and // tokens.toml, beside users_file. func (c *Config) TemplatesDir() string { return filepath.Dir(c.UsersFile) } // SecretKeyFile returns the path of the session secret the server // generates for itself, kept beside the state files. [admin].session_key // overrides it. func (c *Config) SecretKeyFile() string { return filepath.Join(filepath.Dir(c.UsersFile), "secret.key") } // TrimmedBaseURL returns [site].base_url without a trailing slash. func (c *Config) TrimmedBaseURL() string { return strings.TrimRight(c.Site.BaseURL, "/") }