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
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.
Server:
- MSC4140 delayed events (send/state with a delay, plus the
delayed_events list/restart/cancel/send API), stored and scheduled, so
a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.
Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
897 lines
35 KiB
Go
897 lines
35 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"`
|
||
|
||
// TURN is what GET /voip/turnServer hands clients for voice and video
|
||
// calls.
|
||
TURN TURN `yaml:"turn"`
|
||
|
||
// RTC is the media backend for group calls (MatrixRTC), advertised in
|
||
// /.well-known/matrix/client.
|
||
RTC RTC `yaml:"rtc"`
|
||
|
||
// DelayedEvents bounds the delayed events (MSC4140) a client may
|
||
// schedule. Group calls depend on them: a member's "I left" is
|
||
// scheduled in advance, so a client that disappears still leaves.
|
||
DelayedEvents DelayedEvents `yaml:"delayed_events"`
|
||
}
|
||
|
||
// RTC configures group calls. Warp does not carry call media itself:
|
||
// MatrixRTC clients publish to a LiveKit SFU, and get the token for it from
|
||
// a LiveKit JWT service (lk-jwt-service) in exchange for an OpenID token
|
||
// from this server. With no service URL no focus is advertised, and clients
|
||
// that need one cannot start a group call.
|
||
type RTC struct {
|
||
// LiveKitServiceURL is the base URL of the LiveKit JWT service, e.g.
|
||
// "https://livekit-jwt.example.org". It is advertised as an
|
||
// org.matrix.msc4143.rtc_foci entry of type livekit.
|
||
LiveKitServiceURL string `yaml:"livekit_service_url" env:"WARP_RTC_LIVEKIT_SERVICE_URL"`
|
||
}
|
||
|
||
// DelayedEvents configures MSC4140 delayed events.
|
||
type DelayedEvents struct {
|
||
// MaxDelay is the longest delay a client may ask for. Zero turns
|
||
// delayed events off: the delay parameter is refused and the feature
|
||
// is not advertised.
|
||
MaxDelay time.Duration `yaml:"max_delay" env:"WARP_DELAYED_EVENTS_MAX_DELAY"`
|
||
|
||
// MaxPerUser caps how many delayed events one user may have scheduled
|
||
// at once.
|
||
MaxPerUser int `yaml:"max_per_user" env:"WARP_DELAYED_EVENTS_MAX_PER_USER"`
|
||
}
|
||
|
||
// TURN configures the relay servers clients use to set up calls. With no
|
||
// URIs the endpoint answers an empty object, and calls only connect where
|
||
// the two browsers can reach each other directly.
|
||
//
|
||
// Credentials come one of two ways. A shared secret is coturn's
|
||
// `use-auth-secret` (the TURN REST API): every user gets a username that
|
||
// expires and a password derived from it, and nothing has to be kept in
|
||
// step with the TURN server. A static username and password are handed to
|
||
// every user as they are, and are only for servers that cannot do the first.
|
||
type TURN struct {
|
||
// URIs are the turn:, turns: and stun: URIs, e.g.
|
||
// "turn:turn.example:3478?transport=udp".
|
||
URIs []string `yaml:"uris" env:"WARP_TURN_URIS"`
|
||
|
||
// SharedSecret is coturn's static-auth-secret.
|
||
SharedSecret string `yaml:"shared_secret" env:"WARP_TURN_SHARED_SECRET"`
|
||
|
||
// UserLifetime is how long credentials made from SharedSecret last.
|
||
UserLifetime time.Duration `yaml:"user_lifetime" env:"WARP_TURN_USER_LIFETIME"`
|
||
|
||
// Username and Password are static credentials, used when there is no
|
||
// SharedSecret.
|
||
Username string `yaml:"username" env:"WARP_TURN_USERNAME"`
|
||
Password string `yaml:"password" env:"WARP_TURN_PASSWORD"`
|
||
}
|
||
|
||
// 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},
|
||
},
|
||
TURN: TURN{UserLifetime: time.Hour},
|
||
// A MatrixRTC client restarts its delayed leave every few
|
||
// seconds; a day leaves room for other uses without letting a
|
||
// forgotten event linger for weeks.
|
||
DelayedEvents: DelayedEvents{MaxDelay: 24 * time.Hour, MaxPerUser: 100},
|
||
},
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
for i, uri := range c.ClientAPI.TURN.URIs {
|
||
if !strings.HasPrefix(uri, "turn:") && !strings.HasPrefix(uri, "turns:") && !strings.HasPrefix(uri, "stun:") {
|
||
bad("client_api.turn.uris[%d]: must be a turn:, turns: or stun: URI, got %q", i, uri)
|
||
}
|
||
}
|
||
if c.ClientAPI.TURN.SharedSecret != "" && c.ClientAPI.TURN.UserLifetime <= 0 {
|
||
bad("client_api.turn.user_lifetime: must be positive")
|
||
}
|
||
if raw := c.ClientAPI.RTC.LiveKitServiceURL; raw != "" {
|
||
if u, err := url.Parse(raw); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||
bad("client_api.rtc.livekit_service_url: must be an http(s) URL, got %q", raw)
|
||
}
|
||
}
|
||
if c.ClientAPI.DelayedEvents.MaxDelay < 0 {
|
||
bad("client_api.delayed_events.max_delay: must not be negative")
|
||
}
|
||
if c.ClientAPI.DelayedEvents.MaxDelay > 0 && c.ClientAPI.DelayedEvents.MaxPerUser < 1 {
|
||
bad("client_api.delayed_events.max_per_user: must be at least 1")
|
||
}
|
||
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
|
||
}
|