Packages

Elixir STUN/TURN relay server for WebRTC -- UDP, TCP, TLS, and DTLS listeners with long-term and shared-secret auth.

Current section

Files

Jump to
xturn ARCHITECTURE.md
Raw

ARCHITECTURE.md

# XTurn architecture

This note is for people who know WebRTC from the JavaScript side
(`RTCPeerConnection`, `iceServers`, "TURN is the relay") and want a friendly map
of how XTurn is built -- without assuming you live in RFCs all day.

**One-line summary:** [xsockets](https://github.com/Lazarus404/xsockets) owns
sockets and "when is a full packet ready?". This package owns TURN/STUN logic,
allocations, and the relay path.

If you just want to run the server, start with [README.md](README.md).

## Mental model (JS -> XTurn)

| You already know | What XTurn does |
|---|---|
| `iceServers: [{ urls: "stun:..." }]` | STUN Binding -- "what is my public address?" |
| `iceServers: [{ urls: "turn:...", username, credential }]` | TURN Allocate -- "give me a relay address" |
| ICE gathering / connectivity checks | Binding + permissions + channels on the wire |
| Media / datachannel bytes after ICE connects | Hot path: ChannelData (or Send) through the relay |

Think of an **allocation** as a rented mailbox on the server: the client gets a
relay IP:port, peers send UDP there, and XTurn forwards to the right browser
socket.

## Layers (big picture)

```
Browser / native ICE client
        |
   UDP/TCP/TLS/DTLS   (often :3478 plain, :5349 secure)
        v
xsockets  (listen, accept, frame packets)
        |
Handlers.StunTurn     <- "is this media or a control request?"
   |-- DataPlane      <- ChannelData / Send  (fast path, stays on the listener)
   +-- ClientWorker   <- Allocate, Refresh, ChannelBind, ...
            |
     Allocate.Client  <- one process per allocation ("the mailbox")
            |
     relay UDP socket <- owned by RelayIngress shards
            |
          Peer
```

`RootSupervisor` runs the TURN tree and the Maru HTTP API as siblings. If the
HTTP API crashes, listeners keep running (and the other way around).

## Supervision (startup order)

`Xirsys.XTurn.Supervisor` starts roughly:

1. Cert watcher / SIGHUP hook (only if you have `:secure` listeners)
2. `Server`, `PacketLog.Writer`
3. `ClientWorker.Pool` -- workers for slow/control STUN requests
4. `RelayIngress` -- shards that own relay UDP sockets
5. Plugin supervisor
6. `Allocate.Supervisor` -- one child per live allocation
7. Auth supervisor -- users + nonces
8. xsockets supervisors (`SockSupervisor`, tier pools)
9. Listeners from `config :xturn, :listen`

On boot, `Xirsys.XTurn` also sets up ETS tables (fast in-memory maps): allocations,
channels, permissions, auth, byte counters, plugins, reservations, listen registry.

## Module map

### Listen and classify

| Module | Plain English |
|---|---|
| `ListenConfig` | Apply `XTURN_*` env overrides and extra bind IPs |
| `ListenRegistry` | Remember UDP endpoints for optional RFC 5780 tricks |
| `DatagramListener` | Wrap a UDP server and register its bound address |
| `SocketPipeline` | xsockets pipelines (stream vs datagram framing details) |
| `Accumulators.StunTurn` | Find STUN message / ChannelData boundaries |
| `Handlers.StunTurn` | Rate-limit *requests*; decide media vs control |

### Control plane (signaling-ish TURN messages)

| Module | Plain English |
|---|---|
| `Pipeline` | Decode STUN; run the right action list for the method |
| `Conn` | Request context + helpers (including nonce reuse on 401) |
| `Binding` | STUN Binding (ICE consent, XOR-MAPPED-ADDRESS) |
| `Actions.*` | Allocate, auth, Refresh, ChannelBind, CreatePerm, Send, ... |
| `ClientWorker` / `.Pool` | Run that pipeline *off* the hot listener process |
| `Auth.*` | Long-term users, TTL shared secret, optional ACCESS-TOKEN |

### Allocations and relay state

| Module | Plain English |
|---|---|
| `Allocate.Client` | One allocation: sockets, timers, lifetime |
| `Allocate.Store` | ETS indexes (client 5-tuple -> pid, relay -> dest, ...) |
| `Channels.Store` | Channel number -> peer + relay socket |
| `Permissions.Store` | Which peer IPs this allocation may talk to |
| `TimedEntry` | Map + cancelable timers (permissions, channels) |
| `Tuple5` | Client/server/protocol key (maps `0.0.0.0` to advertised IP) |
| `Allocate.Bytes`, `Quota`, `RelayPort`, `PeerFilter` | Counters, caps, ports, ACL |
| `ClientSocket` | Send back to the client without bothering `Allocate.Client` |

### Data plane (media bytes)

| Module | Plain English |
|---|---|
| `DataPlane` | Classify + forward ChannelData/Send; hairpin same-host calls |
| `RelayIngress` | Peer UDP -> permission check -> plugins -> client |
| `StunHelper` | Wrap Data / ICMP indications |
| `Plugin.Dispatch` | Optional per-allocation hooks ([PLUGIN.md](PLUGIN.md)) |

### HTTP API

`Xirsys.API` (Maru) plus auth/allocation routers: mint users, mint TURN REST
credentials, show allocation stats. Port comes from `config :maru, Xirsys.API`.

## Control vs media (why two paths?)

Same idea as separating your signaling WebSocket from your media path.

`Handlers.StunTurn` asks `DataPlane.classify/1`:

| Class | What it is | Where it runs |
|---|---|---|
| `{:channel, n, payload}` | ChannelData (typical browser media after ChannelBind) | `DataPlane` on the listener |
| `{:send, peer, payload}` | Send indication (before a channel exists) | `DataPlane` on the listener |
| `:control` | Allocate, Binding, Refresh, ... | `ClientWorker.Pool` -> `Pipeline` |
| ConnectionBind on spliced TCP | RFC 6062 TCP relay setup | `Pipeline` inline |

**Media never waits in the control worker pool.** That keeps ICE/auth work from
blocking the bytes once the call is up.

## Flow: client -> peer

After ICE has allocated (and usually bound a channel):

```mermaid
flowchart TD
  C[Client] -->|ChannelData or Send| L[DatagramServer / Connection]
  L --> A[StunTurn accumulator]
  A --> H[Handlers.StunTurn]
  H -->|classify| DP[DataPlane]
  DP -->|ChannelData| CH[Channels.Store lookup]
  DP -->|Send| ST[Allocate.Store + Permissions]
  CH --> PL[Plugin.Dispatch.egress]
  ST --> PL
  PL -->|drop| X[drop]
  PL -->|ok| HP{Store.lookup_relay dest?}
  HP -->|yes hairpin| IN[Plugin.Dispatch.ingress on dest]
  IN --> TC[DataPlane.to_client]
  TC --> C2[Other client on this server]
  HP -->|no| UDP[UDP send on source relay socket]
  UDP --> P[Peer]
```

Step by step:

1. **Socket.** xsockets hands one framed packet to `Handlers.StunTurn`.
2. **Classify.** Looks like ChannelData? Send indication? Otherwise control.
3. **Lookup.** ChannelData uses the channel table; Send uses the allocation +
   permissions (when required).
4. **5-tuple key.** Listeners bound to `0.0.0.0` still match the *advertised*
   `server_ip` clients used in ICE.
5. **Plugins (egress).** Optional rewrite or `:drop`.
6. **Forward.**
   - **Hairpin:** both peers allocated on *this* XTurn -- deliver locally, no
     extra UDP hop.
   - **Otherwise:** write UDP on the allocation's relay socket toward the peer.
7. **Touch.** Roughly once a minute, live traffic refreshes permission / channel /
   allocation timers so Chrome's occasional missing Refresh does not kill a long
   call.

Before ChannelBind, browsers often use Send indications. After ChannelBind,
they prefer ChannelData (smaller headers, happier hot path).

## Flow: peer -> client

Relay sockets are **not** the same as the public STUN/TURN listen sockets.
`Allocate.Client` opens them with `UDP.open_relay/2`; `RelayIngress` owns them
(sharded by port).

```mermaid
flowchart TD
  P[Peer] -->|UDP to relay port| W[RelayIngress.Worker]
  W --> PERM{permission for peer IP?}
  PERM -->|no| X[drop]
  PERM -->|yes| PL[Plugin.Dispatch.ingress]
  PL -->|drop| X
  PL -->|ok| TC[DataPlane.to_client]
  TC --> CS[ClientSocket.send]
  CS --> C[Client]
```

`to_client/4` picks ChannelData when a channel is bound for that peer, otherwise
a STUN Data indication.

ICMP on a relay socket becomes a STUN error indication back on the client socket.

## Lifetimes (why "touch" matters)

TURN leases expire. If nothing renews them, the call dies even though ICE once
succeeded.

| Timer | Default | Kept alive by |
|---|---|---|
| Permission | 300s | CreatePermission, ChannelBind, traffic `touch` |
| Channel | 600s | ChannelBind, `touch` with a channel number |
| Allocation | 600s | Refresh, `touch` |

Timers are cancelable (`TimedEntry`). A late "expired" message is ignored if a
newer timer already replaced it.

## Related packages

| Package | Role |
|---|---|
| [xsockets](https://github.com/Lazarus404/xsockets) | Transports, framing engine, listeners |
| [xmedialib](https://github.com/Lazarus404/xmedialib) | STUN codec (`XMediaLib.Stun`) |
| [xturn-plugin-api](https://github.com/Lazarus404/xturn-plugin-api) | Plugin behaviour |
| [xturn-plugins](https://github.com/Lazarus404/xturn-plugins) | Example Guard / Metrics plugins |