feat(webrtc): integrate Cloudflare TURN/STUN servers

- Add backend endpoint to fetch ICE server credentials from Cloudflare
- Implement dynamic ICE server configuration in frontend
- Add fallback to public STUN servers when Cloudflare unavailable
- Create comprehensive test suite for WebRTC API endpoint
- Update environment configuration with Cloudflare TURN credentials

Backend changes:
- New route: GET /api/webrtc/ice-servers (authenticated)
- Fetches temporary credentials from Cloudflare API with 24h TTL
- Returns formatted ICE servers for RTCPeerConnection
- Graceful fallback to Google STUN servers on errors

Frontend changes:
- Remove hardcoded ICE servers from useWebRTC hook
- Fetch ICE servers dynamically from backend on mount
- Store servers in ref for peer connection initialization
- Add webrtcAPI service for backend communication

Tests:
- 9 comprehensive tests covering all scenarios
- 100% coverage for webrtc.js route
- Tests authentication, success, and all fallback scenarios
This commit is contained in:
Radosław Gierwiało
2025-12-05 21:23:50 +01:00
parent e1138c789e
commit a92d7469e4
6 changed files with 422 additions and 28 deletions

View File

@@ -62,3 +62,8 @@ MATCHING_MIN_INTERVAL_SEC=60
# Cloudflare Turnstile (CAPTCHA) # Cloudflare Turnstile (CAPTCHA)
# Get your secret key from: https://dash.cloudflare.com/ # Get your secret key from: https://dash.cloudflare.com/
TURNSTILE_SECRET_KEY=your-secret-key-here TURNSTILE_SECRET_KEY=your-secret-key-here
# Cloudflare TURN/STUN
# Get your credentials from: https://dash.cloudflare.com/ -> Calls -> TURN
CLOUDFLARE_TURN_TOKEN_ID=your-turn-token-id-here
CLOUDFLARE_TURN_API_TOKEN=your-turn-api-token-here

View File

