Add TURN/STUN server for audio/video calls.

Jingle A/V (XEP-0166/0167/0176) needs a STUN/TURN server for NAT
traversal. Add coturn and advertise it to clients via XEP-0215.

- coturn container, host networking (a relay needs its full UDP port
  range reachable, and Docker NAT hides the peer addresses coturn needs).
- mod_turn_external in Prosody to advertise the service and mint
  time-limited credentials from a shared secret.
- Advertise UDP and TCP transports plus turns: on 5349, so clients on
  UDP-blocking networks can still connect.
- Deny relaying to private ranges, so this is not an open proxy into
  internal services.

TURN host is guschin.info rather than turn.guschin.info because only the
former is in the certificate SANs and turns: clients validate the name.

TURN_HOST/TURN_SECRET go in .env, which is gitignored and set per
deployment; TURN_SECRET must match static-auth-secret in turnserver.conf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-08-18 22:17:44 +03:00
parent 218b1be0dd
commit 3fc1ac3d12
4 changed files with 162 additions and 0 deletions

View File

@@ -49,6 +49,9 @@ This will:
| Component Protocol | 5347 | External components | | Component Protocol | 5347 | External components |
| HTTP/BOSH/WebSocket | 5280 | Web-based connections | | HTTP/BOSH/WebSocket | 5280 | Web-based connections |
| HTTPS | 5281 | Secure web connections | | HTTPS | 5281 | Secure web connections |
| STUN/TURN | 3478 (UDP+TCP) | NAT traversal for audio/video calls |
| STUN/TURN over TLS | 5349 (UDP+TCP) | Encrypted TURN (`turns:`) |
| TURN relay range | 49160-49200 (UDP) | Media relay ports |
## Features ## Features
@@ -79,6 +82,61 @@ Use any XMPP client with:
- **Username**: your configured user - **Username**: your configured user
- **Domain**: xmpp.guschin.info - **Domain**: xmpp.guschin.info
## Audio/Video Calls (Jingle)
Voice and video calls use Jingle (XEP-0166/0167/0176). Prosody only carries the
signalling; media flows directly between clients, falling back to a TURN relay
when NAT prevents a direct path.
Two pieces make this work:
- **coturn** (`coturn` container) — the STUN/TURN server. Runs with host
networking because a relay needs its whole UDP port range reachable.
- **`mod_turn_external`** in Prosody — advertises coturn to clients over
XEP-0215 and issues time-limited TURN credentials.
### Configuration
`TURN_SECRET` in `.env` must match `static-auth-secret` in
[coturn/turnserver.conf](coturn/turnserver.conf). `.env` is gitignored, so
these values are set per-deployment and not committed:
```env
TURN_HOST=guschin.info
TURN_SECRET=<same value as static-auth-secret>
```
`TURN_HOST` is `guschin.info`, not `turn.guschin.info`: `turns:` (TLS) clients
validate the hostname against the certificate, and only `guschin.info` is in
the current cert's SANs. To use `turn.guschin.info` instead, reissue the
certificate with that name added and update both `TURN_HOST` and the
`external-ip`/`cert` settings accordingly.
`external-ip` in `turnserver.conf` is the server's public address. **Update it
if the server IP changes**, otherwise coturn advertises relay candidates that
peers cannot reach.
### Verifying
Check that Prosody advertises the service and coturn answers:
```bash
docker logs coturn # should show listeners on 3478/5349
docker exec prosody-xmpp prosodyctl check config
```
From a client, place a call between two accounts. Clients that support Jingle
audio/video include Conversations (Android), Dino, Gajim, and Monal (iOS).
Note that Jingle A/V is client-to-client — both parties need a client that
supports it.
### Firewall
Ports 3478 and 5349 (both UDP and TCP) plus the UDP relay range 49160-49200
must be reachable from the internet. The host has no local firewall
(`iptables` INPUT policy is ACCEPT), but any provider-level firewall needs
these opened.
## Notes ## Notes
- TLS encryption is not required by default (configurable in [prosody.cfg.lua](data/prosody/configuration/prosody.cfg.lua)) - TLS encryption is not required by default (configurable in [prosody.cfg.lua](data/prosody/configuration/prosody.cfg.lua))

64
coturn/turnserver.conf Normal file
View File

