Files
volumen/internal/config/config.go
T

524 lines
17 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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, "/")
}