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 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
9.2 KiB
Go
311 lines
9.2 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/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/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/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
|
|
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 <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.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
|
|
}
|
|
|
|
s.clientAPI, err = clientapi.New(clientapi.Options{
|
|
ServerName: cfg.Global.ServerName,
|
|
ClientBaseURL: cfg.ClientBaseURL(),
|
|
Users: s.users,
|
|
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,
|
|
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 }
|
|
|
|
// 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)
|
|
}
|