@@ -0,0 +1,273 @@
const request = require('supertest');
const app = require('../app');
const { generateToken } = require('../utils/auth');
const { prisma } = require('../utils/db');
// Mock fetch globally
global.fetch = jest.fn();
describe('WebRTC API Routes', () => {
let testUser;
let authToken;
beforeAll(async () => {
// Clean up existing test user if any
await prisma.user.deleteMany({
where: {
email: 'webrtc_api_test@test.com',
},
});
// Create test user
testUser = await prisma.user.create({
data: {
username: 'webrtc_api_user',
email: 'webrtc_api_test@test.com',
passwordHash: 'hash',
emailVerified: true,
},
});
authToken = generateToken({ userId: testUser.id });
});
afterAll(async () => {
await prisma.user.deleteMany({
where: {
id: testUser.id,
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('GET /api/webrtc/ice-servers', () => {
it('should require authentication', async () => {
const response = await request(app)
.get('/api/webrtc/ice-servers')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Unauthorized');
expect(response.body.message).toBe('No token provided');
});
it('should return Cloudflare TURN credentials when configured', async () => {
// Mock successful Cloudflare API response
const mockCloudflareResponse = {
iceServers: {
urls: [
'stun:stun.cloudflare.com:3478',
'turn:turn.cloudflare.com:3478?transport=udp',
'turn:turn.cloudflare.com:3478?transport=tcp',
'turns:turn.cloudflare.com:5349?transport=tcp',
],
username: 'test-username-12345',
credential: 'test-credential-67890',
},
};
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockCloudflareResponse,
});
// Set environment variables
process.env.CLOUDFLARE_TURN_TOKEN_ID = 'test-token-id';
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
const response = await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.iceServers).toHaveLength(1);
expect(response.body.iceServers[0]).toEqual({
urls: mockCloudflareResponse.iceServers.urls,
username: mockCloudflareResponse.iceServers.username,
credential: mockCloudflareResponse.iceServers.credential,
});
// Verify fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('rtc.live.cloudflare.com'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer test-api-token',
'Content-Type': 'application/json',
}),
body: expect.stringContaining('ttl'),
})
);
});
it('should fallback to public STUN servers when Cloudflare credentials not configured', async () => {
// Remove environment variables
delete process.env.CLOUDFLARE_TURN_TOKEN_ID;
delete process.env.CLOUDFLARE_TURN_API_TOKEN;
const response = await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.iceServers).toHaveLength(2);
expect(response.body.iceServers[0].urls).toContain('stun.l.google.com');
expect(response.body.iceServers[1].urls).toContain('stun1.l.google.com');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should fallback to public STUN servers when Cloudflare API fails', async () => {
// Mock failed Cloudflare API response
global.fetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
process.env.CLOUDFLARE_TURN_TOKEN_ID = 'test-token-id';
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
const response = await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.iceServers).toHaveLength(2);
expect(response.body.iceServers[0].urls).toContain('stun.l.google.com');
expect(global.fetch).toHaveBeenCalled();
});
it('should fallback to public STUN servers when Cloudflare response is invalid', async () => {
// Mock invalid Cloudflare API response (missing urls)
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
iceServers: {
username: 'test-username',
credential: 'test-credential',
// Missing urls field
},
}),
});
process.env.CLOUDFLARE_TURN_TOKEN_ID = 'test-token-id';
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
const response = await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.iceServers).toHaveLength(2);
expect(response.body.iceServers[0].urls).toContain('stun.l.google.com');
});
it('should fallback to public STUN servers when fetch throws error', async () => {
// Mock network error
global.fetch.mockRejectedValueOnce(new Error('Network error'));
process.env.CLOUDFLARE_TURN_TOKEN_ID = 'test-token-id';
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
const response = await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.iceServers).toHaveLength(2);
expect(response.body.iceServers[0].urls).toContain('stun.l.google.com');
});
it('should request credentials with 24 hour TTL', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
iceServers: {
urls: ['stun:stun.cloudflare.com:3478'],
username: 'test',
credential: 'test',
},
}),
});
process.env.CLOUDFLARE_TURN_TOKEN_ID = 'test-token-id';
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const fetchCall = global.fetch.mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.ttl).toBe(86400); // 24 hours in seconds
});
it('should include both STUN and TURN servers from Cloudflare', async () => {
const mockResponse = {
iceServers: {
urls: [
'stun:stun.cloudflare.com:3478',
'turn:turn.cloudflare.com:3478?transport=udp',
'turns:turn.cloudflare.com:5349?transport=tcp',
],
username: 'user123',
credential: 'pass456',
},
};
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
process.env.CLOUDFLARE_TURN_TOKEN_ID = 'test-token-id';
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
const response = await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.iceServers[0].urls).toEqual(mockResponse.iceServers.urls);
expect(response.body.iceServers[0].username).toBe('user123');
expect(response.body.iceServers[0].credential).toBe('pass456');
});
it('should use correct Cloudflare API endpoint', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
iceServers: {
urls: ['stun:stun.cloudflare.com:3478'],
username: 'test',
credential: 'test',
},
}),
});
const testTokenId = 'my-token-id-123';
process.env.CLOUDFLARE_TURN_TOKEN_ID = testTokenId;
process.env.CLOUDFLARE_TURN_API_TOKEN = 'test-api-token';
await request(app)
.get('/api/webrtc/ice-servers')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const fetchUrl = global.fetch.mock.calls[0][0];
expect(fetchUrl).toBe(
`https://rtc.live.cloudflare.com/v1/turn/keys/${testTokenId}/credentials/generate`
);
});
});
});

View File

@@ -144,6 +144,7 @@ app.use('/api/divisions', require('./routes/divisions'));
app.use('/api/competition-types', require('./routes/competitionTypes')); app.use('/api/competition-types', require('./routes/competitionTypes'));
app.use('/api/matches', require('./routes/matches')); app.use('/api/matches', require('./routes/matches'));
app.use('/api/admin', require('./routes/admin')); app.use('/api/admin', require('./routes/admin'));
app.use('/api/webrtc', require('./routes/webrtc'));
// app.use('/api/ratings', require('./routes/ratings')); // app.use('/api/ratings', require('./routes/ratings'));
// 404 handler // 404 handler

View File

