Technical notes for SimpleX Chat calls through a self-hosted coturn relay
  • HTML 82.9%
  • Shell 17.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-03 09:55:43 +03:00
docs Initial public documentation 2026-07-03 09:55:43 +03:00
examples Initial public documentation 2026-07-03 09:55:43 +03:00
public Initial public documentation 2026-07-03 09:55:43 +03:00
scripts Initial public documentation 2026-07-03 09:55:43 +03:00
.gitignore Initial public documentation 2026-07-03 09:55:43 +03:00
LICENSE Initial public documentation 2026-07-03 09:55:43 +03:00
README.md Initial public documentation 2026-07-03 09:55:43 +03:00

SimpleX Chat calls through a self-hosted coturn relay

Technical notes for running and validating a self-hosted WebRTC ICE relay for SimpleX Chat calls.

This repository intentionally uses placeholder values only. Replace the placeholders in the examples with your own values in a private deployment branch or in local environment variables. Do not commit TURN passwords, real one-time invite links, private keys, certificate files, production logs, or full packet captures.

The SimpleX menu paths in this repository were written for the tested Android UI at the time of validation. If the app version changes, treat the exact labels as guidance and verify them against the current app before publishing user-facing instructions.

Scope

This setup covers SimpleX Chat audio/video calls using a self-hosted WebRTC ICE server backed by coturn. It does not replace the SimpleX SMP message relay server. SimpleX messaging and WebRTC calls are separate pieces of infrastructure:

  • SMP/XFTP servers handle chat delivery and file transfer.
  • WebRTC ICE servers handle call candidate discovery and relay fallback.
  • coturn provides STUN and TURN service for WebRTC.

The SimpleX mobile app documents custom WebRTC ICE configuration under Settings / Network & Servers / WebRTC ICE servers, with one ICE server address per line. The documented example for port 5349 contains two entries: one stun: URI and one authenticated turn: URI. Source: https://simplex.chat/docs/webrtc.html

The Always use relay call option is documented under the app's audio/video call settings. Source: https://simplex.chat/docs/guide/audio-video-calls.html

coturn documents --listening-port, --tls-listening-port, --listening-ip, --relay-ip, --external-ip, --min-port, and --max-port. It also documents that the TLS/DTLS listener port defaults to 5349, and that plain TCP/UDP sessions may connect to TLS/DTLS ports if allowed by the configuration. Primary source: https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf. Historical reference: https://github.com/coturn/coturn/wiki/turnserver (the wiki currently marks itself as outdated and points readers back to the source tree).

Sanitized topology

caller device ── NAT/router ── Internet ── NAT/router ── TURN host
                                  │
                                  └── peer device

Example placeholder roles:

Role Placeholder
TURN DNS name turn.example.net
TURN private/LAN address 192.0.2.10
TURN public address 203.0.113.10
TURN realm turn.example.net
TURN username <turn-user>
TURN password local secret only; never commit
TURN listener port 5349/udp for the verified SimpleX path
Optional TURN listener 3478/tcp+udp for generic STUN/TURN compatibility
Relay port range 49152-49200/udp in the tested constrained-range design

192.0.2.0/24 and 203.0.113.0/24 are documentation address ranges, not deployment addresses.

Client ICE configuration

For the verified SimpleX path, configure two ICE server lines in the mobile app:

stun:turn.example.net:5349
turn:<turn-user>:<turn-password>@turn.example.net:5349

Use the app path:

Settings / Network & Servers / WebRTC ICE servers

Enable:

Configure ICE servers

Then enable relay-only calls:

Settings / Audio and Video calls / Always use relay

Why two lines, not three

The two-line list is enough for the verified path:

  1. stun: lets WebRTC discover server-reflexive candidates.
  2. turn: lets WebRTC authenticate and allocate a relay candidate through coturn.

A third turns: line would mean an additional secure TURN transport candidate that was not part of the verified working path. Adding unverified ICE transports can make the app test or prefer a different route and hide the known-good UDP relay path behind unrelated fallback behaviour. Keep the client config minimal until the additional transport has been separately tested.

The working test demonstrated turn: over UDP to port 5349, not a turns: transport. coturn can listen on UDP sockets on the TLS/DTLS listener port, and the verified capture showed UDP traffic entering and leaving the TURN host on port 5349.

Server-side coturn model

The tested architecture forces the effective systemd service to start coturn with explicit CLI options rather than relying on a partially effective distribution config file:

turnserver \
  -n \
  -v \
  -a \
  -f \
  -L <turn-private-ip> \
  -E <turn-private-ip> \
  -X <turn-public-ip>/<turn-private-ip> \
  -p 3478 \
  --tls-listening-port=5349 \
  --min-port=49152 \
  --max-port=49200 \
  -r <turn-realm> \
  -u <turn-user>:<turn-password> \
  --cert=/path/to/fullchain.pem \
  --pkey=/path/to/privkey.pem \
  --no-multicast-peers \
  --syslog

