diff --git a/README.md b/README.md index 2817571..7e43519 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ This will: | Component Protocol | 5347 | External components | | HTTP/BOSH/WebSocket | 5280 | Web-based 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 @@ -79,6 +82,61 @@ Use any XMPP client with: - **Username**: your configured user - **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= +``` + +`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 - TLS encryption is not required by default (configurable in [prosody.cfg.lua](data/prosody/configuration/prosody.cfg.lua)) diff --git a/coturn/turnserver.conf b/coturn/turnserver.conf new file mode 100644 index 0000000..9e8be8f --- /dev/null +++ b/coturn/turnserver.conf @@ -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 diff --git a/data/prosody/configuration/prosody.cfg.lua b/data/prosody/configuration/prosody.cfg.lua index fc491e3..12adab5 100644 --- a/data/prosody/configuration/prosody.cfg.lua +++ b/data/prosody/configuration/prosody.cfg.lua @@ -31,6 +31,11 @@ modules_enabled = { -- Gateway support "privilege"; "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 = { @@ -76,6 +81,24 @@ archive_expires_after = "never" -- Certificates directory (global, before VirtualHost) 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) VirtualHost "guschin.info" ssl = { diff --git a/docker-compose.yml b/docker-compose.yml index bfb6515..77501c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} SLIDGRAM_COMPONENT_SECRET: ${SLIDGRAM_COMPONENT_SECRET} + TURN_HOST: ${TURN_HOST} + TURN_SECRET: ${TURN_SECRET} extra_hosts: - "host.docker.internal:172.17.0.1" ports: @@ -58,6 +60,21 @@ services: networks: - 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: prosody: driver: bridge