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 5: the Server-Server API. Warp joins rooms on other servers and lets their users join its rooms, invites across servers with the invitee's server countersigning, rejects remote invites (locally if the inviter is unreachable), and exchanges events, typing, receipts and to-device messages with other homeservers — all behind the runtime switch, which now starts and stops real outbound traffic. The protocol lives in internal/federation with no knowledge of rooms: X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping the dial address and the TLS name apart, a signed client that refuses every request while the switch is off or the policy denies the destination, and a key ring that caches other servers' keys for at most a week, with optional notaries. The roomserver takes events from elsewhere through the spec's intake: signatures and hashes (a hash mismatch keeps the redacted form), the rules against the event's own auth events, against the state before it — with /get_missing_events for small gaps and /state_ids for wide ones — and against the current state, where a failure is a soft failure. Events held without their state are outliers: a column on roomserver_events, because the current state of a room joined over federation is almost all outliers, and excluded from every timeline query. Forks are resolved at the room's head. Membership across servers is make_join/send_join, make_leave/send_leave and countersigned invites, in both directions; m.room.server_acl is enforced on everything another server sends. History before a remote join is backfilled when /messages pages past it, stored at negative stream positions. internal/federationapi is the running component: the inbound endpoints, and an outbound queue that persists what it owes each destination, sends it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to six hours, survives restarts, and drops events persisted while federation is off rather than delivering a backlog in one burst. Its Module is what the switch starts and stops. Also: a TLS listener for port 8448, trusted key servers, key validity and an outbound CA bundle in the config; per-destination health and backoff reset in the admin API; remote profiles, aliases and directories in the Client-Server API; a Complement federation profile. See docs/federation.md, including its known gaps (no partial-state joins, no key rotation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
12 KiB
Go
324 lines
12 KiB
Go
package roomserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/stateres"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Outbound backfill: asking other servers for a room's history.
|
|
//
|
|
// A room joined over federation starts, on this server, at the join. The
|
|
// join is handed the room's state, which is enough to take part, but none
|
|
// of the events that led to it — so a client scrolling up reaches the join
|
|
// and stops, in a room that may have years of history on every other
|
|
// server in it. Backfill is what fills that in: when /messages pages back
|
|
// past the oldest event held, the servers in the room are asked for what
|
|
// came before it (GET /backfill), and what they send is checked like any
|
|
// other event from another server and stored *below* everything already
|
|
// here.
|
|
//
|
|
// "Below" is the whole difficulty. The stream ordering is the order clients
|
|
// are shown a room in, and its counter only goes up, because every event it
|
|
// has ever numbered arrived after the ones before it. History arrives last
|
|
// and belongs first, so it is numbered by a second counter that runs
|
|
// downwards from -1 (storage.RoomStore.WriteBackfill). Nothing streams
|
|
// through those positions: a live /sync is "everything after my token",
|
|
// which never reaches below zero, so backfilled history reaches clients
|
|
// only through /messages, /context and the state at a point in it — which
|
|
// is right, because it is not news.
|
|
//
|
|
// A backfilled event goes through the same checks as one that arrived
|
|
// live, but one: it is not checked against the room's current state. Soft
|
|
// failure is about refusing events built on a state the room has since
|
|
// moved past, and history is by definition built on a state the room has
|
|
// since moved past.
|
|
|
|
// Bounds on what one page of /messages can make this server fetch.
|
|
const (
|
|
// backfillMinBatch is the fewest events one /backfill request asks
|
|
// for, however small the page. A request costs a round trip and a
|
|
// state fetch at its far end whatever its size, so asking for a
|
|
// handful at a time would make scrolling a room's history slow for no
|
|
// saving.
|
|
backfillMinBatch = 20
|
|
|
|
// backfillRounds is how many /backfill requests one page may make
|
|
// before it is returned short. Events a server sends that fail the
|
|
// checks, or that the page's filter leaves out, are why one round may
|
|
// not be enough.
|
|
backfillRounds = 3
|
|
|
|
// backfillFrontierScan is how many of the room's oldest events are
|
|
// read to find where its history stops.
|
|
backfillFrontierScan = 50
|
|
)
|
|
|
|
// batchEvent is an event of a backfill batch that has passed the checks,
|
|
// with the state after it: what the events after it in the batch are
|
|
// checked against.
|
|
type batchEvent struct {
|
|
event *event.Event
|
|
stateAfter []*event.Event
|
|
}
|
|
|
|
// wantsBackfill reports whether a page of a room's history came back short
|
|
// at the bottom of what this server holds, where other servers may hold
|
|
// more.
|
|
func (r *API) wantsBackfill(req TimelineRequest, visible VisibleRange, page Timeline) bool {
|
|
if !req.Backfill || req.Direction != storage.DirectionBackward || page.Limited || !r.federating() {
|
|
return false
|
|
}
|
|
// Only a window that is open at the bottom — no `to` from the client,
|
|
// and a history visibility that lets the user see before their join —
|
|
// can reach history this server does not have.
|
|
if low, _, ok := visible.Clamp(req.Low, req.High); !ok || low != 0 {
|
|
return false
|
|
}
|
|
// A page that reaches the create event has reached the beginning of
|
|
// the room.
|
|
return len(page.Events) == 0 || page.Events[0].Type != event.TypeCreate
|
|
}
|
|
|
|
// backfillLock serialises the backfills of one room, so that two clients
|
|
// scrolling the same room at once do not fetch the same history twice.
|
|
// It is not the room's write lock: a backfill spends most of its time
|
|
// waiting for other servers, and live events must not wait with it.
|
|
func (r *API) backfillLock(roomID string) *sync.Mutex {
|
|
actual, _ := r.backfills.LoadOrStore(roomID, &sync.Mutex{})
|
|
mu, ok := actual.(*sync.Mutex)
|
|
if !ok {
|
|
// Unreachable: nothing else ever stores into this map.
|
|
panic("roomserver: backfill lock map holds something that is not a mutex")
|
|
}
|
|
return mu
|
|
}
|
|
|
|
// backfill asks the servers in a room for the history before the oldest
|
|
// event this server holds, and stores what passes below it. It returns how
|
|
// many events were added; zero with no error means there was nothing to
|
|
// ask for, or nobody had anything.
|
|
func (r *API) backfill(ctx context.Context, room storage.Room, rules roomversion.Rules, want int) (int, error) {
|
|
mu := r.backfillLock(room.ID)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Worked out under the lock: a backfill that finished while this one
|
|
// waited has moved it.
|
|
frontier, err := r.backfillFrontier(ctx, room.ID, rules)
|
|
if err != nil || len(frontier) == 0 {
|
|
return 0, err
|
|
}
|
|
limit := min(max(want, backfillMinBatch), maxBackfillLimit)
|
|
|
|
var lastErr error
|
|
for _, server := range r.candidateServers(ctx, room.ID, nil) {
|
|
if err := r.requireFederation("backfilling a room"); err != nil {
|
|
return 0, err
|
|
}
|
|
txn, err := r.remote.Backfill(ctx, server, room.ID, frontier, limit)
|
|
if err != nil {
|
|
lastErr = remoteError(server, err)
|
|
r.log.Info("a server could not backfill a room", "room_id", room.ID, "server", server, "error", err)
|
|
continue
|
|
}
|
|
added, err := r.acceptBackfill(ctx, server, room, rules, txn.PDUs)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if added > 0 {
|
|
return added, nil
|
|
}
|
|
// A server that joined after this one did may have none of the
|
|
// history; the next one may.
|
|
}
|
|
return 0, lastErr
|
|
}
|
|
|
|
// backfillFrontier finds where a room's history stops on this server: the
|
|
// prev_events of its oldest timeline events that are not themselves in the
|
|
// timeline. They are what /backfill is asked to start from.
|
|
//
|
|
// The oldest events, not only the oldest one: a room's history is a graph,
|
|
// and a batch that ends partway down two branches leaves an edge on each.
|
|
// Reading a bounded number of them keeps this one query however long the
|
|
// room is; the edges it misses are the ones a gap left higher up, which
|
|
// are not history.
|
|
func (r *API) backfillFrontier(ctx context.Context, roomID string, rules roomversion.Rules) ([]string, error) {
|
|
oldest, err := r.store.Timeline(ctx, storage.TimelineRequest{
|
|
RoomID: roomID,
|
|
Direction: storage.DirectionForward,
|
|
Limit: backfillFrontierScan,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inScan := make(map[string]bool, len(oldest))
|
|
for _, s := range oldest {
|
|
inScan[s.EventID] = true
|
|
}
|
|
var prevs []string
|
|
for _, s := range oldest {
|
|
e, err := parseEvent(s, rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range e.PrevEventIDs() {
|
|
if !inScan[id] && !slices.Contains(prevs, id) {
|
|
prevs = append(prevs, id)
|
|
}
|
|
}
|
|
}
|
|
if len(prevs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// An outlier is held without its place in the timeline, so it is still
|
|
// history to ask for; an event in the timeline further up is not.
|
|
held, err := r.store.EventsByID(ctx, roomID, prevs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, s := range held {
|
|
if !s.Outlier {
|
|
prevs = slices.DeleteFunc(prevs, func(id string) bool { return id == s.EventID })
|
|
}
|
|
}
|
|
return prevs, nil
|
|
}
|
|
|
|
// acceptBackfill checks the events a /backfill answered with, oldest
|
|
// first, and stores the ones that pass in one write.
|
|
//
|
|
// Each goes through the intake's steps 1 to 3 (see federated.go): it
|
|
// verifies, it passes the rules against its own auth events, and it passes
|
|
// them against the state before it. That state comes from the events
|
|
// before it in the same batch where it can, and otherwise from the state
|
|
// at its prev_events as the sending server reports it — which, for the
|
|
// oldest events of a batch, is a /state_ids request: the price of having a
|
|
// state snapshot for every backfilled event, which is what lets a client
|
|
// be told the room's state at any point of its history.
|
|
//
|
|
// An event that fails is left out and logged. It is not an error: the rest
|
|
// of the batch may still be good, and a server that sends bad history is
|
|
// one this server will not take history from, not one that breaks
|
|
// /messages.
|
|
func (r *API) acceptBackfill(ctx context.Context, origin string, room storage.Room, rules roomversion.Rules, raws []json.RawMessage) (int, error) {
|
|
var events []*event.Event
|
|
seen := map[string]bool{}
|
|
for _, raw := range raws {
|
|
e, err := parseRemote(raw, rules)
|
|
if err != nil {
|
|
r.log.Debug("ignoring an unparseable backfilled event", "room_id", room.ID, "origin", origin, "error", err)
|
|
continue
|
|
}
|
|
if !inRoom(e, room.ID) || seen[e.EventID()] {
|
|
continue
|
|
}
|
|
seen[e.EventID()] = true
|
|
events = append(events, e)
|
|
}
|
|
|
|
// What is already in the timeline needs nothing; an outlier is checked
|
|
// again, because it is about to be given a place in the timeline and
|
|
// a state snapshot, which it never had.
|
|
ids := make([]string, 0, len(events))
|
|
for _, e := range events {
|
|
ids = append(ids, e.EventID())
|
|
}
|
|
held, err := r.store.EventsByID(ctx, room.ID, ids)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, s := range held {
|
|
if !s.Outlier {
|
|
events = slices.DeleteFunc(events, func(e *event.Event) bool { return e.EventID() == s.EventID })
|
|
}
|
|
}
|
|
if len(events) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
eventsByDepth(events)
|
|
verified := r.verifyAll(ctx, events)
|
|
create, err := r.createEvent(ctx, room, rules)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
batch := map[string]batchEvent{}
|
|
writes := make([]storage.BackfillEvent, 0, len(verified))
|
|
rejected := 0
|
|
for _, e := range verified {
|
|
in := incoming{origin: origin, room: room, rules: rules, event: e, batch: batch}
|
|
stateBefore, err := r.checkBackfilled(ctx, in, create)
|
|
if err != nil {
|
|
rejected++
|
|
r.log.Debug("a backfilled event was not accepted",
|
|
"room_id", room.ID, "origin", origin, "event_id", e.EventID(), "type", e.Type(), "error", err)
|
|
continue
|
|
}
|
|
after := stateres.Sorted(stateBefore)
|
|
if e.IsState() {
|
|
after = append(after, e)
|
|
}
|
|
batch[e.EventID()] = batchEvent{event: e, stateAfter: after}
|
|
writes = append(writes, storage.BackfillEvent{
|
|
Event: storedOf(room.ID, e),
|
|
StateBefore: stateEntries(stateBefore),
|
|
})
|
|
}
|
|
if rejected > 0 || len(verified) < len(events) {
|
|
r.log.Info("refused events in a backfill",
|
|
"room_id", room.ID, "origin", origin,
|
|
"unverified", len(events)-len(verified), "rejected", rejected)
|
|
}
|
|
|
|
if err := r.store.WriteBackfill(ctx, room.ID, writes); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(writes), nil
|
|
}
|
|
|
|
// checkBackfilled runs one backfilled event through the intake's checks and
|
|
// returns the state before it.
|
|
func (r *API) checkBackfilled(ctx context.Context, in incoming, create *event.Event) (auth.State, error) {
|
|
e := in.event
|
|
|
|
authEvents, err := r.authEventsOf(ctx, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := auth.CheckAuthEvents(e, authEvents); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := auth.Check(ctx, e, withCreate(auth.NewState(authEvents), create), r.verifier()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The create event is the one event with nothing before it.
|
|
if e.Type() == event.TypeCreate && len(e.PrevEventIDs()) == 0 {
|
|
if create == nil || e.EventID() != create.EventID() {
|
|
return nil, fmt.Errorf("%w: a second create event", ErrBadEvent)
|
|
}
|
|
return auth.State{}, nil
|
|
}
|
|
|
|
stateBefore, err := r.stateBeforeIncoming(ctx, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := auth.Check(ctx, e, withCreate(stateBefore, create), r.verifier()); err != nil {
|
|
return nil, fmt.Errorf("%w (against the state before it)", err)
|
|
}
|
|
return stateBefore, nil
|
|
}
|