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

+117
View File
@@ -0,0 +1,117 @@
# Calls for a local Warp: a LiveKit SFU and the LiveKit JWT service
# (lk-jwt-service), layered over docker-compose.yml.
#
# cd deploy/compose
# docker compose -f docker-compose.yml -f calls.yml --profile web up -d --build
#
# Then open http://localhost:8080 in a few browsers (or private windows),
# sign in as different users, and press the voice or video call button in
# a room: in a room of two it rings the other person.
#
# How the pieces find each other:
#
# browser ── /.well-known/matrix/client ──▶ warp rtc_foci: http://localhost:8070
# browser ── openid/request_token ────────▶ warp an OpenID token
# browser ── POST /sfu/get ───────────────▶ jwt {url, jwt} for the LiveKit room
# jwt ───── /_matrix/federation/v1/openid/userinfo ─▶ warp (https://<server_name>:8448)
# browser ── ws://localhost:7880 + UDP 7882 ─▶ livekit
#
# The JWT service checks a token by asking the user's homeserver over the
# federation port, with TLS. To make that work with the default server name
# "localhost", warp gets a federation listener on :8448 with a self-signed
# certificate, and the JWT service and LiveKit share warp's network
# namespace, so that "localhost" means warp for all three. This is a local
# setup: in production, give each its own hostname and real certificates
# (see docs/calls.md).
services:
# Makes the self-signed certificate for warp's federation listener once.
calls-cert:
image: alpine:3.21
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- >-
test -f /tls/tls.crt ||
(apk add --no-cache openssl >/dev/null &&
openssl req -x509 -newkey rsa:2048 -nodes -days 3650
-subj "/CN=$${WARP_SERVER_NAME}" -addext "subjectAltName=DNS:$${WARP_SERVER_NAME}"
-keyout /tls/tls.key -out /tls/tls.crt &&
chmod 644 /tls/tls.key /tls/tls.crt)
environment:
WARP_SERVER_NAME: ${WARP_SERVER_NAME:-localhost}
volumes:
- warp-tls:/tls
warp:
depends_on:
calls-cert:
condition: service_completed_successfully
environment:
WARP_RTC_LIVEKIT_SERVICE_URL: ${WARP_LIVEKIT_JWT_PUBLIC_URL:-http://localhost:8070}
WARP_LISTEN_FEDERATION_ADDRESS: ":8448"
WARP_TLS_CERT_FILE: /tls/tls.crt
WARP_TLS_KEY_FILE: /tls/tls.key
volumes:
- warp-tls:/tls:ro
# LiveKit and the JWT service live in this container's network
# namespace, so their ports are published here.
ports:
- "8070:8070" # lk-jwt-service
- "7880:7880" # LiveKit signalling (WebSocket)
- "7881:7881" # LiveKit media over TCP, the fallback
- "7882:7882/udp" # LiveKit media over UDP
livekit:
image: livekit/livekit-server:v1.13.7
restart: unless-stopped
network_mode: service:warp
depends_on:
- warp
environment:
# node_ip is what LiveKit tells browsers to send media to. 127.0.0.1
# works for browsers on this machine; set LIVEKIT_NODE_IP to the
# machine's LAN address to call from other devices.
LIVEKIT_CONFIG: |
port: 7880
bind_addresses: [""]
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: false
node_ip: ${LIVEKIT_NODE_IP:-127.0.0.1}
room:
auto_create: false
keys:
${LIVEKIT_KEY:-warp}: ${LIVEKIT_SECRET:-warp-local-livekit-secret-change-me-0123456789}
logging:
level: info
livekit-jwt:
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
restart: unless-stopped
network_mode: service:warp
depends_on:
- warp
- livekit
environment:
LIVEKIT_JWT_BIND: ":8070"
# The URL handed to browsers, and the one the service creates rooms
# through: both reach LiveKit at localhost here.
LIVEKIT_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
LIVEKIT_KEY: ${LIVEKIT_KEY:-warp}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-warp-local-livekit-secret-change-me-0123456789}
# Users of this server may create call rooms on the SFU.
LIVEKIT_FULL_ACCESS_HOMESERVERS: ${WARP_SERVER_NAME:-localhost}
# warp's federation certificate is self-signed.
LIVEKIT_INSECURE_SKIP_VERIFY_TLS: YES_I_KNOW_WHAT_I_AM_DOING
web:
environment:
# The browser connects to the JWT service and to LiveKit directly.
WARP_WEB_CALL_ORIGINS: >-
${WARP_LIVEKIT_JWT_PUBLIC_URL:-http://localhost:8070}
${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
volumes:
warp-tls:
+7 -1
View File
@@ -12,6 +12,12 @@
# http://localhost:8009; with the web profile, the web client is on
# http://localhost:8080.
#
# For voice and video calls (LiveKit and its JWT service) add calls.yml:
#
# docker compose -f docker-compose.yml -f calls.yml --profile web up -d
#
# WARP_PORT moves the homeserver off host port 8008.
#
# Create the first administrator once the server is up:
#
# docker compose exec warp warp create-account --username admin --admin
@@ -63,7 +69,7 @@ services:
volumes:
- warp-data:/data
ports:
- "8008:8008"
- "${WARP_PORT:-8008}:8008"
# The admin panel is optional. Start it with the admin profile:
# docker compose --profile admin up -d
+29
View File
@@ -65,3 +65,32 @@ app.kubernetes.io/name: {{ printf "%s-admin" (include "warp.name" .) }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: admin
{{- end -}}
{{/*
The LiveKit JWT service for calls, with its own name label like the
admin panel.
*/}}
{{- define "warp.calls.fullname" -}}
{{- printf "%s-livekit-jwt" (include "warp.fullname" . | trunc 51 | trimSuffix "-") -}}
{{- end -}}
{{- define "warp.calls.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{ include "warp.calls.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "warp.calls.selectorLabels" -}}
app.kubernetes.io/name: {{ printf "%s-livekit-jwt" (include "warp.name" .) }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: calls
{{- end -}}
{{/* The JWT service's public URL, advertised to clients: explicit, else its ingress. */}}
{{- define "warp.calls.serviceURL" -}}
{{- if .Values.calls.livekitServiceURL -}}
{{- .Values.calls.livekitServiceURL -}}
{{- else if and .Values.calls.jwtService.enabled .Values.calls.jwtService.ingress.enabled -}}
{{- printf "https://%s" .Values.calls.jwtService.ingress.host -}}
{{- end -}}
{{- end -}}
@@ -97,6 +97,10 @@ spec:
value: {{ .Values.tracing.sampleRatio | quote }}
- name: WARP_LOG_FORMAT
value: json
{{- with (include "warp.calls.serviceURL" .) }}
- name: WARP_RTC_LIVEKIT_SERVICE_URL
value: {{ . | quote }}
{{- end }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
+107
View File
@@ -0,0 +1,107 @@
{{- if .Values.calls.jwtService.enabled }}
{{- $jwt := .Values.calls.jwtService }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "warp.calls.fullname" . }}
labels:
{{- include "warp.calls.labels" . | nindent 4 }}
spec:
# The service keeps its state in memory: one replica, unless LIVEKIT_REDIS_URL
# is set through extraEnv.
replicas: 1
selector:
matchLabels:
{{- include "warp.calls.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "warp.calls.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: lk-jwt-service
image: "{{ $jwt.image.repository }}:{{ $jwt.image.tag }}"
imagePullPolicy: {{ $jwt.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: LIVEKIT_JWT_BIND
value: ":8080"
- name: LIVEKIT_URL
value: {{ required "calls.jwtService.livekitURL is required" $jwt.livekitURL | quote }}
- name: LIVEKIT_KEY
valueFrom:
secretKeyRef:
name: {{ required "calls.jwtService.existingSecret is required" $jwt.existingSecret }}
key: {{ $jwt.keyKey }}
- name: LIVEKIT_SECRET
valueFrom:
secretKeyRef:
name: {{ $jwt.existingSecret }}
key: {{ $jwt.secretKey }}
- name: LIVEKIT_FULL_ACCESS_HOMESERVERS
value: {{ $jwt.fullAccessHomeservers | default .Values.serverName | quote }}
{{- with $jwt.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
readinessProbe:
httpGet:
path: /healthz
port: http
resources:
{{- toYaml $jwt.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "warp.calls.fullname" . }}
labels:
{{- include "warp.calls.labels" . | nindent 4 }}
spec:
type: {{ $jwt.service.type }}
ports:
- name: http
port: {{ $jwt.service.port }}
targetPort: http
protocol: TCP
selector:
{{- include "warp.calls.selectorLabels" . | nindent 4 }}
{{- if $jwt.ingress.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "warp.calls.fullname" . }}
labels:
{{- include "warp.calls.labels" . | nindent 4 }}
{{- with $jwt.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with $jwt.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with $jwt.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ required "calls.jwtService.ingress.host is required" $jwt.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "warp.calls.fullname" . }}
port:
name: http
{{- end }}
{{- end }}
+47
View File
@@ -190,3 +190,50 @@ admin:
nodeSelector: {}
tolerations: []
affinity: {}
# Calls (MatrixRTC with a LiveKit SFU). Warp only advertises the LiveKit
# JWT service and answers its OpenID checks; the media goes through LiveKit.
# See docs/calls.md.
calls:
# The JWT service's public URL, advertised in /.well-known/matrix/client.
# Defaults to https://<jwtService.ingress.host> when the chart runs the
# service with an ingress; empty and without one, there are no calls.
livekitServiceURL: ""
# lk-jwt-service: hands out LiveKit tokens to users of this server, who
# prove who they are with an OpenID token. It checks that token with this
# server's federation endpoint at https://<serverName>/.well-known/matrix/server
# or <serverName>:8448, so one of the two must reach Warp.
jwtService:
enabled: false
image:
repository: ghcr.io/element-hq/lk-jwt-service
tag: "0.8.0"
pullPolicy: IfNotPresent
# The LiveKit SFU's WebSocket URL as browsers reach it, e.g.
# wss://livekit.example.org. Run LiveKit itself with its own chart
# (https://github.com/livekit/livekit-helm); it needs UDP from clients.
livekitURL: wss://livekit.example.org
# A secret with the LiveKit API key and secret under these keys.
existingSecret: ""
keyKey: LIVEKIT_KEY
secretKey: LIVEKIT_SECRET
# Homeservers whose users may create call rooms on the SFU. Defaults to
# serverName.
fullAccessHomeservers: ""
service:
type: ClusterIP
port: 8080
ingress:
enabled: false
className: ""
annotations: {}
host: livekit-jwt.example.org
tls: []
extraEnv: []
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi