Files
Warp/server/internal/roomserver/remote.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

336 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)
}
// 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
}
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
}