Files
Warp/server/internal/federation/client.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

529 lines
17 KiB
Go

package federation
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
)
// Errors an outbound request can fail with before anything is sent.
var (
// ErrDisabled means federation is switched off. Every outbound request
// checks the switch itself, so that a component that missed the
// toggle still cannot reach another server.
ErrDisabled = errors.New("federation is disabled")
// ErrNotAllowed means the destination is excluded by the allow and
// deny lists.
ErrNotAllowed = errors.New("federation with that server is not allowed")
// ErrSelf means a request was addressed to this server. Asking
// ourselves over the network can only be a bug in the caller, and on
// a server whose name does not resolve to itself it would ask
// somebody else.
ErrSelf = errors.New("refusing to send a federation request to this server")
)
// HTTPError is a non-2xx answer from another server.
type HTTPError struct {
Status int
Code string
Message string
}
func (e *HTTPError) Error() string {
if e.Code != "" {
return fmt.Sprintf("remote server answered %d %s: %s", e.Status, e.Code, e.Message)
}
return fmt.Sprintf("remote server answered %d", e.Status)
}
// IsNotFound reports a 404 from another server.
func IsNotFound(err error) bool {
var httpErr *HTTPError
return errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound
}
// Response size limits. A send_join answer for a large room is the room's
// entire state and auth chain, which for a public room of tens of
// thousands of members is hundreds of megabytes; nothing else comes close.
const (
defaultMaxResponse = 16 << 20
largeMaxResponse = 1 << 30
)
// Request timeouts. Joining a large room makes the resident server
// assemble and sign that whole answer first, which takes minutes.
const (
defaultRequestTimeout = time.Minute
longRequestTimeout = 10 * time.Minute
)
// ResolveFunc turns a server name into where to connect.
type ResolveFunc func(ctx context.Context, serverName string) (Resolved, error)
// ClientOptions configure a Client.
type ClientOptions struct {
// ServerName is this server's name: the origin of every request.
ServerName string
// Key signs every request.
Key *signing.KeyPair
// Resolve is server discovery. Nil uses a Resolver.
Resolve ResolveFunc
// TLSConfig is the client TLS configuration, for tests and for
// deployments with a private CA. Nil uses the system roots.
TLSConfig *tls.Config
// Enabled reports the live federation switch. Nil means off.
Enabled func() bool
// Policy filters destinations. Nil allows any.
Policy *Policy
}
// Client sends signed Server-Server API requests.
type Client struct {
serverName string
key *signing.KeyPair
resolve ResolveFunc
http *http.Client
enabled func() bool
policy *Policy
userAgent string
}
// dialTargetKey carries the resolved addresses of a request down to the
// dialer, which is the only place they are needed: the URL holds the Host
// the certificate is checked against, and the address the connection goes
// to can be something else entirely.
type dialTargetKey struct{}
// NewClient builds a client.
func NewClient(opts ClientOptions) *Client {
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
tlsConfig := opts.TLSConfig
if tlsConfig == nil {
tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
transport := &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
targets, _ := ctx.Value(dialTargetKey{}).([]string)
if len(targets) == 0 {
return dialer.DialContext(ctx, network, addr)
}
var lastErr error
for _, target := range targets {
conn, err := dialer.DialContext(ctx, network, target)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
},
TLSClientConfig: tlsConfig,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 0, // bounded by the request context instead
MaxIdleConns: 200,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
}
if opts.Enabled == nil {
opts.Enabled = func() bool { return false }
}
if opts.Policy == nil {
opts.Policy = NewPolicy(nil, nil)
}
c := &Client{
serverName: opts.ServerName,
key: opts.Key,
resolve: opts.Resolve,
http: &http.Client{
Transport: transport,
// Federation endpoints do not redirect; following one would
// send a signed request somewhere it was not addressed.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
enabled: opts.Enabled,
policy: opts.Policy,
userAgent: buildinfo.UserAgent(),
}
if c.resolve == nil {
// The well-known document is fetched with ordinary HTTPS and the
// same roots as everything else.
resolver := NewResolver(&http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsConfig, Proxy: nil},
})
c.resolve = resolver.Resolve
}
return c
}
// ServerName returns the origin this client signs as.
func (c *Client) ServerName() string { return c.serverName }
// check refuses a destination before anything goes on the wire.
func (c *Client) check(destination string) error {
switch {
case !c.enabled():
return ErrDisabled
case destination == c.serverName:
return ErrSelf
case !c.policy.Allowed(destination):
return fmt.Errorf("%w: %s", ErrNotAllowed, destination)
}
return nil
}
// request describes one call.
type request struct {
method string
destination string
path string // already escaped
query url.Values
body any
signed bool
maxBytes int64
timeout time.Duration
}
// do performs a request and decodes a JSON answer into out (which may be
// nil).
func (c *Client) do(ctx context.Context, req request, out any) error {
if err := c.check(req.destination); err != nil {
return err
}
resolved, err := c.resolve(ctx, req.destination)
if err != nil {
return fmt.Errorf("resolve %s: %w", req.destination, err)
}
timeout := req.timeout
if timeout == 0 {
timeout = defaultRequestTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ctx = context.WithValue(ctx, dialTargetKey{}, resolved.Addresses)
var content []byte
if req.body != nil {
if content, err = json.Marshal(req.body); err != nil {
return fmt.Errorf("encode request: %w", err)
}
}
target := "https://" + resolved.Host + req.path
if len(req.query) > 0 {
target += "?" + req.query.Encode()
}
httpReq, err := http.NewRequestWithContext(ctx, req.method, target, bytes.NewReader(content))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if content == nil {
httpReq.Body = http.NoBody
} else {
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("User-Agent", c.userAgent)
httpReq.Header.Set("Accept", "application/json")
if req.signed {
// The signature covers the request target exactly as it goes on
// the wire, so it is taken from the built request rather than
// from the path this function was handed.
x, err := SignRequest(c.key, c.serverName, req.destination, req.method,
httpReq.URL.RequestURI(), content)
if err != nil {
return fmt.Errorf("sign request: %w", err)
}
httpReq.Header.Set("Authorization", x.String())
}
resp, err := c.http.Do(httpReq)
if err != nil {
return fmt.Errorf("%s %s%s: %w", req.method, req.destination, req.path, err)
}
defer func() { _ = resp.Body.Close() }()
limit := req.maxBytes
if limit == 0 {
limit = defaultMaxResponse
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return fmt.Errorf("%s %s%s: read response: %w", req.method, req.destination, req.path, err)
}
if int64(len(body)) > limit {
return fmt.Errorf("%s %s%s: response is larger than %d bytes", req.method, req.destination, req.path, limit)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
httpErr := &HTTPError{Status: resp.StatusCode}
var envelope struct {
Code string `json:"errcode"`
Message string `json:"error"`
}
if json.Unmarshal(body, &envelope) == nil {
httpErr.Code, httpErr.Message = envelope.Code, envelope.Message
}
return httpErr
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%s %s%s: decode response: %w", req.method, req.destination, req.path, err)
}
return nil
}
// esc escapes one path segment. Room, event and user IDs carry sigils and
// colons, which are legal in a path; slashes would not be, and an event ID
// of room versions 3 and before can contain one.
func esc(s string) string { return url.PathEscape(s) }
// ServerKeys fetches a server's own key response, unsigned and unverified:
// verifying it is the key ring's job.
func (c *Client) ServerKeys(ctx context.Context, serverName string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, request{method: http.MethodGet, destination: serverName, path: "/_matrix/key/v2/server"}, &out)
return out, err
}
// QueryKeys asks a notary for other servers' keys.
func (c *Client) QueryKeys(ctx context.Context, notary string, query map[string]map[string]KeyCriteria) ([]json.RawMessage, error) {
var out struct {
ServerKeys []json.RawMessage `json:"server_keys"`
}
err := c.do(ctx, request{
method: http.MethodPost, destination: notary, path: "/_matrix/key/v2/query",
body: map[string]any{"server_keys": query},
}, &out)
return out.ServerKeys, err
}
// KeyCriteria is one key asked of a notary.
type KeyCriteria struct {
MinimumValidUntilTS int64 `json:"minimum_valid_until_ts,omitempty"`
}
// SendTransaction delivers a transaction.
func (c *Client) SendTransaction(ctx context.Context, destination, txnID string, txn Transaction) (SendTransactionResponse, error) {
var out SendTransactionResponse
err := c.do(ctx, request{
method: http.MethodPut, destination: destination, signed: true,
path: "/_matrix/federation/v1/send/" + esc(txnID), body: txn,
}, &out)
return out, err
}
// MakeJoin asks a resident server for a join event template.
func (c *Client) MakeJoin(ctx context.Context, destination, roomID, userID string, versions []string) (MakeJoinResponse, error) {
query := url.Values{}
for _, v := range versions {
query.Add("ver", v)
}
var out MakeJoinResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/make_join/" + esc(roomID) + "/" + esc(userID),
query: query,
}, &out)
return out, err
}
// SendJoin submits a signed join and receives the room's state.
func (c *Client) SendJoin(ctx context.Context, destination, roomID, eventID string, joinEvent json.RawMessage) (SendJoinResponse, error) {
var out SendJoinResponse
err := c.do(ctx, request{
method: http.MethodPut, destination: destination, signed: true,
path: "/_matrix/federation/v2/send_join/" + esc(roomID) + "/" + esc(eventID),
body: joinEvent,
maxBytes: largeMaxResponse,
timeout: longRequestTimeout,
}, &out)
return out, err
}
// MakeLeave asks a resident server for a leave event template.
func (c *Client) MakeLeave(ctx context.Context, destination, roomID, userID string) (MakeJoinResponse, error) {
var out MakeJoinResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/make_leave/" + esc(roomID) + "/" + esc(userID),
}, &out)
return out, err
}
// SendLeave submits a signed leave.
func (c *Client) SendLeave(ctx context.Context, destination, roomID, eventID string, leaveEvent json.RawMessage) error {
return c.do(ctx, request{
method: http.MethodPut, destination: destination, signed: true,
path: "/_matrix/federation/v2/send_leave/" + esc(roomID) + "/" + esc(eventID),
body: leaveEvent,
}, nil)
}
// Invite asks the invitee's server to countersign an invite.
func (c *Client) Invite(ctx context.Context, destination, roomID, eventID string, req InviteRequest) (InviteResponse, error) {
var out InviteResponse
err := c.do(ctx, request{
method: http.MethodPut, destination: destination, signed: true,
path: "/_matrix/federation/v2/invite/" + esc(roomID) + "/" + esc(eventID),
body: req,
}, &out)
return out, err
}
// Event fetches one event.
func (c *Client) Event(ctx context.Context, destination, eventID string) (Transaction, error) {
var out Transaction
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/event/" + esc(eventID),
}, &out)
return out, err
}
// EventAuth fetches the auth chain of an event.
func (c *Client) EventAuth(ctx context.Context, destination, roomID, eventID string) (EventAuthResponse, error) {
var out EventAuthResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/event_auth/" + esc(roomID) + "/" + esc(eventID),
maxBytes: largeMaxResponse,
timeout: longRequestTimeout,
}, &out)
return out, err
}
// StateIDs fetches the IDs of the room state at an event.
func (c *Client) StateIDs(ctx context.Context, destination, roomID, eventID string) (StateIDsResponse, error) {
var out StateIDsResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/state_ids/" + esc(roomID),
query: url.Values{"event_id": {eventID}},
maxBytes: largeMaxResponse,
timeout: longRequestTimeout,
}, &out)
return out, err
}
// State fetches the room state at an event, events and all.
func (c *Client) State(ctx context.Context, destination, roomID, eventID string) (StateResponse, error) {
var out StateResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/state/" + esc(roomID),
query: url.Values{"event_id": {eventID}},
maxBytes: largeMaxResponse,
timeout: longRequestTimeout,
}, &out)
return out, err
}
// MissingEvents asks for the events between what this server has and an
// event it was sent.
func (c *Client) MissingEvents(ctx context.Context, destination, roomID string, req MissingEventsRequest) (MissingEventsResponse, error) {
var out MissingEventsResponse
err := c.do(ctx, request{
method: http.MethodPost, destination: destination, signed: true,
path: "/_matrix/federation/v1/get_missing_events/" + esc(roomID),
body: req,
}, &out)
return out, err
}
// Backfill asks for history before the given events.
func (c *Client) Backfill(ctx context.Context, destination, roomID string, from []string, limit int) (Transaction, error) {
query := url.Values{"limit": {strconv.Itoa(limit)}}
for _, id := range from {
query.Add("v", id)
}
var out Transaction
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/backfill/" + esc(roomID),
query: query,
}, &out)
return out, err
}
// QueryProfile asks a server for one of its users' profile. An empty field
// asks for the whole profile.
func (c *Client) QueryProfile(ctx context.Context, destination, userID, field string) (map[string]any, error) {
query := url.Values{"user_id": {userID}}
if field != "" {
query.Set("field", field)
}
var out map[string]any
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/query/profile", query: query,
}, &out)
return out, err
}
// QueryDirectory resolves an alias on the server that owns it.
func (c *Client) QueryDirectory(ctx context.Context, destination, alias string) (DirectoryResponse, error) {
var out DirectoryResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination, signed: true,
path: "/_matrix/federation/v1/query/directory", query: url.Values{"room_alias": {alias}},
}, &out)
return out, err
}
// PublicRooms reads another server's public room directory.
func (c *Client) PublicRooms(ctx context.Context, destination string, limit int, since, search string) (json.RawMessage, error) {
body := map[string]any{}
if limit > 0 {
body["limit"] = limit
}
if since != "" {
body["since"] = since
}
if search = strings.TrimSpace(search); search != "" {
body["filter"] = map[string]string{"generic_search_term": search}
}
var out json.RawMessage
err := c.do(ctx, request{
method: http.MethodPost, destination: destination, signed: true,
path: "/_matrix/federation/v1/publicRooms", body: body,
}, &out)
return out, err
}
// Version asks a server what software it runs.
func (c *Client) Version(ctx context.Context, destination string) (VersionResponse, error) {
var out VersionResponse
err := c.do(ctx, request{
method: http.MethodGet, destination: destination,
path: "/_matrix/federation/v1/version",
}, &out)
return out, err
}