From e13981f9401d08dda937221795e739b49f0e25f5 Mon Sep 17 00:00:00 2001 From: Nikita Sliusarev Date: Sun, 1 Feb 2026 22:59:13 +0100 Subject: [PATCH] feat: add coturn TURN support --- .env.example | 8 +++++- src/index.ts | 6 +++- src/webrtc.ts | 79 ++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index a7a246a..4a25849 100644 --- a/.env.example +++ b/.env.example @@ -10,10 +10,16 @@ API_HOSTNAME= APP_HOSTNAME= ## -## Cloudflare TURN Service +## TURN Service: Cloudflare or Coturn ## CLOUDFLARE_TURN_ID= CLOUDFLARE_TURN_TOKEN= +# URLs are comma separated, e.g. turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp +COTURN_TURN_URLS= +# Coturn use-auth-secret authentication scheme is used (https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00) +COTURN_TURN_SECRET= +# TTL is in seconds, default is 3600 seconds (1 hour) +TURN_TTL= ## ## Session Cookie Secret diff --git a/src/index.ts b/src/index.ts index b6fdecb..dd7da5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,9 +29,12 @@ declare global { GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; - // We use Cloudflare STUN & TURN server for cloud users + // We can use either Cloudflare or Coturn TURN server for cloud users CLOUDFLARE_TURN_ID: string; CLOUDFLARE_TURN_TOKEN: string; + COTURN_TURN_URLS: string; + COTURN_TURN_SECRET: string; + TURN_TTL: string; // We use R2 for storing releases R2_ENDPOINT: string; @@ -44,6 +47,7 @@ declare global { // Real IP REAL_IP_HEADER: string; + ICE_SERVERS: string; ALLOWED_IDENTITIES?: string; diff --git a/src/webrtc.ts b/src/webrtc.ts index fca86bc..c7e0972 100644 --- a/src/webrtc.ts +++ b/src/webrtc.ts @@ -1,10 +1,19 @@ import { WebSocket, WebSocketServer } from "ws"; import express from "express"; import * as jose from "jose"; +import * as crypto from "crypto"; import { prisma } from "./db"; -import { NotFoundError, UnprocessableEntityError } from "./errors"; +import { BadRequestError, InternalServerError, NotFoundError, UnprocessableEntityError } from "./errors"; import { activeConnections, iceServers, inFlight } from "./webrtc-signaling"; +const CLOUDFLARE_TURN_ID = process.env.CLOUDFLARE_TURN_ID; +const CLOUDFLARE_TURN_TOKEN = process.env.CLOUDFLARE_TURN_TOKEN; +const COTURN_TURN_URLS = process.env.COTURN_TURN_URLS?.split(",") + .map(url => url.trim()) + .filter(Boolean); +const COTURN_TURN_SECRET = process.env.COTURN_TURN_SECRET; +const TURN_TTL = Number.parseInt(process.env.TURN_TTL ?? "", 10) || 3600; + export const CreateSession = async (req: express.Request, res: express.Response) => { const idToken = req.session?.id_token; const { sub } = jose.decodeJwt(idToken); @@ -102,31 +111,61 @@ export const CreateIceCredentials = async ( req: express.Request, res: express.Response, ) => { - const resp = await fetch( - `https://rtc.live.cloudflare.com/v1/turn/keys/${process.env.CLOUDFLARE_TURN_ID}/credentials/generate`, - { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.CLOUDFLARE_TURN_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ ttl: 3600 }), - }, - ); + const idToken = req.session?.id_token; + if (!idToken) { + throw new UnprocessableEntityError("Missing ID token"); + } + const { sub } = jose.decodeJwt(idToken); - const data = (await resp.json()) as { - iceServers: { credential?: string; urls: string | string[]; username?: string }; + let iceConfig: { + iceServers: { urls: string | string[]; username?: string, credential?: string } }; - if (!data.iceServers.urls) { - throw new Error("No ice servers returned"); - } + if (CLOUDFLARE_TURN_ID && CLOUDFLARE_TURN_TOKEN) { + const resp = await fetch( + `https://rtc.live.cloudflare.com/v1/turn/keys/${CLOUDFLARE_TURN_ID}/credentials/generate`, + { + method: "POST", + headers: { + Authorization: `Bearer ${CLOUDFLARE_TURN_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ttl: TURN_TTL }), + }, + ); + + const cloudflareIceConfig = await resp.json() as { + iceServers: { urls: string | string[]; username?: string, credential?: string } + }; + + if (!cloudflareIceConfig?.iceServers.urls) { + throw new InternalServerError("No ice servers returned"); + } + + if (cloudflareIceConfig.iceServers.urls instanceof Array) { + cloudflareIceConfig.iceServers.urls = cloudflareIceConfig.iceServers.urls.filter(url => !url.startsWith("turns")); + } - if (data.iceServers.urls instanceof Array) { - data.iceServers.urls = data.iceServers.urls.filter(url => !url.startsWith("turns")); + iceConfig = cloudflareIceConfig; + } else if (COTURN_TURN_URLS && COTURN_TURN_SECRET && COTURN_TURN_URLS.length > 0) { + const username = `${Math.floor(Date.now() / 1000) + TURN_TTL}:${sub}`; + const credential = crypto + .createHmac("sha1", COTURN_TURN_SECRET) + .update(username) + .digest("base64"); + + iceConfig = { + iceServers: { + urls: COTURN_TURN_URLS, + username: username, + credential: credential, + } + }; + } else { + throw new BadRequestError("No TURN configuration available", "no_turn_configuration"); } - return res.json(data); + return res.json(iceConfig); }; export const CreateTurnActivity = async (req: express.Request, res: express.Response) => {