Skip to content

Repository files navigation

mtxturn

A small and simple TURN/STUN relay for a self-hosted Matrix homeserver, built on pion/turn. One-binary approach, perfect companion to conduit forks to build a fully functional self-hosted Matrix server with working audio/video calls for your community.

It exists to replace coturn in a setup where coturn's configuration surface is far larger than a single homeserver needs. Everything here is one purpose: relay WebRTC media for the users of your own homeserver, and refuse everyone else.

  • Authenticates with the TURN REST API shared-secret scheme — the same thing coturn calls static-auth-secret and your homeserver calls turn_secret. No user database, no state.
  • Restricts relaying to users of your homeserver. A credential is only accepted if the Matrix user id inside it belongs to a domain you configured.
  • Refuses to relay into private address space by default, so an authenticated client cannot use the relay as a way into your LAN.
  • Runs in the foreground and logs to stdout. No forking, no pidfile, no log files. Supervise it with systemd or a container runtime.

Build

make

That produces a statically linked binary. Use it rather than a bare go build if the machine you build on is not the machine you run on: with cgo enabled (the Go default) the binary links against the build host's glibc and dies on an older one with

./ver: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found

The equivalent by hand, and a cross-build for an ARM box:

CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o mtxturn .
make build-arm64

Configure

Every option has a flag and an environment variable. Flags win.

The shared secret is the one exception: it is read from $TURN_STATIC_AUTH_SECRET or -secret-file, and deliberately cannot be passed as a flag, because command line arguments are visible to every user on the machine through ps.

export TURN_STATIC_AUTH_SECRET="$(openssl rand -hex 32)"

./mtxturn \
  -public-ip 203.0.113.10 \
  -matrix-domain example.org \
  -min-port 49152 -max-port 65535
Flag Env Default Meaning
-public-ip TURN_PUBLIC_IP (required) Public IPv4 address clients send relayed media to. Behind NAT this is the router's WAN address, not the host's.
-listen-ip TURN_LISTEN_IP 0.0.0.0 Comma separated addresses to accept clients on. Use 0.0.0.0,:: for both families.
-relay-ip TURN_RELAY_IP 0.0.0.0 Local IPv4 address to bind relay sockets to. Separate from -listen-ip; see below.
-port TURN_PORT 3478 UDP and TCP signalling port.
-tcp TURN_ENABLE_TCP true Also accept TURN over TCP, for clients on UDP-blocking networks.
-tls-port TURN_TLS_PORT 0 (off) TURNS port, usually 5349. Needs -tls-cert and -tls-key.
-tls-cert / -tls-key TURN_TLS_CERT / TURN_TLS_KEY PEM chain and private key for -tls-port.
-realm TURN_REALM first -matrix-domain TURN realm. Clients take this from the server, so it can be anything stable.
-matrix-domain TURN_MATRIX_DOMAINS (empty) Comma separated homeserver domains whose users may relay. Set this.
-secret-file TURN_SECRET_FILE File holding the shared secret, instead of the environment variable.
-min-port / -max-port TURN_MIN_PORT / TURN_MAX_PORT 49152 / 65535 Relay port range. Open exactly this range in the firewall.
-max-allocations TURN_MAX_ALLOCATIONS 128 Simultaneous relays one Matrix user may hold. 0 disables the limit. Needs to be generous; see below.
-max-credential-ttl TURN_MAX_CREDENTIAL_TTL 0 (off) Refuse credentials whose expiry is further out than this.
-clock-skew TURN_CLOCK_SKEW 5m Tolerance for clock drift against the homeserver.
-allow-stun-binding TURN_ALLOW_STUN_BINDING false Answer unauthenticated STUN Binding requests. Off by default; see below.
-allow-peer TURN_ALLOW_PEER CIDRs allowed as relay peers even though the built-in list denies them.
-deny-peer TURN_DENY_PEER Extra CIDRs to refuse as relay peers.
-log-level TURN_LOG_LEVEL info error, warn, info, debug, trace.
-library-log-level TURN_LIBRARY_LOG_LEVEL warn Level for pion/turn's own internal messages, kept separate because they are chatty.
-status-interval TURN_STATUS_INTERVAL 0 (off) Emit a periodic line with the active allocation count.
-gen-credential Print a valid credential pair for a user id and exit, for testing.

Point your homeserver at it

The secret here must be byte-for-byte the one your homeserver uses. On startup the server prints a fingerprint of the secret it loaded so you can compare the two without printing either:

INFO  shared secret loaded, fingerprint=sha256:4e60ed5f (compare with your homeserver's turn_secret)

For tuwunel / conduwuit / Conduit, in the config:

turn_uris = ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"]
turn_secret = "the same secret"
turn_ttl = 86400

turn_ttl must be longer than your longest call. A client fetches credentials once when the call starts and keeps using them: TURN allocations, permissions and channel bindings all have to be re-authenticated every few minutes with that same credential. Once it expires the refresh is refused and the call drops mid-conversation, which looks like this:

WARN  auth REJECT src=198.51.100.7:62721 user="@alice:example.org" reason=credential expired at 2026-07-26T06:00:35Z (6m49s ago)

Shortening turn_ttl buys very little security — the credential is already bound to one Matrix user, capped by -max-allocations, and unable to reach private address space — so a short value mostly just cuts calls off. 86400 (24 hours) is Synapse's default and a good choice. -clock-skew adds a few minutes of grace on top, and if you set -max-credential-ttl it must be at least turn_ttl or the server will reject every credential.

For Synapse, in homeserver.yaml:

turn_uris: ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"]
turn_shared_secret: "the same secret"
turn_user_lifetime: 86400000
turn_allow_guests: false

Migrating from coturn: static-auth-secret becomes $TURN_STATIC_AUTH_SECRET, realm becomes -realm, min-port/max-port carry over unchanged, and external-ip becomes -public-ip. The rest of turnserver.conf has no equivalent because the behaviour it configured is either the default here or not implemented.

IPv4 and IPv6

Listen on both families by listing both addresses:

./mtxturn -public-ip 203.0.113.10 -listen-ip '0.0.0.0,::' -matrix-domain example.org

That opens two independent sockets and says so at startup:

INFO  listening for TURN over UDP on 0.0.0.0:3478 (IPv4)
INFO  listening for TURN over UDP on [::]:3478 (IPv6 only)

A lone -listen-ip :: instead gives a single dual-stack socket, which on Linux with the default net.ipv6.bindv6only=0 serves IPv4 clients too, reported as (IPv4 and IPv6). Both arrangements work; listing both addresses is more explicit and does not depend on that sysctl.

Relay sockets are always IPv4. pion allocates them as udp4 and does not implement RFC 6156 REQUESTED-ADDRESS-FAMILY, so there is no way to hand out an IPv6 relay candidate. -public-ip must therefore be an IPv4 address, and the server refuses to start if it is not.

That is less limiting than it sounds, because the two legs of a relayed call are independent. The client↔server leg runs over whatever family the client has; the relay↔peer leg is IPv4. So an IPv6-only client reaches the server over IPv6, is handed an IPv4 relay candidate, and an IPv4-only peer on the far side talks to that candidate happily. Cross-family calls work — that is exactly what a relay is for.

The one case that does not work is two IPv6-only peers that both need a relay: neither can use an IPv4 relay candidate, and ICE only pairs candidates within a family. In practice those peers usually reach each other directly over IPv6, and genuinely IPv6-only clients are rare — most mobile networks that look IPv6-only provide IPv4 through 464XLAT.

For any of this to matter, the hostname in your turn_uris needs an AAAA record as well as an A record, or IPv6 clients will never try the IPv6 socket.

Firewall

3478/udp, 3478/tcp     signalling, on IPv4 and IPv6 if you listen on both
49152-65535/udp        relay range, IPv4 only, must match -min-port/-max-port
5349/tcp               only if you enable -tls-port

How the authorization works

Your homeserver hands each logged-in user a short-lived credential derived from the shared secret:

username = "<unix-expiry>:<@user:example.org>"
password = base64(HMAC-SHA1(secret, username))

A client proves possession of that password through STUN MESSAGE-INTEGRITY. It cannot invent a username, because the matching password requires the secret.

On top of that signature check, a request is refused unless:

  • the username parses as <expiry>:<matrix user id>;
  • the expiry has not passed (within -clock-skew);
  • the expiry is not further out than -max-credential-ttl, when set;
  • the user id belongs to a domain in -matrix-domain;
  • the user is under their -max-allocations limit.

Then, for each peer the client asks to reach, the address must not be in private, loopback, link-local, multicast, CGNAT or reserved space. This is the check that stops a relay being used to reach your router's admin page or a cloud metadata endpoint, and it applies to authenticated users too.

STUN Binding is off by default

Unauthenticated STUN Binding is the one thing a TURN server normally answers without a credential. Here it is disabled, along with anything that is not a well-formed STUN or ChannelData packet. Both are discarded on the UDP socket before the TURN state machine sees them, so a scanner gets no reply and produces no log line:

INFO  STUN Binding is disabled: unauthenticated probes on UDP are dropped without a reply

Discarded traffic is still counted, so silence is not blindness. Turn on -status-interval to see the totals:

INFO  status active_allocations=1 tracked_users=1 ... dropped_non_stun=1482 dropped_stun_binding=93

-log-level debug adds one line per source address, deduplicated per minute, saying what was dropped and why.

Testing it

Generate a credential the way the homeserver would, then point any TURN client at the server:

export TURN_STATIC_AUTH_SECRET="..."
./mtxturn -realm example.org -gen-credential '@alice:example.org'

The pair it prints works with turnutils_uclient, the Trickle ICE page, or Element's own call diagnostics. A working relay shows a candidate of type relay in Trickle ICE.

Run the test suite with:

go test -race ./...

It covers credential derivation against a vector from the reference Python implementation, every rejection path, the peer filter, the per-user quota, and a full allocation with real traffic pushed across the relay.

Running under systemd

mtxturn.service in this directory is a starting point. It runs the binary in the foreground as a dynamic user with no privileges, reads the secret from an environment file, and lets systemd handle restarts and journald handle logs.

sudo install -m 0755 mtxturn /usr/local/bin/mtxturn
sudo install -m 0600 /dev/null /etc/mtxturn.env   # then put the secret in it
sudo cp mtxturn.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now mtxturn
journalctl -u mtxturn -f

Scope

Not implemented, because a Matrix homeserver does not need it: TCP relay allocations (RFC 6062), TURN over DTLS, ALPN, mobility (RFC 8016), a user database, quotas by bandwidth, or Prometheus metrics. If you need any of them, coturn is the right answer.

About

A small and simple TURN/STUN relay for a self-hosted Matrix homeserver

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages