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>
312 lines
11 KiB
Go
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
|
|
}
|