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 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
419 lines
15 KiB
Go
419 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"`
|
|
}
|
|
|
|
// 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://<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},
|
|
} {
|
|
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
|
|
}
|