Meaning of the important options:

Option Purpose
-n Do not rely on a config file. The effective runtime is exactly the CLI plus defaults.
-v Verbose logs, useful while proving the call path. Consider removing after validation if you want quieter production logs.
-a Long-term credential mechanism; required for static username/password TURN auth.
-f Include fingerprint attributes in STUN/TURN messages.
-L Listener IP. The TURN host binds here.
-E Relay IP. coturn allocates relay sockets here.
-X public/private NAT mapping. coturn advertises the public relay address while binding the private address locally.
-p 3478 Plain STUN/TURN listener port. Useful for compatibility.
--tls-listening-port=5349 TLS/DTLS listener port. In the verified path, UDP packets reached this port.
--min-port, --max-port Constrain relay allocation ports so firewall/NAT rules can be precise.
-r TURN realm. Must match the realm expected by the client challenge/response.
-u Static user credential. Keep the real value outside Git.
--no-multicast-peers Blocks multicast peer addresses.
--syslog Sends coturn logs to journald/syslog.

What the successful test proved

The successful test proved all of these at the same time:

  1. coturn was active under systemd.
  2. coturn was bound to the expected LAN/private address on 3478 and 5349 for TCP and UDP.
  3. The client reached the TURN host on UDP port 5349.
  4. The TURN long-term credential handshake completed.
  5. coturn allocated relay ports in the configured relay range.
  6. The client created peer permissions.
  7. The client established channel bindings.
  8. The relay session moved bidirectional traffic, not just setup packets.

The key log events are:

error 401: Unauthorized
Local relay addr: <turn-private-ip>:<relay-port>
new, realm=<realm>, username=<turn-user>, lifetime=600
incoming packet ALLOCATE processed, success
incoming packet CREATE_PERMISSION processed, success
incoming packet CHANNEL_BIND processed, success
usage: realm=<realm>, username=<turn-user>, rp=<n>, rb=<bytes>, sp=<n>, sb=<bytes>
peer usage: realm=<realm>, username=<turn-user>, rp=<n>, rb=<bytes>, sp=<n>, sb=<bytes>

401 Unauthorized at the beginning of a TURN allocation is expected. It is the normal long-term credential challenge. The first request arrives without enough authentication material, coturn returns the realm and nonce, and the client retries with the username and credential response. Failure would be repeated 401 responses without later ALLOCATE processed, success.

ALLOCATE processed, success means coturn created a relay allocation.

Local relay addr means coturn selected a local relay socket from the configured relay port range.

CREATE_PERMISSION processed, success means the client authorized a peer address for the allocation.

CHANNEL_BIND processed, success means TURN channel data mode was established.

Non-zero usage and peer usage byte counters mean real relayed traffic moved through coturn. This is the strongest evidence that the call path used the relay rather than merely creating an unused allocation.

REFRESH ... lifetime=0 and reason: allocation timeout after the call/test ends are normal teardown indicators when the client releases or stops refreshing candidate allocations.

Why the previous state failed

The effective service state was the problem layer. The fixed drop-in forced coturn to run with explicit listener, relay, NAT-mapping, realm, credential, TLS/DTLS listener, and relay-port-range options. After restart, the active process command line matched the intended config and ss showed listeners on the expected IP and ports.

Before that, the SimpleX app could have been pointed at syntactically plausible ICE server lines while coturn was not actually exposing the expected effective listener/relay behaviour. WebRTC ICE config only works when the advertised transport and the actual server sockets match.

Validation workflow

Use the smallest validation path that proves the same failing path:

  1. Verify effective systemd command line.
  2. Verify listeners on the expected local address and ports.
  3. Start packet capture before placing a test call.
  4. Place the SimpleX call.
  5. Check packet capture for UDP traffic to the TURN host on 5349 and relay-port traffic.
  6. Check coturn logs for ALLOCATE, CREATE_PERMISSION, CHANNEL_BIND, and non-zero usage counters.

A successful test should include both packet movement and coturn semantic logs. Packet movement alone only proves network reachability. coturn logs alone can show allocations that never became useful media relays. The combination proves the full path.

Repository contents

.
├── README.md
├── docs
│   ├── client-simplex.md
│   ├── coturn-systemd.md
│   ├── packet-flow.md
│   ├── troubleshooting.md
│   └── security.md
├── examples
│   ├── simplex-ice-servers.example.txt
│   └── turnserver.service.d
│       └── 10-force-effective.conf.example
├── public
│   └── simple.html
└── scripts
    └── validate-turn-path.sh