// Package config loads and validates the Warp server configuration: a single // YAML file, overridable per-field by WARP_* environment variables. package config import ( "crypto/subtle" "errors" "fmt" "net/url" "os" "path/filepath" "strings" "time" ) // Config is the whole server configuration. type Config struct { // Version is the config schema version; bumped only on breaking changes. Version int `yaml:"version"` Global Global `yaml:"global"` Listen Listen `yaml:"listen"` Database Database `yaml:"database"` ClientAPI ClientAPI `yaml:"client_api"` Federation Federation `yaml:"federation"` Registration Registration `yaml:"registration"` Logging Logging `yaml:"logging"` Metrics Metrics `yaml:"metrics"` // path is the file this config was read from, if any. path string } // Global holds identity settings shared by every component. type Global struct { // ServerName is the Matrix server name, e.g. "warp.example". It becomes // part of every user, room and event ID and cannot be changed later. ServerName string `yaml:"server_name" env:"WARP_SERVER_NAME"` // PrivateKeyPath points at the ed25519 signing key written by // `warp generate-keys`. PrivateKeyPath string `yaml:"private_key" env:"WARP_PRIVATE_KEY"` WellKnown WellKnown `yaml:"well_known"` } // WellKnown configures the delegation documents served under /.well-known. type WellKnown struct { // ClientBaseURL is advertised to clients as m.homeserver.base_url. It // defaults to https://. ClientBaseURL string `yaml:"client_base_url" env:"WARP_WELL_KNOWN_CLIENT_BASE_URL"` // IdentityServerBaseURL is optional and advertised as m.identity_server. IdentityServerBaseURL string `yaml:"identity_server_base_url" env:"WARP_WELL_KNOWN_IDENTITY_SERVER_BASE_URL"` // ServerAddress is advertised to other homeservers as "m.server", e.g. // "warp.example:8448". Only served while federation is enabled; empty // means /.well-known/matrix/server returns 404 and other servers fall // back to SRV/A record resolution. ServerAddress string `yaml:"server_address" env:"WARP_WELL_KNOWN_SERVER_ADDRESS"` } // Listen configures the single HTTP listener. type Listen struct { Address string `yaml:"address" env:"WARP_LISTEN_ADDRESS"` ReadHeaderTimeout time.Duration `yaml:"read_header_timeout" env:"WARP_READ_HEADER_TIMEOUT"` ReadTimeout time.Duration `yaml:"read_timeout" env:"WARP_READ_TIMEOUT"` WriteTimeout time.Duration `yaml:"write_timeout" env:"WARP_WRITE_TIMEOUT"` IdleTimeout time.Duration `yaml:"idle_timeout" env:"WARP_IDLE_TIMEOUT"` MaxHeaderBytes int `yaml:"max_header_bytes" env:"WARP_MAX_HEADER_BYTES"` // ShutdownTimeout bounds graceful shutdown before connections are cut. ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"WARP_SHUTDOWN_TIMEOUT"` } // Database selects and configures the storage driver. type Database struct { // Driver is one of "sqlite", "postgres", "pocketbase". Driver string `yaml:"driver" env:"WARP_DB_DRIVER"` // ConnectionString is driver specific: a DSN for postgres, a file URI // for sqlite, a directory for pocketbase. ConnectionString string `yaml:"connection_string" env:"WARP_DB_CONNECTION_STRING"` MaxOpenConns int `yaml:"max_open_conns" env:"WARP_DB_MAX_OPEN_CONNS"` MaxIdleConns int `yaml:"max_idle_conns" env:"WARP_DB_MAX_IDLE_CONNS"` ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime" env:"WARP_DB_CONN_MAX_LIFETIME"` // AutoMigrate runs pending migrations on startup. Turn it off to gate // schema changes behind an explicit `warp migrate`. AutoMigrate bool `yaml:"auto_migrate" env:"WARP_DB_AUTO_MIGRATE"` } // ClientAPI configures the Client-Server API surface. type ClientAPI struct { // CORSAllowedOrigins lists origins allowed to call the C-S API from a // browser. The Matrix spec expects "*"; narrow it if the only client is // your own web app on a known origin. CORSAllowedOrigins []string `yaml:"cors_allowed_origins" env:"WARP_CORS_ALLOWED_ORIGINS"` // TrustXForwardedFor takes the client address from the X-Forwarded-For // header instead of the socket. Turn it on only behind a reverse proxy // that overwrites the header: anyone can send it, and the rate limiter // keys its buckets on the result. TrustXForwardedFor bool `yaml:"trust_x_forwarded_for" env:"WARP_TRUST_X_FORWARDED_FOR"` // AccessTokenLifetime expires access tokens after this long. Zero — the // default — issues tokens that never expire, which is what clients // without refresh-token support need. AccessTokenLifetime time.Duration `yaml:"access_token_lifetime" env:"WARP_ACCESS_TOKEN_LIFETIME"` // RefreshTokenLifetime bounds how long a refresh token may be exchanged // for a new access token. Zero means no expiry. RefreshTokenLifetime time.Duration `yaml:"refresh_token_lifetime" env:"WARP_REFRESH_TOKEN_LIFETIME"` RateLimit RateLimit `yaml:"rate_limit"` } // RateLimit configures the per-endpoint token buckets in front of the C-S // API. Login and registration get their own, tighter, buckets because they // are the two endpoints worth brute-forcing. // // Only `enabled` has an environment override: the three buckets share one Go // type, so per-bucket `env` tags would all collide on the same variable. // Turning the whole limiter off (WARP_RATE_LIMIT_ENABLED=false) is what test // harnesses such as Complement need anyway. type RateLimit struct { Enabled bool `yaml:"enabled" env:"WARP_RATE_LIMIT_ENABLED"` // Default applies to every rate-limited endpoint without its own bucket. Default RateLimitBucket `yaml:"default"` // Login covers /login and /refresh. Login RateLimitBucket `yaml:"login"` // Register covers /register and /register/available. Register RateLimitBucket `yaml:"register"` } // RateLimitBucket is one token bucket: Burst requests may arrive back to // back, and one slot returns every Interval. type RateLimitBucket struct { Burst int `yaml:"burst"` Interval time.Duration `yaml:"interval"` } // Federation controls the server-to-server surface. See PLAN.md section 3: it // is a first-class mode, not an afterthought. type Federation struct { Enabled bool `yaml:"enabled" env:"WARP_FEDERATION_ENABLED"` // Allowlist, when non-empty, restricts federation to these server names. Allowlist []string `yaml:"allowlist" env:"WARP_FEDERATION_ALLOWLIST"` // Denylist blocks these server names even in allowlist mode. Denylist []string `yaml:"denylist" env:"WARP_FEDERATION_DENYLIST"` // AllowRuntimeToggle lets the admin API flip Enabled without a restart. // When true the persisted setting wins over the value above on boot. AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE"` } // Registration controls open registration. type Registration struct { Enabled bool `yaml:"enabled" env:"WARP_REGISTRATION_ENABLED"` AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE"` // Tokens, when non-empty, adds m.login.registration_token as a required // stage to the registration flow and lists the tokens that satisfy it. // // Phase 1 keeps the tokens in the config file: per-token use counts and // expiry belong to the admin token management of Phase 9, and a table // nothing can write to yet would be dead weight. Tokens []string `yaml:"tokens" env:"WARP_REGISTRATION_TOKENS"` } // RequiresToken reports whether the registration flow includes the // m.login.registration_token stage. func (r Registration) RequiresToken() bool { return len(r.Tokens) > 0 } // TokenValid reports whether token is one of the configured registration // tokens. Every candidate is compared in constant time, and the loop does // not stop early, so neither the value nor the position of a token leaks // through the time this takes. func (r Registration) TokenValid(token string) bool { found := 0 for _, want := range r.Tokens { found |= subtle.ConstantTimeCompare([]byte(token), []byte(want)) } return found == 1 } // Logging configures slog. type Logging struct { Level string `yaml:"level" env:"WARP_LOG_LEVEL"` Format string `yaml:"format" env:"WARP_LOG_FORMAT"` AddSource bool `yaml:"add_source" env:"WARP_LOG_ADD_SOURCE"` } // Metrics configures the Prometheus endpoint. type Metrics struct { Enabled bool `yaml:"enabled" env:"WARP_METRICS_ENABLED"` Path string `yaml:"path" env:"WARP_METRICS_PATH"` } // Path returns the file this config was loaded from, or "" for defaults. func (c *Config) Path() string { return c.path } // Default returns a config with every optional field populated. Only // global.server_name is left empty: there is no sane default for it. func Default() *Config { return &Config{ Version: 1, Global: Global{ PrivateKeyPath: "./keys/signing.key", }, Listen: Listen{ Address: ":8008", ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20, ShutdownTimeout: 30 * time.Second, }, Database: Database{ Driver: "sqlite", ConnectionString: "file:warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)", MaxOpenConns: 10, MaxIdleConns: 5, ConnMaxLifetime: time.Hour, AutoMigrate: true, }, ClientAPI: ClientAPI{ CORSAllowedOrigins: []string{"*"}, RateLimit: RateLimit{ Enabled: true, // Generous enough that a chatty client never notices, // tight enough that a password-guessing script does. Default: RateLimitBucket{Burst: 30, Interval: 100 * time.Millisecond}, Login: RateLimitBucket{Burst: 10, Interval: time.Second}, Register: RateLimitBucket{Burst: 5, Interval: 2 * time.Second}, }, }, Federation: Federation{ Enabled: false, AllowRuntimeToggle: true, }, Registration: Registration{ Enabled: false, AllowRuntimeToggle: true, }, Logging: Logging{Level: "info", Format: "text"}, Metrics: Metrics{Enabled: true, Path: "/metrics"}, } } // ClientBaseURL returns the URL advertised to clients, falling back to // https:// when not configured. func (c *Config) ClientBaseURL() string { if c.Global.WellKnown.ClientBaseURL != "" { return strings.TrimRight(c.Global.WellKnown.ClientBaseURL, "/") } return "https://" + c.Global.ServerName } // Validate reports every problem it finds, not just the first. func (c *Config) Validate() error { var errs []error bad := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) } if c.Version != 1 { bad("version: unsupported config version %d (expected 1)", c.Version) } if err := validateServerName(c.Global.ServerName); err != nil { bad("global.server_name: %w", err) } if c.Global.PrivateKeyPath == "" { bad("global.private_key: must not be empty") } for _, u := range []struct{ name, raw string }{ {"global.well_known.client_base_url", c.Global.WellKnown.ClientBaseURL}, {"global.well_known.identity_server_base_url", c.Global.WellKnown.IdentityServerBaseURL}, } { if u.raw == "" { continue } parsed, err := url.Parse(u.raw) if err != nil { bad("%s: %w", u.name, err) } else if parsed.Scheme != "http" && parsed.Scheme != "https" { bad("%s: must be an http(s) URL, got %q", u.name, u.raw) } } if c.Listen.Address == "" { bad("listen.address: must not be empty") } if c.Listen.ShutdownTimeout <= 0 { bad("listen.shutdown_timeout: must be positive") } switch c.Database.Driver { case "sqlite", "postgres", "pocketbase": case "": bad("database.driver: must not be empty") default: bad("database.driver: unknown driver %q (want sqlite, postgres or pocketbase)", c.Database.Driver) } if c.Database.ConnectionString == "" { bad("database.connection_string: must not be empty") } if c.Database.MaxOpenConns < 0 { bad("database.max_open_conns: must not be negative") } switch strings.ToLower(c.Logging.Level) { case "debug", "info", "warn", "warning", "error": default: bad("logging.level: unknown level %q", c.Logging.Level) } switch strings.ToLower(c.Logging.Format) { case "text", "json": default: bad("logging.format: unknown format %q (want text or json)", c.Logging.Format) } if c.Metrics.Enabled && !strings.HasPrefix(c.Metrics.Path, "/") { bad("metrics.path: must start with /") } if c.ClientAPI.AccessTokenLifetime < 0 { bad("client_api.access_token_lifetime: must not be negative") } if c.ClientAPI.RefreshTokenLifetime < 0 { bad("client_api.refresh_token_lifetime: must not be negative") } if c.ClientAPI.RateLimit.Enabled { for _, b := range []struct { name string bucket RateLimitBucket }{ {"default", c.ClientAPI.RateLimit.Default}, {"login", c.ClientAPI.RateLimit.Login}, {"register", c.ClientAPI.RateLimit.Register}, } { if b.bucket.Burst < 1 { bad("client_api.rate_limit.%s.burst: must be at least 1", b.name) } if b.bucket.Interval <= 0 { bad("client_api.rate_limit.%s.interval: must be positive", b.name) } } } for i, token := range c.Registration.Tokens { if strings.TrimSpace(token) == "" { bad("registration.tokens[%d]: must not be empty", i) } } for i, s := range c.Federation.Allowlist { if err := validateServerName(s); err != nil { bad("federation.allowlist[%d]: %w", i, err) } } for i, s := range c.Federation.Denylist { if err := validateServerName(s); err != nil { bad("federation.denylist[%d]: %w", i, err) } } return errors.Join(errs...) } // validateServerName applies a cheap sanity check. The full grammar (IPv6 // literals, ports, IDNA) lands with the event core in Phase 2. func validateServerName(s string) error { switch { case s == "": return errors.New("must not be empty") case strings.ContainsAny(s, " \t\r\n"): return errors.New("must not contain whitespace") case strings.Contains(s, "/"): return errors.New("must be a host[:port], not a URL") case strings.HasPrefix(s, ":"), strings.HasSuffix(s, ":"): return fmt.Errorf("malformed host:port %q", s) } return nil } // Load reads path, applies environment overrides and validates the result. // An empty path yields the defaults plus environment overrides, which is how // container deployments configured purely through env vars start up. func Load(path string) (*Config, error) { cfg := Default() if path != "" { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config: %w", err) } if err := unmarshalStrict(data, cfg); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } abs, err := filepath.Abs(path) if err != nil { abs = path } cfg.path = abs } if err := ApplyEnv(cfg, os.Getenv); err != nil { return nil, err } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration:\n%w", err) } return cfg, nil }