You set up calling, you test it with a colleague, it works. Then members report that calls ring, connect, and carry no audio — or never connect at all. Nothing appears in any log, and it works every time you try it.
This is almost always the same thing: you tested from two machines that could reach each other directly, and a large share of real members cannot.
Why a relay is not optional
WebRTC prefers to send audio and video straight between the two browsers. That works when at least one side is reachable. It fails when both sides sit behind the kind of NAT that will not accept an unsolicited inbound packet — symmetric NAT, carrier-grade NAT on mobile networks, most corporate firewalls. Roughly a third of real-world connections land in that case.
For those, the media has to be forwarded by a server that both sides can reach. That server is a TURN relay, and coturn is the usual choice. STUN alone does not do this — STUN only tells a browser what its public address looks like, which does not help when the problem is that nothing may connect inbound.
There are three common ways this ends up half-working. Each leaves a relay that looks healthy and is not doing its job, and none of them produces an error anywhere an administrator would look.
Docker's NAT, if you did not use host networking
coturn advertises the addresses it believes it has. Inside a bridge network those are private addresses no caller can reach, so the call negotiates happily and then carries no media at all. Run it with network_mode: host, and set external-ip to the server's real public address.
A configuration file coturn cannot read
The official image drops privileges to nobody. A config file you tightened to chmod 600 as root is then unreadable, and coturn starts on its defaults — no shared secret, no relay range, none of your settings — logging a single Cannot find config file line and otherwise appearing to run normally. Mode 644 is correct here; the file holds a secret, but one that is useless without the server it belongs to.
TLS that never actually switched on
The same permission problem applies to certificates, and it matters more because the failure is invisible for months. Let's Encrypt private keys are mode 0600 owned by root; coturn runs as nobody; so a tls-listening-port with a pkey it cannot read gives you an open port serving nothing.
Copy the certificate somewhere coturn can read rather than loosening /etc/letsencrypt for every service on the machine:
install -m 0644 -o root -g root "$SRC/fullchain.pem" "$DST/fullchain.pem"
install -m 0640 -o root -g 65534 "$SRC/privkey.pem" "$DST/privkey.pem"
Then register that copy step as a certbot deploy hook, in /etc/letsencrypt/renewal-hooks/deploy/. Skip it and TLS works for ninety days and then stops — for exactly the members who most needed it, so you may not hear about it quickly.
turns:chat.example.com:5349, never turns:203.0.113.5:5349. The hostname must resolve to the relay itself, so a domain sitting behind a proxy that terminates connections elsewhere will not work.Offer every route, not just one
Most software that accepts TURN settings accepts a list. Give it all of them; they share one credential and the browser uses whichever gets through.
turn:chat.example.com:3478
turn:chat.example.com:3478?transport=tcp
turns:chat.example.com:5349
The middle line costs nothing, needs no certificate, and rescues networks that permit TCP but not UDP. The last covers networks that block UDP outright — corporate guest wifi, hotels, some mobile carriers — because on port 5349 with TLS the traffic is indistinguishable from ordinary HTTPS to whatever is filtering it.
Prove it relays, rather than assuming
"coturn is listening" and "calls will connect" are different claims. The honest test forbids every direct route and checks that a candidate still appears — if one does, the browser reached your relay across the public internet, authenticated, and was granted a relay address.
const pc = new RTCPeerConnection( {
iceServers: [ { urls: 'turns:chat.example.com:5349', username, credential } ],
iceTransportPolicy: 'relay', // host and reflexive candidates forbidden
} );
pc.createDataChannel( 'probe' );
await pc.setLocalDescription( await pc.createOffer() );
// listen on icecandidate; anything ending "typ relay" came from TURN
Run it from outside your own network, in a page served over HTTPS. No relay candidate means the firewall, the credentials or the certificate — and it is far better to learn that now than from a member.
Failing that, watch the relay during a real call with docker logs -f on the coturn container. A line mentioning an allocation appears when a browser genuinely uses it. Silence during a successful call is fine, and simply means the two sides went direct. Silence during a failed one means TURN is not being reached at all.
On credentials
Use coturn's use-auth-secret mode rather than static user accounts. The application derives a short-lived username and credential from a shared secret for each member, so nothing long-lived is ever handed to a browser, and revoking access is a matter of the application no longer minting one. A relay configured with fixed credentials in client-side settings is an open proxy the moment anyone reads the page source.
Also deny the private ranges, or your relay will forward traffic into your own network on request:
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
Related
The pattern here is worth generalising: every one of these failures is silent, and every one of them passes the test an administrator would naturally run. When a feature depends on infrastructure you configured yourself, decide in advance what evidence would distinguish "working" from "appears to work", and go and get that evidence.
Related application: Chat Calls — Chat Calls ships a ready-made coturn configuration and a script that stages the certificate where the relay can read it, so the failures described here are handled rather than left to be discovered.
Recommended Comments