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 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
810 lines
31 KiB
Go
810 lines
31 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"
|
||
|
||
"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://<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"`
|
||
|
||
// 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://<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.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
|
||
}
|