Group voice and video calls with MatrixRTC and LiveKit
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
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s

Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.

Server:
- MSC4140 delayed events (send/state with a delay, plus the
  delayed_events list/restart/cancel/send API), stored and scheduled, so
  a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
  hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
  under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.

Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 19:33:29 +02:00
1 parent 325431a5be
commit 76dfc98a86
49 files changed
+3849 -432

No files matched your search

+25
View File
@@ -19,6 +19,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/appservice"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/keyserver"
@@ -27,6 +28,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/syncapi"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
@@ -48,6 +50,9 @@ type ClientAPI struct {
regTokens *userapi.RegistrationTokens
appservices *appservice.API
turn config.TURN
delayed *delayedevents.Service
rtcTransports []RTCTransport
openID storage.OpenIDTokenStore
log *slog.Logger
now func() time.Time
@@ -145,6 +150,20 @@ type Options struct {
// TURN is the relay handed to clients for calls.
TURN config.TURN
// RTC is the group call backend, listed at the MSC4143 transports
// endpoint.
RTC config.RTC
// DelayedEvents schedules MSC4140 delayed events. Nil, or a service
// with delayed events switched off, leaves the endpoints unregistered
// and refuses the delay parameter.
DelayedEvents *delayedevents.Service
// OpenIDTokens keeps the OpenID tokens a user hands third parties,
// such as the LiveKit JWT service of a group call. Nil leaves the
// OpenID endpoints unregistered.
OpenIDTokens storage.OpenIDTokenStore
// TrustXForwardedFor takes the client address from the proxy header.
TrustXForwardedFor bool
@@ -205,6 +224,9 @@ func New(opts Options) (*ClientAPI, error) {
regTokens: opts.RegistrationTokens,
appservices: opts.AppServices,
turn: opts.TURN,
delayed: opts.DelayedEvents,
rtcTransports: RTCTransports(opts.RTC.LiveKitServiceURL),
openID: opts.OpenIDTokens,
log: opts.Logger,
now: opts.Now,
uia: newUIAStore(uiaSessionTTL, opts.Now),
@@ -274,6 +296,9 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
// Calls: the relay a client sets its peer connection up with.
c.authed(r, "GET "+v3+"/voip/turnServer", c.limitDefault, c.handleTurnServer)
c.registerOpenIDRoutes(r)
c.registerDelayedEventRoutes(r)
c.registerRTCRoutes(r)
// Devices.
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
+73 -8
View File
@@ -2,16 +2,20 @@ package clientapi_test
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
@@ -39,6 +43,44 @@ type testServer struct {
settings *settings.Service
fedGate *federation.Gate
db storage.Database
// delayed is the delayed event service, on a clock of its own that
// tests move with advance.
delayed *delayedevents.Service
clock *testClock
}
// testClock is a clock a test moves by hand.
type testClock struct {
mu sync.Mutex
now time.Time
}
func (c *testClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *testClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// teeNotifier passes the roomserver's emit stage on to the syncapi and,
// once it is built, to the delayed event service, which needs the
// roomserver to exist first.
type teeNotifier struct {
first roomserver.Notifier
then roomserver.Notifier
}
func (n *teeNotifier) EventPersisted(ctx context.Context, note roomserver.Notification) {
n.first.EventPersisted(ctx, note)
if n.then != nil {
n.then.EventPersisted(ctx, note)
}
}
// newTestServer builds a server. tweak adjusts the options before the
@@ -91,13 +133,14 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
// stage and the syncapi's wake-up are the same object, exactly as in
// a real server.
notifier := syncapi.NewNotifier()
emit := &teeNotifier{first: notifier}
rooms, err := roomserver.New(roomserver.Options{
ServerName: testServerName,
Store: db.Rooms(),
Profiles: users,
SigningKey: key,
FederationEnabled: settingsService.FederationEnabled,
Notifier: notifier,
Notifier: emit,
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
@@ -121,14 +164,34 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
}
t.Cleanup(sync.Close)
// Delayed events are on, with the scheduler left stopped: a test
// moves the clock and calls SendDue itself.
clock := &testClock{now: time.Now()}
delayed, err := delayedevents.New(delayedevents.Options{
Store: db.DelayedEvents(),
Rooms: rooms,
MaxDelay: time.Hour,
MaxPerUser: 5,
DescribeError: clientapi.DescribeRoomError,
Logger: slog.New(slog.DiscardHandler),
Now: clock.Now,
})
if err != nil {
t.Fatalf("delayedevents.New: %v", err)
}
t.Cleanup(delayed.Close)
emit.then = delayed
opts := clientapi.Options{
ServerName: testServerName,
Users: users,
Rooms: rooms,
Sync: sync,
Settings: settingsService,
Federation: gate,
Logger: slog.New(slog.DiscardHandler),
ServerName: testServerName,
Users: users,
Rooms: rooms,
Sync: sync,
Settings: settingsService,
Federation: gate,
DelayedEvents: delayed,
OpenIDTokens: db.OpenIDTokens(),
Logger: slog.New(slog.DiscardHandler),
}
if tweak != nil {
tweak(&opts)
@@ -150,6 +213,8 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
settings: settingsService,
fedGate: gate,
db: db,
delayed: delayed,
clock: clock,
}
}
+259
View File
@@ -0,0 +1,259 @@
package clientapi
import (
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
"time"
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// MSC4140 is still unstable, so its endpoints and parameters carry its
// prefix.
const (
delayedEventsPrefix = "/_matrix/client/unstable/org.matrix.msc4140"
delayParam = "org.matrix.msc4140.delay"
parentDelayParam = "org.matrix.msc4140.parent_delay_id"
)
// registerDelayedEventRoutes adds the MSC4140 endpoints, if delayed events
// are switched on.
//
// The action endpoints are public: the delay ID is the capability, which is
// what lets a client hand its delayed leave to something else to keep alive.
// Both spellings are served — the action in the path, which is the current
// draft, and in the body, which clients fall back to.
func (c *ClientAPI) registerDelayedEventRoutes(r *httputil.Router) {
if !c.delayed.Enabled() || c.rooms == nil {
return
}
c.authed(r, "GET "+delayedEventsPrefix+"/delayed_events", c.limitDefault, c.handleGetDelayedEvents)
c.authed(r, "GET "+delayedEventsPrefix+"/delayed_events/{delayID}", c.limitDefault, c.handleGetDelayedEvent)
c.public(r, "POST "+delayedEventsPrefix+"/delayed_events/{delayID}", c.limitDefault, c.handleUpdateDelayedEvent)
c.public(r, "POST "+delayedEventsPrefix+"/delayed_events/{delayID}/{action}", c.limitDefault, c.handleUpdateDelayedEvent)
}
// delayRequested reads the delay parameter of a send or state PUT. ok is
// false when there is none, and the event is sent at once as usual.
func (c *ClientAPI) delayRequested(r *http.Request) (time.Duration, bool, error) {
q := r.URL.Query()
if q.Has(parentDelayParam) {
return 0, false, httputil.InvalidParam("%s is not supported", parentDelayParam)
}
if !q.Has(delayParam) {
return 0, false, nil
}
if !c.delayed.Enabled() {
// Refused rather than ignored: sending a delayed "left the call"
// at once would be exactly the wrong thing.
return 0, false, httputil.InvalidParam("Delayed events are not enabled on this server")
}
ms, err := strconv.ParseInt(q.Get(delayParam), 10, 64)
if err != nil || ms < 0 {
return 0, false, httputil.InvalidParam("%s must be a non-negative number of milliseconds", delayParam)
}
return time.Duration(ms) * time.Millisecond, true, nil
}
// delayIDResponse answers a delayed send.
type delayIDResponse struct {
DelayID string `json:"delay_id"`
}
// scheduleDelayed schedules what a send or state PUT asked to delay.
func (c *ClientAPI) scheduleDelayed(w http.ResponseWriter, r *http.Request, req delayedevents.ScheduleRequest) error {
delayID, err := c.delayed.Schedule(r.Context(), req)
if err != nil {
return delayedError(err, c.delayed.MaxDelay())
}
httputil.WriteJSON(w, http.StatusOK, delayIDResponse{DelayID: delayID})
return nil
}
// delayedError maps the service's errors onto Matrix errors. An overlong
// delay is reported the way Synapse does: M_UNKNOWN for a client that knows
// nothing of MSC4140, with the MSC's own code and the maximum alongside.
func delayedError(err error, maxDelay time.Duration) error {
switch {
case errors.Is(err, delayedevents.ErrMaxDelayExceeded):
merr := httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnknown, "%s", err.Error())
merr.DelayedErrCode = "M_MAX_DELAY_EXCEEDED"
merr.MaxDelayMS = maxDelay.Milliseconds()
return merr
case errors.Is(err, delayedevents.ErrTooMany):
return httputil.NewError(http.StatusBadRequest, "M_LIMIT_EXCEEDED", "%s", err.Error())
case errors.Is(err, delayedevents.ErrDisabled):
return httputil.Unrecognized()
case errors.Is(err, storage.ErrNoSuchDelayedEvent):
return httputil.NotFound("No such scheduled delayed event")
default:
return roomError(err)
}
}
// delayedEventJSON is one delayed event as MSC4140 lists it.
type delayedEventJSON struct {
DelayID string `json:"delay_id"`
RoomID string `json:"room_id"`
Type string `json:"type"`
StateKey *string `json:"state_key,omitempty"`
Content json.RawMessage `json:"content"`
Delay int64 `json:"delay"`
RunningSince int64 `json:"running_since"`
}
type finalisedDelayedEventJSON struct {
DelayedEvent delayedEventJSON `json:"delayed_event"`
Outcome string `json:"outcome"`
Reason string `json:"reason"`
Error json.RawMessage `json:"error,omitempty"`
EventID string `json:"event_id,omitempty"`
OriginServerTS int64 `json:"origin_server_ts,omitempty"`
}
type delayedEventsResponse struct {
Scheduled []delayedEventJSON `json:"scheduled"`
Finalised []finalisedDelayedEventJSON `json:"finalised"`
// DelayedEvents is the scheduled list under the name an earlier draft
// of MSC4140 used.
DelayedEvents []delayedEventJSON `json:"delayed_events"`
}
func toDelayedEventJSON(e storage.DelayedEvent) delayedEventJSON {
return delayedEventJSON{
DelayID: e.DelayID,
RoomID: e.RoomID,
Type: e.Type,
StateKey: e.StateKey,
Content: e.Content,
Delay: e.DelayMS,
RunningSince: e.RunningSinceMS,
}
}
func toFinalisedJSON(e storage.DelayedEvent) finalisedDelayedEventJSON {
out := finalisedDelayedEventJSON{
DelayedEvent: toDelayedEventJSON(e),
Outcome: e.Outcome,
Reason: e.Reason,
Error: e.Error,
EventID: e.EventID,
}
if e.EventID != "" {
out.OriginServerTS = e.FinalisedAtMS
}
return out
}
// handleGetDelayedEvents implements
// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events.
//
// status narrows the answer to scheduled or finalised events, and delay_id,
// which may repeat, to particular ones. Nobody has enough delayed events
// for paging to matter, so there is never a next_batch.
func (c *ClientAPI) handleGetDelayedEvents(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
q := r.URL.Query()
status := q.Get("status")
if status != "" && status != "scheduled" && status != "finalised" {
return httputil.InvalidParam("status must be scheduled or finalised")
}
ids := q["delay_id"]
events, err := c.delayed.List(r.Context(), auth.UserID)
if err != nil {
return err
}
resp := delayedEventsResponse{
Scheduled: []delayedEventJSON{},
Finalised: []finalisedDelayedEventJSON{},
}
for _, e := range events {
if len(ids) > 0 && !slices.Contains(ids, e.DelayID) {
continue
}
if e.FinalisedAtMS == 0 {
if status != "finalised" {
resp.Scheduled = append(resp.Scheduled, toDelayedEventJSON(e))
}
} else if status != "scheduled" {
resp.Finalised = append(resp.Finalised, toFinalisedJSON(e))
}
}
resp.DelayedEvents = resp.Scheduled
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// handleGetDelayedEvent implements
// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delayID}.
func (c *ClientAPI) handleGetDelayedEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
e, err := c.delayed.Get(r.Context(), auth.UserID, r.PathValue("delayID"))
if err != nil {
return delayedError(err, 0)
}
if e.FinalisedAtMS == 0 {
httputil.WriteJSON(w, http.StatusOK, toDelayedEventJSON(e))
} else {
httputil.WriteJSON(w, http.StatusOK, toFinalisedJSON(e))
}
return nil
}
type updateDelayedEventRequest struct {
Action string `json:"action"`
}
// handleUpdateDelayedEvent implements
// POST /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delayID}[/{action}].
func (c *ClientAPI) handleUpdateDelayedEvent(w http.ResponseWriter, r *http.Request) error {
delayID := r.PathValue("delayID")
action := r.PathValue("action")
if action == "" {
var req updateDelayedEventRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
action = req.Action
}
var err error
switch action {
case "restart":
err = c.delayed.Restart(r.Context(), delayID)
case "cancel":
err = c.delayed.Cancel(r.Context(), delayID)
case "send":
err = c.delayed.Send(r.Context(), delayID)
case "":
return httputil.MissingParam("An action is required")
default:
return httputil.InvalidParam("action must be restart, cancel or send")
}
if err != nil {
return delayedError(err, 0)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// DescribeRoomError is the Matrix error a failed delayed send is recorded
// with: the error the client would have got had it sent the event itself.
func DescribeRoomError(err error) json.RawMessage {
var matrixErr *httputil.MatrixError
if !errors.As(roomError(err), &matrixErr) {
matrixErr = httputil.NewError(http.StatusInternalServerError, httputil.ErrCodeUnknown, "%s", err.Error())
}
raw, _ := json.Marshal(matrixErr)
return raw
}
// Compile-time check that the roomserver can be the service's sender.
var _ delayedevents.Sender = (*roomserver.API)(nil)
+300
View File
@@ -0,0 +1,300 @@
package clientapi_test
import (
"net/http"
"net/url"
"strconv"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
const delayedEventsPath = "/_matrix/client/unstable/org.matrix.msc4140/delayed_events"
// callMemberPath is where a member of a room's group call puts its state.
func callMemberPath(roomID, stateKey string) string {
return "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) +
"/state/org.matrix.msc3401.call.member/" + url.PathEscape(stateKey)
}
// scheduleDelayed PUTs a delayed state event and returns its delay ID.
func (ts *testServer) scheduleDelayed(path string, delayMS int, acc account, content map[string]any) string {
ts.t.Helper()
rec := ts.put(path+"?org.matrix.msc4140.delay="+strconv.Itoa(delayMS), acc.AccessToken, content)
expectStatus(ts.t, rec, http.StatusOK)
delayID, _ := jsonBody(ts.t, rec)["delay_id"].(string)
if delayID == "" {
ts.t.Fatalf("a delayed send returned no delay_id: %s", rec.Body.String())
}
return delayID
}
// tick moves the clock and sends whatever has come due.
func (ts *testServer) tick(d time.Duration) {
ts.t.Helper()
ts.clock.advance(d)
ts.delayed.SendDue(ts.t.Context())
}
type delayedList struct {
Scheduled []struct {
DelayID string `json:"delay_id"`
RoomID string `json:"room_id"`
Type string `json:"type"`
StateKey *string `json:"state_key"`
Content map[string]any `json:"content"`
Delay int64 `json:"delay"`
RunningSince int64 `json:"running_since"`
} `json:"scheduled"`
Finalised []struct {
DelayedEvent struct {
DelayID string `json:"delay_id"`
} `json:"delayed_event"`
Outcome string `json:"outcome"`
Reason string `json:"reason"`
EventID string `json:"event_id"`
Error map[string]any `json:"error"`
} `json:"finalised"`
}
func (ts *testServer) delayedList(acc account) delayedList {
ts.t.Helper()
rec := ts.get(delayedEventsPath, acc.AccessToken)
expectStatus(ts.t, rec, http.StatusOK)
return decode[delayedList](ts.t, rec)
}
// TestDelayedStateEventIsSentWhenDue is the MatrixRTC case: a member's
// "left the call" is scheduled when it joins, and arrives on its own once
// the delay runs out.
func TestDelayedStateEventIsSentWhenDue(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
stateKey := "_" + alice.UserID + "_" + alice.DeviceID + "_m.call"
path := callMemberPath(roomID, stateKey)
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
// Nothing is in the room yet.
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
list := ts.delayedList(alice)
if len(list.Scheduled) != 1 || list.Scheduled[0].DelayID != delayID {
t.Fatalf("scheduled = %+v", list.Scheduled)
}
got := list.Scheduled[0]
if got.RoomID != roomID || got.Type != "org.matrix.msc3401.call.member" ||
got.StateKey == nil || *got.StateKey != stateKey || got.Delay != 5000 {
t.Errorf("the scheduled event is %+v", got)
}
ts.tick(4 * time.Second)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
ts.tick(2 * time.Second)
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
list = ts.delayedList(alice)
if len(list.Scheduled) != 0 {
t.Errorf("still scheduled after sending: %+v", list.Scheduled)
}
if len(list.Finalised) != 1 || list.Finalised[0].Outcome != "send" ||
list.Finalised[0].Reason != "delay" || list.Finalised[0].EventID == "" {
t.Errorf("finalised = %+v", list.Finalised)
}
// Looked up on its own, it is the same.
rec := ts.get(delayedEventsPath+"/"+delayID, alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["outcome"] != "send" {
t.Errorf("GET by ID = %s", rec.Body.String())
}
}
// TestDelayedEventRestart: a client still in the call keeps pushing its
// leave back, and it never arrives.
func TestDelayedEventRestart(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
for range 3 {
ts.tick(4 * time.Second)
// The action endpoints need no token: the delay ID is the
// capability.
expectStatus(t, ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil), http.StatusOK)
}
ts.tick(4 * time.Second)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
ts.tick(2 * time.Second)
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
// Once it has been sent it cannot be restarted.
rec := ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil)
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
}
// TestDelayedEventActions covers cancel and send, in both the path and the
// body spelling.
func TestDelayedEventActions(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
escaped := url.PathEscape(roomID)
// Cancelled with the action in the body: never sent.
topic := "/_matrix/client/v3/rooms/" + escaped + "/state/m.room.topic"
cancelled := ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "never"})
expectStatus(t, ts.post(delayedEventsPath+"/"+cancelled, alice.AccessToken,
map[string]any{"action": "cancel"}), http.StatusOK)
ts.tick(2 * time.Second)
expectError(t, ts.get(topic, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
// Sent at once with the action in the path: a timeline event, which
// is how a client sends a message it held back.
send := "/_matrix/client/v3/rooms/" + escaped + "/send/m.room.message/txn1"
sent := ts.scheduleDelayed(send, 60000, alice, map[string]any{"msgtype": "m.text", "body": "later"})
expectStatus(t, ts.post(delayedEventsPath+"/"+sent+"/send", "", nil), http.StatusOK)
list := ts.delayedList(alice)
if len(list.Scheduled) != 0 || len(list.Finalised) != 2 {
t.Fatalf("list = %+v", list)
}
outcomes := map[string]string{}
for _, f := range list.Finalised {
outcomes[f.DelayedEvent.DelayID] = f.Outcome + "/" + f.Reason
}
if outcomes[cancelled] != "cancel/action" || outcomes[sent] != "send/action" {
t.Errorf("outcomes = %v", outcomes)
}
// status narrows the list.
rec := ts.get(delayedEventsPath+"?status=scheduled", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if l := decode[delayedList](t, rec); len(l.Finalised) != 0 {
t.Errorf("status=scheduled listed finalised events: %s", rec.Body.String())
}
expectError(t, ts.post(delayedEventsPath+"/nope/cancel", "", nil), http.StatusNotFound, httputil.ErrCodeNotFound)
expectError(t, ts.post(delayedEventsPath+"/"+sent+"/explode", "", nil),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
}
// TestDelayedEventFailureIsRecorded: an event that can no longer be sent
// is finalised with the error the client would have got.
func TestDelayedEventFailureIsRecorded(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
ts.scheduleDelayed(path, 1000, alice, map[string]any{})
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave",
alice.AccessToken, map[string]any{}), http.StatusOK)
ts.tick(2 * time.Second)
list := ts.delayedList(alice)
if len(list.Finalised) != 1 || list.Finalised[0].Reason != "error" ||
list.Finalised[0].Error["errcode"] != httputil.ErrCodeForbidden {
t.Errorf("finalised = %+v", list.Finalised)
}
}
// TestDelayedStateIsCancelledByOthers: someone else setting the same state
// cancels a delayed event that would have overwritten it, and the owner's
// own updates do not. It also proves an ordinary member may join a call.
func TestDelayedStateIsCancelledByOthers(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join",
bob.AccessToken, map[string]any{}), http.StatusOK)
aliceKey := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
ts.scheduleDelayed(aliceKey, 5000, alice, map[string]any{})
// Alice refreshing her own membership leaves the delayed leave alone.
expectStatus(t, ts.put(aliceKey, alice.AccessToken, map[string]any{"application": "m.call"}), http.StatusOK)
// Bob, an ordinary member, may put call state of his own.
expectStatus(t, ts.put(callMemberPath(roomID, "_"+bob.UserID+"_B_m.call"), bob.AccessToken,
map[string]any{"application": "m.call"}), http.StatusOK)
// The cancellation runs off the emit stage, so give it a moment.
time.Sleep(100 * time.Millisecond)
if l := ts.delayedList(alice); len(l.Scheduled) != 1 {
t.Fatalf("alice's own update cancelled her delayed event: %+v", l)
}
// Bob writing the same piece of state cancels it.
expectStatus(t, ts.put(aliceKey, bob.AccessToken, map[string]any{}), http.StatusOK)
deadline := time.Now().Add(5 * time.Second)
for {
l := ts.delayedList(alice)
if len(l.Scheduled) == 0 {
if len(l.Finalised) != 1 || l.Finalised[0].Outcome != "cancel" {
t.Errorf("finalised = %+v", l.Finalised)
}
break
}
if time.Now().After(deadline) {
t.Fatal("the overwritten delayed event was never cancelled")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestDelayedEventLimits(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
topic := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.topic"
// Longer than the server allows: the MSC's own code and the maximum
// come with the error.
rec := ts.put(topic+"?org.matrix.msc4140.delay=7200000", alice.AccessToken, map[string]any{"topic": "x"})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnknown)
body := jsonBody(t, rec)
if body["org.matrix.msc4140.errcode"] != "M_MAX_DELAY_EXCEEDED" || body["org.matrix.msc4140.max_delay"] != float64(3600000) {
t.Errorf("error = %s", rec.Body.String())
}
expectError(t, ts.put(topic+"?org.matrix.msc4140.delay=soon", alice.AccessToken, map[string]any{"topic": "x"}),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
expectError(t, ts.put(topic+"?org.matrix.msc4140.parent_delay_id=x", alice.AccessToken, map[string]any{"topic": "x"}),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
// Five at once is the test server's limit.
for range 5 {
ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "x"})
}
rec = ts.put(topic+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{"topic": "x"})
expectError(t, rec, http.StatusBadRequest, "M_LIMIT_EXCEEDED")
// Nobody else can read them.
bob := ts.register("bob", "correct-horse-battery-staple")
if l := ts.delayedList(bob); len(l.Scheduled) != 0 {
t.Errorf("bob sees alice's delayed events: %+v", l)
}
expectError(t, ts.get(delayedEventsPath, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
}
// TestDelayedEventsDisabled: without the service the parameter is refused,
// rather than the event being sent at once, and the endpoints are absent.
func TestDelayedEventsDisabled(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) { o.DelayedEvents = nil })
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
path := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
expectError(t, ts.put(path+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{}),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
expectError(t, ts.get(delayedEventsPath, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeUnrecognized)
}
+106
View File
@@ -0,0 +1,106 @@
package clientapi
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// openIDTokenLifetime is how long an OpenID token is good for. The LiveKit
// JWT service checks it once, when a call is joined, so an hour is plenty.
const openIDTokenLifetime = time.Hour
// openIDTokenPrefix marks OpenID tokens the way the userapi marks access
// tokens, so one handed to the wrong endpoint is recognisable.
const openIDTokenPrefix = "warp_oi_"
// openIDUserInfoPath is where a third party exchanges an OpenID token for
// the user's ID. It is a federation endpoint by name, but needs neither
// federation nor a signing key: Synapse serves it as a resource of its own
// for the same reason. It is registered here so that a server with
// federation off can still let its users into a group call.
const openIDUserInfoPath = "/_matrix/federation/v1/openid/userinfo"
// registerOpenIDRoutes adds the OpenID endpoints, if there is a store for
// the tokens.
func (c *ClientAPI) registerOpenIDRoutes(r *httputil.Router) {
if c.openID == nil {
return
}
c.authed(r, "POST "+v3+"/user/{userID}/openid/request_token", c.limitDefault, c.handleRequestOpenIDToken)
c.public(r, "GET "+openIDUserInfoPath, c.limitDefault, c.handleOpenIDUserInfo)
}
type openIDTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
MatrixServerName string `json:"matrix_server_name"`
ExpiresIn int64 `json:"expires_in"`
}
// handleRequestOpenIDToken implements
// POST /_matrix/client/v3/user/{userID}/openid/request_token.
//
// The token proves who the user is to whoever they hand it to, and nothing
// else: it is not an access token and opens no other endpoint.
func (c *ClientAPI) handleRequestOpenIDToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if r.PathValue("userID") != auth.UserID {
return httputil.Forbidden("You cannot request an OpenID token for another user")
}
// The body is an empty object; anything in it is ignored.
var ignored map[string]any
if _, err := httputil.DecodeOptionalJSON(w, r, &ignored); err != nil {
return err
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("generate OpenID token: %w", err)
}
token := openIDTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
now := c.now()
// Expired tokens go as new ones are made; nothing else reads them.
if err := c.openID.PurgeOpenIDTokens(r.Context(), now.UnixMilli()); err != nil {
c.log.Warn("could not purge expired OpenID tokens", "error", err)
}
if err := c.openID.InsertOpenIDToken(r.Context(), userapi.HashToken(token), auth.UserID,
now.Add(openIDTokenLifetime).UnixMilli()); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, openIDTokenResponse{
AccessToken: token,
TokenType: "Bearer",
MatrixServerName: c.serverName,
ExpiresIn: int64(openIDTokenLifetime.Seconds()),
})
return nil
}
type openIDUserInfoResponse struct {
Sub string `json:"sub"`
}
// handleOpenIDUserInfo implements
// GET /_matrix/federation/v1/openid/userinfo.
func (c *ClientAPI) handleOpenIDUserInfo(w http.ResponseWriter, r *http.Request) error {
token := r.URL.Query().Get("access_token")
if token == "" {
return httputil.MissingToken()
}
userID, err := c.openID.OpenIDTokenUser(r.Context(), userapi.HashToken(token), c.now().UnixMilli())
if errors.Is(err, storage.ErrNoSuchOpenIDToken) {
return httputil.UnknownToken(false)
}
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, openIDUserInfoResponse{Sub: userID})
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package clientapi_test
import (
"net/http"
"net/url"
"testing"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// TestOpenIDToken is the exchange the LiveKit JWT service relies on: a user
// asks for a token, hands it over, and the service asks this server who it
// belongs to.
func TestOpenIDToken(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
path := "/_matrix/client/v3/user/" + url.PathEscape(alice.UserID) + "/openid/request_token"
rec := ts.post(path, alice.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
token, _ := body["access_token"].(string)
if token == "" || body["token_type"] != "Bearer" || body["matrix_server_name"] != testServerName ||
body["expires_in"] != float64(3600) {
t.Fatalf("request_token = %s", rec.Body.String())
}
// The token is no access token.
expectError(t, ts.get("/_matrix/client/v3/account/whoami", token), http.StatusUnauthorized, httputil.ErrCodeUnknownToken)
rec = ts.get("/_matrix/federation/v1/openid/userinfo?access_token="+url.QueryEscape(token), "")
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["sub"]; got != alice.UserID {
t.Errorf("sub = %v, want %s", got, alice.UserID)
}
expectError(t, ts.get("/_matrix/federation/v1/openid/userinfo?access_token=nope", ""),
http.StatusUnauthorized, httputil.ErrCodeUnknownToken)
expectError(t, ts.get("/_matrix/federation/v1/openid/userinfo", ""),
http.StatusUnauthorized, "M_MISSING_TOKEN")
// Nobody gets a token in someone else's name.
expectError(t, ts.post(path, bob.AccessToken, map[string]any{}), http.StatusForbidden, httputil.ErrCodeForbidden)
}
// TestOpenIDUserInfoIgnoresFederationSwitch: the userinfo endpoint answers
// with federation off, so a server that does not federate can still let its
// users into group calls.
func TestOpenIDUserInfoIgnoresFederationSwitch(t *testing.T) {
ts := newTestServer(t, nil)
if ts.settings.FederationEnabled() {
t.Fatal("the test server is expected to start with federation off")
}
alice := ts.register("alice", "correct-horse-battery-staple")
rec := ts.post("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/openid/request_token",
alice.AccessToken, nil)
expectStatus(t, rec, http.StatusOK)
token, _ := jsonBody(t, rec)["access_token"].(string)
expectStatus(t, ts.get("/_matrix/federation/v1/openid/userinfo?access_token="+url.QueryEscape(token), ""), http.StatusOK)
}
+21
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
@@ -155,10 +156,20 @@ func (c *ClientAPI) handleSendEvent(w http.ResponseWriter, r *http.Request, auth
return httputil.MissingParam("A transaction ID is required")
}
delay, delayed, err := c.delayRequested(r)
if err != nil {
return err
}
var content sendEventRequest
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
if delayed {
return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{
UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID,
Type: eventType, Content: content, Delay: delay,
})
}
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
RoomID: roomID,
@@ -191,10 +202,20 @@ func (c *ClientAPI) handlePutState(w http.ResponseWriter, r *http.Request, auth
}
stateKey := r.PathValue("stateKey")
delay, delayed, err := c.delayRequested(r)
if err != nil {
return err
}
var content sendEventRequest
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
if delayed {
return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{
UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID,
Type: eventType, StateKey: &stateKey, Content: content, Delay: delay,
})
}
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
RoomID: roomID,
+51
View File
@@ -0,0 +1,51 @@
package clientapi
import (
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// rtcTransportsPath is MSC4143's endpoint for the group call backends a
// client may use: the same list /.well-known/matrix/client carries as
// org.matrix.msc4143.rtc_foci, behind authentication and without needing
// the discovery document.
const rtcTransportsPath = "/_matrix/client/unstable/org.matrix.msc4143/rtc/transports"
// RTCTransport is one group call backend: a LiveKit SFU, named by the JWT
// service that hands out tokens for it.
type RTCTransport struct {
Type string `json:"type"`
LiveKitServiceURL string `json:"livekit_service_url"`
}
// RTCTransports lists the configured group call backends, or nil for none.
func RTCTransports(liveKitServiceURL string) []RTCTransport {
if liveKitServiceURL == "" {
return nil
}
return []RTCTransport{{Type: "livekit", LiveKitServiceURL: strings.TrimRight(liveKitServiceURL, "/")}}
}
type rtcTransportsResponse struct {
Transports []RTCTransport `json:"rtc_transports"`
}
// registerRTCRoutes adds the MSC4143 endpoint, if a backend is configured.
// Without one it is left unregistered: the 404 is how a client learns there
// is none.
func (c *ClientAPI) registerRTCRoutes(r *httputil.Router) {
if len(c.rtcTransports) == 0 {
return
}
c.authed(r, "GET "+rtcTransportsPath, c.limitDefault, c.handleRTCTransports)
}
// handleRTCTransports implements
// GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports.
func (c *ClientAPI) handleRTCTransports(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, rtcTransportsResponse{Transports: c.rtcTransports})
return nil
}
+24
View File
@@ -87,3 +87,27 @@ func TestTurnServerStatic(t *testing.T) {
t.Errorf("response = %+v", got)
}
}
func TestRTCTransports(t *testing.T) {
const path = "/_matrix/client/unstable/org.matrix.msc4143/rtc/transports"
ts := newTestServer(t, func(o *clientapi.Options) {
o.RTC = config.RTC{LiveKitServiceURL: "https://livekit-jwt.warp.example/"}
})
acc := ts.register("alice", "correct horse battery")
expectError(t, ts.get(path, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
rec := ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
got := decode[struct {
Transports []map[string]string `json:"rtc_transports"`
}](t, rec)
if len(got.Transports) != 1 || got.Transports[0]["type"] != "livekit" ||
got.Transports[0]["livekit_service_url"] != "https://livekit-jwt.warp.example" {
t.Errorf("rtc_transports = %v", got.Transports)
}
// Without a backend there is no endpoint, which is how a client knows.
ts = newTestServer(t, nil)
acc = ts.register("alice", "correct horse battery")
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_UNRECOGNIZED")
}