Files
Warp/server/internal/roomserver/remote.go
T
tajniak81andClaude Opus 5.5 487e0f410b
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
Phase 10: hardening and ops
Implements PLAN.md Phase 10.

Simplified Sliding Sync (MSC4186) is served at the unstable
org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists
are sorted by activity and filtered by DM, invite, encryption, room type,
space and tag; room subscriptions, required_state with wildcards, $ME and
$LAZY, and the to_device, e2ee, account_data, receipts and typing
extensions are supported. Per-connection state is kept in memory and an
unknown pos answers M_UNKNOWN_POS.

Threads and relations: m.relates_to is indexed on roomserver_events
(migration 0012), /relations (with recursion) and /threads are served, and
every event a timeline read returns carries its m.thread, m.replace and
m.reference summaries in unsigned.m.relations. Notifications carry a
thread ID (migration 0013): threaded receipts clear only their thread, and
sync reports unread_thread_notifications when the filter asks.

Spaces: /hierarchy walks a space breadth first, asking other servers about
rooms this one is not in through the new federation hierarchy endpoint;
room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase)
or tsvector (postgres), bounded to what each user may see, with context,
profiles, state, grouping and paging.

The PocketBase driver is real: the sqlite schema and store on an embedded
PocketBase app's database, with media in its filesystem, local or S3
(database.pocketbase.s3). It runs the full storage test suite.

Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans
per route, component spans, and trace propagation on federation, push and
application service calls. New metrics for persisted events, waiting
syncs, sliding sync connections and the database pool, with a Grafana
dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana,
Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports
request and delivery latencies.

The load tester found a long-standing bug, fixed here: on a server where
no account data had been written, every incremental /sync re-sent the push
rules and returned at once, and an all-zero token was taken for an initial
sync, so long polls became busy loops.

The PostgreSQL migrations and search dialect compile but were not run
against a live PostgreSQL in this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:21:31 +02:00

339 lines
12 KiB
Go