@@ -0,0 +1,64 @@
# coturn TURN/STUN server for XMPP Jingle audio/video calls
#
# Credentials are NOT stored here. Prosody's mod_turn_external mints
# time-limited credentials using the shared secret (see static-auth-secret),
# and hands them to clients over XEP-0215. Keep the secret in sync between
# this file and TURN_SECRET in .env.
# Listening sockets. The container runs with network_mode: host, so these
# bind directly to the server's public interface.
listening-port=3478
tls-listening-port=5349
# Public address advertised in relay candidates. Without this, coturn hands
# out its own view of the interface address, which breaks behind any NAT.
external-ip=176.124.216.197
# Relay port range. Each concurrent call leg consumes a port from this range.
min-port=49160
max-port=49200
# Authentication: long-term credentials derived from a shared secret
# (REST API / TURN time-limited credentials, as used by XEP-0215).
use-auth-secret
static-auth-secret=e7b94c2d2af838f332a2173a5318a336bdf3cfdb812e5842266d5cd6e6e31d60
realm=guschin.info
# TLS for turns:. Uses the existing multi-SAN guschin.info certificate, which
# is why the advertised TURN host is guschin.info and not turn.guschin.info
# (the latter is not in the cert's SANs, so TLS validation would fail).
cert=/etc/coturn/certs/fullchain.pem
pkey=/etc/coturn/certs/privkey.pem
# Harden: this is a relay for our own users, not an open proxy.
# Deny relaying to private ranges so TURN can't be used to reach internal
# services (loopback, RFC1918, link-local, CGNAT, multicast).
no-multicast-peers
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=240.0.0.0-255.255.255.255
denied-peer-ip=::1
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
# No TURN-over-plain-old-CLI admin interface, no SQLite/Redis state.
no-cli
# Disable legacy/weak mechanisms.
no-tlsv1
no-tlsv1_1
# Per-user relay caps, to limit the blast radius of a leaked credential.
user-quota=12
total-quota=1200
# Logging to stdout so `docker logs coturn` works.
log-file=stdout
simple-log

View File

@@ -31,6 +31,11 @@ modules_enabled = {
-- Gateway support -- Gateway support
"privilege"; "privilege";
"http_file_share"; "http_file_share";
-- Audio/video calls (Jingle): advertise the TURN/STUN server to clients
-- via XEP-0215. Clients do the media themselves; Prosody only signals
-- and hands out time-limited TURN credentials.
"turn_external";
}; };
modules_disabled = { modules_disabled = {
@@ -76,6 +81,24 @@ archive_expires_after = "never"
-- Certificates directory (global, before VirtualHost) -- Certificates directory (global, before VirtualHost)
certificates = "/etc/prosody/certs" certificates = "/etc/prosody/certs"
-- TURN/STUN for audio/video calls (XEP-0215 External Service Discovery).
-- mod_turn_external does not run a relay itself; it tells clients where the
-- coturn instance is and issues short-lived credentials signed with the
-- shared secret. turn_external_secret must equal static-auth-secret in
-- coturn/turnserver.conf.
--
-- turn_external_host is guschin.info rather than turn.guschin.info because
-- only the former is in the certificate SANs, and turns:// (TLS) clients
-- validate the name against the cert.
turn_external_host = os.getenv("TURN_HOST") or "guschin.info"
turn_external_secret = os.getenv("TURN_SECRET")
turn_external_port = 3478
turn_external_tls_port = 5349
turn_external_ttl = 86400
-- Also advertise TCP transports, not just UDP: some restrictive networks
-- block UDP entirely, and TCP/TLS on 5349 is the fallback that still works.
turn_external_tcp = true
-- Primary virtual host (user@guschin.info) -- Primary virtual host (user@guschin.info)
VirtualHost "guschin.info" VirtualHost "guschin.info"
ssl = { ssl = {

View File

@@ -13,6 +13,8 @@ services:
MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_DATABASE: ${MYSQL_DATABASE}
SLIDGRAM_COMPONENT_SECRET: ${SLIDGRAM_COMPONENT_SECRET} SLIDGRAM_COMPONENT_SECRET: ${SLIDGRAM_COMPONENT_SECRET}
TURN_HOST: ${TURN_HOST}
TURN_SECRET: ${TURN_SECRET}
extra_hosts: extra_hosts:
- "host.docker.internal:172.17.0.1" - "host.docker.internal:172.17.0.1"
ports: ports:
@@ -58,6 +60,21 @@ services:
networks: networks:
- prosody - prosody
coturn:
image: coturn/coturn:4.6-alpine
container_name: coturn
# Host networking: a TURN relay needs its whole UDP port range reachable,
# and Docker's userland NAT both mangles the source addresses coturn needs
# to see and makes publishing the range impractical.
network_mode: host
volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
- /etc/letsencrypt/live/guschin.info:/etc/coturn/certs:ro
- /etc/letsencrypt/archive/guschin.info:/etc/letsencrypt/archive/guschin.info:ro
command: ["-c", "/etc/coturn/turnserver.conf"]
restart: unless-stopped
mem_limit: 128M
networks: networks:
prosody: prosody:
driver: bridge driver: bridge