Files
Warp/server/internal/roomserver/federated_membership.go
T
tajniak81andClaude Opus 5.5 6aaf554336
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 5: federation
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>
2026-10-16 17:14:20 +02:00

788 lines
27 KiB
Go

package roomserver
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"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"
)
// Membership across servers.
//
// A server can only sign as itself, and a room's state lives with the
// servers in it, so every membership change that crosses a server
// boundary is a little protocol of its own:
//
// - Joining a room this server is not in: ask a resident server for a
// join template (make_join), fill it in and sign it, and hand it back
// (send_join), which returns the room's state.
// - Leaving one it is not in — rejecting an invite, in practice — is the
// same with make_leave and send_leave.
// - Inviting a user of another server: their server has to countersign
// the invite before it can go into the room.
//
// And the other side of each, for when the other server is the one asking.
// joinNeedsFederation reports whether a join has to go through another
// server: this server does not hold the room, holds only an invite to it,
// or holds a stale copy of a room it has left that still has remote
// members to ask.
func (r *API) joinNeedsFederation(ctx context.Context, req JoinRequest) (bool, error) {
if _, err := r.store.Room(ctx, req.RoomID); errors.Is(err, ErrNoSuchRoom) {
return true, nil
} else if err != nil {
return false, err
}
if _, err := r.store.CurrentStateEvent(ctx, req.RoomID, event.TypeCreate, ""); isNotFound(err) {
return true, nil
} else if err != nil {
return false, err
}
in, err := r.participating(ctx, req.RoomID)
if err != nil || in {
return false, err
}
// Every local user has left. A room that only ever had local members
// is still this server's to rejoin on its own; one with members
// elsewhere has moved on without us, and has to be asked.
servers, err := r.JoinedServers(ctx, req.RoomID)
if err != nil {
return false, err
}
return slices.ContainsFunc(servers, func(s string) bool { return s != r.serverName }), nil
}
// joinRemote joins a room through another server, trying each candidate
// in turn.
func (r *API) joinRemote(ctx context.Context, req JoinRequest) (string, error) {
if err := r.requireFederation(fmt.Sprintf("%s is not a room on this server, and joining a remote room", req.RoomID)); err != nil {
return "", err
}
servers := r.candidateServers(ctx, req.RoomID, req.ServerNames)
if len(servers) == 0 {
return "", fmt.Errorf("%w: no server to join %s through", ErrNoSuchRoom, req.RoomID)
}
var firstErr error
for _, server := range servers {
err := r.joinVia(ctx, server, req)
if err == nil {
return req.RoomID, nil
}
r.log.Info("joining through a server failed",
"room_id", req.RoomID, "server", server, "user_id", req.User, "error", err)
// The first server's answer is the one worth reporting: it is the
// one the client named, or the room's own.
if firstErr == nil {
firstErr = err
}
}
return "", firstErr
}
// eventTemplate is the part of a make_join or make_leave answer the
// joining server uses.
type eventTemplate struct {
RoomID string `json:"room_id"`
Type string `json:"type"`
StateKey *string `json:"state_key"`
Sender string `json:"sender"`
Content json.RawMessage `json:"content"`
PrevEvents []string `json:"prev_events"`
AuthEvents []string `json:"auth_events"`
Depth int64 `json:"depth"`
}
// buildFromTemplate fills in and signs a membership template from another
// server. The template's DAG references are kept as given — they are what
// the resident server knows and this one does not — and its content is
// merged with this server's own, so that a restricted join keeps the
// authorisation the resident server chose.
func (r *API) buildFromTemplate(raw json.RawMessage, rules roomversion.Rules, roomID, userID, membership string, extra map[string]any) (*event.Event, error) {
var tmpl eventTemplate
if err := json.Unmarshal(raw, &tmpl); err != nil {
return nil, fmt.Errorf("%w: the event template is unreadable: %v", ErrRemote, err)
}
if tmpl.Type != event.TypeMember || tmpl.StateKey == nil || *tmpl.StateKey != userID ||
tmpl.Sender != userID || tmpl.RoomID != roomID {
return nil, fmt.Errorf("%w: the event template is not a membership event for %s in %s", ErrRemote, userID, roomID)
}
content := map[string]any{}
if len(tmpl.Content) > 0 {
if err := json.Unmarshal(tmpl.Content, &content); err != nil {
return nil, fmt.Errorf("%w: the template content is not an object", ErrRemote)
}
}
for k, v := range extra {
content[k] = v
}
content["membership"] = membership
builder := event.Builder{
RoomID: roomID,
Type: event.TypeMember,
StateKey: statePtr(userID),
Sender: userID,
Content: mustMarshal(content),
PrevEvents: tmpl.PrevEvents,
AuthEvents: tmpl.AuthEvents,
Depth: tmpl.Depth,
}
built, err := builder.Build(r.now(), r.signingKey, rules)
if err != nil {
return nil, fmt.Errorf("%w: the template does not make a valid event: %v", ErrRemote, err)
}
return built, nil
}
// joinVia runs make_join and send_join against one server.
func (r *API) joinVia(ctx context.Context, server string, req JoinRequest) error {
tmpl, err := r.remote.MakeJoin(ctx, server, req.RoomID, req.User, supportedVersionStrings())
if err != nil {
return remoteError(server, err)
}
version := tmpl.RoomVersion
if version == "" {
// The spec's default for a server too old to say.
version = "1"
}
rules, err := roomversion.Get(roomversion.Version(version))
if err != nil {
return fmt.Errorf("%w: %s is version %q: %v", ErrIncompatibleRoomVersion, req.RoomID, version, err)
}
// The profile and the reason are this server's to add; anything about
// authorisation is the template's.
extra := r.membershipContentMap(ctx, event.MembershipJoin, req.User, req.Reason, false)
delete(extra, "membership")
join, err := r.buildFromTemplate(tmpl.Event, rules, req.RoomID, req.User, event.MembershipJoin, extra)
if err != nil {
return err
}
resp, err := r.remote.SendJoin(ctx, server, req.RoomID, join.EventID(), join.JSON())
if err != nil {
return remoteError(server, err)
}
// A restricted join comes back countersigned by the server that
// authorised it, and that copy is the one the room will hold.
if len(resp.Event) > 0 {
countersigned, err := parseRemote(resp.Event, rules)
if err == nil && countersigned.EventID() == join.EventID() {
join = countersigned
}
}
return r.completeJoin(ctx, server, req.RoomID, rules, join, resp)
}
// completeJoin takes in the room a send_join answer describes, and the
// join on top of it.
//
// Every event of the state and auth chain is verified and authorised
// against its own auth events before any of it is believed: the resident
// server is trusted to know the room, not to tell the truth about it.
func (r *API) completeJoin(ctx context.Context, server, roomID string, rules roomversion.Rules, join *event.Event, resp federation.SendJoinResponse) error {
byID := map[string]*event.Event{}
var all []*event.Event
stateIDs := map[string]bool{}
for i, raw := range append(slices.Clone(resp.State), resp.AuthChain...) {
e, err := parseRemote(raw, rules)
if err != nil || !inRoom(e, roomID) {
continue
}
if i < len(resp.State) {
stateIDs[e.EventID()] = true
}
if _, dup := byID[e.EventID()]; dup {
continue
}
byID[e.EventID()] = e
all = append(all, e)
}
verified := r.verifyAll(ctx, all)
passed, rejected := r.authCheckChain(ctx, verified, nil)
if len(rejected) > 0 {
r.log.Info("rejected events in a send_join answer",
"room_id", roomID, "server", server, "rejected", len(rejected))
}
var stateEvents []*event.Event
for _, e := range verified {
if stateIDs[e.EventID()] && e.IsState() {
if _, ok := passed[e.EventID()]; ok {
stateEvents = append(stateEvents, e)
}
}
}
state := auth.NewState(stateEvents)
create := state.Create()
if create == nil {
return fmt.Errorf("%w: %s sent the state of %s without its create event", ErrRemote, server, roomID)
}
if err := auth.Check(ctx, join, state, r.verifier()); err != nil {
return fmt.Errorf("the room as %s describes it does not allow the join: %w", server, err)
}
mu := r.lock(roomID)
mu.Lock()
defer mu.Unlock()
if err := r.store.CreateRoom(ctx, storage.NewRoom{
ID: roomID,
Version: string(rules.Version),
Creator: create.Sender(),
CreatedAtMS: r.nowMS(),
}); err != nil && !errors.Is(err, storage.ErrRoomExists) {
return err
}
room, err := r.store.Room(ctx, roomID)
if err != nil {
return err
}
if room.Version != string(rules.Version) {
return fmt.Errorf("%w: this server knows %s as version %s, and %s says %s",
ErrIncompatibleRoomVersion, roomID, room.Version, server, rules.Version)
}
rows := make([]storage.StoredEvent, 0, len(passed))
for _, e := range verified {
if _, ok := passed[e.EventID()]; ok {
rows = append(rows, storedOf(roomID, e))
}
}
if err := r.store.WriteOutliers(ctx, roomID, rows); err != nil {
return err
}
stateKey, _ := join.StateKey()
if err := r.persist(ctx, persistRequest{
Room: room,
Rules: rules,
Event: join,
StateBefore: state,
CurrentState: withState(state, event.TypeMember, stateKey, join),
// The resident server has the join already and passes it on to
// everybody else; sending it from here as well would only double
// the traffic.
Origin: server,
}); err != nil {
return err
}
r.log.Info("joined a room over federation",
"room_id", roomID, "via", server, "user_id", join.Sender(),
"state_events", len(stateEvents), "auth_chain", len(resp.AuthChain))
return nil
}
// leaveRemote leaves a room this server is not in — rejects an invite,
// that is — through a server that is.
//
// If no server can be reached the invite is still rejected locally: a
// leave is built and stored here and nowhere else. The inviting server
// never hears of it, which is the lesser problem; an invite the user
// cannot get rid of because its server is gone is the greater one.
func (r *API) leaveRemote(ctx context.Context, roomID, userID, reason string) error {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return err
}
membership, err := r.store.Membership(ctx, roomID, userID)
if err != nil {
return err
}
if membership.Membership != event.MembershipInvite && membership.Membership != event.MembershipKnock {
return fmt.Errorf("%w: %s is not in %s", ErrForbidden, userID, roomID)
}
extra := map[string]any{}
if reason != "" {
extra["reason"] = reason
}
if r.federating() {
for _, server := range r.candidateServers(ctx, roomID, nil) {
leave, err := r.leaveVia(ctx, server, rules, roomID, userID, extra)
if err != nil {
r.log.Info("leaving through a server failed",
"room_id", roomID, "server", server, "user_id", userID, "error", err)
continue
}
return r.persistOutOfBand(ctx, room, leave, nil, server)
}
}
// Nobody could be asked: reject locally, on top of the invite.
inviteStored, err := r.store.Event(ctx, roomID, membership.EventID)
if err != nil {
return err
}
invite, err := parseEvent(inviteStored, rules)
if err != nil {
return err
}
extra["membership"] = event.MembershipLeave
builder := event.Builder{
RoomID: roomID,
Type: event.TypeMember,
StateKey: statePtr(userID),
Sender: userID,
Content: mustMarshal(extra),
PrevEvents: []string{invite.EventID()},
AuthEvents: []string{invite.EventID()},
Depth: nextDepth([]*event.Event{invite}),
}
leave, err := builder.Build(r.now(), r.signingKey, rules)
if err != nil {
return err
}
r.log.Info("rejected an invite locally; no server in the room could be reached",
"room_id", roomID, "user_id", userID)
return r.persistOutOfBand(ctx, room, leave, nil, "")
}
// leaveVia runs make_leave and send_leave against one server.
func (r *API) leaveVia(ctx context.Context, server string, rules roomversion.Rules, roomID, userID string, extra map[string]any) (*event.Event, error) {
tmpl, err := r.remote.MakeLeave(ctx, server, roomID, userID)
if err != nil {
return nil, remoteError(server, err)
}
leave, err := r.buildFromTemplate(tmpl.Event, rules, roomID, userID, event.MembershipLeave, extra)
if err != nil {
return nil, err
}
if err := r.remote.SendLeave(ctx, server, roomID, leave.EventID(), leave.JSON()); err != nil {
return nil, remoteError(server, err)
}
return leave, nil
}
// persistOutOfBand stores a membership event for a room this server is not
// in: an invite it was asked to countersign, or the rejection of one.
//
// The event is an outlier — there is no state here to put it in the
// timeline against — and still becomes the user's membership in the
// room's current state, which is the one thing /sync needs to tell them
// they were invited, or are no longer.
func (r *API) persistOutOfBand(ctx context.Context, room storage.Room, e *event.Event, inviteState []byte, origin string) error {
mu := r.lock(room.ID)
mu.Lock()
defer mu.Unlock()
stateKey, _ := e.StateKey()
membership, err := e.Membership()
if err != nil {
return err
}
stored := storedOf(room.ID, e)
ordering, err := r.store.Write(ctx, storage.RoomWrite{
Event: stored,
Outlier: true,
StateDelta: []storage.StateEntry{{Type: event.TypeMember, StateKey: stateKey, EventID: e.EventID(), Membership: membership}},
InviteState: inviteState,
})
if err != nil {
return err
}
if r.notifier != nil {
r.notifier.EventPersisted(ctx, Notification{
RoomID: room.ID,
Event: e,
StreamOrdering: ordering,
StateChanged: true,
Origin: origin,
})
}
return nil
}
// inviteRemote sends an invite to a user of another server through the
// pipeline, with their server's countersignature taken under the room
// lock.
func (r *API) inviteRemote(ctx context.Context, req InviteRequest) error {
if err := r.requireFederation(fmt.Sprintf("%s is on another server, and inviting a remote user", req.Target)); err != nil {
return err
}
targetServer, err := id.ServerNameOf(req.Target)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidContent, err)
}
room, rules, err := r.roomAndRules(ctx, req.RoomID)
if err != nil {
return err
}
_, err = r.send(ctx, sendRequest{
RoomID: req.RoomID,
Type: event.TypeMember,
StateKey: statePtr(req.Target),
Sender: req.Sender,
Content: r.membershipContent(ctx, event.MembershipInvite, req.Target, req.Reason, req.IsDirect),
countersign: func(ctx context.Context, invite *event.Event) (*event.Event, error) {
stripped, err := r.strippedState(ctx, room.ID, rules, []string{req.Sender})
if err != nil {
return nil, err
}
strippedRaw := make([]json.RawMessage, 0, len(stripped))
for _, s := range stripped {
strippedRaw = append(strippedRaw, mustMarshal(s))
}
resp, err := r.remote.Invite(ctx, targetServer, room.ID, invite.EventID(), federation.InviteRequest{
RoomVersion: string(rules.Version),
Event: invite.JSON(),
InviteRoomState: strippedRaw,
})
if err != nil {
return nil, remoteError(targetServer, err)
}
signed, err := parseRemote(resp.Event, rules)
if err != nil {
return nil, fmt.Errorf("%w: %s countersigned something unreadable: %v", ErrRemote, targetServer, err)
}
if signed.EventID() != invite.EventID() {
return nil, fmt.Errorf("%w: %s returned a different event from the invite", ErrRemote, targetServer)
}
if err := r.verifier().VerifyEventSignature(ctx, targetServer, signed); err != nil {
return nil, fmt.Errorf("%w: %s did not countersign the invite: %v", ErrRemote, targetServer, err)
}
return signed, nil
},
})
return err
}
// ---------------------------------------------------------------------------
// The other side: requests from servers joining, leaving and inviting.
// MakeJoin answers GET /make_join: a join template for a user of origin.
func (r *API) MakeJoin(ctx context.Context, origin, roomID, userID string, versions []string) (string, json.RawMessage, error) {
return r.makeMembership(ctx, origin, roomID, userID, event.MembershipJoin, versions)
}
// MakeLeave answers GET /make_leave.
func (r *API) MakeLeave(ctx context.Context, origin, roomID, userID string) (string, json.RawMessage, error) {
return r.makeMembership(ctx, origin, roomID, userID, event.MembershipLeave, nil)
}
// makeMembership builds a membership template for another server's user.
//
// The template is the event as this server would build it, minus the
// signature and hashes only the user's own server can add. It is checked
// against the room before it is handed out, so that a banned user is told
// so at make_join rather than after signing and sending a join that was
// never going to be accepted.
func (r *API) makeMembership(ctx context.Context, origin, roomID, userID, membership string, versions []string) (string, json.RawMessage, error) {
if err := r.requireFederation("answering another server's membership request"); err != nil {
return "", nil, err
}
if server, err := id.ServerNameOf(userID); err != nil || server != origin {
return "", nil, fmt.Errorf("%w: %s cannot ask on behalf of %s", ErrForbidden, origin, userID)
}
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return "", nil, err
}
if in, err := r.participating(ctx, roomID); err != nil {
return "", nil, err
} else if !in {
return "", nil, fmt.Errorf("%w: this server is not in %s", ErrNotInRoom, roomID)
}
if err := r.requireAllowed(ctx, roomID, origin); err != nil {
return "", nil, err
}
if membership == event.MembershipJoin {
if len(versions) == 0 {
versions = []string{"1"}
}
if !slices.Contains(versions, room.Version) {
return "", nil, fmt.Errorf("%w: %s is version %s", ErrIncompatibleRoomVersion, roomID, room.Version)
}
}
mu := r.lock(roomID)
mu.Lock()
defer mu.Unlock()
st, extremities, _, err := r.roomState(ctx, room, rules)
if err != nil {
return "", nil, err
}
content := map[string]any{"membership": membership}
if membership == event.MembershipJoin {
authoriser, err := r.restrictedJoinAuthoriser(ctx, JoinRequest{RoomID: roomID, User: userID})
if err != nil {
return "", nil, err
}
if authoriser != "" {
content["join_authorised_via_users_server"] = authoriser
}
}
contentRaw := mustMarshal(content)
authEvents, err := auth.SelectAuthEvents(rules, event.TypeMember, userID, &userID, contentRaw, st)
if err != nil {
return "", nil, fmt.Errorf("%w: %s", ErrRejected, err)
}
tmpl := map[string]any{
"room_id": roomID,
"type": event.TypeMember,
"state_key": userID,
"sender": userID,
"content": contentRaw,
"prev_events": eventIDs(extremities),
"auth_events": eventIDs(authEvents),
"depth": nextDepth(extremities),
"origin_server_ts": r.nowMS(),
"origin": r.serverName,
}
// A trial build, signed with this server's key in the user's name. The
// signature is worthless and nothing here checks it: what is being
// asked is only whether the room would let this user in or out.
trial, err := (&event.Builder{
RoomID: roomID, Type: event.TypeMember, StateKey: &userID, Sender: userID,
Content: contentRaw, PrevEvents: eventIDs(extremities), AuthEvents: eventIDs(authEvents),
Depth: nextDepth(extremities),
}).Build(r.now(), r.signingKey, rules)
if err != nil {
return "", nil, err
}
if _, ok := content["join_authorised_via_users_server"]; ok {
// The real join will carry this server's countersignature, added
// at send_join; the trial needs it too, or the rule that checks
// for it refuses the join this server is about to authorise.
if trial, err = trial.Sign(r.serverName, r.signingKey); err != nil {
return "", nil, err
}
}
if err := auth.Check(ctx, trial, checkState(authEvents, st), r.verifier()); err != nil {
return "", nil, err
}
return room.Version, mustMarshal(tmpl), nil
}
// SendJoinResult is what PUT /send_join answers with.
type SendJoinResult struct {
State []json.RawMessage
AuthChain []json.RawMessage
// Event is the join countersigned by this server, for a restricted
// join it authorised; nil otherwise.
Event json.RawMessage
ServersInRoom []string
}
// SendJoin takes a signed join from another server's user into the room
// and returns the room as of the join.
func (r *API) SendJoin(ctx context.Context, origin, roomID, eventID string, raw json.RawMessage) (SendJoinResult, error) {
e, room, rules, err := r.receiveMembership(ctx, origin, roomID, eventID, raw, event.MembershipJoin)
if err != nil {
return SendJoinResult{}, err
}
var countersigned json.RawMessage
var content struct {
AuthorisedVia string `json:"join_authorised_via_users_server"`
}
_ = json.Unmarshal(e.Content(), &content)
if content.AuthorisedVia != "" && r.isLocal(content.AuthorisedVia) {
if e, err = e.Sign(r.serverName, r.signingKey); err != nil {
return SendJoinResult{}, err
}
countersigned = e.JSON()
}
if err := r.receive(ctx, incoming{origin: origin, room: room, rules: rules, event: e, relay: true}); err != nil {
return SendJoinResult{}, err
}
stored, err := r.store.Event(ctx, roomID, e.EventID())
if err != nil {
return SendJoinResult{}, err
}
if stored.Outlier {
return SendJoinResult{}, fmt.Errorf("%w: the join was not accepted by the room's current state", ErrForbidden)
}
stateIDs, err := r.stateIDsBefore(ctx, roomID, e.EventID())
if err != nil {
return SendJoinResult{}, err
}
state, err := r.rawEvents(ctx, roomID, stateIDs)
if err != nil {
return SendJoinResult{}, err
}
chainIDs, err := r.authChainIDs(ctx, roomID, rules, append(slices.Clone(stateIDs), e.AuthEventIDs()...))
if err != nil {
return SendJoinResult{}, err
}
chain, err := r.rawEvents(ctx, roomID, chainIDs)
if err != nil {
return SendJoinResult{}, err
}
servers, err := r.JoinedServers(ctx, roomID)
if err != nil {
return SendJoinResult{}, err
}
return SendJoinResult{State: state, AuthChain: chain, Event: countersigned, ServersInRoom: servers}, nil
}
// SendLeave takes a signed leave from another server's user into the
// room.
func (r *API) SendLeave(ctx context.Context, origin, roomID, eventID string, raw json.RawMessage) error {
e, room, rules, err := r.receiveMembership(ctx, origin, roomID, eventID, raw, event.MembershipLeave)
if err != nil {
return err
}
return r.receive(ctx, incoming{origin: origin, room: room, rules: rules, event: e, relay: true})
}
// receiveMembership checks a send_join or send_leave body: the event the
// path names, a membership event of the right kind, by a user of the
// server sending it, about themselves.
func (r *API) receiveMembership(ctx context.Context, origin, roomID, eventID string, raw json.RawMessage, membership string) (*event.Event, storage.Room, roomversion.Rules, error) {
fail := func(err error) (*event.Event, storage.Room, roomversion.Rules, error) {
return nil, storage.Room{}, roomversion.Rules{}, err
}
if err := r.requireFederation("answering another server's membership request"); err != nil {
return fail(err)
}
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return fail(err)
}
if in, err := r.participating(ctx, roomID); err != nil {
return fail(err)
} else if !in {
return fail(fmt.Errorf("%w: this server is not in %s", ErrNotInRoom, roomID))
}
if err := r.requireAllowed(ctx, roomID, origin); err != nil {
return fail(err)
}
e, err := parseRemote(raw, rules)
if err != nil {
return fail(err)
}
stateKey, _ := e.StateKey()
got, _ := e.Membership()
sender, _ := e.SenderServer()
switch {
case e.EventID() != eventID:
return fail(fmt.Errorf("%w: the event is %s, not %s", ErrBadEvent, e.EventID(), eventID))
case e.RoomID() != roomID:
return fail(fmt.Errorf("%w: the event is in %s, not %s", ErrBadEvent, e.RoomID(), roomID))
case e.Type() != event.TypeMember || got != membership:
return fail(fmt.Errorf("%w: not a %s event", ErrBadEvent, membership))
case stateKey != e.Sender():
return fail(fmt.Errorf("%w: a %s must be the sender's own", ErrBadEvent, membership))
case sender != origin:
return fail(fmt.Errorf("%w: %s cannot send a %s for %s", ErrForbidden, origin, membership, e.Sender()))
}
return e, room, rules, nil
}
// ReceiveInvite answers PUT /invite: another server asks this one to
// countersign an invite to one of its users.
func (r *API) ReceiveInvite(ctx context.Context, origin, roomVersion string, raw json.RawMessage, inviteRoomState []json.RawMessage) (json.RawMessage, error) {
if err := r.requireFederation("receiving an invite from another server"); err != nil {
return nil, err
}
rules, err := roomversion.Get(roomversion.Version(roomVersion))
if err != nil {
return nil, fmt.Errorf("%w: version %q: %v", ErrIncompatibleRoomVersion, roomVersion, err)
}
e, err := parseRemote(raw, rules)
if err != nil {
return nil, err
}
target, _ := e.StateKey()
membership, _ := e.Membership()
sender, _ := e.SenderServer()
switch {
case e.Type() != event.TypeMember || membership != event.MembershipInvite:
return nil, fmt.Errorf("%w: not an invite", ErrBadEvent)
case sender != origin:
return nil, fmt.Errorf("%w: %s cannot send an invite from %s", ErrForbidden, origin, e.Sender())
case !r.isLocal(target):
return nil, fmt.Errorf("%w: %s is not a user of this server", ErrForbidden, target)
}
if localpart, _ := r.localpartOf(target); r.profiles != nil {
if _, err := r.profiles.Account(ctx, localpart); errors.Is(err, storage.ErrNoSuchUser) {
return nil, fmt.Errorf("%w: %s does not exist", ErrNoSuchEvent, target)
} else if err != nil {
return nil, err
}
}
verified, err := r.verifyIncoming(ctx, e)
if err != nil {
return nil, err
}
signed, err := verified.Sign(r.serverName, r.signingKey)
if err != nil {
return nil, err
}
roomID := signed.RoomID()
// A room this server is in hears about the invite like any other
// event, and judges it the same way. One it is not in gets the invite
// as an out-of-band membership with the state the inviter sent along.
in, err := r.participating(ctx, roomID)
if err != nil {
return nil, err
}
if in {
if err := r.requireAllowed(ctx, roomID, origin); err != nil {
return nil, err
}
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return nil, err
}
if err := r.receive(ctx, incoming{origin: origin, room: room, rules: rules, event: signed, fetchMissing: true}); err != nil {
return nil, err
}
return signed.JSON(), nil
}
if err := r.store.CreateRoom(ctx, storage.NewRoom{
ID: roomID, Version: roomVersion, Creator: signed.Sender(), CreatedAtMS: r.nowMS(),
}); err != nil && !errors.Is(err, storage.ErrRoomExists) {
return nil, err
}
room, err := r.store.Room(ctx, roomID)
if err != nil {
return nil, err
}
var stripped []json.RawMessage
for _, s := range inviteRoomState {
var probe map[string]json.RawMessage
if json.Unmarshal(s, &probe) == nil && probe != nil {
stripped = append(stripped, s)
}
}
if err := r.persistOutOfBand(ctx, room, signed, mustMarshal(orEmptyRaw(stripped)), origin); err != nil {
return nil, err
}
r.log.Info("received an invite over federation",
"room_id", roomID, "user_id", target, "sender", signed.Sender())
return signed.JSON(), nil
}
func orEmptyRaw(s []json.RawMessage) []json.RawMessage {
if s == nil {
return []json.RawMessage{}
}
return s
}