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 3: the roomserver, the room half of storage, the Client-Server room endpoints and the admin room endpoints. A client can now create a room, invite, join, leave, kick, ban, send messages and state, redact, name a room with an alias and find one in the public directory. Nothing talks to another server; every operation that would need to is refused with a message that says so rather than a bare M_FORBIDDEN. roomserver: one input pipeline every event goes through, whatever sent it — validate, authorise, resolve, persist, emit. The rules are not reimplemented here; internal/matrix is pure library code and this is what connects it to a database and to a room's history. The Client-Server handlers do no authorisation of their own, deliberately: whether Alice may kick Bob is a question about the room's power levels, and the one place that is answered correctly is the authorisation rules inside the pipeline. A mutex per room serialises writers. Two events racing into one room would each read the same forward extremities, both become one, and fork the room for no reason — which would then need state resolution to put back together. State resolution is wired in anyway for the multi-extremity case. It is unreachable from outside in this phase, and the alternative is a pipeline that authorises against one arbitrary branch of a fork without saying so. Room versions 3 to 12 all work and are each tested end to end. Version 12 earns that coverage: its room ID is the create event's own ID, its create event carries no room_id, and the create event is excluded from every other event's auth_events — so the state auth.Check is given is the event's auth_events plus the create event, which that version identifies by room ID rather than citing. createRoom sends the spec's events in the spec's order, each authorised against what the ones before it produced. A failure part-way leaves an incomplete room, which is the lesser evil: the alternative is holding every event of a new room in memory and writing them in one transaction, making the pipeline that authorises them a special case. The one failure a second client can cause at any time, an alias clash, takes the room row back out. Redactions stay two decisions, as the spec keeps them. The rules let almost any redaction into the room; whether it applies to its target is a separate test, run inside the same transaction as the redaction itself so that a client holding an event ID can always read the stripped event. storage: events keyed by event ID rather than by a surrogate, since an event ID appears in prev_events, auth_events, state snapshots and client requests alike. Stream ordering comes from a counter row bumped inside the write transaction, because a PostgreSQL sequence is explicitly not transactional and a rolled-back write would leave a hole in the stream that /sync would have to be taught to skip. State snapshots are stored per event and deduplicated by content hash, so the table grows with state changes rather than with traffic — and that is what makes "the state after a forward extremity" answerable at all. The room's listing columns are denormalised onto its row because the public directory orders every published room by member count and pages the result, which cannot be done by reading each room's state in turn. Federation-disabled behaviour from PLAN.md section 3 is enforced here. Restricted joins are the exception that works: the rule needs a signature from the authorising user's server verified, and while federation is off that server is always this one. adminapi: room list and detail, purge, force-leave, and room and event counters on the dashboard. clientapi gains its own rate-limit buckets for createRoom and for joining, both of which write events rather than read them. Tested against the real event core and a real database rather than fakes: what is under test is whether an event this server signs is one the authorisation rules accept, and a fake of either would only prove that the pipeline calls what the fake expects. The storage tests run against both SQL drivers. docs/rooms.md describes the pipeline, the storage decisions and the known gaps — chief among them that history visibility is room-level rather than per-event, and that /sync still reports no rooms, both of which belong with the event stream in Phase 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
341 lines
10 KiB
Go
341 lines
10 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/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/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
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
if s.signingKey != nil {
|
|
s.rooms, err = roomserver.New(roomserver.Options{
|
|
ServerName: cfg.Global.ServerName,
|
|
Store: s.db.Rooms(),
|
|
Profiles: s.users,
|
|
SigningKey: s.signingKey,
|
|
FederationEnabled: settingsService.FederationEnabled,
|
|
Logger: log,
|
|
})
|
|
if err != nil {
|
|
_ = s.closeDB()
|
|
return nil, err
|
|
}
|
|
} else {
|
|
log.Warn("started without a signing key: rooms are unavailable")
|
|
}
|
|
|
|
s.clientAPI, err = clientapi.New(clientapi.Options{
|
|
ServerName: cfg.Global.ServerName,
|
|
ClientBaseURL: cfg.ClientBaseURL(),
|
|
Users: s.users,
|
|
Rooms: s.rooms,
|
|
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 }
|
|
|
|
// 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)
|
|
}
|