Implements PLAN.md Phase 8. internal/pushrules is the rule language: the spec's default ruleset, all six condition kinds, glob and word-boundary matching, intentional mentions switching off the legacy body rules, and rule ordering as Synapse has it. internal/pushserver keeps the rules, pushers and notifications and talks to push gateways. Push rules are the m.push_rules account data, written only by the push server; reads keep the user's own rules and the enabled/actions of each default and take the rest from the current defaults, so new default rules reach every user without a migration. PUT account_data/m.push_rules is refused with 405. The push server listens on the roomserver's emit stage and evaluates each event on its own worker for every local member and an invite's target. Notifications and per-room unread counts are stored in migration 0010; counts are a seventh sync token stream, so /sync reports unread_notifications and picks up count changes made after the event or by a receipt from another device. m.read and m.read.private receipts clear the counts; leaving a room clears it. Pushers (http kind only) push through a bounded sender pool with retries, honour event_id_only, drop push keys the gateway rejects, and go away with their device. GET /notifications pages the history. New config section push: enabled, include_content, gateway_timeout, notification_retention. Account data writes now wake the user's blocked syncs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
665 lines
26 KiB
Go
665 lines
26 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"`
|
||
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"`
|
||
|
||
// 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.
|
||
//
|
||
// Phase 1 keeps the tokens in the config file: per-token use counts and
|
||
// expiry belong to the admin token management of Phase 9, and a table
|
||
// nothing can write to yet would be dead weight.
|
||
Tokens []string `yaml:"tokens" env:"WARP_REGISTRATION_TOKENS"`
|
||
}
|
||
|
||
// RequiresToken reports whether the registration flow includes the
|
||
// m.login.registration_token stage.
|
||
func (r Registration) RequiresToken() bool { return len(r.Tokens) > 0 }
|
||
|
||
// TokenValid reports whether token is one of the configured registration
|
||
// tokens. Every candidate is compared in constant time, and the loop does
|
||
// not stop early, so neither the value nor the position of a token leaks
|
||
// through the time this takes.
|
||
func (r Registration) TokenValid(token string) bool {
|
||
found := 0
|
||
for _, want := range r.Tokens {
|
||
found |= subtle.ConstantTimeCompare([]byte(token), []byte(want))
|
||
}
|
||
return found == 1
|
||
}
|
||
|
||
// Logging configures slog.
|
||
type Logging struct {
|
||
Level string `yaml:"level" env:"WARP_LOG_LEVEL"`
|
||
Format string `yaml:"format" env:"WARP_LOG_FORMAT"`
|
||
AddSource bool `yaml:"add_source" env:"WARP_LOG_ADD_SOURCE"`
|
||
}
|
||
|
||
// Metrics configures the Prometheus endpoint.
|
||
type Metrics struct {
|
||
Enabled bool `yaml:"enabled" env:"WARP_METRICS_ENABLED"`
|
||
Path string `yaml:"path" env:"WARP_METRICS_PATH"`
|
||
}
|
||
|
||
// Path returns the file this config was loaded from, or "" for defaults.
|
||
func (c *Config) Path() string { return c.path }
|
||
|
||
// Default returns a config with every optional field populated. Only
|
||
// global.server_name is left empty: there is no sane default for it.
|
||
func Default() *Config {
|
||
return &Config{
|
||
Version: 1,
|
||
Global: Global{
|
||
PrivateKeyPath: "./keys/signing.key",
|
||
},
|
||
Listen: Listen{
|
||
Address: ":8008",
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
ReadTimeout: 60 * time.Second,
|
||
WriteTimeout: 120 * time.Second,
|
||
IdleTimeout: 120 * time.Second,
|
||
MaxHeaderBytes: 1 << 20,
|
||
ShutdownTimeout: 30 * time.Second,
|
||
},
|
||
Database: Database{
|
||
Driver: "sqlite",
|
||
ConnectionString: "file:warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)",
|
||
MaxOpenConns: 10,
|
||
MaxIdleConns: 5,
|
||
ConnMaxLifetime: time.Hour,
|
||
AutoMigrate: true,
|
||
},
|
||
ClientAPI: ClientAPI{
|
||
CORSAllowedOrigins: []string{"*"},
|
||
RateLimit: RateLimit{
|
||
Enabled: true,
|
||
// Generous enough that a chatty client never notices,
|
||
// tight enough that a password-guessing script does.
|
||
Default: RateLimitBucket{Burst: 30, Interval: 100 * time.Millisecond},
|
||
Login: RateLimitBucket{Burst: 10, Interval: time.Second},
|
||
Register: RateLimitBucket{Burst: 5, Interval: 2 * time.Second},
|
||
// Room creation and joining are deliberately slow. Both
|
||
// are rare for a real client and expensive for the server,
|
||
// and a client that hits either limit is a script.
|
||
RoomCreate: RateLimitBucket{Burst: 10, Interval: time.Second},
|
||
Join: RateLimitBucket{Burst: 10, Interval: time.Second},
|
||
// Loose enough that a client in a long poll with typing
|
||
// and receipts on top never notices it.
|
||
Sync: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
|
||
// 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,
|
||
},
|
||
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.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, 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
|
||
}
|