// 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" "github.com/dariuszjaniak/warp/server/internal/storage" ) // 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"` Media Media `yaml:"media"` Push Push `yaml:"push"` Registration Registration `yaml:"registration"` AppService AppService `yaml:"appservice"` ServerNotices ServerNotices `yaml:"server_notices"` Logging Logging `yaml:"logging"` Metrics Metrics `yaml:"metrics"` Tracing Tracing `yaml:"tracing"` // 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://. 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"` // PocketBase configures what the pocketbase driver adds over sqlite. PocketBase PocketBase `yaml:"pocketbase"` } // PocketBase configures the pocketbase storage driver. type PocketBase struct { // S3 puts media content in an S3-compatible bucket through // PocketBase's filesystem, instead of under the data directory. S3 PocketBaseS3 `yaml:"s3"` } // PocketBaseS3 is an S3-compatible bucket. type PocketBaseS3 struct { Enabled bool `yaml:"enabled" env:"WARP_PB_S3_ENABLED"` Bucket string `yaml:"bucket" env:"WARP_PB_S3_BUCKET"` Region string `yaml:"region" env:"WARP_PB_S3_REGION"` Endpoint string `yaml:"endpoint" env:"WARP_PB_S3_ENDPOINT"` AccessKey string `yaml:"access_key" env:"WARP_PB_S3_ACCESS_KEY"` Secret string `yaml:"secret" env:"WARP_PB_S3_SECRET"` ForcePathStyle bool `yaml:"force_path_style" env:"WARP_PB_S3_FORCE_PATH_STYLE"` } // Storage is the storage driver configuration this section describes. func (d Database) Storage() storage.Config { s3 := d.PocketBase.S3 return storage.Config{ Driver: d.Driver, ConnectionString: d.ConnectionString, MaxOpenConns: d.MaxOpenConns, MaxIdleConns: d.MaxIdleConns, ConnMaxLifetime: d.ConnMaxLifetime, S3: storage.S3Config{ Enabled: s3.Enabled, Bucket: s3.Bucket, Region: s3.Region, Endpoint: s3.Endpoint, AccessKey: s3.AccessKey, Secret: s3.Secret, ForcePathStyle: s3.ForcePathStyle, }, } } // 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"` // Media covers downloads and thumbnails. A client opening a room asks // for every member's avatar at once, so it is as loose as Sync. // Uploads stay in the default bucket. Media RateLimitBucket `yaml:"media"` } // 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"` } // Media configures the media repository: uploads, downloads, thumbnails // and the cache of other servers' media. type Media struct { // BasePath is the directory media content is stored under. It is // created on the first upload. A storage driver with its own file // storage (PocketBase, when it lands) ignores it. BasePath string `yaml:"base_path" env:"WARP_MEDIA_BASE_PATH"` // MaxUploadBytes caps one upload, and one file fetched from another // server. It is what /media/config reports as m.upload.size. MaxUploadBytes int64 `yaml:"max_upload_bytes" env:"WARP_MEDIA_MAX_UPLOAD_BYTES"` // MaxUserStorageBytes caps how much one user may have uploaded in all; // 0 is no limit. The admin API can give a user a quota of their own. MaxUserStorageBytes int64 `yaml:"max_user_storage_bytes" env:"WARP_MEDIA_MAX_USER_STORAGE_BYTES"` // MaxThumbnailPixels is the largest image, in pixels, that will be // decoded to make a thumbnail. Decoding allocates four bytes a pixel // whatever the file size, and a 100 KiB PNG can claim to be 30000 × // 30000. MaxThumbnailPixels int64 `yaml:"max_thumbnail_pixels" env:"WARP_MEDIA_MAX_THUMBNAIL_PIXELS"` // ThumbnailSizes are the thumbnails this server makes. A request is // answered with the smallest configured size that is at least as big // as the one asked for, so arbitrary sizes cannot be used to make the // server resize the same image a thousand times. ThumbnailSizes []ThumbnailSize `yaml:"thumbnail_sizes"` // PendingUploadExpiry is how long an ID from POST /media/v1/create // waits for its content before it is thrown away. PendingUploadExpiry time.Duration `yaml:"pending_upload_expiry" env:"WARP_MEDIA_PENDING_UPLOAD_EXPIRY"` // MaxPendingUploads caps the IDs one user may hold without content. MaxPendingUploads int `yaml:"max_pending_uploads" env:"WARP_MEDIA_MAX_PENDING_UPLOADS"` // LegacyUnauthenticatedDownloads keeps the deprecated // /_matrix/media/v3/download and /thumbnail endpoints, which need no // access token. Off by default: anybody who learns an mxc:// URI can // read the file through them, which is why Matrix 1.11 replaced them // with authenticated endpoints. Turn it on only for clients too old // to know those. LegacyUnauthenticatedDownloads bool `yaml:"legacy_unauthenticated_downloads" env:"WARP_MEDIA_LEGACY_UNAUTHENTICATED_DOWNLOADS"` } // ThumbnailSize is one configured thumbnail. type ThumbnailSize struct { Width int `yaml:"width"` Height int `yaml:"height"` // Method is "crop" (fill the box exactly, cutting the edges off) or // "scale" (fit inside the box, keeping the aspect ratio). Method string `yaml:"method"` } // Push configures notifications and pushes to push gateways. type Push struct { // Enabled sends pushes to the push gateways of users' pushers. Off, // notifications are still counted — a client's unread badges come // from the counts, not from pushes — and nothing leaves the server. Enabled bool `yaml:"enabled" env:"WARP_PUSH_ENABLED"` // IncludeContent puts the event's content into a push. Off, a push // says who sent what kind of event in which room and nothing of what // it said, for operators who would rather message text did not pass // through a third-party push service. A pusher can ask for even less // with the event_id_only format. IncludeContent bool `yaml:"include_content" env:"WARP_PUSH_INCLUDE_CONTENT"` // GatewayTimeout bounds one request to a push gateway. GatewayTimeout time.Duration `yaml:"gateway_timeout" env:"WARP_PUSH_GATEWAY_TIMEOUT"` // NotificationRetention is how long a notification that has been read // is kept for GET /notifications. Unread ones are kept until read. NotificationRetention time.Duration `yaml:"notification_retention" env:"WARP_PUSH_NOTIFICATION_RETENTION"` } // 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. // // These never run out. Tokens with a use count or an expiry are made // through the admin API instead, and both kinds are accepted. Tokens []string `yaml:"tokens" env:"WARP_REGISTRATION_TOKENS"` // RequireToken adds the m.login.registration_token stage even when // Tokens is empty, for a server whose tokens are all made through the // admin API, with use counts and expiry. RequireToken bool `yaml:"require_token" env:"WARP_REGISTRATION_REQUIRE_TOKEN"` } // RequiresToken reports whether the registration flow includes the // m.login.registration_token stage. func (r Registration) RequiresToken() bool { return r.RequireToken || 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 } // AppService configures the application services: bridges and bots that // own a namespace of users and aliases. type AppService struct { // ConfigFiles are the services' registration files, the YAML document // every bridge writes. A file that does not parse stops the server // from starting, as a bad config file does. ConfigFiles []string `yaml:"config_files" env:"WARP_APPSERVICE_CONFIG_FILES"` // Timeout bounds one request to a service: a transaction, a query or // a ping. A transaction that times out is retried. Timeout time.Duration `yaml:"timeout" env:"WARP_APPSERVICE_TIMEOUT"` } // ServerNotices configures messages from the server to its users, sent // through the admin API. type ServerNotices struct { // Enabled creates the notices user at startup and turns the admin // endpoint on. Off by default: the user is created with the localpart // below, and a server with a real user of that name must pick another // before turning this on. Enabled bool `yaml:"enabled" env:"WARP_SERVER_NOTICES_ENABLED"` // Localpart and DisplayName are the user notices come from. Localpart string `yaml:"localpart" env:"WARP_SERVER_NOTICES_LOCALPART"` DisplayName string `yaml:"display_name" env:"WARP_SERVER_NOTICES_DISPLAY_NAME"` // RoomName names the room each user gets their notices in. RoomName string `yaml:"room_name" env:"WARP_SERVER_NOTICES_ROOM_NAME"` } // 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"` } // Tracing configures OpenTelemetry tracing. type Tracing struct { Enabled bool `yaml:"enabled" env:"WARP_TRACING_ENABLED"` // Endpoint is the OTLP/HTTP collector, e.g. "http://localhost:4318". // Empty falls back to the standard OTEL_EXPORTER_OTLP_ENDPOINT. Endpoint string `yaml:"endpoint" env:"WARP_TRACING_ENDPOINT"` // SampleRatio is the fraction of new traces kept, from 0 to 1. A // request that arrives as part of a sampled trace is always kept. SampleRatio float64 `yaml:"sample_ratio" env:"WARP_TRACING_SAMPLE_RATIO"` // ServiceName is what the traces are reported as. ServiceName string `yaml:"service_name" env:"WARP_TRACING_SERVICE_NAME"` } // 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}, // Avatars: a client opening a room asks for dozens at once. Media: 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, }, Media: Media{ BasePath: "./media_store", MaxUploadBytes: 50 << 20, MaxThumbnailPixels: 32_000_000, ThumbnailSizes: DefaultThumbnailSizes(), // The spec's own example, and long enough for a client that // reserves an ID before a slow upload. PendingUploadExpiry: 24 * time.Hour, MaxPendingUploads: 10, }, Push: Push{ Enabled: true, IncludeContent: true, GatewayTimeout: 10 * time.Second, NotificationRetention: 30 * 24 * time.Hour, }, Registration: Registration{ Enabled: false, AllowRuntimeToggle: true, }, AppService: AppService{ Timeout: 30 * time.Second, }, ServerNotices: ServerNotices{ Enabled: false, Localpart: "server-notices", DisplayName: "Server Notices", RoomName: "Server Notices", }, Logging: Logging{Level: "info", Format: "text"}, Metrics: Metrics{Enabled: true, Path: "/metrics"}, Tracing: Tracing{SampleRatio: 1, ServiceName: "warp"}, } } // DefaultThumbnailSizes are the thumbnails made when the config names // none: two square crops for avatars, three scaled sizes for images in the // timeline. They are the sizes Element and most other clients ask for. func DefaultThumbnailSizes() []ThumbnailSize { return []ThumbnailSize{ {Width: 32, Height: 32, Method: "crop"}, {Width: 96, Height: 96, Method: "crop"}, {Width: 320, Height: 240, Method: "scale"}, {Width: 640, Height: 480, Method: "scale"}, {Width: 800, Height: 600, Method: "scale"}, } } // ClientBaseURL returns the URL advertised to clients, falling back to // https:// 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.Tracing.SampleRatio < 0 || c.Tracing.SampleRatio > 1 { bad("tracing.sample_ratio: must be between 0 and 1") } if c.Tracing.Endpoint != "" { if u, err := url.Parse(c.Tracing.Endpoint); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { bad("tracing.endpoint: must be an http or https URL") } } if c.Database.Driver == "pocketbase" && c.Database.PocketBase.S3.Enabled { s3 := c.Database.PocketBase.S3 if s3.Bucket == "" || s3.Region == "" || s3.Endpoint == "" || s3.AccessKey == "" || s3.Secret == "" { bad("database.pocketbase.s3: bucket, region, endpoint, access_key and secret are all required") } } 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}, {"media", c.ClientAPI.RateLimit.Media}, } { 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") } if c.Media.BasePath == "" { bad("media.base_path: must not be empty") } if c.Media.MaxUploadBytes < 1 { bad("media.max_upload_bytes: must be positive") } if c.Media.MaxUserStorageBytes < 0 { bad("media.max_user_storage_bytes: must not be negative") } if c.Media.MaxThumbnailPixels < 1 { bad("media.max_thumbnail_pixels: must be positive") } for i, size := range c.Media.ThumbnailSizes { if size.Width < 1 || size.Height < 1 || size.Width > 4096 || size.Height > 4096 { bad("media.thumbnail_sizes[%d]: width and height must be between 1 and 4096", i) } if size.Method != "crop" && size.Method != "scale" { bad("media.thumbnail_sizes[%d].method: must be crop or scale, got %q", i, size.Method) } } if c.Media.PendingUploadExpiry <= 0 { bad("media.pending_upload_expiry: must be positive") } if c.Media.MaxPendingUploads < 1 { bad("media.max_pending_uploads: must be at least 1") } if c.Push.GatewayTimeout <= 0 { bad("push.gateway_timeout: must be positive") } if c.Push.NotificationRetention <= 0 { bad("push.notification_retention: 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, path := range c.AppService.ConfigFiles { if strings.TrimSpace(path) == "" { bad("appservice.config_files[%d]: must not be empty", i) } } if c.AppService.Timeout <= 0 { bad("appservice.timeout: must be positive") } if c.ServerNotices.Enabled { if c.ServerNotices.Localpart == "" { bad("server_notices.localpart: must not be empty") } if c.ServerNotices.RoomName == "" { bad("server_notices.room_name: must not be empty") } } 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 }