Skip to content

Commit e13981f

Browse files
committed
feat: add coturn TURN support
1 parent 8596c72 commit e13981f

3 files changed

Lines changed: 71 additions & 22 deletions

File tree

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ API_HOSTNAME=
1010
APP_HOSTNAME=
1111

1212
##
13-
## Cloudflare TURN Service
13+
## TURN Service: Cloudflare or Coturn
1414
##
1515
CLOUDFLARE_TURN_ID=
1616
CLOUDFLARE_TURN_TOKEN=
17+
# URLs are comma separated, e.g. turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp
18+
COTURN_TURN_URLS=
19+
# Coturn use-auth-secret authentication scheme is used (https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00)
20+
COTURN_TURN_SECRET=
21+
# TTL is in seconds, default is 3600 seconds (1 hour)
22+
TURN_TTL=
1723

1824
##
1925
## Session Cookie Secret

src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ declare global {
2929
GOOGLE_CLIENT_ID: string;
3030
GOOGLE_CLIENT_SECRET: string;
3131

32-
// We use Cloudflare STUN & TURN server for cloud users
32+
// We can use either Cloudflare or Coturn TURN server for cloud users
3333
CLOUDFLARE_TURN_ID: string;
3434
CLOUDFLARE_TURN_TOKEN: string;
35+
COTURN_TURN_URLS: string;
36+
COTURN_TURN_SECRET: string;
37+
TURN_TTL: string;
3538

3639
// We use R2 for storing releases
3740
R2_ENDPOINT: string;
@@ -44,6 +47,7 @@ declare global {
4447

4548
// Real IP
4649
REAL_IP_HEADER: string;
50+
4751
ICE_SERVERS: string;
4852

4953
ALLOWED_IDENTITIES?: string;

src/webrtc.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { WebSocket, WebSocketServer } from "ws";
22
import express from "express";
33
import * as jose from "jose";
4+
import * as crypto from "crypto";
45
import { prisma } from "./db";
5-
import { NotFoundError, UnprocessableEntityError } from "./errors";
6+
import { BadRequestError, InternalServerError, NotFoundError, UnprocessableEntityError } from "./errors";
67
import { activeConnections, iceServers, inFlight } from "./webrtc-signaling";
78

9+
const CLOUDFLARE_TURN_ID = process.env.CLOUDFLARE_TURN_ID;
10+
const CLOUDFLARE_TURN_TOKEN = process.env.CLOUDFLARE_TURN_TOKEN;
11+
const COTURN_TURN_URLS = process.env.COTURN_TURN_URLS?.split(",")
12+
.map(url => url.trim())
13+
.filter(Boolean);
14+
const COTURN_TURN_SECRET = process.env.COTURN_TURN_SECRET;
15+
const TURN_TTL = Number.parseInt(process.env.TURN_TTL ?? "", 10) || 3600;
16+
817
export const CreateSession = async (req: express.Request, res: express.Response) => {
918
const idToken = req.session?.id_token;
1019
const { sub } = jose.decodeJwt(idToken);
@@ -102,31 +111,61 @@ export const CreateIceCredentials = async (
102111
req: express.Request,
103112
res: express.Response,
104113
) => {
105-
const resp = await fetch(
106-
`https://rtc.live.cloudflare.com/v1/turn/keys/${process.env.CLOUDFLARE_TURN_ID}/credentials/generate`,
107-
{
108-
method: "POST",
109-
headers: {
110-
Authorization: `Bearer ${process.env.CLOUDFLARE_TURN_TOKEN}`,
111-
"Content-Type": "application/json",
112-
},
113-
body: JSON.stringify({ ttl: 3600 }),
114-
},
115-
);
114+
const idToken = req.session?.id_token;
115+
if (!idToken) {
116+
throw new UnprocessableEntityError("Missing ID token");
117+
}
118+
const { sub } = jose.decodeJwt(idToken);
116119

117-
const data = (await resp.json()) as {
118-
iceServers: { credential?: string; urls: string | string[]; username?: string };
120+
let iceConfig: {
121+
iceServers: { urls: string | string[]; username?: string, credential?: string }
119122
};
120123

121-
if (!data.iceServers.urls) {
122-
throw new Error("No ice servers returned");
123-
}
124+
if (CLOUDFLARE_TURN_ID && CLOUDFLARE_TURN_TOKEN) {
125+
const resp = await fetch(
126+
`https://rtc.live.cloudflare.com/v1/turn/keys/${CLOUDFLARE_TURN_ID}/credentials/generate`,
127+
{
128+
method: "POST",
129+
headers: {
130+
Authorization: `Bearer ${CLOUDFLARE_TURN_TOKEN}`,
131+
"Content-Type": "application/json",
132+
},
133+
body: JSON.stringify({ ttl: TURN_TTL }),
134+
},
135+
);
136+
137+
const cloudflareIceConfig = await resp.json() as {
138+
iceServers: { urls: string | string[]; username?: string, credential?: string }
139+
};
140+
141+
if (!cloudflareIceConfig?.iceServers.urls) {
142+
throw new InternalServerError("No ice servers returned");
143+
}
144+
145+
if (cloudflareIceConfig.iceServers.urls instanceof Array) {
146+
cloudflareIceConfig.iceServers.urls = cloudflareIceConfig.iceServers.urls.filter(url => !url.startsWith("turns"));
147+
}
124148

125-
if (data.iceServers.urls instanceof Array) {
126-
data.iceServers.urls = data.iceServers.urls.filter(url => !url.startsWith("turns"));
149+
iceConfig = cloudflareIceConfig;
150+
} else if (COTURN_TURN_URLS && COTURN_TURN_SECRET && COTURN_TURN_URLS.length > 0) {
151+
const username = `${Math.floor(Date.now() / 1000) + TURN_TTL}:${sub}`;
152+
const credential = crypto
153+
.createHmac("sha1", COTURN_TURN_SECRET)
154+
.update(username)
155+
.digest("base64");
156+
157+
iceConfig = {
158+
iceServers: {
159+
urls: COTURN_TURN_URLS,
160+
username: username,
161+
credential: credential,
162+
}
163+
};
164+
} else {
165+
throw new BadRequestError("No TURN configuration available", "no_turn_configuration");
127166
}
128167

129-
return res.json(data);
168+
return res.json(iceConfig);
130169
};
131170

132171
export const CreateTurnActivity = async (req: express.Request, res: express.Response) => {

0 commit comments

Comments
 (0)