Files
Warp/server/internal/config/config.go
T
tajniak81andClaude Opus 5 c96f88e994
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 / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 3: rooms, local only
Implements PLAN.md Phase 3: the roomserver, the room half of storage, the
Client-Server room endpoints and the admin room endpoints. A client can now
create a room, invite, join, leave, kick, ban, send messages and state,
redact, name a room with an alias and find one in the public directory.
Nothing talks to another server; every operation that would need to is
refused with a message that says so rather than a bare M_FORBIDDEN.

roomserver: one input pipeline every event goes through, whatever sent it —
validate, authorise, resolve, persist, emit. The rules are not
reimplemented here; internal/matrix is pure library code and this is what
connects it to a database and to a room's history. The Client-Server
handlers do no authorisation of their own, deliberately: whether Alice may
kick Bob is a question about the room's power levels, and the one place
that is answered correctly is the authorisation rules inside the pipeline.

A mutex per room serialises writers. Two events racing into one room would
each read the same forward extremities, both become one, and fork the room
for no reason — which would then need state resolution to put back
together. State resolution is wired in anyway for the multi-extremity
case. It is unreachable from outside in this phase, and the alternative is
a pipeline that authorises against one arbitrary branch of a fork without
saying so.

Room versions 3 to 12 all work and are each tested end to end. Version 12
earns that coverage: its room ID is the create event's own ID, its create
event carries no room_id, and the create event is excluded from every other
event's auth_events — so the state auth.Check is given is the event's
auth_events plus the create event, which that version identifies by room ID
rather than citing.

createRoom sends the spec's events in the spec's order, each authorised
against what the ones before it produced. A failure part-way leaves an
incomplete room, which is the lesser evil: the alternative is holding every
event of a new room in memory and writing them in one transaction, making
the pipeline that authorises them a special case. The one failure a second
client can cause at any time, an alias clash, takes the room row back out.

Redactions stay two decisions, as the spec keeps them. The rules let almost
any redaction into the room; whether it applies to its target is a separate
test, run inside the same transaction as the redaction itself so that a
client holding an event ID can always read the stripped event.

storage: events keyed by event ID rather than by a surrogate, since an
event ID appears in prev_events, auth_events, state snapshots and client
requests alike. Stream ordering comes from a counter row bumped inside the
write transaction, because a PostgreSQL sequence is explicitly not
transactional and a rolled-back write would leave a hole in the stream that
/sync would have to be taught to skip. State snapshots are stored per event
and deduplicated by content hash, so the table grows with state changes
rather than with traffic — and that is what makes "the state after a
forward extremity" answerable at all. The room's listing columns are
denormalised onto its row because the public directory orders every
published room by member count and pages the result, which cannot be done
by reading each room's state in turn.

Federation-disabled behaviour from PLAN.md section 3 is enforced here.
Restricted joins are the exception that works: the rule needs a signature
from the authorising user's server verified, and while federation is off
that server is always this one.

adminapi: room list and detail, purge, force-leave, and room and event
counters on the dashboard. clientapi gains its own rate-limit buckets for
createRoom and for joining, both of which write events rather than read
them.

Tested against the real event core and a real database rather than fakes:
what is under test is whether an event this server signs is one the
authorisation rules accept, and a fake of either would only prove that the
pipeline calls what the fake expects. The storage tests run against both
SQL drivers. docs/rooms.md describes the pipeline, the storage decisions
and the known gaps — chief among them that history visibility is
room-level rather than per-event, and that /sync still reports no rooms,
both of which belong with the event stream in Phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 10:34:57 +02:00

434 lines
15 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 (
"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://<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"`
// 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"`
// RoomCreate covers /createRoom, which writes a dozen events per call.
RoomCreate RateLimitBucket `yaml:"room_create"`
// Join covers the two join endpoints. A join writes a membership event
// into a room that everybody in it then sees, so it is worth a tighter
// bucket than a read.
Join RateLimitBucket `yaml:"join"`
}
// 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},
// Room creation and joining are deliberately slow. Both
// are rare for a real client and expensive for the server,
// and a client that hits either limit is a script.
RoomCreate: RateLimitBucket{Burst: 10, Interval: time.Second},
Join: RateLimitBucket{Burst: 10, Interval: 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://<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 /")
}
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},
{"room_create", c.ClientAPI.RateLimit.RoomCreate},
{"join", c.ClientAPI.RateLimit.Join},
} {
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
}