Skip to content

Voice & LiveKit ​

Calls in Refract.Chat (voice channels, and calls in direct messages and group chats) run on LiveKit, an open-source WebRTC media server. LiveKit moves the audio, video and screen-shares. Refract decides who may join a call and what each person may send, and keeps checking as permissions change.

How it fits together ​

text
people's apps ── wss://livekit… ──▶ LiveKit ◀── its own Redis
     │                               │   ▲
     │ https://chat.example.com      │   │ server API: grants,
     ▼                               │   │ removals (:7866)
Refract server ◀── signed webhooks ──┘   │
  :8778 API    :8779 (internal only)     │
     └───────────────────────────────────┘
  • People's apps connect to the Refract server for everything except media, and to LiveKit directly for the call itself.
  • The Refract server issues join tokens, receives LiveKit's webhooks on a separate internal port (8779), and calls LiveKit's server API to grant or take away permissions and to remove people.
  • LiveKit has its own Redis, separate from Refract's.

Each call is a LiveKit room, named after its channel.

What happens when someone joins a call ​

  1. They ask for a token. The app calls GET /api/v1/livekit/token/:channelId. The server checks they're signed in, a member, and allowed to connect to that channel.
  2. They get an entry token that can do nothing. It's valid for 30 seconds, and grants no listening, no sending and no data. It's tied to their sign-in.
  3. They connect to LiveKit, which reports it to Refract with a signed participant_joined webhook.
  4. Refract checks again, then grants. It rechecks their sign-in, account and current channel permissions, and confirms the connection is the one that just joined, so an old webhook can't authorise a new connection. Only then does it tell LiveKit what they may do: listen, and publish their microphone (Speak), camera (Video), screen (Share screen) and soundboard (Speak and Use soundboard). The app waits for this before it turns on the microphone.
  5. Refract keeps checking. When roles, membership or channel permissions change, the server updates LiveKit straight away: it unpublishes whatever someone may no longer send, and removes them if they can no longer see or connect to the channel. Signing out, a password reset or a denied account ends that sign-in's calls.
  6. And reconciles. Every server process also compares LiveKit's actual rooms with what it expects every 10 seconds or so, repairing missed webhooks and expired sign-ins.

LiveKit's other webhooks keep everyone's view of the call current: who's in it, and who's talking, on camera or sharing, as it happens. Removals that can't reach LiveKit right away are saved and retried, with backoff, until they succeed.

End-to-end encryption ​

Call media is end-to-end encrypted by default. When a call starts, the Refract server makes a random key for it and gives it to each person allowed to join, along with their token. Their apps encrypt everything they send with it, so LiveKit relays media it can't decrypt. The Refract server does know the key, since it hands it out, but it only keeps it in Redis for the length of the call and discards it when the call ends. This covers live call media only: messages and uploaded files are not end-to-end encrypted.

Turn it off with LIVEKIT_ENABLE_ENCRYPTION=false.

Screen-share codecs and quality ​

  • Codecs. A screen share uses the codec the sharer picks. Automatic uses H.264 where the device has a hardware encoder for it, and VP8 otherwise. VP9 and AV1 are only used when picked by hand. LiveKit only accepts the codecs listed under room.enabled_codecs in livekit-server.yaml, and anything else falls back to the first video codec listed. The example config lists VP8, H.264, VP9 and AV1. An older config listing only VP8 keeps every share in VP8, so add the others.
  • Encryption and codecs. With encryption on, LiveKit can't send a viewer a fallback codec, so someone whose device can't decode VP9 or AV1 sees no picture of a share in that codec.
  • Bitrate limits. Admins cap each kind of stream under Manage instance → Config → Streaming: screen shares (12,000 kbps by default), cameras (3,000 kbps) and audio (128 kbps). Apps keep their streams under these limits, and lower ones already running when the limits change. LiveKit doesn't enforce them itself.

Connecting it to Refract ​

LiveKit's configuration is livekit-server.yaml (from livekit-server.example.yaml). Three things in it must match .env:

