Files
Warp/server/internal/syncapi/todevice.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

188 lines
6.2 KiB
Go

package syncapi
import (
"context"
"encoding/json"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// To-device messaging: a message from one device to another, which the
// server stores and forwards without reading.
//
// It exists for end-to-end encryption — a device sending another one a
// Megolm session key is a to-device message — and the server deliberately
// cannot tell what is in one. Phase 7 builds the keyserver on top of this;
// the transport itself belongs here, because the only way a device ever
// receives one is in a /sync response.
// wildcardDevice is the device ID that means "every device of this user".
const wildcardDevice = "*"
// SendToDeviceRequest is PUT /sendToDevice/{eventType}/{txnID}.
type SendToDeviceRequest struct {
// Sender is the full user ID sending, which is the caller.
Sender string
EventType string
// Messages is user ID -> device ID -> content, as the request body
// nests it. A device ID of "*" addresses every device of that user.
Messages map[string]map[string]json.RawMessage
}
// SendToDevice queues messages for delivery.
//
// A message to a user of another server goes to the federation queue. With
// federation off it is dropped rather than refused. That is deliberate: a
// client encrypting for a room with remote members sends one message per
// device in one request, and failing the request because federation is
// off would stop it from delivering to the local devices as well. The
// dropped recipients simply never see the key and their client reports an
// undecryptable message, which is what they would see anyway.
func (s *API) SendToDevice(ctx context.Context, req SendToDeviceRequest) error {
if req.EventType == "" {
return errInvalidParamf("an event type is required")
}
var (
queued []storage.NewToDeviceMessage
woken []string
remote = map[string]map[string]json.RawMessage{}
sentAt = s.nowMS()
)
for userID, byDevice := range req.Messages {
localpart, ok := s.localpartOf(userID)
if !ok {
// A remote recipient goes to the federation queue when there
// is one and the sender is ours; a remote sender's message to
// a remote user is not this server's to forward.
if s.outbound != nil && s.isLocal(req.Sender) {
remote[userID] = byDevice
continue
}
s.log.Debug("dropping a to-device message for a remote user",
"sender", req.Sender, "recipient", userID)
continue
}
for deviceID, content := range byDevice {
if deviceID != wildcardDevice {
queued = append(queued, storage.NewToDeviceMessage{
Localpart: localpart,
DeviceID: deviceID,
Sender: req.Sender,
Type: req.EventType,
Content: contentOrEmpty(content),
SentAtMS: sentAt,
})
continue
}
// A wildcard is expanded here rather than stored as one: the
// set of a user's devices changes, and a message addressed to
// "every device" means the ones that existed when it was sent.
// Storing the wildcard would deliver it to a device that was
// created afterwards, which for a key share is both useless
// and a leak.
devices, err := s.accounts.Devices(ctx, localpart)
if err != nil {
return err
}
for _, device := range devices {
queued = append(queued, storage.NewToDeviceMessage{
Localpart: localpart,
DeviceID: device.ID,
Sender: req.Sender,
Type: req.EventType,
Content: contentOrEmpty(content),
SentAtMS: sentAt,
})
}
}
woken = append(woken, userID)
}
if len(remote) > 0 {
s.outbound.ToDevice(ctx, req.Sender, req.EventType, remote)
}
if len(queued) == 0 {
return nil
}
if _, err := s.store.SendToDevice(ctx, queued); err != nil {
return err
}
s.notifier.NotifyUsers(woken)
return nil
}
// contentOrEmpty keeps a null or absent content out of the store: the
// to-device content is passed through untouched, and "untouched" has to
// still be valid JSON on the way out.
func contentOrEmpty(content json.RawMessage) []byte {
if len(content) == 0 {
return []byte("{}")
}
return content
}
// toDeviceEvent is one message in the shape a sync reports it.
type toDeviceEvent struct {
Sender string `json:"sender"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
}
// toDeviceMessages reads a device's queue between two positions and returns
// the events plus the position of the last one.
//
// The returned position is what the next token carries, and it is the
// position of the last message *delivered* rather than the top of the
// stream: a queue longer than one batch is drained over several syncs, and
// a token naming the top would skip the rest.
func (s *API) toDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64) ([]toDeviceEvent, int64, error) {
if deviceID == "" {
// An access token always names a device, so this cannot happen
// through the API; a guest session later might.
return nil, from, nil
}
msgs, err := s.store.ToDeviceMessages(ctx, localpart, deviceID, from, to, 0)
if err != nil {
return nil, 0, err
}
if len(msgs) == 0 {
return nil, from, nil
}
out := make([]toDeviceEvent, 0, len(msgs))
for _, m := range msgs {
out = append(out, toDeviceEvent{
Sender: m.Sender,
Type: m.Type,
Content: m.Content,
})
}
return out, msgs[len(msgs)-1].StreamPos, nil
}
// ackToDevice deletes the messages a device has acknowledged by syncing
// past them.
//
// A device acknowledges a batch by sending back the token that batch came
// with, so the deletion happens on the *next* sync rather than when the
// batch is sent. A client that crashes between the two gets the batch
// again, which is the right way round: a lost key share leaves a message
// permanently undecryptable, and a duplicate one is ignored.
func (s *API) ackToDevice(ctx context.Context, localpart, deviceID string, through int64) {
if deviceID == "" || through <= 0 {
return
}
if err := s.store.DeleteToDeviceMessages(ctx, localpart, deviceID, through); err != nil {
// Failing the sync over this would be worse than leaving the rows:
// the client has the messages, and the next sync will try again.
s.log.Warn("could not drop acknowledged to-device messages",
"localpart", localpart, "device_id", deviceID, "through", through, "error", err)
}
}