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
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>
434 lines
15 KiB
Go
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
|
|
}
|