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 5: the Server-Server API. Warp joins rooms on other servers and lets their users join its rooms, invites across servers with the invitee's server countersigning, rejects remote invites (locally if the inviter is unreachable), and exchanges events, typing, receipts and to-device messages with other homeservers — all behind the runtime switch, which now starts and stops real outbound traffic. The protocol lives in internal/federation with no knowledge of rooms: X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping the dial address and the TLS name apart, a signed client that refuses every request while the switch is off or the policy denies the destination, and a key ring that caches other servers' keys for at most a week, with optional notaries. The roomserver takes events from elsewhere through the spec's intake: signatures and hashes (a hash mismatch keeps the redacted form), the rules against the event's own auth events, against the state before it — with /get_missing_events for small gaps and /state_ids for wide ones — and against the current state, where a failure is a soft failure. Events held without their state are outliers: a column on roomserver_events, because the current state of a room joined over federation is almost all outliers, and excluded from every timeline query. Forks are resolved at the room's head. Membership across servers is make_join/send_join, make_leave/send_leave and countersigned invites, in both directions; m.room.server_acl is enforced on everything another server sends. History before a remote join is backfilled when /messages pages past it, stored at negative stream positions. internal/federationapi is the running component: the inbound endpoints, and an outbound queue that persists what it owes each destination, sends it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to six hours, survives restarts, and drops events persisted while federation is off rather than delivering a backlog in one burst. Its Module is what the switch starts and stops. Also: a TLS listener for port 8448, trusted key servers, key validity and an outbound CA bundle in the config; per-destination health and backoff reset in the admin API; remote profiles, aliases and directories in the Client-Server API; a Complement federation profile. See docs/federation.md, including its known gaps (no partial-state joins, no key rotation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
523 lines
20 KiB
Go
523 lines
20 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"`
|
|
Sync Sync `yaml:"sync"`
|
|
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"`
|
|
|
|
// FederationAddress, when set, opens a second listener serving the
|
|
// same API over TLS. It is the port other servers connect to when
|
|
// nothing delegates them elsewhere — 8448 — and the one deployment
|
|
// shape where Warp terminates TLS itself rather than sitting behind a
|
|
// reverse proxy. Empty means no TLS listener: federation traffic then
|
|
// arrives through whatever the well-known document or SRV record
|
|
// points at.
|
|
FederationAddress string `yaml:"federation_address" env:"WARP_LISTEN_FEDERATION_ADDRESS"`
|
|
|
|
// TLSCertFile and TLSKeyFile are the PEM certificate chain and key for
|
|
// the federation listener. Both are required when it is enabled.
|
|
TLSCertFile string `yaml:"tls_cert_file" env:"WARP_TLS_CERT_FILE"`
|
|
TLSKeyFile string `yaml:"tls_key_file" env:"WARP_TLS_KEY_FILE"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// Sync covers /sync and the chatty writes behind it: typing,
|
|
// receipts, to-device messages and presence. It is the loosest bucket
|
|
// of the lot on purpose — a client is in /sync all the time, and a
|
|
// long poll that is refused leaves it reconnecting in a loop, which is
|
|
// the one case where rate limiting makes the load worse.
|
|
Sync RateLimitBucket `yaml:"sync"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// Sync configures the read model: /sync and the streams it carries.
|
|
type Sync struct {
|
|
// Presence turns the presence stream on. It is off by default
|
|
// (PLAN.md open question 3): presence is the noisiest thing in Matrix
|
|
// — every user's state change goes to everybody they share a room
|
|
// with — and a private homeserver gains little from it. With it off
|
|
// the presence endpoints answer M_FORBIDDEN and the presence section
|
|
// of a sync is empty.
|
|
Presence bool `yaml:"presence" env:"WARP_SYNC_PRESENCE"`
|
|
|
|
// TypingTimeout is how long a typing notification lasts without being
|
|
// renewed, and the cap on what a client may ask for.
|
|
TypingTimeout time.Duration `yaml:"typing_timeout" env:"WARP_SYNC_TYPING_TIMEOUT"`
|
|
|
|
// LongPollTimeout caps a /sync's ?timeout=, whatever the client asks
|
|
// for. Holding a connection longer buys nothing — a sync is woken the
|
|
// moment anything happens — and a client asking for ten minutes would
|
|
// hold a connection for ten minutes.
|
|
LongPollTimeout time.Duration `yaml:"long_poll_timeout" env:"WARP_SYNC_LONG_POLL_TIMEOUT"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// TrustedKeyServers are the notaries asked for another server's
|
|
// signing key when that server cannot be reached directly. Empty —
|
|
// the default — means keys are only ever fetched from their own
|
|
// server, which trusts nobody but requires everybody to be online.
|
|
TrustedKeyServers []string `yaml:"trusted_key_servers" env:"WARP_FEDERATION_TRUSTED_KEY_SERVERS"`
|
|
|
|
// KeyValidityPeriod is how long this server's key response tells
|
|
// others they may cache its key. Shorter means a rotated key is
|
|
// noticed sooner; longer means fewer fetches. The spec caps what a
|
|
// receiver believes at a week.
|
|
KeyValidityPeriod time.Duration `yaml:"key_validity_period" env:"WARP_FEDERATION_KEY_VALIDITY_PERIOD"`
|
|
|
|
// TLSCAFile adds a PEM bundle of certificate authorities to the system
|
|
// roots for outbound federation, for a private federation whose
|
|
// servers have certificates from an internal CA. The system roots
|
|
// still apply.
|
|
TLSCAFile string `yaml:"tls_ca_file" env:"WARP_FEDERATION_TLS_CA_FILE"`
|
|
}
|
|
|
|
// 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},
|
|
// Loose enough that a client in a long poll with typing
|
|
// and receipts on top never notices it.
|
|
Sync: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
|
|
},
|
|
},
|
|
Sync: Sync{
|
|
Presence: false,
|
|
TypingTimeout: 30 * time.Second,
|
|
LongPollTimeout: 30 * time.Second,
|
|
},
|
|
Federation: Federation{
|
|
Enabled: false,
|
|
AllowRuntimeToggle: true,
|
|
KeyValidityPeriod: 24 * time.Hour,
|
|
},
|
|
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},
|
|
{"sync", c.ClientAPI.RateLimit.Sync},
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
if c.Sync.TypingTimeout <= 0 {
|
|
bad("sync.typing_timeout: must be positive")
|
|
}
|
|
if c.Sync.LongPollTimeout <= 0 {
|
|
bad("sync.long_poll_timeout: must be positive")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
for i, s := range c.Federation.TrustedKeyServers {
|
|
if err := validateServerName(s); err != nil {
|
|
bad("federation.trusted_key_servers[%d]: %w", i, err)
|
|
}
|
|
}
|
|
if c.Federation.KeyValidityPeriod <= 0 || c.Federation.KeyValidityPeriod > 7*24*time.Hour {
|
|
bad("federation.key_validity_period: must be positive and at most a week (168h)")
|
|
}
|
|
if c.Listen.FederationAddress != "" && (c.Listen.TLSCertFile == "" || c.Listen.TLSKeyFile == "") {
|
|
bad("listen.federation_address: needs listen.tls_cert_file and listen.tls_key_file")
|
|
}
|
|
|
|
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
|
|
}
|