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 9. internal/appservice reads registration files (appservice.config_files), matches namespaces the way Synapse does (anchored at the start), and delivers events to each service as transactions read from the roomserver's event stream at a cursor kept in appservice_state. A transaction in flight is recorded, so after a restart it is resent under the same ID; failures back off from 2s to 5m, and a successful ping cuts the backoff short. In the Client-Server API an as_token acts as the service's sender or, with ?user_id=, as any existing user in its namespace. Services register and log in their users with m.login.application_service; exclusive user and alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown aliases and users in a namespace are asked of the service first. The ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when rate_limited is false, skip the rate limiter. Users can change their password (POST /account/password) and deactivate their account (POST /account/deactivate), both behind the password. Deactivation revokes tokens, deletes devices, optionally erases the profile and leaves every room through a new userapi.AccountObserver. The admin API is complete: user detail, the admin flag, deactivation and reactivation, password resets, devices, media quotas (media.max_user_storage_bytes plus per-user overrides), registration tokens with use limits and expiry (registration.require_token), server notices (internal/servernotices, server_notices.*), an editor for the federation allow/deny lists persisted in server_settings, a delivery view of the application services, and an audit log of every change. Migration 0011 adds the registration token, server notice room, media quota, audit log and appservice cursor tables. Policy lists are now returned sorted. The Complement entrypoint loads the registrations Complement mounts in /complement/appservice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
734 lines
28 KiB
Go
734 lines
28 KiB
Go
// Package config loads and validates the Warp server configuration: a single
|
||
// YAML file, overridable per-field by WARP_* environment variables.
|
||
package config
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Config is the whole server configuration.
|
||
type Config struct {
|
||
// Version is the config schema version; bumped only on breaking changes.
|
||
Version int `yaml:"version"`
|
||
|
||
Global Global `yaml:"global"`
|
||
Listen Listen `yaml:"listen"`
|
||
Database Database `yaml:"database"`
|
||
ClientAPI ClientAPI `yaml:"client_api"`
|
||
Sync Sync `yaml:"sync"`
|
||
Federation Federation `yaml:"federation"`
|
||
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"`
|
||
|
||
// path is the file this config was read from, if any.
|
||
path string
|
||
}
|
||
|
||
// Global holds identity settings shared by every component.
|
||
type Global struct {
|
||
// ServerName is the Matrix server name, e.g. "warp.example". It becomes
|
||
// part of every user, room and event ID and cannot be changed later.
|
||
ServerName string `yaml:"server_name" env:"WARP_SERVER_NAME"`
|
||
|
||
// PrivateKeyPath points at the ed25519 signing key written by
|
||
// `warp generate-keys`.
|
||
PrivateKeyPath string `yaml:"private_key" env:"WARP_PRIVATE_KEY"`
|
||
|
||
WellKnown WellKnown `yaml:"well_known"`
|
||
}
|
||
|
||
// WellKnown configures the delegation documents served under /.well-known.
|
||
type WellKnown struct {
|
||
// ClientBaseURL is advertised to clients as m.homeserver.base_url. It
|
||
// defaults to https://<server_name>.
|
||
ClientBaseURL string `yaml:"client_base_url" env:"WARP_WELL_KNOWN_CLIENT_BASE_URL"`
|
||
|
||
// IdentityServerBaseURL is optional and advertised as m.identity_server.
|
||
IdentityServerBaseURL string `yaml:"identity_server_base_url" env:"WARP_WELL_KNOWN_IDENTITY_SERVER_BASE_URL"`
|
||
|
||
// ServerAddress is advertised to other homeservers as "m.server", e.g.
|
||
// "warp.example:8448". Only served while federation is enabled; empty
|
||
// means /.well-known/matrix/server returns 404 and other servers fall
|
||
// back to SRV/A record resolution.
|
||
ServerAddress string `yaml:"server_address" env:"WARP_WELL_KNOWN_SERVER_ADDRESS"`
|
||
}
|
||
|
||
// Listen configures the single HTTP listener.
|
||
type Listen struct {
|
||
Address string `yaml:"address" env:"WARP_LISTEN_ADDRESS"`
|
||
ReadHeaderTimeout time.Duration `yaml:"read_header_timeout" env:"WARP_READ_HEADER_TIMEOUT"`
|
||
ReadTimeout time.Duration `yaml:"read_timeout" env:"WARP_READ_TIMEOUT"`
|
||
WriteTimeout time.Duration `yaml:"write_timeout" env:"WARP_WRITE_TIMEOUT"`
|
||
IdleTimeout time.Duration `yaml:"idle_timeout" env:"WARP_IDLE_TIMEOUT"`
|
||
MaxHeaderBytes int `yaml:"max_header_bytes" env:"WARP_MAX_HEADER_BYTES"`
|
||
|
||
// ShutdownTimeout bounds graceful shutdown before connections are cut.
|
||
ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"WARP_SHUTDOWN_TIMEOUT"`
|
||
|
||
// FederationAddress, when set, opens a second listener serving the
|
||
// same API over TLS. It is the port other servers connect to when
|
||
// nothing delegates them elsewhere — 8448 — and the one deployment
|
||
// shape where Warp terminates TLS itself rather than sitting behind a
|
||
// reverse proxy. Empty means no TLS listener: federation traffic then
|
||
// arrives through whatever the well-known document or SRV record
|
||
// points at.
|
||
FederationAddress string `yaml:"federation_address" env:"WARP_LISTEN_FEDERATION_ADDRESS"`
|
||
|
||
// TLSCertFile and TLSKeyFile are the PEM certificate chain and key for
|
||
// the federation listener. Both are required when it is enabled.
|
||
TLSCertFile string `yaml:"tls_cert_file" env:"WARP_TLS_CERT_FILE"`
|
||
TLSKeyFile string `yaml:"tls_key_file" env:"WARP_TLS_KEY_FILE"`
|
||
}
|
||
|
||
// Database selects and configures the storage driver.
|
||
type Database struct {
|
||
// Driver is one of "sqlite", "postgres", "pocketbase".
|
||
Driver string `yaml:"driver" env:"WARP_DB_DRIVER"`
|
||
|
||
// ConnectionString is driver specific: a DSN for postgres, a file URI
|
||
// for sqlite, a directory for pocketbase.
|
||
ConnectionString string `yaml:"connection_string" env:"WARP_DB_CONNECTION_STRING"`
|
||
|
||
MaxOpenConns int `yaml:"max_open_conns" env:"WARP_DB_MAX_OPEN_CONNS"`
|
||
MaxIdleConns int `yaml:"max_idle_conns" env:"WARP_DB_MAX_IDLE_CONNS"`
|
||
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime" env:"WARP_DB_CONN_MAX_LIFETIME"`
|
||
|
||
// AutoMigrate runs pending migrations on startup. Turn it off to gate
|
||
// schema changes behind an explicit `warp migrate`.
|
||
AutoMigrate bool `yaml:"auto_migrate" env:"WARP_DB_AUTO_MIGRATE"`
|
||
}
|
||
|
||
// ClientAPI configures the Client-Server API surface.
|
||
type ClientAPI struct {
|
||
// CORSAllowedOrigins lists origins allowed to call the C-S API from a
|
||
// browser. The Matrix spec expects "*"; narrow it if the only client is
|
||
// your own web app on a known origin.
|
||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins" env:"WARP_CORS_ALLOWED_ORIGINS"`
|
||
|
||
// TrustXForwardedFor takes the client address from the X-Forwarded-For
|
||
// header instead of the socket. Turn it on only behind a reverse proxy
|
||
// that overwrites the header: anyone can send it, and the rate limiter
|
||
// keys its buckets on the result.
|
||
TrustXForwardedFor bool `yaml:"trust_x_forwarded_for" env:"WARP_TRUST_X_FORWARDED_FOR"`
|
||
|
||
// AccessTokenLifetime expires access tokens after this long. Zero — the
|
||
// default — issues tokens that never expire, which is what clients
|
||
// without refresh-token support need.
|
||
AccessTokenLifetime time.Duration `yaml:"access_token_lifetime" env:"WARP_ACCESS_TOKEN_LIFETIME"`
|
||
|
||
// RefreshTokenLifetime bounds how long a refresh token may be exchanged
|
||
// for a new access token. Zero means no expiry.
|
||
RefreshTokenLifetime time.Duration `yaml:"refresh_token_lifetime" env:"WARP_REFRESH_TOKEN_LIFETIME"`
|
||
|
||
RateLimit RateLimit `yaml:"rate_limit"`
|
||
}
|
||
|
||
// RateLimit configures the per-endpoint token buckets in front of the C-S
|
||
// API. Login and registration get their own, tighter, buckets because they
|
||
// are the two endpoints worth brute-forcing.
|
||
//
|
||
// Only `enabled` has an environment override: the three buckets share one Go
|
||
// type, so per-bucket `env` tags would all collide on the same variable.
|
||
// Turning the whole limiter off (WARP_RATE_LIMIT_ENABLED=false) is what test
|
||
// harnesses such as Complement need anyway.
|
||
type RateLimit struct {
|
||
Enabled bool `yaml:"enabled" env:"WARP_RATE_LIMIT_ENABLED"`
|
||
|
||
// Default applies to every rate-limited endpoint without its own bucket.
|
||
Default RateLimitBucket `yaml:"default"`
|
||
|
||
// Login covers /login and /refresh.
|
||
Login RateLimitBucket `yaml:"login"`
|
||
|
||
// Register covers /register and /register/available.
|
||
Register RateLimitBucket `yaml:"register"`
|
||
|
||
// RoomCreate covers /createRoom, which writes a dozen events per call.
|
||
RoomCreate RateLimitBucket `yaml:"room_create"`
|
||
|
||
// Join covers the two join endpoints. A join writes a membership event
|
||
// into a room that everybody in it then sees, so it is worth a tighter
|
||
// bucket than a read.
|
||
Join RateLimitBucket `yaml:"join"`
|
||
|
||
// Sync covers /sync and the chatty writes behind it: typing,
|
||
// receipts, to-device messages and presence. It is the loosest bucket
|
||
// of the lot on purpose — a client is in /sync all the time, and a
|
||
// long poll that is refused leaves it reconnecting in a loop, which is
|
||
// the one case where rate limiting makes the load worse.
|
||
Sync RateLimitBucket `yaml:"sync"`
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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"},
|
||
}
|
||
}
|
||
|
||
// 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.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
|
||
}
|