Files
Warp/server/internal/setup/server.go
T
tajniak81andClaude Opus 5 b4fb60a8ab
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 / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 0: bootstrap the Warp homeserver
Scaffolds the server module and everything Phase 0 of PLAN.md calls for:
a binary that boots, serves its discovery endpoints and nothing else yet.

- config: one YAML file plus per-field WARP_* environment overrides,
  unknown fields rejected, validation that reports every problem at once
- logging and metrics: slog, a Prometheus registry, per-route HTTP
  instrumentation labelled by pattern rather than path
- httputil: the Matrix error envelope, error-returning handlers, CORS,
  request logging, panic recovery, and a router over http.ServeMux
- endpoints: /_matrix/client/versions, /.well-known/matrix/client,
  /.well-known/matrix/server (gated), /healthz, /metrics, and
  M_UNRECOGNIZED for everything else
- storage: driver registry, shared database/sql base, checksummed
  migration runner, sqlite and postgres drivers, pocketbase stub
- settings: server_settings table behind a cached service, so the
  federation and registration switches change without a restart
- federation: the gate that makes federation a runtime mode
- cmd/warp: serve, migrate, generate-keys, version
- Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests,
  race on Linux, storage tests against PostgreSQL, lint, boot smoke test),
  Dockerfile, example config and docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 17:01:13 +02:00

259 lines
7.7 KiB
Go

// 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/buildinfo"
"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/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
)
// 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
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 <file>` 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.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 }
// 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(),
)
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 {
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)
}