diff --git a/apps/ui/src/icons/index.ts b/apps/ui/src/icons/index.ts index 1ac0d6c..7a108df 100644 --- a/apps/ui/src/icons/index.ts +++ b/apps/ui/src/icons/index.ts @@ -22,9 +22,15 @@ export type IconName = | 'lock' | 'logout' | 'megaphone' + | 'maximize' | 'menu' + | 'mic' + | 'mic-off' + | 'minimize' | 'moon' | 'more' + | 'phone' + | 'phone-off' | 'plug' | 'plus' | 'react' @@ -32,6 +38,7 @@ export type IconName = | 'refresh' | 'reply' | 'room' + | 'screen-share' | 'search' | 'send' | 'server' @@ -43,6 +50,7 @@ export type IconName = | 'user' | 'users' | 'video' + | 'video-off' const files = import.meta.glob('./*.svg', { query: '?raw', import: 'default', eager: true }) diff --git a/apps/ui/src/icons/maximize.svg b/apps/ui/src/icons/maximize.svg new file mode 100644 index 0000000..94fdd20 --- /dev/null +++ b/apps/ui/src/icons/maximize.svg @@ -0,0 +1 @@ +maximize \ No newline at end of file diff --git a/apps/ui/src/icons/mic-off.svg b/apps/ui/src/icons/mic-off.svg new file mode 100644 index 0000000..e040c5d --- /dev/null +++ b/apps/ui/src/icons/mic-off.svg @@ -0,0 +1 @@ +mic-off \ No newline at end of file diff --git a/apps/ui/src/icons/mic.svg b/apps/ui/src/icons/mic.svg new file mode 100644 index 0000000..d3fe2e6 --- /dev/null +++ b/apps/ui/src/icons/mic.svg @@ -0,0 +1 @@ +mic \ No newline at end of file diff --git a/apps/ui/src/icons/minimize.svg b/apps/ui/src/icons/minimize.svg new file mode 100644 index 0000000..1b638cc --- /dev/null +++ b/apps/ui/src/icons/minimize.svg @@ -0,0 +1 @@ +minimize \ No newline at end of file diff --git a/apps/ui/src/icons/phone-off.svg b/apps/ui/src/icons/phone-off.svg new file mode 100644 index 0000000..eecc9b7 --- /dev/null +++ b/apps/ui/src/icons/phone-off.svg @@ -0,0 +1 @@ +phone-off \ No newline at end of file diff --git a/apps/ui/src/icons/phone.svg b/apps/ui/src/icons/phone.svg new file mode 100644 index 0000000..1b71ddf --- /dev/null +++ b/apps/ui/src/icons/phone.svg @@ -0,0 +1 @@ +phone \ No newline at end of file diff --git a/apps/ui/src/icons/screen-share.svg b/apps/ui/src/icons/screen-share.svg new file mode 100644 index 0000000..e771e8e --- /dev/null +++ b/apps/ui/src/icons/screen-share.svg @@ -0,0 +1 @@ +screen-share \ No newline at end of file diff --git a/apps/ui/src/icons/video-off.svg b/apps/ui/src/icons/video-off.svg new file mode 100644 index 0000000..709bc13 --- /dev/null +++ b/apps/ui/src/icons/video-off.svg @@ -0,0 +1 @@ +video-off \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md index 1d46536..be9e82a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -63,6 +63,12 @@ that one origin. them asks you to acknowledge it; a reset since you *verified* them says their sessions are no longer trusted, with Verify again or Withdraw verification. +- Voice and video calls in a room with one other person (Matrix 1:1 VoIP, + WebRTC between the browsers): ringing, answer with or without video, + mute, camera on and off, screen sharing, and a panel that stays up while + you move between rooms and expands to fill the window. In an encrypted + room the call signalling is encrypted too. Calls outside one network + need a TURN server; see `client_api.turn` in the server configuration. - Encryption setup in Settings: cross-signing keys, secret storage and a key backup in one step, behind a recovery key shown once. A new session verifies itself with the recovery key (or another session) and restores @@ -166,7 +172,7 @@ page saying the client is missing. | Path | What | |---|---| | `src/matrix/` | everything that touches matrix-js-sdk: the client's lifecycle, the timeline projection, sending, media, encryption (`crypto.ts`, `attachments.ts`) | -| `src/stores/` | session, room list and connection state, encryption and verification (`security.ts`), profile, toasts (Pinia) | +| `src/stores/` | session, room list and connection state, encryption and verification (`security.ts`), calls (`call.ts`), profile, toasts (Pinia) | | `src/views/` | sign-in, sign-up, home, room | | `src/components/` | timeline, message body, room info, dialogs | | `cmd/warp-web/` | the binary | diff --git a/apps/web/src/components/CallView.vue b/apps/web/src/components/CallView.vue new file mode 100644 index 0000000..e145282 --- /dev/null +++ b/apps/web/src/components/CallView.vue @@ -0,0 +1,204 @@ + + + diff --git a/apps/web/src/layouts/ClientShell.vue b/apps/web/src/layouts/ClientShell.vue index 794e043..dbcb71c 100644 --- a/apps/web/src/layouts/ClientShell.vue +++ b/apps/web/src/layouts/ClientShell.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref, watchEffect } from 'vue' import { useRoute, useRouter } from 'vue-router' import { WBanner, WButton, WIcon, WIconButton, WLogo, WSpinner } from '@warp/ui' +import CallView from '@/components/CallView.vue' import CreateRoomDialog from '@/components/CreateRoomDialog.vue' import EncryptionDialog from '@/components/EncryptionDialog.vue' import JoinRoomDialog from '@/components/JoinRoomDialog.vue' @@ -13,6 +14,7 @@ import SettingsDialog from '@/components/SettingsDialog.vue' import StartChatDialog from '@/components/StartChatDialog.vue' import VerificationDialog from '@/components/VerificationDialog.vue' import { formatListTime, localpart } from '@/lib/format' +import { useCall } from '@/stores/call' import { useChat } from '@/stores/chat' import { useProfile } from '@/stores/profile' import { useSecurity } from '@/stores/security' @@ -20,6 +22,7 @@ import { useSession } from '@/stores/session' const session = useSession() const chat = useChat() +const call = useCall() const profile = useProfile() const security = useSecurity() const route = useRoute() @@ -40,6 +43,7 @@ async function start() { if (session.phase === 'ready') { chat.attach() security.attach() + call.attach() profile.load() } } catch (e) { @@ -204,5 +208,6 @@ const ownName = computed(() => profile.displayName || localpart(session.userId)) + diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 23d3859..c651521 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -43,6 +43,15 @@ export function sameDay(a: number, b: number): boolean { return startOfDay(a) === startOfDay(b) } +/** A call's length: "0:42", "12:05", "1:02:09". */ +export function formatDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const ss = String(s % 60).padStart(2, '0') + return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}` +} + const units = ['B', 'KB', 'MB', 'GB', 'TB'] export function formatBytes(n: number | undefined): string { diff --git a/apps/web/src/matrix/timeline.ts b/apps/web/src/matrix/timeline.ts index 2b74dcf..7defb3f 100644 --- a/apps/web/src/matrix/timeline.ts +++ b/apps/web/src/matrix/timeline.ts @@ -101,6 +101,36 @@ function statusOf(ev: MatrixEvent): MessageStatus | undefined { } } +/** Whether an m.call.invite offers video. */ +export function isVideoCallInvite(ev: MatrixEvent): boolean { + const sdp = ev.getContent().offer?.sdp + return typeof sdp === 'string' && sdp.includes('m=video') +} + +/** A one-line description of a call event, or null for the signalling in between. */ +function describeCall(ev: MatrixEvent, room: Room): string | null { + const sender = nameOf(room, ev.getSender() ?? '') + switch (ev.getType()) { + case EventType.CallInvite: + return `${sender} started a ${isVideoCallInvite(ev) ? 'video' : 'voice'} call` + case EventType.CallReject: + return `${sender} declined the call` + case EventType.CallHangup: + switch (ev.getContent().reason) { + case 'invite_timeout': + return 'The call wasn’t answered' + case 'user_busy': + return `${sender} was busy` + case 'ice_failed': + return 'The call couldn’t connect' + default: + return `${sender} ended the call` + } + default: + return null + } +} + /** A one-line description of a state change, or null to leave it out. */ function describeState(ev: MatrixEvent, room: Room): string | null { const sender = nameOf(room, ev.getSender() ?? '') @@ -272,7 +302,14 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] { const isMessage = type === EventType.RoomMessage || type === EventType.Sticker || type === EventType.RoomMessageEncrypted if (!isMessage) { - if (!ev.isState()) continue + if (!ev.isState()) { + const text = describeCall(ev, room) + if (text) { + items.push({ kind: 'notice', key, ts, text }) + prev = null + } + continue + } const text = describeState(ev, room) if (text) { items.push({ kind: 'notice', key, ts, text }) diff --git a/apps/web/src/matrix/tones.ts b/apps/web/src/matrix/tones.ts new file mode 100644 index 0000000..9f29fa5 --- /dev/null +++ b/apps/web/src/matrix/tones.ts @@ -0,0 +1,67 @@ +// The ring of an incoming call and the ringback of an outgoing one, made +// with Web Audio rather than shipped as sound files. One tone plays at a +// time. A browser that hasn't seen the page interacted with yet may keep +// the audio context suspended: the call still shows, just silently. + +export type Tone = 'ring' | 'ringback' + +// Each pattern repeats: [frequencies played together, seconds on, seconds off]. +const patterns: Record = { + // Two short bursts, a pause: a phone ringing. + ring: [ + { freqs: [440, 480], on: 0.4, off: 0.2 }, + { freqs: [440, 480], on: 0.4, off: 2 }, + ], + // The long tone you hear while the other side rings. + ringback: [{ freqs: [440, 480], on: 1.5, off: 3 }], +} + +let ctx: AudioContext | null = null +let playing: Tone | null = null +let timer = 0 +let gain: GainNode | null = null + +function context(): AudioContext | null { + try { + ctx ??= new AudioContext() + if (ctx.state === 'suspended') void ctx.resume().catch(() => undefined) + return ctx + } catch { + return null + } +} + +export function startTone(tone: Tone) { + if (playing === tone) return + stopTone() + const ac = context() + if (!ac) return + playing = tone + gain = ac.createGain() + gain.gain.value = tone === 'ring' ? 0.12 : 0.06 + gain.connect(ac.destination) + const steps = patterns[tone] + let i = 0 + const step = () => { + if (playing !== tone || !gain) return + const s = steps[i++ % steps.length]! + const start = ac.currentTime + for (const f of s.freqs) { + const osc = ac.createOscillator() + osc.frequency.value = f + osc.connect(gain) + osc.start(start) + osc.stop(start + s.on) + } + timer = window.setTimeout(step, (s.on + s.off) * 1000) + } + step() +} + +export function stopTone() { + playing = null + window.clearTimeout(timer) + timer = 0 + gain?.disconnect() + gain = null +} diff --git a/apps/web/src/stores/call.ts b/apps/web/src/stores/call.ts new file mode 100644 index 0000000..bcec574 --- /dev/null +++ b/apps/web/src/stores/call.ts @@ -0,0 +1,340 @@ +import { CallEvent, CallFeedEvent, createNewMatrixCall, type MatrixCall, type MatrixClient } from 'matrix-js-sdk' +import { CallDirection, CallErrorCode, CallParty, CallState, CallType, type CallError } from 'matrix-js-sdk/lib/webrtc/call' +import { CallEventHandlerEvent } from 'matrix-js-sdk/lib/webrtc/callEventHandler' +import type { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed' +import { defineStore } from 'pinia' +import { computed, ref, shallowRef } from 'vue' +import { errorMessage } from '@/lib/format' +import { client } from '@/matrix/client' +import { startTone, stopTone } from '@/matrix/tones' +import { useToasts } from '@/stores/toasts' + +// Voice and video calls between two people: Matrix's 1:1 VoIP (m.call.* +// events in the room, WebRTC between the browsers), which the SDK runs. +// In an encrypted room the call events are encrypted like any other; the +// media itself is always encrypted by WebRTC (DTLS-SRTP). +// +// One call at a time. A call that comes in while one is going is answered +// "busy", as a phone would. + +export type CallPhase = 'ringing' | 'calling' | 'connecting' | 'connected' | 'ended' + +/** How long the ended call stays on screen, saying why. */ +const endedLingerMs = 2500 + +export const useCall = defineStore('call', () => { + const call = shallowRef(null) + /** Bumps on every change the call view shows. */ + const version = ref(0) + /** Seconds since the call connected. */ + const length = ref(0) + /** Why the call ended, once it has. */ + const endReason = ref('') + let attached: MatrixClient | null = null + let clearTimer = 0 + /** The call got as far as connecting media, at some point. */ + let connected = false + /** The feeds whose mute state is listened to. */ + let feeds: CallFeed[] = [] + + const phase = computed(() => { + void version.value + const c = call.value + if (!c) return null + switch (c.state) { + case CallState.Ringing: + return 'ringing' + case CallState.Fledgling: + case CallState.WaitLocalMedia: + case CallState.CreateOffer: + case CallState.InviteSent: + return c.direction === CallDirection.Inbound ? 'connecting' : 'calling' + case CallState.CreateAnswer: + case CallState.Connecting: + return 'connecting' + case CallState.Connected: + return 'connected' + default: + return 'ended' + } + }) + + const isVideo = computed(() => { + void version.value + return call.value?.type === CallType.Video + }) + + const peerName = computed(() => { + void version.value + const c = call.value + if (!c) return '' + const member = c.getOpponentMember() + if (member) return member.name + const room = attached?.getRoom(c.roomId) + return room?.name ?? c.invitee ?? '' + }) + + const peerAvatarMxc = computed(() => { + void version.value + return call.value?.getOpponentMember()?.getMxcAvatarUrl() ?? undefined + }) + + function bump() { + version.value++ + } + + // A feed muting its camera or microphone is not a change in the call's + // feeds, so each feed is listened to as well. + function watchFeeds(c: MatrixCall | null) { + for (const f of feeds) { + f.off(CallFeedEvent.MuteStateChanged, bump) + f.off(CallFeedEvent.NewStream, bump) + } + feeds = c ? c.getFeeds() : [] + for (const f of feeds) { + f.on(CallFeedEvent.MuteStateChanged, bump) + f.on(CallFeedEvent.NewStream, bump) + } + } + + function onFeeds() { + watchFeeds(call.value) + bump() + } + + function tones() { + const p = phase.value + if (p === 'ringing') startTone('ring') + else if (p === 'calling') startTone('ringback') + else stopTone() + } + + function onState(state: CallState) { + if (state === CallState.Connected) connected = true + bump() + tones() + } + + function onLength(seconds: number) { + length.value = seconds + } + + function onError(err: CallError) { + // The hangup that follows says the rest. + if (err.code === CallErrorCode.NoUserMedia) { + forgetFailedMedia() + useToasts().error(isVideo.value ? 'Can’t use your camera or microphone. Check that the browser may use them.' : 'Can’t use your microphone. Check that the browser may use it.') + } else { + useToasts().error(`Call failed: ${errorMessage(err)}`) + } + } + + function onHangup(c: MatrixCall) { + if (c !== call.value) return + endReason.value = describeHangup(c, connected) + bump() + stopTone() + window.clearTimeout(clearTimer) + clearTimer = window.setTimeout(() => { + if (call.value === c) release() + }, endedLingerMs) + } + + function onReplaced(newCall: MatrixCall) { + // Both of you called at once: the SDK keeps one of the two calls. + show(newCall) + } + + function listen(c: MatrixCall, on: boolean) { + const m = on ? 'on' : 'off' + c[m](CallEvent.State, onState) + c[m](CallEvent.FeedsChanged, onFeeds) + c[m](CallEvent.LocalHoldUnhold, bump) + c[m](CallEvent.RemoteHoldUnhold, bump) + c[m](CallEvent.LengthChanged, onLength) + c[m](CallEvent.Error, onError) + c[m](CallEvent.Hangup, onHangup) + c[m](CallEvent.Replaced, onReplaced) + } + + function show(c: MatrixCall) { + if (call.value) listen(call.value, false) + window.clearTimeout(clearTimer) + call.value = c + connected = false + length.value = 0 + endReason.value = '' + listen(c, true) + watchFeeds(c) + bump() + tones() + } + + function release() { + if (call.value) listen(call.value, false) + watchFeeds(null) + window.clearTimeout(clearTimer) + call.value = null + stopTone() + bump() + } + + function onIncoming(incoming: MatrixCall) { + if (call.value && phase.value !== 'ended') { + incoming.hangup(CallErrorCode.UserBusy, false) + return + } + show(incoming) + // Hung up (or answered elsewhere) before we did anything. + if (incoming.callHasEnded()) onHangup(incoming) + } + + /** Starts listening for calls on the running client. */ + function attach() { + const c = client() + if (attached === c) return + if (attached) attached.off(CallEventHandlerEvent.Incoming, onIncoming) + release() + attached = c + c.on(CallEventHandlerEvent.Incoming, onIncoming) + } + + /** Whether this browser can make calls at all. */ + function supported(): boolean { + return Boolean(attached?.supportsVoip()) + } + + async function place(roomId: string, invitee: string | undefined, video: boolean) { + if (call.value && phase.value !== 'ended') { + useToasts().error('You’re already in a call.') + return + } + const c = createNewMatrixCall(client(), roomId, { invitee }) + if (!c) { + useToasts().error('This browser can’t make calls.') + return + } + show(c) + try { + if (video) await c.placeVideoCall() + else await c.placeVoiceCall() + } catch (e) { + // The SDK has hung up already and said why through CallEvent.Error. + console.error('call: ', e) + } + } + + /** Answers the ringing call; a video call with the camera off when `video` is false. */ + async function answer(video = true) { + const c = call.value + if (!c || phase.value !== 'ringing') return + stopTone() + try { + await c.answer(true, video && c.type === CallType.Video) + } catch (e) { + console.error('call: ', e) + } + } + + function decline() { + const c = call.value + if (!c) return + if (phase.value === 'ringing') c.reject() + else hangup() + } + + function hangup() { + const c = call.value + if (!c) return + if (phase.value === 'ended') release() + else c.hangup(CallErrorCode.UserHangup, false) + } + + async function toggleMic() { + const c = call.value + if (!c) return + await c.setMicrophoneMuted(!c.isMicrophoneMuted()) + bump() + } + + async function toggleCamera() { + const c = call.value + if (!c) return + await c.setLocalVideoMuted(!c.isLocalVideoMuted()) + bump() + } + + async function toggleScreenShare() { + const c = call.value + if (!c) return + try { + await c.setScreensharingEnabled(!c.isScreensharing()) + } catch (e) { + useToasts().error(`Can’t share your screen: ${errorMessage(e)}`) + } + bump() + } + + function toggleHold() { + const c = call.value + if (!c) return + c.setRemoteOnHold(!c.isRemoteOnHold()) + bump() + } + + return { + call, + version, + length, + endReason, + phase, + isVideo, + peerName, + peerAvatarMxc, + attach, + supported, + place, + answer, + decline, + hangup, + toggleMic, + toggleCamera, + toggleScreenShare, + toggleHold, + } +}) + +/** + * The SDK's media handler queues every request for the microphone and + * camera behind the previous one, and never lets go of a request that + * failed: once you said no, every later call would fail the same way until + * the page reloads. Dropping the failed request lets the next call ask again. + */ +function forgetFailedMedia() { + const handler = client().getMediaHandler() as unknown as { getMediaStreamPromise?: Promise } + handler.getMediaStreamPromise = undefined +} + +function describeHangup(c: MatrixCall, connected: boolean): string { + switch (c.hangupReason) { + case CallErrorCode.UserBusy: + return 'Busy' + case CallErrorCode.InviteTimeout: + return c.direction === CallDirection.Inbound ? 'Missed call' : 'No answer' + case CallErrorCode.AnsweredElsewhere: + return 'Answered in another session' + case CallErrorCode.IceFailed: + return 'Couldn’t connect' + case CallErrorCode.NoUserMedia: + return 'No microphone or camera' + case CallErrorCode.Replaced: + return 'Call replaced' + default: + if (!connected) { + const outbound = c.direction === CallDirection.Outbound + if (c.hangupParty === CallParty.Remote) return outbound ? 'Declined' : 'Missed call' + return outbound ? 'Call cancelled' : 'Declined' + } + return 'Call ended' + } +} diff --git a/apps/web/src/stores/chat.ts b/apps/web/src/stores/chat.ts index 50bf2d8..650e2f8 100644 --- a/apps/web/src/stores/chat.ts +++ b/apps/web/src/stores/chat.ts @@ -19,6 +19,7 @@ import { import { defineStore } from 'pinia' import { computed, ref, shallowRef, watch } from 'vue' import { client } from '@/matrix/client' +import { isVideoCallInvite } from '@/matrix/timeline' import { onShieldsChanged } from '@/matrix/trust' // The room list and the client's connection state, as plain reactive data @@ -59,6 +60,10 @@ export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSende // own placeholder text: never show that. if (ev.isDecryptionFailure()) return withSender ? `${senderName(ev, me, room)}: Can’t decrypt this message` : 'Can’t decrypt this message' if (type === EventType.RoomMessageEncrypted) return 'Encrypted message' + if (type === EventType.CallInvite) { + const text = isVideoCallInvite(ev) ? 'Video call' : 'Voice call' + return withSender ? `${senderName(ev, me, room)}: ${text}` : text + } if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null if (ev.isRelation('m.replace')) return null const content = ev.getContent() diff --git a/apps/web/src/views/RoomView.vue b/apps/web/src/views/RoomView.vue index fd2f254..fafd860 100644 --- a/apps/web/src/views/RoomView.vue +++ b/apps/web/src/views/RoomView.vue @@ -11,6 +11,7 @@ import { errorMessage, formatBytes } from '@/lib/format' import { client, cryptoError } from '@/matrix/client' import { acceptIdentity, identityWarnings, withdrawVerification, type IdentityWarning } from '@/matrix/crypto' import { sendFile, sendText } from '@/matrix/send' +import { useCall } from '@/stores/call' import { useChat } from '@/stores/chat' import { useSecurity } from '@/stores/security' import { useToasts } from '@/stores/toasts' @@ -18,6 +19,7 @@ import { useToasts } from '@/stores/toasts' const props = defineProps<{ roomId: string }>() const chat = useChat() +const call = useCall() const security = useSecurity() const toasts = useToasts() const router = useRouter() @@ -52,6 +54,21 @@ const canSend = computed(() => { const infoOpen = ref(false) +// Calls are between two people: offered in a room with just you and one +// other person in it. +const callee = computed(() => { + void chat.version + const r = room.value + if (!r || r.getJoinedMemberCount() !== 2) return undefined + return r.getJoinedMembers().find((m) => m.userId !== me)?.userId +}) +const canCall = computed(() => Boolean(callee.value) && call.supported()) +// One call at a time, wherever it is. +const inCall = computed(() => Boolean(call.call) && call.phase !== 'ended') +function startCall(video: boolean) { + void call.place(props.roomId, callee.value, video) +} + // Someone in an encrypted room whose identity changed: said before you // write to them, until you acknowledge it. const warnings = ref([]) @@ -277,6 +294,10 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room' + diff --git a/docs/configuration.md b/docs/configuration.md index ccd5f00..79d5734 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,10 @@ List-valued variables are comma-separated. A single `-` clears a list: | `client_api.rate_limit.join.burst` / `.interval` | — | `10` / `1s` | The two join endpoints. | | `client_api.rate_limit.sync.burst` / `.interval` | — | `100` / `20ms` | `/sync` and the writes behind it: typing, receipts, to-device, presence. | | `client_api.rate_limit.media.burst` / `.interval` | — | `100` / `20ms` | Media downloads and thumbnails: a client opening a room asks for every avatar at once. Uploads are in the default bucket. | +| `client_api.turn.uris` | `WARP_TURN_URIS` | `[]` | `turn:`, `turns:` and `stun:` URIs for calls. Empty = no relay; `/voip/turnServer` answers `{}`. | +| `client_api.turn.shared_secret` | `WARP_TURN_SHARED_SECRET` | — | coturn's `static-auth-secret`: each user gets expiring TURN REST API credentials. | +| `client_api.turn.user_lifetime` | `WARP_TURN_USER_LIFETIME` | `1h` | How long shared-secret credentials last. | +| `client_api.turn.username` / `.password` | `WARP_TURN_USERNAME` / `WARP_TURN_PASSWORD` | — | Static credentials, used only without a shared secret. | | `sync.presence` | `WARP_SYNC_PRESENCE` | `false` | Off by default; see below. | | `sync.typing_timeout` | `WARP_SYNC_TYPING_TIMEOUT` | `30s` | How long a typing notification lasts, and the cap on what a client may ask for. | | `sync.long_poll_timeout` | `WARP_SYNC_LONG_POLL_TIMEOUT` | `30s` | The cap on a `/sync` `?timeout=`. | diff --git a/server/internal/clientapi/clientapi.go b/server/internal/clientapi/clientapi.go index 3ccc9f2..e802ac9 100644 --- a/server/internal/clientapi/clientapi.go +++ b/server/internal/clientapi/clientapi.go @@ -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) diff --git a/server/internal/clientapi/voip.go b/server/internal/clientapi/voip.go new file mode 100644 index 0000000..1db4bd8 --- /dev/null +++ b/server/internal/clientapi/voip.go @@ -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 +} diff --git a/server/internal/clientapi/voip_test.go b/server/internal/clientapi/voip_test.go new file mode 100644 index 0000000..5ad3e2c --- /dev/null +++ b/server/internal/clientapi/voip_test.go @@ -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 :%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) + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index ae9180f..75a7ce2 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -178,6 +178,36 @@ type ClientAPI struct { RefreshTokenLifetime time.Duration `yaml:"refresh_token_lifetime" env:"WARP_REFRESH_TOKEN_LIFETIME"` RateLimit RateLimit `yaml:"rate_limit"` + + // TURN is what GET /voip/turnServer hands clients for voice and video + // calls. + TURN TURN `yaml:"turn"` +} + +// TURN configures the relay servers clients use to set up calls. With no +// URIs the endpoint answers an empty object, and calls only connect where +// the two browsers can reach each other directly. +// +// Credentials come one of two ways. A shared secret is coturn's +// `use-auth-secret` (the TURN REST API): every user gets a username that +// expires and a password derived from it, and nothing has to be kept in +// step with the TURN server. A static username and password are handed to +// every user as they are, and are only for servers that cannot do the first. +type TURN struct { + // URIs are the turn:, turns: and stun: URIs, e.g. + // "turn:turn.example:3478?transport=udp". + URIs []string `yaml:"uris" env:"WARP_TURN_URIS"` + + // SharedSecret is coturn's static-auth-secret. + SharedSecret string `yaml:"shared_secret" env:"WARP_TURN_SHARED_SECRET"` + + // UserLifetime is how long credentials made from SharedSecret last. + UserLifetime time.Duration `yaml:"user_lifetime" env:"WARP_TURN_USER_LIFETIME"` + + // Username and Password are static credentials, used when there is no + // SharedSecret. + Username string `yaml:"username" env:"WARP_TURN_USERNAME"` + Password string `yaml:"password" env:"WARP_TURN_PASSWORD"` } // RateLimit configures the per-endpoint token buckets in front of the C-S @@ -500,6 +530,7 @@ func Default() *Config { // Avatars: a client opening a room asks for dozens at once. Media: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond}, }, + TURN: TURN{UserLifetime: time.Hour}, }, Sync: Sync{ Presence: false, @@ -675,6 +706,14 @@ func (c *Config) Validate() error { } } } + for i, uri := range c.ClientAPI.TURN.URIs { + if !strings.HasPrefix(uri, "turn:") && !strings.HasPrefix(uri, "turns:") && !strings.HasPrefix(uri, "stun:") { + bad("client_api.turn.uris[%d]: must be a turn:, turns: or stun: URI, got %q", i, uri) + } + } + if c.ClientAPI.TURN.SharedSecret != "" && c.ClientAPI.TURN.UserLifetime <= 0 { + bad("client_api.turn.user_lifetime: must be positive") + } if c.Sync.TypingTimeout <= 0 { bad("sync.typing_timeout: must be positive") } diff --git a/server/internal/setup/server.go b/server/internal/setup/server.go index 7d4ef42..4ebc929 100644 --- a/server/internal/setup/server.go +++ b/server/internal/setup/server.go @@ -489,6 +489,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error) RegistrationTokens: s.regTokens, AppServices: s.appservices, RateLimit: cfg.ClientAPI.RateLimit, + TURN: cfg.ClientAPI.TURN, TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor, Logger: log, }) diff --git a/warp.yaml.example b/warp.yaml.example index 623c778..5770827 100644 --- a/warp.yaml.example +++ b/warp.yaml.example @@ -103,6 +103,23 @@ client_api: # 0 means no expiry. WARP_REFRESH_TOKEN_LIFETIME refresh_token_lifetime: 0s + # Relay servers for voice and video calls, handed to clients by + # GET /voip/turnServer. With no uris, calls only connect where the two + # browsers can reach each other directly (the same network, mostly). + # + # With shared_secret (coturn's `use-auth-secret` + `static-auth-secret`) + # every user gets credentials that expire after user_lifetime. username + # and password are static credentials for servers that cannot do that. + turn: + uris: [] # WARP_TURN_URIS + # - "turn:turn.example.org:3478?transport=udp" + # - "turn:turn.example.org:3478?transport=tcp" + # - "turns:turn.example.org:5349?transport=tcp" + shared_secret: "" # WARP_TURN_SHARED_SECRET + user_lifetime: 1h # WARP_TURN_USER_LIFETIME + username: "" # WARP_TURN_USERNAME + password: "" # WARP_TURN_PASSWORD + # Per-endpoint rate limiting. Each bucket allows `burst` requests back # to back and returns one slot every `interval`, per client address. # Login and registration get their own, tighter, buckets because they