@@ -0,0 +1,100 @@
const express = require('express');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
/**
* GET /api/webrtc/ice-servers
* Get ICE servers configuration (STUN/TURN) for WebRTC
* Requires authentication
*/
router.get('/ice-servers', authenticate, async (req, res) => {
try {
const turnTokenId = process.env.CLOUDFLARE_TURN_TOKEN_ID;
const turnApiToken = process.env.CLOUDFLARE_TURN_API_TOKEN;
if (!turnTokenId || !turnApiToken) {
console.error('Cloudflare TURN credentials not configured');
// Fallback to public STUN servers if TURN not configured
return res.json({
success: true,
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
});
}
// Request TURN credentials from Cloudflare
const cloudflareUrl = `https://rtc.live.cloudflare.com/v1/turn/keys/${turnTokenId}/credentials/generate`;
const response = await fetch(cloudflareUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${turnApiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ttl: 86400, // 24 hours
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Cloudflare TURN API error:', response.status, errorText);
// Fallback to public STUN servers
return res.json({
success: true,
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
});
}
const data = await response.json();
// Cloudflare returns: { iceServers: { urls: [...], username: "...", credential: "..." } }
// We need to return it in the format expected by RTCPeerConnection
const cloudflareIceServers = data.iceServers;
if (!cloudflareIceServers || !cloudflareIceServers.urls) {
console.error('Invalid response from Cloudflare TURN API:', data);
// Fallback to public STUN servers
return res.json({
success: true,
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
});
}
// Return in RTCPeerConnection format
// Cloudflare provides all URLs (STUN + TURN) in one object with credentials
res.json({
success: true,
iceServers: [
{
urls: cloudflareIceServers.urls,
username: cloudflareIceServers.username,
credential: cloudflareIceServers.credential,
}
],
});
} catch (error) {
console.error('Error fetching TURN credentials:', error);
// Fallback to public STUN servers on error
res.json({
success: true,
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
});
}
});
module.exports = router;

View File

@@ -1,33 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { getSocket } from '../services/socket'; import { getSocket } from '../services/socket';
import { CONNECTION_STATE } from '../constants'; import { CONNECTION_STATE } from '../constants';
import { webrtcAPI } from '../services/api';
// WebRTC configuration with STUN and TURN servers for NAT traversal // Default fallback ICE servers (used if backend request fails)
const rtcConfig = { const DEFAULT_ICE_SERVERS = [
iceServers: [
// STUN servers for basic NAT traversal
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }, ];
// TURN servers for symmetric NAT and strict firewalls (public relay for testing)
{
urls: 'turn:openrelay.metered.ca:80',
username: 'openrelayproject',
credential: 'openrelayproject',
},
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject',
},
{
urls: 'turn:openrelay.metered.ca:443?transport=tcp',
username: 'openrelayproject',
credential: 'openrelayproject',
},
],
};
// File chunk size (16KB recommended for WebRTC DataChannel) // File chunk size (16KB recommended for WebRTC DataChannel)
const CHUNK_SIZE = 16384; const CHUNK_SIZE = 16384;
@@ -50,6 +30,7 @@ export const useWebRTC = (matchId, userId) => {
const socketRef = useRef(null); const socketRef = useRef(null);
const matchIdRef = useRef(matchId); const matchIdRef = useRef(matchId);
const userIdRef = useRef(userId); const userIdRef = useRef(userId);
const iceServersRef = useRef(null);
// Update refs when props change // Update refs when props change
useEffect(() => { useEffect(() => {
@@ -57,6 +38,27 @@ export const useWebRTC = (matchId, userId) => {
userIdRef.current = userId; userIdRef.current = userId;
}, [matchId, userId]); }, [matchId, userId]);
// Fetch ICE servers from backend on mount
useEffect(() => {
const fetchIceServers = async () => {
try {
const response = await webrtcAPI.getIceServers();
if (response.success && response.iceServers) {
iceServersRef.current = response.iceServers;
console.log('✅ Fetched ICE servers from backend:', response.iceServers.length, 'servers');
} else {
console.warn('⚠️ Using default ICE servers (backend response invalid)');
iceServersRef.current = DEFAULT_ICE_SERVERS;
}
} catch (error) {
console.error('❌ Failed to fetch ICE servers, using defaults:', error);
iceServersRef.current = DEFAULT_ICE_SERVERS;
}
};
fetchIceServers();
}, []);
// File transfer state // File transfer state
const fileTransferRef = useRef({ const fileTransferRef = useRef({
file: null, file: null,
@@ -80,10 +82,13 @@ export const useWebRTC = (matchId, userId) => {
return peerConnectionRef.current; return peerConnectionRef.current;
} }
// Use full config with STUN servers for production // Use ICE servers from backend or fallback to defaults
const iceServers = iceServersRef.current || DEFAULT_ICE_SERVERS;
const rtcConfig = { iceServers };
const pc = new RTCPeerConnection(rtcConfig); const pc = new RTCPeerConnection(rtcConfig);
peerConnectionRef.current = pc; peerConnectionRef.current = pc;
console.log('🔧 Using rtcConfig with STUN servers for NAT traversal'); console.log('🔧 Using ICE servers for NAT traversal:', iceServers.length, 'servers');
// ICE candidate handler // ICE candidate handler
pc.onicecandidate = (event) => { pc.onicecandidate = (event) => {

View File

@@ -516,4 +516,14 @@ export const publicAPI = {
}, },
}; };
// WebRTC API
export const webrtcAPI = {
async getIceServers() {
const data = await fetchAPI('/webrtc/ice-servers', {
method: 'GET',
});
return data;
},
};
export { ApiError }; export { ApiError };