package roomserver
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"sort"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// RemoteServers is what the roomserver asks of other servers.
//
// *federation.Client satisfies it. It is an interface so that the
// roomserver's federation paths can be tested against a fake server in
// the same process, and so that the roomserver depends on the shape of the
// federation API rather than on how requests are signed and routed.
type RemoteServers interface {
MakeJoin(ctx context.Context, destination, roomID, userID string, versions []string) (federation.MakeJoinResponse, error)
SendJoin(ctx context.Context, destination, roomID, eventID string, joinEvent json.RawMessage) (federation.SendJoinResponse, error)
MakeLeave(ctx context.Context, destination, roomID, userID string) (federation.MakeJoinResponse, error)
SendLeave(ctx context.Context, destination, roomID, eventID string, leaveEvent json.RawMessage) error
Invite(ctx context.Context, destination, roomID, eventID string, req federation.InviteRequest) (federation.InviteResponse, error)
Event(ctx context.Context, destination, eventID string) (federation.Transaction, error)
EventAuth(ctx context.Context, destination, roomID, eventID string) (federation.EventAuthResponse, error)
StateIDs(ctx context.Context, destination, roomID, eventID string) (federation.StateIDsResponse, error)
State(ctx context.Context, destination, roomID, eventID string) (federation.StateResponse, error)
MissingEvents(ctx context.Context, destination, roomID string, req federation.MissingEventsRequest) (federation.MissingEventsResponse, error)
Backfill(ctx context.Context, destination, roomID string, from []string, limit int) (federation.Transaction, error)
QueryDirectory(ctx context.Context, destination, alias string) (federation.DirectoryResponse, error)
PublicRooms(ctx context.Context, destination string, limit int, since, search string) (json.RawMessage, error)
Hierarchy(ctx context.Context, destination, roomID string, suggestedOnly bool) (json.RawMessage, error)
}
// federating reports whether this server may talk to others right now.
func (r *API) federating() bool {
return r.remote != nil && r.keys != nil && r.federationEnabled()
}
// requireFederation is the error for an operation that needs another
// server: the switch is off, or this build has no federation at all.
func (r *API) requireFederation(action string) error {
if r.federating() {
return nil
}
return fmt.Errorf("%w: %s", ErrFederationDisabled, action)
}
// participating reports whether this server is in a room: it holds the
// room's state, and at least one of its users is joined.
//
// Holding the room row is not enough. An invite to a room nobody here is
// in leaves a row with one membership and no create event, and a room
// every local user has left is a room whose state stopped being kept up
// to date the moment the last of them went.
func (r *API) participating(ctx context.Context, roomID string) (bool, error) {
if _, err := r.store.CurrentStateEvent(ctx, roomID, event.TypeCreate, ""); err != nil {
if isNotFound(err) {
return false, nil
}
return false, err
}
return r.store.HasJoinedMemberOn(ctx, roomID, r.serverName)
}
// JoinedServers lists the servers with a user joined to a room, this one
// included when it is, sorted. It is who an event sent into the room is
// delivered to.
func (r *API) JoinedServers(ctx context.Context, roomID string) ([]string, error) {
joined, err := r.store.MembershipEntries(ctx, roomID, []string{event.MembershipJoin})
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, m := range joined {
if server, err := id.ServerNameOf(m.UserID); err == nil {
seen[server] = true
}
}
out := make([]string, 0, len(seen))
for server := range seen {
out = append(out, server)
}
sort.Strings(out)
return out, nil
}
// ServerInRoom reports whether a server has a user joined to a room.
func (r *API) ServerInRoom(ctx context.Context, roomID, serverName string) (bool, error) {
return r.store.HasJoinedMemberOn(ctx, roomID, serverName)
}
// candidateServers orders the servers worth asking about a room: the ones
// the caller suggested, then the one named in the room ID, then the
// servers of the room's members as this server last knew them. This
// server is never among them.
func (r *API) candidateServers(ctx context.Context, roomID string, hints []string) []string {
var out []string
add := func(server string) {
if server != "" && server != r.serverName && !slices.Contains(out, server) {
out = append(out, server)
}
}
for _, h := range hints {
add(h)
}
if server, err := id.ServerNameOfRoomID(roomID); err == nil {
add(server)
}
if members, err := r.store.MembershipEntries(ctx, roomID, nil); err == nil {
// Joined members first: they are in the room, and an invited or
// departed one may not be.
for _, m := range members {
if m.Membership == event.MembershipJoin {
if server, err := id.ServerNameOf(m.UserID); err == nil {
add(server)
}
}
}
for _, m := range members {
if server, err := id.ServerNameOf(m.UserID); err == nil {
add(server)
}
// The sender of an invite to a local user is who brought this
// server to the room, and is the server most likely to let it
// in.
if m.Membership == event.MembershipInvite && r.isLocal(m.UserID) {
if stored, err := r.store.Event(ctx, roomID, m.EventID); err == nil {
if server, err := id.ServerNameOf(stored.Sender); err == nil {
add(server)
}
}
}
}
}
return out
}
// remoteError translates a failure from another server into the
// roomserver's own sentinels, so that a client hears "forbidden" when the
// remote server said forbidden, rather than "a remote server failed".
func remoteError(server string, err error) error {
var httpErr *federation.HTTPError
if errors.As(err, &httpErr) {
switch {
case httpErr.Code == "M_INCOMPATIBLE_ROOM_VERSION":
return fmt.Errorf("%w: %s: %s", ErrIncompatibleRoomVersion, server, httpErr.Message)
case httpErr.Status == http.StatusForbidden:
return fmt.Errorf("%w: %s refused: %s", ErrForbidden, server, httpErr.Message)
case httpErr.Status == http.StatusNotFound:
return fmt.Errorf("%w: %s does not know the room: %s", ErrNoSuchRoom, server, httpErr.Message)
}
}
switch {
case errors.Is(err, federation.ErrDisabled):
return fmt.Errorf("%w: %v", ErrFederationDisabled, err)
case errors.Is(err, federation.ErrNotAllowed):
return fmt.Errorf("%w: %v", ErrForbidden, err)
}
return fmt.Errorf("%w: %s: %v", ErrRemote, server, err)
}
// parseRemote parses an event received from another server under the
// rules of the room it claims to belong to.
func parseRemote(raw json.RawMessage, rules roomversion.Rules) (*event.Event, error) {
e, err := event.Parse(stripUnsigned(raw), rules)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadEvent, err)
}
return e, nil
}
// stripUnsigned drops the unsigned block of an event received from another
// server. It is that server's annotation — age, the state it replaced, the
// invite room state — and anything kept from it would be served to our
// own clients as if this server had written it.
func stripUnsigned(raw json.RawMessage) json.RawMessage {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return raw
}
if _, ok := obj["unsigned"]; !ok {
return raw
}
delete(obj, "unsigned")
out, err := json.Marshal(obj)
if err != nil {
return raw
}
return out
}
// roomIDOfRaw reads the room_id of an event that has not been parsed yet,
// which is what decides the rules it will be parsed under.
func roomIDOfRaw(raw json.RawMessage) (string, error) {
var probe struct {
RoomID string `json:"room_id"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return "", fmt.Errorf("%w: %v", ErrBadEvent, err)
}
if probe.RoomID == "" {
return "", fmt.Errorf("%w: no room_id", ErrBadEvent)
}
return probe.RoomID, nil
}
// storedOf projects an event onto the row the store keeps.
func storedOf(roomID string, e *event.Event) storage.StoredEvent {
s := storage.StoredEvent{
RoomID: roomID,
EventID: e.EventID(),
Type: e.Type(),
Sender: e.Sender(),
Depth: e.Depth(),
OriginServerTS: e.OriginServerTS(),
JSON: e.JSON(),
}
if key, ok := e.StateKey(); ok {
s.StateKey = &key
}
s.RelatesTo, s.RelType = relationOf(e.Content())
s.SearchKey, s.SearchText = searchableText(e)
return s
}
// supportedVersionStrings lists the room versions Warp can join, for the
// ?ver= parameters of make_join.
func supportedVersionStrings() []string {
versions := roomversion.Supported()
out := make([]string, 0, len(versions))
for _, v := range versions {
out = append(out, string(v))
}
return out
}
// eventsByDepth sorts events oldest first, with the event ID as the tie
// break so that the order is the same on every run.
func eventsByDepth(events []*event.Event) {
sort.SliceStable(events, func(i, j int) bool {
if events[i].Depth() != events[j].Depth() {
return events[i].Depth() < events[j].Depth()
}
return events[i].EventID() < events[j].EventID()
})
}
// authCheckChain checks a set of events from another server against their
// own auth events, oldest first, and returns the ones that pass.
//
// It is how a room's state and auth chain, as handed over by a join or a
// /state fetch, is judged: nothing in it is trusted because the resident
// server sent it, and an event whose auth events failed fails with them.
// known supplies auth events from outside the set — ones this server
// already holds.
func (r *API) authCheckChain(ctx context.Context, events []*event.Event, known map[string]*event.Event) (map[string]*event.Event, []string) {
eventsByDepth(events)
passed := map[string]*event.Event{}
var rejected []string
lookup := func(id string) *event.Event {
if e, ok := passed[id]; ok {
return e
}
return known[id]
}
// The create event is found first: from room version 12 nobody cites
// it, and every rule needs it.
var create *event.Event
for _, e := range events {
if e.Type() == event.TypeCreate {
create = e
break
}
}
if create == nil {
for _, e := range known {
if e.Type() == event.TypeCreate {
create = e
break
}
}
}
for _, e := range events {
var authEvents []*event.Event
missing := false
for _, id := range e.AuthEventIDs() {
ae := lookup(id)
if ae == nil {
missing = true
break
}
authEvents = append(authEvents, ae)
}
if missing {
rejected = append(rejected, e.EventID())
continue
}
if err := auth.CheckAuthEvents(e, authEvents); err != nil {
rejected = append(rejected, e.EventID())
continue
}
st := auth.NewState(authEvents)
if st.Create() == nil && create != nil {
st[auth.StateKey{Type: event.TypeCreate}] = create
}
if err := auth.Check(ctx, e, st, r.verifier()); err != nil {
r.log.Debug("event in a received chain fails authorisation",
"event_id", e.EventID(), "type", e.Type(), "error", err)
rejected = append(rejected, e.EventID())
continue
}
passed[e.EventID()] = e
}
return passed, rejected
}
// inRoom reports whether an event belongs to a room. A create event of
// room version 12 carries no room_id; it belongs to the room its own ID
// names.
func inRoom(e *event.Event, roomID string) bool {
if e.Type() == event.TypeCreate && e.Rules().RoomIDIsCreateEventID {
id, err := event.RoomIDOfCreateEvent(e)
return err == nil && id == roomID
}
return e.RoomID() == roomID
}