Files
Warp/server/internal/config/config.go
T
tajniak81andClaude Opus 5 b4fb60a8ab
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 0: bootstrap the Warp homeserver
Scaffolds the server module and everything Phase 0 of PLAN.md calls for:
a binary that boots, serves its discovery endpoints and nothing else yet.

- config: one YAML file plus per-field WARP_* environment overrides,
  unknown fields rejected, validation that reports every problem at once
- logging and metrics: slog, a Prometheus registry, per-route HTTP
  instrumentation labelled by pattern rather than path
- httputil: the Matrix error envelope, error-returning handlers, CORS,
  request logging, panic recovery, and a router over http.ServeMux
- endpoints: /_matrix/client/versions, /.well-known/matrix/client,
  /.well-known/matrix/server (gated), /healthz, /metrics, and
  M_UNRECOGNIZED for everything else
- storage: driver registry, shared database/sql base, checksummed
  migration runner, sqlite and postgres drivers, pocketbase stub
- settings: server_settings table behind a cached service, so the
  federation and registration switches change without a restart
- federation: the gate that makes federation a runtime mode
- cmd/warp: serve, migrate, generate-keys, version
- Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests,
  race on Linux, storage tests against PostgreSQL, lint, boot smoke test),
  Dockerfile, example config and docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 17:01:13 +02:00

312 lines
11 KiB
Go

// Package config loads and validates the Warp server configuration: a single
// YAML file, overridable per-field by WARP_* environment variables.
package config
import (
"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://<server_name>.
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"`
}
// 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"`
}
// 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{"*"},
},
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://<server_name> 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 /")
}
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
}