Configuring TURN with TLS on Synapse with Eturnal

This is a post I was not expecting to make, but after I mentioned in an online community about my Matrix setup for video calls with my family, there was some interest in it, and so I've decided to document it here as it may help others to achieve the same.

What are STUN and TURN?

tl;dr: STUN and TURN are workarounds against the long-term damage caused to the internet (IPv4 NAT) by the horrible horrible people who refuse to learn IPv6.

STUN is, as far as I can tell, not used by Matrix/Synapse, so I won't talk much about it, but since it comes "for free" with Eturnal, I thought I'd briefly mention what it does. In a nutshell, STUN helps two parties that are behind isolated NAT networks to know each other's public IP addresses and outgoing ports, which is useful for cool stuff like UDP hole punching.

TURN on the other hand is what saves the day when a direct connection cannot be established: it sits on the public internet and in between the two parties, relaying packets from one to the other. Matrix uses this TURN server to relay the audio and video to participants during a call.

The purpose of this post is to setup a TURN server using Eturnal and configure Synapse to use it.

Assumptions for this guide

  • You already have a working Matrix server running Synapse.
  • You have a small VPS or some other publicly-accessible box where the TURN server can be deployed.*
  • You have at least a minimal understanding of what you're getting yourself into here (Docker, networking, web technologies, CLI, etc).
✳️
While it's possible to host it at home, I'd recommend against it. Most home connections will be behind NAT, and the number of ports you would need to open between the TURN server and the outside gets ugly very quick.

Architecture diagram

A diagram showing the interactions and protocols between the Matrix Server, the TURN server and the clients. The diagram shows the Matrix and TURN servers communicating with each other on port 5349 (TURNS), and the clients communicating with Matrix on port 443 (HTTPS) and with the TURN server on UDP ports 49160 to 59160.

Obtaining TLS certificates and automating the renewal

First, let's create the directory structure used by both containers and assign the necessary permissions. By default, Eturnal runs with UID 9000, so we need to adjust the permissions so that Let's Encrypt (which will also be running with UID 9000) can work on these directories.

$ cd ~
$ mkdir -p eturnal/letsencrypt
$ cd eturnal/letsencrypt
$ mkdir config logs work
$ cd ..
$ sudo chown -Rv 9000:9000 letsencrypt

Next, we can invoke certbot manually (only needed once!) to obtain the certificate:

ℹ️
In the example below, there are two lines referencing a file named cloudflare_creds.ini which contains my Cloudflare API key. This is because I'm using the DNS challenge method with the Cloudflare plugin. Your requirements may vary, and with other methods these lines may not be required, so consult certbot's documentation for your particular case.
$ docker run --rm \
--name "certbot" \
--user 9000:9000 \
-v "./cloudflare_creds.ini:/cloudflare_creds.ini" \
-v "./letsencrypt:/letsencrypt" \
certbot/dns-cloudflare \
certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /cloudflare_creds.ini \
--non-interactive \
--agree-tos \
--config-dir /letsencrypt/config \
--work-dir /letsencrypt/work \
--logs-dir /letsencrypt/logs \
-d "turn.example.com"

Once the request succeeds, we can create a script which will handle renewing the certificate:

#! /bin/bash

cd "$(dirname "$0")"

docker run --rm \
--name certbot \
--user 9000:9000 \
-v "~/eturnal/cloudflare_creds.ini:/cloudflare_creds.ini" \
-v "~/eturnal/letsencrypt:/letsencrypt" \
certbot/dns-cloudflare \
renew \
--dns-cloudflare \
--non-interactive \
--agree-tos \
--config-dir /letsencrypt/config \
--work-dir /letsencrypt/work \
--logs-dir /letsencrypt/logs \
--dns-cloudflare-credentials /cloudflare_creds.ini

docker container restart eturnal

~/eturnal/renew_certificate.sh

And then set it to run once a day, preferably when everyone is asleep and no calls are taking place:

$ crontab -e
0 4 * * * $HOME/eturnal/renew-certificate.sh

Runs the script every day at 4 am

Configuring Eturnal

Create the following files, replacing the values between angle brackets below and every occurrence of turn.example.com with your own domain:

---
services:
  eturnal:
    container_name: eturnal
    image: ghcr.io/processone/eturnal:latest
    environment:
      ETURNAL_RELAY_MIN_PORT: 49160
      ETURNAL_RELAY_MAX_PORT: 59160
      ETURNAL_RELAY_IPV4_ADDR: <YOUR_VPS_IPV4_ADDRESS>
      ETURNAL_RELAY_IPV6_ADDR: <YOUR_VPS_IPV6_ADDRESS>
      ETURNAL_SECRET: <VERY_BIG_RANDOM_STRING> #You will need this value when configuring Synapse
    ### networking options
    volumes:
      - ./eturnal.yml:/etc/eturnal.yml:ro
      - ./letsencrypt/config:/etc/letsencrypt:ro
    restart: unless-stopped
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    network_mode: host

~/eturnal/docker-compose.yml

eturnal:
  listen:
    -
      ip: "::"           # This is the default.
      port: 3478         # This is the default for "transport: udp".
      transport: udp     # This is the default.
      enable_turn: true  # This is the default.
    -
      ip: "::"           # This is the default.
      port: 3478         # This is the default for "transport: auto".
      transport: auto    # Default: "udp".
      enable_turn: true  # This is the default.
    -
      ip: "::"           # This is the default.
      port: 5349         # This is the default for "transport: tls".
      transport: tls     # Default: "udp".
      enable_turn: true  # This is the default.

  realm: turn.example.com
  tls_crt_file: /etc/letsencrypt/live/turn.example.com/fullchain.pem
  tls_key_file: /etc/letsencrypt/live/turn.exmaple.com/privkey.pem
  tls_options:
    - no_tlsv1
    - no_tlsv1_1
    - cipher_server_preference

~/eturnal/eturnal.yml

With all of this in place, run $ docker compose up -d.

Configuring Synapse

Edit your Synapse's homeserver.yaml file and add the following config (again, replacing the domain and the secret) then restart the service.

turn_uris: [ "turns:turn.example.com?transport=tcp" ]
turn_shared_secret: "<SAME_VERY_BIG_RANDOM_STRING_FROM_ETURNAL_COMPOSE_FILE>"
turn_user_lifetime: 86400000
turn_allow_guests: True

Testing your setup

If all went well, using the legacy Element client (not Element X) you should be able to establish video calls with contacts also using Element or Element Desktop (where these will appear as "legacy calls").

antsu

antsu

Some dude who likes computers
UK