Voice and video calls: TURN endpoint and web client calls
Server: GET /voip/turnServer, configured under client_api.turn. A shared
secret (coturn use-auth-secret) gives each user expiring TURN REST API
credentials; static credentials are the fallback; with nothing configured
it answers {} so clients still try a direct connection.
Web client: 1:1 calls (Matrix VoIP via matrix-js-sdk) in rooms with one
other person. Voice and video buttons in the room header, a ringing card
with answer, answer without video and decline, and a call panel that
stays up across rooms: mute, camera, screen sharing, expand, hang up,
ring tones. Call events show in the timeline and room list. Works around
the SDK keeping a failed microphone request forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
69ce927b6d
commit
325431a5be
25 files changed
+930
-2
No files matched your search
@@ -47,6 +47,7 @@ type ClientAPI struct {
|
||||
remoteProfile RemoteProfiles
|
||||
regTokens *userapi.RegistrationTokens
|
||||
appservices *appservice.API
|
||||
turn config.TURN
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
@@ -141,6 +142,9 @@ type Options struct {
|
||||
// RateLimit configures the per-endpoint buckets.
|
||||
RateLimit config.RateLimit
|
||||
|
||||
// TURN is the relay handed to clients for calls.
|
||||
TURN config.TURN
|
||||
|
||||
// TrustXForwardedFor takes the client address from the proxy header.
|
||||
TrustXForwardedFor bool
|
||||
|
||||
@@ -200,6 +204,7 @@ func New(opts Options) (*ClientAPI, error) {
|
||||
remoteProfile: opts.RemoteProfiles,
|
||||
regTokens: opts.RegistrationTokens,
|
||||
appservices: opts.AppServices,
|
||||
turn: opts.TURN,
|
||||
log: opts.Logger,
|
||||
now: opts.Now,
|
||||
uia: newUIAStore(uiaSessionTTL, opts.Now),
|
||||
@@ -267,6 +272,9 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
|
||||
c.authed(r, "POST "+v1+"/appservice/{appserviceID}/ping", c.limitDefault, c.handleAppServicePing)
|
||||
c.authed(r, "GET "+v3+"/capabilities", c.limitDefault, c.handleCapabilities)
|
||||
|
||||
// Calls: the relay a client sets its peer connection up with.
|
||||
c.authed(r, "GET "+v3+"/voip/turnServer", c.limitDefault, c.handleTurnServer)
|
||||
|
||||
// Devices.
|
||||
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
|
||||
c.authed(r, "GET "+v3+"/devices/{deviceID}", c.limitDefault, c.handleGetDevice)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1" //nolint:gosec // The TURN REST API is defined with HMAC-SHA1; coturn checks exactly that.
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
type turnServerResponse struct {
|
||||
URIs []string `json:"uris"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TTL int64 `json:"ttl"`
|
||||
}
|
||||
|
||||
// staticTURNTTL is the ttl given with static credentials. They never
|
||||
// change, so it only sets how often a client asks again.
|
||||
const staticTURNTTL = 86400
|
||||
|
||||
// handleTurnServer implements GET /_matrix/client/v3/voip/turnServer.
|
||||
//
|
||||
// With no TURN server configured it answers an empty object, as Synapse
|
||||
// does: clients take that as "no relay" and still try a direct connection,
|
||||
// where an error would have some of them refuse to place a call at all.
|
||||
func (c *ClientAPI) handleTurnServer(w http.ResponseWriter, _ *http.Request, auth userapi.Authenticated) error {
|
||||
t := c.turn
|
||||
if len(t.URIs) == 0 || (t.SharedSecret == "" && t.Username == "") {
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
if t.SharedSecret != "" {
|
||||
username, password, ttl := turnRESTCredentials(t, auth.UserID, c.now().Unix())
|
||||
httputil.WriteJSON(w, http.StatusOK, turnServerResponse{
|
||||
URIs: t.URIs, Username: username, Password: password, TTL: ttl,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, turnServerResponse{
|
||||
URIs: t.URIs, Username: t.Username, Password: t.Password, TTL: staticTURNTTL,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// turnRESTCredentials makes TURN REST API credentials: the username is the
|
||||
// expiry time and the user, and the password is the username signed with
|
||||
// the secret the TURN server shares, so it can check them without asking
|
||||
// anyone.
|
||||
func turnRESTCredentials(t config.TURN, userID string, now int64) (username, password string, ttl int64) {
|
||||
ttl = int64(t.UserLifetime.Seconds())
|
||||
username = strconv.FormatInt(now+ttl, 10) + ":" + userID
|
||||
mac := hmac.New(sha1.New, []byte(t.SharedSecret))
|
||||
mac.Write([]byte(username))
|
||||
return username, base64.StdEncoding.EncodeToString(mac.Sum(nil)), ttl
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1" //nolint:gosec // The TURN REST API is HMAC-SHA1.
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
)
|
||||
|
||||
const turnPath = "/_matrix/client/v3/voip/turnServer"
|
||||
|
||||
type turnResponse struct {
|
||||
URIs []string `json:"uris"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TTL int64 `json:"ttl"`
|
||||
}
|
||||
|
||||
func TestTurnServerNeedsAToken(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
expectError(t, ts.get(turnPath, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
}
|
||||
|
||||
func TestTurnServerUnconfiguredIsEmpty(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "correct horse battery")
|
||||
|
||||
rec := ts.get(turnPath, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if body := jsonBody(t, rec); len(body) != 0 {
|
||||
t.Errorf("body = %v, want an empty object", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnServerSharedSecret(t *testing.T) {
|
||||
const secret = "s3cret"
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.TURN = config.TURN{
|
||||
URIs: []string{"turn:turn.warp.example:3478?transport=udp"},
|
||||
SharedSecret: secret,
|
||||
UserLifetime: time.Hour,
|
||||
}
|
||||
})
|
||||
acc := ts.register("alice", "correct horse battery")
|
||||
|
||||
before := time.Now().Unix()
|
||||
rec := ts.get(turnPath, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
got := decode[turnResponse](t, rec)
|
||||
|
||||
if len(got.URIs) != 1 || got.TTL != 3600 {
|
||||
t.Fatalf("response = %+v", got)
|
||||
}
|
||||
expiry, user, ok := strings.Cut(got.Username, ":")
|
||||
if !ok || user != acc.UserID {
|
||||
t.Fatalf("username = %q, want <expiry>:%s", got.Username, acc.UserID)
|
||||
}
|
||||
if n, err := strconv.ParseInt(expiry, 10, 64); err != nil || n < before+3600 || n > before+3600+60 {
|
||||
t.Errorf("expiry = %q, want about an hour from now", expiry)
|
||||
}
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write([]byte(got.Username))
|
||||
if want := base64.StdEncoding.EncodeToString(mac.Sum(nil)); got.Password != want {
|
||||
t.Errorf("password = %q, want %q", got.Password, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnServerStatic(t *testing.T) {
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.TURN = config.TURN{
|
||||
URIs: []string{"turns:turn.warp.example:5349", "stun:turn.warp.example:3478"},
|
||||
Username: "warp",
|
||||
Password: "hunter2",
|
||||
}
|
||||
})
|
||||
acc := ts.register("alice", "correct horse battery")
|
||||
|
||||
got := decode[turnResponse](t, ts.get(turnPath, acc.AccessToken))
|
||||
if len(got.URIs) != 2 || got.Username != "warp" || got.Password != "hunter2" || got.TTL <= 0 {
|
||||
t.Errorf("response = %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user