livekit-server.yaml.env
a key under keys: (camel: <secret>)LIVEKIT_API_KEY=camel, LIVEKIT_API_SECRET=<secret>
a second key (webhook: <secret>), and webhook.api_key: webhookLIVEKIT_WEBHOOK_API_KEY=webhook, LIVEKIT_WEBHOOK_API_SECRET=<secret>
webhook.urls: [http://backend:8779/livekit/webhook]LIVEKIT_WEBHOOK_PORT=8779

Then the two addresses:

  • LIVEKIT_PUBLIC_URL, e.g. wss://livekit.chat.example.com: where people's apps connect. It goes through your reverse proxy to LiveKit's port 7866.
  • LIVEKIT_API_URL, e.g. http://livekit:7866: where the server calls LiveKit, over the Docker network. Every join waits on this, so don't send it out through the public address and back in.

If webhooks don't arrive, people can join a call but stay silent and see nothing until the 10-second reconcile catches up. Check the key, the secret and webhook.urls first.

Ports ​

PortSetting in livekit-server.yamlHow it's reached
7866/tcpportThrough the reverse proxy, with TLS
7867/udprtc.udp_portPublished directly: call media
7868/tcprtc.tcp_portPublished directly: call media when UDP is blocked
3478/udpturn.udp_port, rtc.stun_serversPublished directly: STUN lookups, and TURN if it is on
443/tcpturn.tls_port (TURN, optional)Through Traefik, routed by name: see Built-in TURN
50000-60000/udpturn.relay_range_* (TURN, optional)Published directly if TURN is on: relayed call media

The media ports are published in docker-compose.override.yaml. To use different ones, change them in both places: rtc.udp_port / rtc.tcp_port in livekit-server.yaml, and the ports: mapping in the override. Keep the host and container port numbers the same (7867:7867/udp), because LiveKit tells people's apps which port to use, and it only knows its own.

Also set rtc.node_ip to the server's public IP address, or rtc.use_external_ip: true to have LiveKit discover it with STUN, which suits cloud hosts.

STUN ​

STUN is how a device learns its own public address. It is the first step in getting a call through the router between the device and the internet.

What it does ​

Most devices sit behind a router (a NAT) that gives them a private address such as 10.0.0.5. Machines outside only see the router's public address, so nothing can reach the device directly. A STUN server answers the question "what address do you see me at?":

your device (10.0.0.5:51000)  --- Binding Request --->  STUN server
                              <-- "you are 203.0.113.7:40512" ---

The device sends a small UDP request. The server replies with the address the request came from, as it saw it: the public IP and port the router assigned. That is the device's server-reflexive address, and it is one of the ways the other side can reach it. The exchange is defined in RFC 8489.

STUN carries no call data. It is a few small packets, and the media itself never passes through the STUN server.

How it fits into a call ​

A call sets up its connection with ICE (RFC 8445). Each side gathers candidate addresses:

  • host: its own network interface addresses;
  • server-reflexive: its public address, from a STUN server;
  • relay: an address on a TURN server, used only when nothing else works.

The candidates are exchanged through LiveKit. Each side then tries the pairs with STUN checks, and the first pair that works carries the call. The browser's WebRTC stack does all of this. LiveKit sends each client the list of STUN and TURN servers when it joins a room, so the list is set by this instance's rtc config.

What it cannot do ​

  • It does not get past every router. Some routers (symmetric NAT) give each destination its own public port, so the address the STUN server reports is not the one used toward the call server. Those devices need TURN.
  • It does not open ports. The call server still has to be reachable on rtc.udp_port and rtc.tcp_port (see Ports).
  • TURN is different. TURN relays the media through a server, so it needs credentials and uses that server's bandwidth. A STUN lookup costs almost nothing.

What it reveals ​

A STUN server sees each device's public IP address and port, and when it asked. It never sees audio, video or messages. Google's public STUN servers, which LiveKit uses by default, therefore learn the IP address of every participant in every call. Running your own keeps that lookup inside the instance.

Anyone can send a STUN request, so the server does not check who is asking. That is safe for STUN, because it only reflects an address back. TURN forwards traffic, so it needs credentials.

Running your own ​

docker-compose.override.example.yaml has a commented stun service that runs coturn in STUN-only mode: it answers address lookups and does not relay. Enable it, point rtc.stun_servers at the instance's public host and port 3478, and open UDP 3478 in every firewall in front of it:

yaml
rtc:
  stun_servers:
    - live.chat.example.com:3478

Check the coturn flags in that service against your image version before you enable it. Clients read this list when they join a room, so restart the LiveKit server to apply a change.

When a direct connection can't be made, a call can fall back to TURN, which relays the media through this server. It is opt-in: see Built-in TURN.

Built-in TURN ​

Some calls can't connect directly, even with STUN. A router with symmetric NAT, or a network that blocks UDP, leaves no direct path between two devices. TURN covers that case: both sides send their media to your server, which relays it between them. LiveKit includes a TURN server, so you don't need a separate one.

TURN is opt-in, and it has costs. Relayed calls use this server's bandwidth in both directions, and you need a hostname and a few open ports. Turn it on when some people's calls fail, or connect with no audio.

What you need ​

  1. A hostname for TURN, such as turn.chat.example.com, with a DNS record pointing at this server's public IP address.
  2. Traefik, which the stack already runs. It holds the TLS certificate for that hostname and renews it, through the same refract_chat certificate resolver as the rest of the stack. You don't manage certificate files.
  3. Open ports, listed in the Ports table: 3478/udp and the relay range. TURN over TLS needs no extra port, because Traefik already serves 443/tcp.

Configure it ​

In livekit-server.yaml:

yaml
turn:
  enabled: true
  domain: turn.chat.example.com
  external_tls: true
  udp_port: 3478
  tls_port: 443
  relay_range_start: 50000
  relay_range_end: 60000
  • domain is the hostname Traefik routes on, and the one clients connect to.
  • external_tls: true tells LiveKit that a proxy terminates TLS. LiveKit then receives plain traffic on tls_port, and still advertises that port to clients as TURN over TLS.
  • tls_port is 443, so clients reach TURN on the port that restrictive firewalls usually allow.
  • udp_port is the TURN port over UDP. TURN servers also answer STUN lookups, so with this on you don't need the separate stun service.
  • relay_range_start and relay_range_end are the UDP ports used to relay media. Pick a range nothing else on the host uses, and open all of it to the internet.

Once it is on, LiveKit gives clients the TURN details when they join a call, so the app needs no settings of its own.

In docker-compose.override.yaml, publish the UDP ports on the LiveKit service, and add the Traefik labels for the TURN router. The labels go alongside the HTTP labels that are already there:

yaml
livekit:
  ports:
    - 3478:3478/udp
    - 50000-60000:50000-60000/udp
  labels:
    - "traefik.tcp.routers.livekit_turn.rule=HostSNI(`turn.chat.example.com`)"
    - "traefik.tcp.routers.livekit_turn.entrypoints=https"
    - "traefik.tcp.routers.livekit_turn.tls=true"
    - "traefik.tcp.routers.livekit_turn.tls.certResolver=refract_chat"
    - "traefik.tcp.services.livekit_turn.loadbalancer.server.port=443"

Then restart the LiveKit server.

How 443 is shared ​

Traefik owns port 443 on this host and routes by name. TURN is one more name on that port: the TCP router matches turn.chat.example.com by SNI, terminates TLS with the certificate Traefik holds, and passes plain traffic to LiveKit on port 443 inside the container. The LiveKit container doesn't publish 443 itself, so nothing else on the host needs to give it up.

Checking it works ​

Check that Traefik serves the certificate for the TURN name:

sh
openssl s_client -connect turn.chat.example.com:443 -servername turn.chat.example.com

Then test a real call from a desktop Chrome on a network that blocks UDP, such as a restrictive guest Wi-Fi. In chrome://webrtc-internals, the selected candidate pair shows a relay type when the call goes through TURN.

Bots ​

Bots join calls exactly like people, with the same entry token, webhook check and ongoing checks. See Bots.

Known limitation ​

With a self-hosted LiveKit, someone removed from a call may briefly rejoin with a refreshed token LiveKit issued them earlier, until the next webhook or reconcile removes them again. Refract enforces permissions continuously, but closing that window entirely needs a check inside LiveKit itself. That isn't built yet.

Released under the GNU Affero General Public License v3.0.