# 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:** the repository endpoint now requires an
> active, unexpired screen-signaling grant for the exact office/screen binding
> and authenticates it to the same signed-in subject. Do not infer that the live
> deployment has this build or that coturn is ready: prove the deployed cases in
> §4, then approve DNS/firewall/certificate/quota changes before exposing TURN
> listeners or setting `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET`.

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 repository `POST /api/v1/media/ice` contract carries an active screen-
signaling credential and exact binding in its POST body. The server validates
its token hash, subject, role, binding, lease and revocation state, then rate-
limits issuance independently by subject and trusted client IP. Before enabling
the environment above, prove that this exact behavior is running on the deployed
API; a source-tree test is not evidence that the host was upgraded. 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)
