package federationapi import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "time" "github.com/dariuszjaniak/warp/server/internal/buildinfo" "github.com/dariuszjaniak/warp/server/internal/federation" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/matrix/id" "github.com/dariuszjaniak/warp/server/internal/mediaapi" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/syncapi" ) // maxBody caps a federation request body. A transaction is up to 50 PDUs // of up to 64 KiB each, plus EDUs; anything past this is not a // transaction. const maxBody = 8 << 20 // Error codes the federation API uses beyond the Client-Server set. const ( errCodeIncompatibleRoomVersion = "M_INCOMPATIBLE_ROOM_VERSION" errCodeUnableToAuthoriseJoin = "M_UNABLE_TO_AUTHORISE_JOIN" ) // EphemeralSink takes in EDUs from other servers. *syncapi.API satisfies // it. type EphemeralSink interface { SetTyping(ctx context.Context, req syncapi.TypingRequest) error SetReceipt(ctx context.Context, req syncapi.ReceiptRequest) error SendToDevice(ctx context.Context, req syncapi.SendToDeviceRequest) error } // Profiles answers profile queries for local users. *userapi.API // satisfies it. type Profiles interface { Account(ctx context.Context, localpart string) (storage.Account, error) } // ServerOptions configure the inbound API. type ServerOptions struct { ServerName string Gate *federation.Gate Keys *federation.KeyRing Rooms *roomserver.API Ephemeral EphemeralSink Profiles Profiles Store storage.FederationStore // Media serves this server's media to others. Nil leaves the media // endpoints unregistered. Media *mediaapi.API // UserKeys answers queries and claims of this server's users' // end-to-end keys, and takes in key update EDUs. Nil leaves the key // endpoints unregistered. UserKeys KeyService Logger *slog.Logger Now func() time.Time } // Server is the inbound Server-Server API. type Server struct { serverName string gate *federation.Gate keys *federation.KeyRing rooms *roomserver.API ephemeral EphemeralSink profiles Profiles store storage.FederationStore media *mediaapi.API keyService KeyService log *slog.Logger now func() time.Time } // NewServer builds the inbound API. func NewServer(opts ServerOptions) (*Server, error) { switch { case opts.ServerName == "": return nil, errors.New("federationapi: ServerName must not be empty") case opts.Gate == nil, opts.Keys == nil, opts.Rooms == nil, opts.Store == nil: return nil, errors.New("federationapi: Gate, Keys, Rooms and Store are required") } if opts.Logger == nil { opts.Logger = slog.Default() } if opts.Now == nil { opts.Now = time.Now } return &Server{ serverName: opts.ServerName, gate: opts.Gate, keys: opts.Keys, rooms: opts.Rooms, ephemeral: opts.Ephemeral, profiles: opts.Profiles, store: opts.Store, media: opts.Media, keyService: opts.UserKeys, log: opts.Logger, now: opts.Now, }, nil } // authedHandler is a federation handler that runs after the request has // been authenticated, with the origin and the body it was signed over. type authedHandler func(w http.ResponseWriter, r *http.Request, origin string, body []byte) error // RegisterRoutes adds every federation endpoint, each behind the gate: with // the switch off the whole surface answers M_UNRECOGNIZED, as a server // with no federation API would. func (s *Server) RegisterRoutes(r *httputil.Router) { public := func(pattern string, h httputil.Handler) { r.Handle(pattern, s.gate.ProtectFunc(h)) } authed := func(pattern string, h authedHandler) { r.Handle(pattern, s.gate.ProtectFunc(s.authenticated(h))) } // Keys and version need no authentication: a server has to be able to // fetch our key before it can check anything we sign. public("GET /_matrix/key/v2/server", s.handleServerKey) public("GET /_matrix/key/v2/server/{keyID}", s.handleServerKey) public("POST /_matrix/key/v2/query", s.handleKeyQuery) public("GET /_matrix/key/v2/query/{serverName}", s.handleKeyQueryOne) public("GET /_matrix/key/v2/query/{serverName}/{keyID}", s.handleKeyQueryOne) public("GET /_matrix/federation/v1/version", s.handleVersion) const v1, v2 = "/_matrix/federation/v1", "/_matrix/federation/v2" authed("PUT "+v1+"/send/{txnID}", s.handleSend) authed("GET "+v1+"/make_join/{roomID}/{userID}", s.handleMakeJoin) authed("PUT "+v1+"/send_join/{roomID}/{eventID}", s.handleSendJoinV1) authed("PUT "+v2+"/send_join/{roomID}/{eventID}", s.handleSendJoinV2) authed("GET "+v1+"/make_leave/{roomID}/{userID}", s.handleMakeLeave) authed("PUT "+v1+"/send_leave/{roomID}/{eventID}", s.handleSendLeaveV1) authed("PUT "+v2+"/send_leave/{roomID}/{eventID}", s.handleSendLeaveV2) authed("PUT "+v2+"/invite/{roomID}/{eventID}", s.handleInvite) authed("GET "+v1+"/event/{eventID}", s.handleEvent) authed("GET "+v1+"/state/{roomID}", s.handleState) authed("GET "+v1+"/state_ids/{roomID}", s.handleStateIDs) authed("GET "+v1+"/event_auth/{roomID}/{eventID}", s.handleEventAuth) authed("GET "+v1+"/backfill/{roomID}", s.handleBackfill) authed("POST "+v1+"/get_missing_events/{roomID}", s.handleMissingEvents) authed("GET "+v1+"/query/directory", s.handleQueryDirectory) authed("GET "+v1+"/query/profile", s.handleQueryProfile) authed("GET "+v1+"/publicRooms", s.handlePublicRoomsGet) authed("POST "+v1+"/publicRooms", s.handlePublicRoomsPost) authed("GET "+v1+"/hierarchy/{roomID}", s.handleHierarchy) s.registerMediaRoutes(authed) s.registerKeyRoutes(authed) } // authenticated checks X-Matrix and the allow/deny lists before the // handler runs. func (s *Server) authenticated(h authedHandler) httputil.Handler { return func(w http.ResponseWriter, r *http.Request) error { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody)) if err != nil { return httputil.NewError(http.StatusRequestEntityTooLarge, httputil.ErrCodeTooLarge, "Request body too large") } origin, err := s.keys.AuthenticateRequest(r.Context(), r, body) if err != nil { httputil.LoggerFrom(r.Context()).Info("federation request failed authentication", "error", err) return httputil.NewError(http.StatusUnauthorized, httputil.ErrCodeUnauthorized, "%s", err.Error()) } if !s.gate.Policy().Allowed(origin) { return httputil.Forbidden("Federation with %s is not allowed by this server", origin) } return h(w, r, origin, body) } } // fedError maps a roomserver error onto the Matrix error a remote server // is sent. func fedError(err error) error { var matrixErr *httputil.MatrixError switch { case err == nil: return nil case errors.As(err, &matrixErr): return err case errors.Is(err, roomserver.ErrIncompatibleRoomVersion): return httputil.NewError(http.StatusBadRequest, errCodeIncompatibleRoomVersion, "%s", err.Error()) case errors.Is(err, roomserver.ErrNoSuchRoom), errors.Is(err, roomserver.ErrNoSuchEvent), errors.Is(err, roomserver.ErrNoSuchAlias): return httputil.NotFound("%s", err.Error()) case errors.Is(err, roomserver.ErrForbidden), errors.Is(err, roomserver.ErrRejected), errors.Is(err, roomserver.ErrNotInRoom): return httputil.Forbidden("%s", err.Error()) case errors.Is(err, roomserver.ErrBadEvent), errors.Is(err, roomserver.ErrInvalidContent): return httputil.BadJSON("%s", err.Error()) case errors.Is(err, roomserver.ErrFederationDisabled): return httputil.Unrecognized() case errors.Is(err, roomserver.ErrRemote): return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "%s", err.Error()) } return err } // decode reads an already-read body. func decode(body []byte, v any) error { if len(body) == 0 { return httputil.NotJSON() } if err := json.Unmarshal(body, v); err != nil { return httputil.BadJSON("Invalid request body: %v", err) } return nil } // --- keys and version ------------------------------------------------------- func (s *Server) handleServerKey(w http.ResponseWriter, _ *http.Request) error { doc, err := s.keys.OwnKeyResponse() if err != nil { return err } httputil.WriteRawJSON(w, http.StatusOK, doc) return nil } // keyQueryResponse is the notary answer. type keyQueryResponse struct { ServerKeys []json.RawMessage `json:"server_keys"` } func (s *Server) handleKeyQuery(w http.ResponseWriter, r *http.Request) error { var req struct { ServerKeys map[string]json.RawMessage `json:"server_keys"` } if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } resp := keyQueryResponse{ServerKeys: []json.RawMessage{}} for serverName := range req.ServerKeys { docs, err := s.keys.NotaryResponse(serverName) if err != nil { return err } resp.ServerKeys = append(resp.ServerKeys, docs...) } httputil.WriteJSON(w, http.StatusOK, resp) return nil } func (s *Server) handleKeyQueryOne(w http.ResponseWriter, r *http.Request) error { docs, err := s.keys.NotaryResponse(r.PathValue("serverName")) if err != nil { return err } if docs == nil { docs = []json.RawMessage{} } httputil.WriteJSON(w, http.StatusOK, keyQueryResponse{ServerKeys: docs}) return nil } func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) error { var resp federation.VersionResponse resp.Server.Name = "Warp" resp.Server.Version = buildinfo.Version httputil.WriteJSON(w, http.StatusOK, resp) return nil } // --- transactions ------------------------------------------------------------- func (s *Server) handleSend(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { txnID := r.PathValue("txnID") if cached, ok, err := s.store.InboundTransaction(r.Context(), origin, txnID); err != nil { return err } else if ok { httputil.WriteRawJSON(w, http.StatusOK, cached) return nil } var txn federation.Transaction if err := decode(body, &txn); err != nil { return err } if txn.Origin != "" && txn.Origin != origin { return httputil.Forbidden("The transaction's origin is %s, but it was signed by %s", txn.Origin, origin) } if len(txn.PDUs) > maxPDUsPerTxn || len(txn.EDUs) > maxEDUsPerTxn { return httputil.BadJSON("A transaction carries at most %d PDUs and %d EDUs", maxPDUsPerTxn, maxEDUsPerTxn) } resp := federation.SendTransactionResponse{PDUs: map[string]federation.PDUResult{}} for _, raw := range txn.PDUs { eventID, err := s.rooms.ReceivePDU(r.Context(), origin, raw) if eventID == "" { // Unparseable: there is no ID to report a result under. s.log.Info("dropping an unreadable PDU", "origin", origin, "error", err) continue } result := federation.PDUResult{} if err != nil { result.Error = err.Error() s.log.Info("refused a PDU", "origin", origin, "event_id", eventID, "error", err) } resp.PDUs[eventID] = result } for _, edu := range txn.EDUs { s.receiveEDU(r.Context(), origin, edu) } encoded, err := json.Marshal(resp) if err != nil { return err } if err := s.store.RecordInboundTransaction(r.Context(), origin, txnID, encoded, s.now().UnixMilli()); err != nil { s.log.Warn("could not record an inbound transaction", "origin", origin, "txn_id", txnID, "error", err) } httputil.WriteRawJSON(w, http.StatusOK, encoded) return nil } // sameServer reports whether a user belongs to the server that sent the // EDU about them. Nothing in an EDU is signed, so this is the only check // there is, and without it any server could make anybody appear to type. func sameServer(userID, origin string) bool { server, err := id.ServerNameOf(userID) return err == nil && server == origin } // receiveEDU takes one EDU in. Failures are logged and otherwise ignored: // an EDU is ephemeral, and the transaction it came in succeeds whatever // becomes of it. func (s *Server) receiveEDU(ctx context.Context, origin string, edu federation.EDU) { if s.ephemeral == nil { return } switch edu.Type { case federation.EDUTyping: var c federation.TypingContent if json.Unmarshal(edu.Content, &c) != nil || !sameServer(c.UserID, origin) || !s.allowed(ctx, c.RoomID, origin) { return } if err := s.ephemeral.SetTyping(ctx, syncapi.TypingRequest{RoomID: c.RoomID, User: c.UserID, Typing: c.Typing}); err != nil { s.log.Debug("ignored a typing EDU", "origin", origin, "room_id", c.RoomID, "error", err) } case federation.EDUReceipt: var c federation.ReceiptContent if json.Unmarshal(edu.Content, &c) != nil { return } for roomID, byType := range c { if !s.allowed(ctx, roomID, origin) { continue } for receiptType, byUser := range byType { if receiptType != syncapi.ReceiptRead { continue } for userID, data := range byUser { if !sameServer(userID, origin) || len(data.EventIDs) == 0 { continue } if err := s.ephemeral.SetReceipt(ctx, syncapi.ReceiptRequest{ RoomID: roomID, User: userID, ReceiptType: receiptType, EventID: data.EventIDs[len(data.EventIDs)-1], ThreadID: data.Data.ThreadID, }); err != nil { s.log.Debug("ignored a receipt EDU", "origin", origin, "room_id", roomID, "error", err) } } } } case federation.EDUDirectToDev: var c federation.DirectToDeviceContent if json.Unmarshal(edu.Content, &c) != nil || !sameServer(c.Sender, origin) { return } if err := s.ephemeral.SendToDevice(ctx, syncapi.SendToDeviceRequest{ Sender: c.Sender, EventType: c.Type, Messages: c.Messages, }); err != nil { s.log.Debug("ignored a to-device EDU", "origin", origin, "error", err) } case federation.EDUDeviceListUp, federation.EDUSigningKeys: s.receiveKeyEDU(ctx, origin, edu) default: // Presence is off by default and not federated yet. s.log.Debug("ignored an EDU", "origin", origin, "type", edu.Type) } } // --- membership --------------------------------------------------------------- func (s *Server) handleMakeJoin(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { version, tmpl, err := s.rooms.MakeJoin(r.Context(), origin, r.PathValue("roomID"), r.PathValue("userID"), r.URL.Query()["ver"]) if err != nil { if errors.Is(err, roomserver.ErrForbidden) && strings.Contains(err.Error(), "authorise a restricted join") { return httputil.NewError(http.StatusBadRequest, errCodeUnableToAuthoriseJoin, "%s", err.Error()) } return fedError(err) } httputil.WriteJSON(w, http.StatusOK, federation.MakeJoinResponse{RoomVersion: version, Event: tmpl}) return nil } func (s *Server) sendJoin(r *http.Request, origin string, body []byte) (federation.SendJoinResponse, error) { res, err := s.rooms.SendJoin(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"), body) if err != nil { return federation.SendJoinResponse{}, fedError(err) } return federation.SendJoinResponse{ Origin: s.serverName, State: res.State, AuthChain: res.AuthChain, Event: res.Event, ServersInRoom: res.ServersInRoom, }, nil } func (s *Server) handleSendJoinV2(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { resp, err := s.sendJoin(r, origin, body) if err != nil { return err } httputil.WriteJSON(w, http.StatusOK, resp) return nil } // handleSendJoinV1 is the deprecated form, which wraps the same answer in // a [200, body] pair. func (s *Server) handleSendJoinV1(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { resp, err := s.sendJoin(r, origin, body) if err != nil { return err } httputil.WriteJSON(w, http.StatusOK, []any{http.StatusOK, resp}) return nil } func (s *Server) handleMakeLeave(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { version, tmpl, err := s.rooms.MakeLeave(r.Context(), origin, r.PathValue("roomID"), r.PathValue("userID")) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, federation.MakeJoinResponse{RoomVersion: version, Event: tmpl}) return nil } func (s *Server) handleSendLeaveV2(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { if err := s.rooms.SendLeave(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"), body); err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } func (s *Server) handleSendLeaveV1(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { if err := s.rooms.SendLeave(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"), body); err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, []any{http.StatusOK, struct{}{}}) return nil } func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { var req federation.InviteRequest if err := decode(body, &req); err != nil { return err } signed, err := s.rooms.ReceiveInvite(r.Context(), origin, req.RoomVersion, req.Event, req.InviteRoomState) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, federation.InviteResponse{Event: signed}) return nil } // --- reads -------------------------------------------------------------------- func (s *Server) transaction(pdus []json.RawMessage) federation.Transaction { if pdus == nil { pdus = []json.RawMessage{} } return federation.Transaction{Origin: s.serverName, OriginServerTS: s.now().UnixMilli(), PDUs: pdus} } func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { raw, err := s.rooms.EventForServer(r.Context(), origin, r.PathValue("eventID")) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, s.transaction([]json.RawMessage{raw})) return nil } func (s *Server) handleState(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { eventID := r.URL.Query().Get("event_id") if eventID == "" { return httputil.MissingParam("event_id is required") } state, chain, err := s.rooms.StateForServer(r.Context(), origin, r.PathValue("roomID"), eventID) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, federation.StateResponse{PDUs: state, AuthChain: chain}) return nil } func (s *Server) handleStateIDs(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { eventID := r.URL.Query().Get("event_id") if eventID == "" { return httputil.MissingParam("event_id is required") } state, chain, err := s.rooms.StateIDsForServer(r.Context(), origin, r.PathValue("roomID"), eventID) if err != nil { return fedError(err) } if state == nil { state = []string{} } if chain == nil { chain = []string{} } httputil.WriteJSON(w, http.StatusOK, federation.StateIDsResponse{PDUIDs: state, AuthChainIDs: chain}) return nil } func (s *Server) handleEventAuth(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { chain, err := s.rooms.EventAuthForServer(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID")) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, federation.EventAuthResponse{AuthChain: chain}) return nil } func (s *Server) handleBackfill(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { query := r.URL.Query() from := query["v"] if len(from) == 0 { return httputil.MissingParam("v is required") } limit, err := strconv.Atoi(query.Get("limit")) if err != nil || limit <= 0 { return httputil.InvalidParam("limit must be a positive integer") } events, err := s.rooms.BackfillForServer(r.Context(), origin, r.PathValue("roomID"), from, limit) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, s.transaction(events)) return nil } func (s *Server) handleMissingEvents(w http.ResponseWriter, r *http.Request, origin string, body []byte) error { var req federation.MissingEventsRequest if err := decode(body, &req); err != nil { return err } events, err := s.rooms.MissingEventsForServer(r.Context(), origin, r.PathValue("roomID"), req.EarliestEvents, req.LatestEvents, req.Limit, req.MinDepth) if err != nil { return fedError(err) } if events == nil { events = []json.RawMessage{} } httputil.WriteJSON(w, http.StatusOK, federation.MissingEventsResponse{Events: events}) return nil } // --- queries ------------------------------------------------------------------ func (s *Server) handleQueryDirectory(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error { alias := r.URL.Query().Get("room_alias") if alias == "" { return httputil.MissingParam("room_alias is required") } resolved, err := s.rooms.QueryAliasForServer(r.Context(), alias) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, federation.DirectoryResponse{RoomID: resolved.RoomID, Servers: resolved.Servers}) return nil } func (s *Server) handleQueryProfile(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error { query := r.URL.Query() userID := query.Get("user_id") localpart, server, err := id.SplitUserID(userID) if err != nil { return httputil.InvalidParam("user_id is not a user ID") } if server != s.serverName || s.profiles == nil { return httputil.NotFound("%s is not a user of this server", userID) } account, err := s.profiles.Account(r.Context(), localpart) if errors.Is(err, storage.ErrNoSuchUser) { return httputil.NotFound("Profile not found") } else if err != nil { return err } resp := map[string]string{} switch field := query.Get("field"); field { case "": if account.DisplayName != "" { resp["displayname"] = account.DisplayName } if account.AvatarURL != "" { resp["avatar_url"] = account.AvatarURL } case "displayname": if account.DisplayName != "" { resp["displayname"] = account.DisplayName } case "avatar_url": if account.AvatarURL != "" { resp["avatar_url"] = account.AvatarURL } default: return httputil.InvalidParam("field must be displayname or avatar_url") } httputil.WriteJSON(w, http.StatusOK, resp) return nil } func (s *Server) handlePublicRoomsGet(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error { query := r.URL.Query() limit, _ := strconv.Atoi(query.Get("limit")) return s.publicRooms(w, r, roomserver.PublicRoomsRequest{Limit: limit, Since: query.Get("since")}) } func (s *Server) handlePublicRoomsPost(w http.ResponseWriter, r *http.Request, _ string, body []byte) error { var req struct { Limit int `json:"limit"` Since string `json:"since"` Filter struct { GenericSearchTerm string `json:"generic_search_term"` } `json:"filter"` } if len(body) > 0 { if err := decode(body, &req); err != nil { return err } } return s.publicRooms(w, r, roomserver.PublicRoomsRequest{ Limit: req.Limit, Since: req.Since, Search: req.Filter.GenericSearchTerm, }) } func (s *Server) publicRooms(w http.ResponseWriter, r *http.Request, req roomserver.PublicRoomsRequest) error { resp, err := s.rooms.PublicRooms(r.Context(), req) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, resp) return nil } // handleHierarchy implements GET /_matrix/federation/v1/hierarchy/{roomID}: // another server walking one of this server's spaces. func (s *Server) handleHierarchy(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error { roomID := r.PathValue("roomID") suggestedOnly, _ := strconv.ParseBool(r.URL.Query().Get("suggested_only")) resp, err := s.rooms.FederatedHierarchy(r.Context(), origin, roomID, suggestedOnly) if err != nil { return fedError(err) } httputil.WriteJSON(w, http.StatusOK, resp) return nil } // String is for logs. func (s *Server) String() string { return fmt.Sprintf("federation API for %s", s.serverName) } // allowed applies a room's server ACL to an EDU about it. An EDU is not // a room event, but it is still a server speaking in the room, and a // server the room has banned does not get to make its users appear to // type. func (s *Server) allowed(ctx context.Context, roomID, origin string) bool { ok, err := s.rooms.ServerAllowed(ctx, roomID, origin) return err == nil && ok }