256 lines
12 KiB
Markdown
256 lines
12 KiB
Markdown
# TURN for office screen sharing
|
||
|
||
This directory is an **operator-reviewed template**, not an installer. Nothing
|
||
here changes DNS, Caddy, systemd, UFW or Oracle Cloud. Those are public routing
|
||
changes and require explicit operator approval on the deployment host.
|
||
|
||
Tera's browser peers normally connect directly. Coturn is the fallback for
|
||
symmetric NATs and restrictive networks; it relays encrypted WebRTC packets and
|
||
never receives application signaling, screen URLs or recordings. The signaling
|
||
service remains the separate loopback API described in `server/README.md`.
|
||
|
||
> **Mandatory public-enablement gate:** do not expose the TURN listeners or set
|
||
> `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET` in production until the deployed
|
||
> ICE endpoint requires an active, unexpired screen-signaling grant for the exact
|
||
> office/screen binding and authenticates that grant to the same signed-in
|
||
> subject. The current endpoint checks only global member authentication, which
|
||
> would let any signed-in member mint a general-purpose Internet relay credential.
|
||
> It is intentionally not approved for broad public TURN enablement.
|
||
|
||
The production host audited on 2026-08-11 has private VNIC `10.0.0.2`, public
|
||
IPv4 `170.9.14.61`, and an existing `turn.lumbridgecorp.com` A record. Reconfirm
|
||
all three immediately before deployment. Do not copy those addresses into a
|
||
different installation merely because they appear here.
|
||
|
||
## 1. Review the network boundary
|
||
|
||
The intended public listeners are:
|
||
|
||
| Transport | Port | Purpose |
|
||
| --- | ---: | --- |
|
||
| UDP | 3478 | primary STUN/TURN listener |
|
||
| TCP | 3478 | TURN fallback |
|
||
| TCP/TLS | 5349 | TURN/TLS fallback |
|
||
| UDP | 52000–53023 | dedicated relay allocations |
|
||
|
||
Open the same ingress in **both** UFW and the instance's OCI security list or
|
||
NSG. The OCI console is authoritative; a host firewall rule does not prove the
|
||
cloud edge permits traffic. Relay UDP must accept arbitrary Internet peers, not
|
||
only the browser that created an allocation. Outbound traffic remains allowed.
|
||
|
||
Example commands to review, not paste blindly:
|
||
|
||
```bash
|
||
dig +short A turn.lumbridgecorp.com
|
||
ip -br address
|
||
curl -4 https://api.ipify.org
|
||
sudo ufw status verbose
|
||
|
||
# Only after the operator approves these public ingress changes:
|
||
sudo ufw allow 3478/udp comment 'Tera TURN UDP'
|
||
sudo ufw allow 3478/tcp comment 'Tera TURN TCP'
|
||
sudo ufw allow 5349/tcp comment 'Tera TURN TLS'
|
||
sudo ufw allow 52000:53023/udp comment 'Tera TURN relay'
|
||
```
|
||
|
||
Add equivalent stateful OCI ingress rules. Do not open UDP 5349: this template
|
||
disables DTLS. Do not reuse UDP 50000–50200; stopped LiveKit deployments on the
|
||
audited host reserve that range and could otherwise collide if restarted.
|
||
|
||
The public/private NAT must preserve relay port numbers. This is why the config
|
||
uses `external-ip=PUBLIC/PRIVATE`; a relay candidate advertising `10.0.0.2` or a
|
||
different public port is a failed deployment.
|
||
|
||
## 2. Install and render the config
|
||
|
||
Prefer Ubuntu's native coturn package and unit. Docker adds no isolation benefit
|
||
to a service that needs a large host UDP range, and bridged port translation is
|
||
an extra failure mode. Inspect the package before enabling it:
|
||
|
||
```bash
|
||
apt-cache policy coturn
|
||
systemctl cat coturn.service
|
||
getent passwd turnserver
|
||
getent group turnserver
|
||
```
|
||
|
||
Some distro packages also ship `/etc/default/coturn` with an explicit enable
|
||
switch. Inspect it and the unit's conditions; set `TURNSERVER_ENABLED=1` only as
|
||
part of the reviewed enablement, never by assuming a successful `systemctl`
|
||
command means the daemon actually bound its sockets.
|
||
|
||
Generate one 256-bit hexadecimal secret without putting its value in shell
|
||
history:
|
||
|
||
```bash
|
||
umask 077
|
||
TURN_SECRET="$(openssl rand -hex 32)"
|
||
test "${#TURN_SECRET}" -eq 64
|
||
```
|
||
|
||
Render `turnserver.conf.example` to a root-owned `/etc/turnserver.conf`, replacing
|
||
the two IP placeholders and the secret placeholder. The installed file should
|
||
be `root:turnserver` mode `0640`. Put the **same raw secret** in the Tera API's
|
||
root-owned environment file:
|
||
|
||
```ini
|
||
TERA_TURN_SHARED_SECRET=<same 64 hex characters>
|
||
TERA_ICE_URLS=stun:turn.lumbridgecorp.com:3478,turn:turn.lumbridgecorp.com:3478?transport=udp,turn:turn.lumbridgecorp.com:3478?transport=tcp,turns:turn.lumbridgecorp.com:5349?transport=tcp
|
||
TERA_TURN_CREDENTIAL_TTL=300
|
||
```
|
||
|
||
The audited service reads `/etc/tera-api.env`; the repository unit reads
|
||
`/etc/tera/tera.env`. Use the path its installed unit actually declares. Never
|
||
commit either rendered file, print the secret, put it in a URL, or pass it as a
|
||
process argument. Clear the shell variable after both files are installed:
|
||
|
||
```bash
|
||
unset TURN_SECRET
|
||
```
|
||
|
||
The current authenticated `POST /api/v1/media/ice` endpoint validates only the
|
||
signed-in caller and returns a five-minute username/password generated with
|
||
coturn's REST scheme. That is insufficient authorization for a public relay.
|
||
Before enabling the environment above, the deployed request must also carry an
|
||
active screen-signaling credential and exact binding in its POST body. The server
|
||
must validate its token hash, subject, role, binding, lease and revocation state,
|
||
then rate-limit issuance by both subject and trusted client IP. The presenter or
|
||
viewer must join signaling before requesting ICE configuration. GET/query-string
|
||
credentials remain forbidden.
|
||
|
||
Coturn removes the expiry prefix from a REST username before applying
|
||
`user-quota`. The suffix therefore must be a stable, opaque, session-participant
|
||
accounting key across repeat ICE issuance for that participant. Do not use the
|
||
authentication subject, email, profile id or another identity in this suffix,
|
||
and do not generate a fresh random suffix for every request: the former leaks
|
||
identity into TURN state/logs, while the latter gives every issuance a fresh
|
||
`user-quota`. A keyed digest of the opaque signaling session and participant ids
|
||
is an appropriate suffix. Application-side subject/IP issuance and concurrent
|
||
session limits remain mandatory; coturn cannot infer a Lumbridge member.
|
||
|
||
The template's `max-bps` and `bps-capacity` units are **bytes per second**, with
|
||
input and output accounted separately. In particular, `bps-capacity=500000000`
|
||
is a 500 MB/s (roughly 4 Gbit/s) ceiling, not a recommended launch budget.
|
||
Before public enablement, the operator must lower `total-quota` and
|
||
`bps-capacity` to the measured instance/network ceiling and an explicitly
|
||
accepted relay-egress budget. Start below that budget and raise only from
|
||
observed concurrent screen bitrate and allocation data. The 1,024-port relay
|
||
range is a hard capacity boundary, not evidence that 900 simultaneous relays are
|
||
operationally or financially safe.
|
||
|
||
An accepted response uses:
|
||
|
||
- username: `<expiry-unix-seconds>:<stable-opaque-participant-accounting-key>`
|
||
- credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET`
|
||
- response: `Cache-Control: private, no-store`
|
||
|
||
The shared secret never leaves the server. Expiry prevents a credential from
|
||
starting a newly authenticated TURN session. It does not terminate an allocation
|
||
that already exists, and coturn may continue accepting authenticated refreshes
|
||
within that established session. `stale-nonce` rotates coturn's protocol nonce
|
||
(the browser handles the 438 challenge); it is not credential revocation. Stop
|
||
and revoke must still close browser peer connections, while coturn's total
|
||
allocation and bandwidth limits bound residual abuse.
|
||
|
||
Install `tera-coturn-preflight` as `/usr/local/libexec/tera-coturn-preflight`
|
||
mode `0755`, and install the reviewed systemd drop-in only after confirming the
|
||
distro unit name. The preflight deliberately refuses unresolved placeholders,
|
||
short/non-hex secrets, and missing certificates.
|
||
|
||
## 3. Issue and renew the TLS certificate
|
||
|
||
Do not point coturn into Caddy's private certificate store. It is owned by the
|
||
Caddy account, and Caddy renewal does not provide coturn a reliable reload hook.
|
||
|
||
1. Create `/var/www/turn-acme` root-owned and readable by Caddy.
|
||
2. Review `turn-acme.Caddyfile.example`, replace its private IP, import it, run
|
||
`caddy validate`, and reload Caddy.
|
||
3. Verify a test file under `/.well-known/acme-challenge/` is reachable over
|
||
public port 80.
|
||
4. Use Certbot's webroot mode for `turn.lumbridgecorp.com`.
|
||
5. Install `certbot-deploy-hook` under `/etc/letsencrypt/renewal-hooks/deploy/`
|
||
mode `0755`.
|
||
6. Run the hook once with `RENEWED_LINEAGE` set to the issued lineage, then
|
||
verify ownership and certificate names without printing the private key.
|
||
|
||
Example issuance, after review:
|
||
|
||
```bash
|
||
sudo certbot certonly --webroot -w /var/www/turn-acme \
|
||
-d turn.lumbridgecorp.com
|
||
sudo env RENEWED_LINEAGE=/etc/letsencrypt/live/turn.lumbridgecorp.com \
|
||
/etc/letsencrypt/renewal-hooks/deploy/certbot-deploy-hook
|
||
sudo openssl x509 -in /etc/coturn/certs/turn.fullchain.pem \
|
||
-noout -subject -issuer -dates -ext subjectAltName
|
||
```
|
||
|
||
`try-reload-or-restart` may restart coturn when the package unit has no reload
|
||
action, interrupting allocations. Confirm the installed unit's behavior and
|
||
schedule renewal accordingly. Test renewal with `certbot renew --dry-run` before
|
||
calling the certificate path production-ready.
|
||
|
||
## 4. Validate before enabling
|
||
|
||
First prove the mandatory application gate: a signed-in member with no active
|
||
signaling grant, and a caller using a stopped, revoked, expired, wrong-subject or
|
||
wrong-binding grant, must all receive no TURN credential. Only then configure
|
||
the shared secret/ICE URLs and approve public firewall changes. Keep the TURN
|
||
ports closed and both Tera TURN environment values unset if any case fails.
|
||
|
||
Run the repository preflight against a staged rendered config, then use coturn's
|
||
installed version/config inspection facilities. Do not start the daemon merely
|
||
to discover an unresolved placeholder on a public interface.
|
||
|
||
```bash
|
||
sudo TURN_CONFIG=/etc/turnserver.conf \
|
||
/usr/local/libexec/tera-coturn-preflight
|
||
turnserver --version
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl start coturn.service # operator-approved change
|
||
sudo systemctl --no-pager --full status coturn.service
|
||
sudo journalctl -u coturn.service --since -10m --no-pager
|
||
sudo ss -lntup | grep -E ':3478|:5349'
|
||
```
|
||
|
||
From a machine outside OCI:
|
||
|
||
```bash
|
||
dig +short A turn.lumbridgecorp.com
|
||
openssl s_client -connect turn.lumbridgecorp.com:5349 \
|
||
-servername turn.lumbridgecorp.com </dev/null
|
||
turnutils_stunclient -p 3478 turn.lumbridgecorp.com
|
||
```
|
||
|
||
Obtain a temporary credential through the authenticated ICE endpoint rather
|
||
than reading the shared secret. Exercise it with `turnutils_uclient` and with a
|
||
browser `RTCPeerConnection` configured with `iceTransportPolicy: "relay"`.
|
||
Verify through `getStats()` or browser WebRTC diagnostics that:
|
||
|
||
- the selected candidate type is `relay`;
|
||
- the relay address is the current public IP, never the private VNIC address;
|
||
- two peers on different networks can exchange a screen track;
|
||
- blocking UDP forces TURN/TCP 3478, then TURN/TLS 5349;
|
||
- expired TURN credentials cannot start a new authenticated allocation;
|
||
- repeated ICE issuance for one signaling participant retains one opaque TURN
|
||
accounting suffix and the fifth concurrent allocation is refused by
|
||
`user-quota=4`, while a different participant remains independent;
|
||
- signaling revocation closes the application's peer connections, but an already-issued
|
||
stateless TURN credential remains usable until its short expiry (and an existing
|
||
authenticated allocation may remain refreshable until the client disconnects);
|
||
- presenter stop and viewer leave close their browser peer connections;
|
||
- no CSP or Permissions Policy violation appears on either Tera entry host.
|
||
|
||
Finally test certificate renewal/reload and monitor allocation count, port
|
||
exhaustion, authentication failures and relay bandwidth. TURN is an egress and
|
||
abuse boundary; quota increases require the same operator review as firewall
|
||
changes. TLS on 5349 will not cross networks that allow only destination 443.
|
||
Supporting `turns:443` requires a separate public IP or carefully tested L4/SNI
|
||
multiplexing and is deliberately outside this minimal deployment.
|
||
|
||
## Upstream references
|
||
|
||
- [coturn server options and REST authentication](https://github.com/coturn/coturn/blob/master/README.turnserver)
|
||
- [coturn container/networking notes](https://github.com/coturn/coturn/blob/master/docker/coturn/README.md)
|
||
- [Let's Encrypt port 80 guidance](https://letsencrypt.org/docs/allow-port-80/)
|
||
- [Certbot webroot and deploy-hook documentation](https://eff-certbot.readthedocs.io/en/stable/using.html)
|