// Package setup wires the components together into a running server: it // opens storage, loads the signing key, builds the runtime-settings service // and the HTTP stack, and owns the graceful shutdown. package setup import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "sync/atomic" "time" "github.com/dariuszjaniak/warp/server/internal/adminapi" "github.com/dariuszjaniak/warp/server/internal/buildinfo" "github.com/dariuszjaniak/warp/server/internal/clientapi" "github.com/dariuszjaniak/warp/server/internal/config" "github.com/dariuszjaniak/warp/server/internal/federation" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/matrix/signing" "github.com/dariuszjaniak/warp/server/internal/metrics" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/settings" "github.com/dariuszjaniak/warp/server/internal/storage" _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers "github.com/dariuszjaniak/warp/server/internal/syncapi" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // Server is a configured, not yet listening homeserver. type Server struct { cfg *config.Config log *slog.Logger db storage.Database settings *settings.Service metrics *metrics.Registry federation *federation.Gate signingKey *signing.KeyPair users *userapi.API rooms *roomserver.API sync *syncapi.API clientAPI *clientapi.ClientAPI adminAPI *adminapi.AdminAPI handler http.Handler // addr is the address Run is listening on; empty until then. addr atomic.Pointer[string] // ownsDB is false when the caller supplied the database and will close // it itself. ownsDB bool } // Options are the knobs tests and commands need beyond the config file. type Options struct { // Logger receives everything the server logs. Logger *slog.Logger // Database, when set, is used instead of opening one from the config. // Tests pass an in-memory SQLite database this way. Database storage.Database // SkipSigningKey starts the server without loading the signing key. // Only tests and `warp migrate` use it: a real server must be able to // sign. SkipSigningKey bool } // New builds the server: storage, migrations, settings, routes. It does not // listen; call Run for that. func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error) { log := opts.Logger if log == nil { log = slog.Default() } s := &Server{cfg: cfg, log: log, metrics: metrics.NewRegistry()} if opts.Database != nil { s.db = opts.Database } else { db, err := storage.Open(ctx, storage.Config{ Driver: cfg.Database.Driver, ConnectionString: cfg.Database.ConnectionString, MaxOpenConns: cfg.Database.MaxOpenConns, MaxIdleConns: cfg.Database.MaxIdleConns, ConnMaxLifetime: cfg.Database.ConnMaxLifetime, }, log) if err != nil { return nil, err } s.db, s.ownsDB = db, true } if err := s.prepareSchema(ctx); err != nil { _ = s.closeDB() return nil, err } if !opts.SkipSigningKey { key, err := signing.Load(cfg.Global.PrivateKeyPath) if err != nil { _ = s.closeDB() return nil, fmt.Errorf("%w\nrun `warp generate-keys --config ` to create one", err) } s.signingKey = key log.Info("loaded signing key", "key_id", key.KeyID(), "path", cfg.Global.PrivateKeyPath) } settingsService, err := settings.New(ctx, s.db.Settings(), settings.Defaults{ FederationEnabled: cfg.Federation.Enabled, FederationAllowRuntimeToggle: cfg.Federation.AllowRuntimeToggle, RegistrationEnabled: cfg.Registration.Enabled, RegistrationAllowRuntimeToggle: cfg.Registration.AllowRuntimeToggle, }, log) if err != nil { _ = s.closeDB() return nil, err } s.settings = settingsService s.federation = federation.NewGate( settingsService.FederationEnabled, federation.NewPolicy(cfg.Federation.Allowlist, cfg.Federation.Denylist), ) // Phase 5 replaces this with starting and stopping the federation // module; for now it is the audit trail for a runtime toggle. settingsService.Subscribe(func(snap settings.Snapshot) { log.Info("federation switch applied", "enabled", snap.FederationEnabled) }) s.users, err = userapi.New(userapi.Options{ ServerName: cfg.Global.ServerName, Store: s.db.Users(), AccessTokenLifetime: cfg.ClientAPI.AccessTokenLifetime, RefreshTokenLifetime: cfg.ClientAPI.RefreshTokenLifetime, Logger: log, }) if err != nil { _ = s.closeDB() return nil, err } // The roomserver needs the signing key: it is the only thing in Warp // that creates an event, and an event has to be signed. `warp migrate` // and the Phase 1 tests start without a key, and then start without // rooms — the C-S room endpoints answer M_UNRECOGNIZED rather than // pretending to work. // // The syncapi comes with it, because a sync is almost entirely room // reads: a server with no rooms has no stream to read, and /sync // answering M_UNRECOGNIZED says so where an empty success would not. // // The notifier is built first and handed to both: it is the // roomserver's emit stage and the syncapi's wake-up, which is exactly // the seam Phase 3 left for this. if s.signingKey != nil { notifier := syncapi.NewNotifier() s.rooms, err = roomserver.New(roomserver.Options{ ServerName: cfg.Global.ServerName, Store: s.db.Rooms(), Profiles: s.users, SigningKey: s.signingKey, FederationEnabled: settingsService.FederationEnabled, Notifier: notifier, Logger: log, }) if err != nil { _ = s.closeDB() return nil, err } s.sync, err = syncapi.New(syncapi.Options{ ServerName: cfg.Global.ServerName, Rooms: s.rooms, Accounts: s.users, Store: s.db.Sync(), Notifier: notifier, PresenceEnabled: cfg.Sync.Presence, TypingTimeout: cfg.Sync.TypingTimeout, LongPollTimeout: cfg.Sync.LongPollTimeout, Logger: log, }) if err != nil { _ = s.closeDB() return nil, err } } else { log.Warn("started without a signing key: rooms and /sync are unavailable") } s.clientAPI, err = clientapi.New(clientapi.Options{ ServerName: cfg.Global.ServerName, ClientBaseURL: cfg.ClientBaseURL(), Users: s.users, Rooms: s.rooms, Sync: s.sync, Settings: settingsService, Federation: s.federation, Registration: cfg.Registration, RateLimit: cfg.ClientAPI.RateLimit, TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor, Logger: log, }) if err != nil { _ = s.closeDB() return nil, err } s.adminAPI, err = adminapi.New(adminapi.Options{ ServerName: cfg.Global.ServerName, Users: s.users, Rooms: s.rooms, Settings: settingsService, Federation: s.federation, Database: s.db, Logger: log, }) if err != nil { _ = s.closeDB() return nil, err } s.handler = httputil.Chain(s.routes(), httputil.Recoverer, httputil.RequestLogger(log), s.metrics.InFlight(), httputil.CORS(httputil.CORSOptions{AllowedOrigins: cfg.ClientAPI.CORSAllowedOrigins}), ) return s, nil } // prepareSchema migrates the database, or refuses to start when migrations // are pending and auto-migration is off. func (s *Server) prepareSchema(ctx context.Context) error { if s.cfg.Database.AutoMigrate { if err := s.db.Migrate(ctx); err != nil { return fmt.Errorf("migrate database: %w", err) } return nil } pending, err := s.db.PendingMigrations(ctx) if err != nil { return fmt.Errorf("check migrations: %w", err) } if len(pending) > 0 { return fmt.Errorf("database has %d pending migration(s): %v; run `warp migrate`", len(pending), pending) } return nil } // Addr reports the address Run is listening on, or "" before it starts. // Tests and `warp serve` on port 0 use it to find the chosen port. func (s *Server) Addr() string { if p := s.addr.Load(); p != nil { return *p } return "" } // Handler exposes the fully wrapped HTTP handler, for tests. func (s *Server) Handler() http.Handler { return s.handler } // Settings exposes the runtime-settings service; the admin API in Phase 1 // is its first user. func (s *Server) Settings() *settings.Service { return s.settings } // Database exposes the storage handle. func (s *Server) Database() storage.Database { return s.db } // Users exposes the userapi component, which `warp create-account` and the // tests use to create accounts without going through HTTP. func (s *Server) Users() *userapi.API { return s.users } // Rooms exposes the roomserver component, or nil when the server started // without a signing key. func (s *Server) Rooms() *roomserver.API { return s.rooms } // Sync exposes the syncapi component, or nil when the server started // without a signing key. func (s *Server) Sync() *syncapi.API { return s.sync } // SigningKey returns the server signing key, or nil when it was skipped. func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey } // Run listens and serves until ctx is cancelled, then shuts down // gracefully: it stops accepting connections and gives in-flight requests // up to listen.shutdown_timeout to finish. func (s *Server) Run(ctx context.Context) error { listener, err := net.Listen("tcp", s.cfg.Listen.Address) if err != nil { return fmt.Errorf("listen on %s: %w", s.cfg.Listen.Address, err) } addr := listener.Addr().String() s.addr.Store(&addr) defer s.addr.Store(nil) httpServer := &http.Server{ Handler: s.handler, ReadHeaderTimeout: s.cfg.Listen.ReadHeaderTimeout, ReadTimeout: s.cfg.Listen.ReadTimeout, WriteTimeout: s.cfg.Listen.WriteTimeout, IdleTimeout: s.cfg.Listen.IdleTimeout, MaxHeaderBytes: s.cfg.Listen.MaxHeaderBytes, ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug), BaseContext: func(net.Listener) context.Context { return ctx }, } s.log.Info("warp is listening", "address", addr, "server_name", s.cfg.Global.ServerName, "version", buildinfo.String(), "database", s.db.Driver(), "federation", s.settings.FederationEnabled(), "registration", s.settings.RegistrationEnabled(), "presence", s.cfg.Sync.Presence, ) errCh := make(chan error, 1) go func() { err := httpServer.Serve(listener) if errors.Is(err, http.ErrServerClosed) { err = nil } errCh <- err }() select { case err := <-errCh: return err case <-ctx.Done(): } s.log.Info("shutting down", "timeout", s.cfg.Listen.ShutdownTimeout) shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.cfg.Listen.ShutdownTimeout) defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { s.log.Warn("graceful shutdown timed out; closing connections", "error", err) _ = httpServer.Close() } // Serve has returned by now; surface anything it reported. if err := <-errCh; err != nil { return err } s.log.Info("stopped") return nil } // Close releases the resources New acquired. func (s *Server) Close() error { if s.sync != nil { // The typing tracker holds a timer per typing user; a server // shutting down should not wait for them. s.sync.Close() } return s.closeDB() } func (s *Server) closeDB() error { if s.db == nil || !s.ownsDB { return nil } err := s.db.Close() s.db = nil return err } // contextWithTimeout is a thin wrapper so handlers do not import context // just for a deadline. func contextWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(ctx, d) }