feat: add coturn configurations (#143)

* feat: add coturn changes

* add turn credentials and config

* fix: fix setup_remote script and docker compose
This commit is contained in:
Abhishek 2026-02-03 13:52:50 +05:30 committed by GitHub
parent 7e438ad049
commit bf972fcfec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 470 additions and 86 deletions

View file

@ -32,3 +32,12 @@ ENABLE_TRACING=false
# LANGFUSE_SECRET_KEY="sk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# LANGFUSE_PUBLIC_KEY="pk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# LANGFUSE_HOST="https://cloud.langfuse.com"
# TURN Server Configuration (for WebRTC NAT traversal)
# Required for reliable WebRTC connections behind firewalls/NAT
# Uses time-limited credentials (TURN REST API) for security
TURN_HOST=localhost
TURN_SECRET=dograh-turn-secret-change-in-production
# TURN_PORT=3478 # Default: 3478
# TURN_TLS_PORT=5349 # Default: 5349
# TURN_CREDENTIAL_TTL=86400 # Default: 24 hours in seconds

View file

@ -102,3 +102,10 @@ DEFAULT_CAMPAIGN_RETRY_CONFIG = {
"retry_on_no_answer": True,
"retry_on_voicemail": False,
}
TURN_SECRET = os.getenv("TURN_SECRET")
TURN_HOST = os.getenv("TURN_HOST", "localhost")
TURN_PORT = int(os.getenv("TURN_PORT", "3478"))
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))

View file

@ -18,6 +18,7 @@ from api.routes.service_keys import router as service_keys_router
from api.routes.superuser import router as superuser_router
from api.routes.telephony import router as telephony_router
from api.routes.tool import router as tool_router
from api.routes.turn_credentials import router as turn_credentials_router
from api.routes.user import router as user_router
from api.routes.webrtc_signaling import router as webrtc_signaling_router
from api.routes.workflow import router as workflow_router
@ -43,6 +44,7 @@ router.include_router(looptalk_router)
router.include_router(organization_usage_router)
router.include_router(reports_router)
router.include_router(webrtc_signaling_router)
router.include_router(turn_credentials_router)
router.include_router(public_embed_router)
router.include_router(public_agent_router)
router.include_router(public_download_router)

View file

@ -0,0 +1,163 @@
"""TURN credentials endpoint for time-limited WebRTC authentication.
This module implements the TURN REST API credential generation as specified in
draft-uberti-behave-turn-rest-00. It generates ephemeral credentials that are
valid for a configurable TTL and are cryptographically bound to the user.
The credential format:
- Username: {expiration_timestamp}:{user_id}
- Password: base64(hmac-sha1(shared_secret, username))
References:
- https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00
- https://github.com/coturn/coturn/wiki/turnserver#turn-rest-api
"""
import base64
import hashlib
import hmac
import time
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel
from api.constants import (
ENVIRONMENT,
TURN_CREDENTIAL_TTL,
TURN_HOST,
TURN_PORT,
TURN_SECRET,
TURN_TLS_PORT,
)
from api.db.models import UserModel
from api.enums import Environment
from api.services.auth.depends import get_user
router = APIRouter(prefix="/turn", tags=["turn"])
class TurnCredentialsResponse(BaseModel):
"""Response model for TURN credentials."""
username: str
password: str
ttl: int
uris: List[str]
class TurnConfigResponse(BaseModel):
"""Response model for TURN configuration status."""
enabled: bool
host: Optional[str] = None
def generate_turn_credentials(user_id: str, ttl: int = TURN_CREDENTIAL_TTL) -> dict:
"""Generate time-limited TURN credentials using HMAC-SHA1.
Args:
user_id: Unique identifier for the user (for auditing)
ttl: Time-to-live in seconds for the credentials
Returns:
Dictionary with username, password, ttl, and TURN URIs
Raises:
ValueError: If TURN_SECRET is not configured
"""
if not TURN_SECRET:
raise ValueError("TURN_SECRET is not configured")
# Calculate expiration timestamp
expiration = int(time.time()) + ttl
# Username format: {expiration}:{user_id}
# This allows the TURN server to:
# 1. Verify the credential hasn't expired
# 2. Track usage per user for auditing
username = f"{expiration}:{user_id}"
# Password: base64(hmac-sha1(secret, username))
# This is the standard TURN REST API algorithm
password = base64.b64encode(
hmac.new(
TURN_SECRET.encode("utf-8"),
username.encode("utf-8"),
hashlib.sha1,
).digest()
).decode("utf-8")
# Build TURN URIs
# Note: aiortc only uses the FIRST valid TURN URI, so ordering matters.
# Priority:
# 1. TURNS (TLS) if configured - most secure
# 2. TURN TCP for LOCAL env (macOS Docker compatibility)
# 3. TURN UDP for production (more efficient)
uris = []
# Add non-TLS TURN as fallback, ordered by environment
if ENVIRONMENT == Environment.LOCAL.value:
uris.extend(
[
f"turn:{TURN_HOST}:{TURN_PORT}?transport=tcp", # TCP for macOS Docker
f"turn:{TURN_HOST}:{TURN_PORT}", # UDP fallback
]
)
else:
uris.extend(
[
f"turn:{TURN_HOST}:{TURN_PORT}", # UDP preferred for other environments
f"turn:{TURN_HOST}:{TURN_PORT}?transport=tcp", # TCP fallback
]
)
# Add TLS URIs if TLS port is configured
if TURN_TLS_PORT:
uris.extend(
[
f"turns:{TURN_HOST}:{TURN_TLS_PORT}", # TURN over TLS
f"turns:{TURN_HOST}:{TURN_TLS_PORT}?transport=tcp", # TURN over TLS+TCP
]
)
return {
"username": username,
"password": password,
"ttl": ttl,
"uris": uris,
}
@router.get("/credentials", response_model=TurnCredentialsResponse)
async def get_turn_credentials(
user: UserModel = Depends(get_user),
) -> TurnCredentialsResponse:
"""Get time-limited TURN credentials for WebRTC connections.
This endpoint generates ephemeral TURN credentials that are:
- Valid for the configured TTL (default: 24 hours)
- Cryptographically bound to the user via HMAC
- Compatible with coturn's use-auth-secret mode
Returns:
TurnCredentialsResponse with username, password, ttl, and TURN URIs
"""
if not TURN_SECRET:
logger.warning("TURN credentials requested but TURN_SECRET not configured")
raise HTTPException(
status_code=503,
detail="TURN server not configured",
)
try:
credentials = generate_turn_credentials(str(user.id))
logger.debug(f"Generated TURN credentials for user {user.id}")
return TurnCredentialsResponse(**credentials)
except Exception as e:
logger.error(f"Failed to generate TURN credentials: {e}")
raise HTTPException(
status_code=500,
detail="Failed to generate TURN credentials",
)

View file

@ -7,12 +7,17 @@ Uses the SmallWebRTC API contract:
- SmallWebRTCConnection for peer connection management
- candidate_from_sdp() for parsing ICE candidates
- add_ice_candidate() for trickling support
TURN Authentication:
- Uses time-limited credentials (TURN REST API) when TURN_SECRET is configured
- Credentials are generated per-connection using HMAC-SHA1
- Falls back to static credentials if TURN_SECRET is not set (legacy mode)
"""
import asyncio
import os
from datetime import UTC, datetime
from typing import Dict, List
from typing import Dict, List, Optional
from aiortc import RTCIceServer
from aiortc.sdp import candidate_from_sdp
@ -22,6 +27,12 @@ from starlette.websockets import WebSocketState
from api.db import db_client
from api.db.models import UserModel
from api.routes.turn_credentials import (
TURN_HOST,
TURN_PORT,
TURN_SECRET,
generate_turn_credentials,
)
from api.services.auth.depends import get_user_ws
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
from api.services.pipecat.ws_sender_registry import (
@ -35,35 +46,62 @@ from pipecat.utils.context import set_current_run_id
router = APIRouter(prefix="/ws")
def get_ice_servers() -> List[RTCIceServer]:
"""Build ICE servers configuration including TURN if configured."""
def get_ice_servers(user_id: Optional[str] = None) -> List[RTCIceServer]:
"""Build ICE servers configuration including TURN if configured.
Args:
user_id: Optional user ID for generating time-limited TURN credentials.
If provided and TURN_SECRET is configured, uses TURN REST API.
Returns:
List of RTCIceServer configurations for WebRTC peer connection.
"""
servers: List[RTCIceServer] = [RTCIceServer(urls="stun:stun.l.google.com:19302")]
# Add TURN server if configured
turn_host = os.getenv("TURN_HOST")
# Check if TURN is configured
if not TURN_HOST:
return servers
# Use time-limited credentials if TURN_SECRET is configured (recommended)
if TURN_SECRET and user_id:
try:
credentials = generate_turn_credentials(user_id)
servers.append(
RTCIceServer(
urls=credentials["uris"],
username=credentials["username"],
credential=credentials["password"],
)
)
logger.info(
f"TURN server configured with time-limited credentials, TTL: {credentials['ttl']}s"
)
return servers
except Exception as e:
logger.error(f"Failed to generate TURN credentials: {e}")
# Fallback to static credentials (legacy mode - not recommended for production)
turn_username = os.getenv("TURN_USERNAME")
turn_password = os.getenv("TURN_PASSWORD")
if turn_host and turn_username and turn_password:
if turn_username and turn_password:
servers.append(
RTCIceServer(
urls=[
f"turn:{turn_host}:3478",
f"turn:{turn_host}:3478?transport=tcp",
f"turn:{TURN_HOST}:{TURN_PORT}",
f"turn:{TURN_HOST}:{TURN_PORT}?transport=tcp",
],
username=turn_username,
credential=turn_password,
)
)
logger.info(f"TURN server configured: {turn_host}:3478")
logger.warning(
f"TURN server configured with static credentials (consider using TURN_SECRET for time-limited auth)"
)
return servers
# ICE servers configuration
ice_servers = get_ice_servers()
class SignalingManager:
"""Manages WebSocket connections and WebRTC peer connections."""
@ -178,8 +216,10 @@ class SignalingManager:
)
else:
# Create new connection using correct SmallWebRTC API
# Generate ICE servers with time-limited TURN credentials for this user
user_ice_servers = get_ice_servers(user_id=str(user.id))
pc = SmallWebRTCConnection(
ice_servers=ice_servers, connection_timeout_secs=60
ice_servers=user_ice_servers, connection_timeout_secs=60
)
# Set the pc_id before initialization so it's available in get_answer()
pc._pc_id = pc_id

View file

@ -0,0 +1,105 @@
# Coturn TURN Server Configuration
# For use with time-limited credentials (TURN REST API)
# =============================================================================
# Listener Configuration
# =============================================================================
# TURN listener port for UDP and TCP
listening-port=3478
# TURN listener port for TLS and DTLS
tls-listening-port=5349
# Relay port range for media
# These ports are used for actual media relay between peers
min-port=49152
max-port=49200
# =============================================================================
# Authentication Configuration (TURN REST API)
# =============================================================================
# Enable TURN REST API authentication (time-limited credentials)
# This uses HMAC-SHA1 based authentication with a shared secret
# Username format: {expiration_timestamp}:{user_id}
# Password: base64(hmac-sha1(secret, username))
use-auth-secret
# Shared secret for TURN REST API authentication
# IMPORTANT: Change this value in production!
# This must match the TURN_SECRET environment variable in the backend API
static-auth-secret=dograh-turn-secret-change-in-production
# Realm for the TURN server (required for long-term credential mechanism)
# This is sent to clients in 401 challenges and used in credential validation
realm=dograh.local
# =============================================================================
# Security Settings
# =============================================================================
# Use fingerprint in TURN messages for additional security
fingerprint
# Disable multicast peers (security best practice)
no-multicast-peers
# Disable CLI interface (not needed in containerized deployment)
no-cli
# =============================================================================
# Logging Configuration
# =============================================================================
# Log to stdout for Docker compatibility
log-file=stdout
# Enable verbose logging (comment out in production for performance)
verbose
# Use ISO-8601 timestamps in logs
new-log-timestamp
# =============================================================================
# Performance & Limits
# =============================================================================
# Total allocation quota (0 = unlimited)
# total-quota=0
# Per-user allocation quota (0 = unlimited)
# user-quota=0
# Max bandwidth per session in bytes per second (0 = unlimited)
# max-bps=0
# =============================================================================
# Network Configuration
# =============================================================================
# For Docker with host networking, external-ip is auto-detected
# For cloud deployments (AWS, GCP), set external IP explicitly:
# external-ip=<PUBLIC_IP>/<PRIVATE_IP>
# Explicitly bind to all interfaces (required for Docker port mapping)
listening-ip=0.0.0.0
# =============================================================================
# STUN Configuration
# =============================================================================
# Allow STUN binding requests (enabled by default)
# no-stun
# =============================================================================
# TLS Configuration (optional - for TURNS)
# =============================================================================
# Uncomment and set paths for TLS support:
# cert=/etc/coturn/certs/turn_server_cert.pem
# pkey=/etc/coturn/certs/turn_server_pkey.pem
# Disable TLS/DTLS if not using certificates
no-tls
no-dtls

View file

@ -58,6 +58,23 @@ services:
networks:
- app-network
coturn:
image: coturn/coturn:4.8.0
container_name: coturn
restart: unless-stopped
ports:
- "3478:3478/udp" # TURN/STUN UDP
- "3478:3478/tcp" # TURN/STUN TCP
- "5349:5349/tcp" # TURNS (TLS)
- "49152-49200:49152-49200/udp" # Relay ports
volumes:
- ./config/coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
command:
- "-c"
- "/etc/coturn/turnserver.conf"
networks:
- app-network
volumes:
postgres_data:
redis_data:

View file

@ -114,9 +114,9 @@ services:
SENTRY_DSN: "https://3acdb63d5f1f70430953353b82de61e0@o4509486225096704.ingest.us.sentry.io/4510152922693632"
# TURN server configuration (for WebRTC NAT traversal in remote server)
# Uses time-limited credentials via TURN REST API (HMAC-SHA1)
TURN_HOST: "${TURN_HOST:-}"
TURN_USERNAME: "${TURN_USERNAME:-}"
TURN_PASSWORD: "${TURN_PASSWORD:-}"
TURN_SECRET: "${TURN_SECRET:-}"
ports:
- "8000:8000"
@ -158,12 +158,6 @@ services:
# Sentry
SENTRY_DSN: "https://d9387fed5f80e90781f1dbd9b2c0994c@o4509486225096704.ingest.us.sentry.io/4510124708200448"
# TURN server configuration (for WebRTC NAT traversal in remote server)
# Fetched at runtime via /api/config/turn endpoint
TURN_HOST: "${TURN_HOST:-}"
TURN_USERNAME: "${TURN_USERNAME:-}"
TURN_PASSWORD: "${TURN_PASSWORD:-}"
ports:
- "3010:3010"
depends_on:
@ -196,10 +190,15 @@ services:
container_name: coturn
restart: unless-stopped
profiles: ["remote"]
network_mode: host
ports:
- "3478:3478/udp"
- "3478:3478/tcp"
- "5349:5349/udp"
- "5349:5349/tcp"
- "49152-49200:49152-49200/udp"
environment:
TURN_USERNAME: ${TURN_USERNAME}
TURN_PASSWORD: ${TURN_PASSWORD}
TURN_SECRET: ${TURN_SECRET}
TURN_HOST: ${TURN_HOST}
command: >
-n
--listening-port=3478
@ -207,8 +206,8 @@ services:
--min-port=49152
--max-port=49200
--realm=${TURN_REALM:-dograh.com}
--user=${TURN_USERNAME}:${TURN_PASSWORD}
--lt-cred-mech
--use-auth-secret
--static-auth-secret=${TURN_SECRET}
--fingerprint
--no-cli
--log-file=stdout
@ -216,6 +215,8 @@ services:
--no-tlsv1
--no-tlsv1_1
${TURN_HOST:+--external-ip=$TURN_HOST}
networks:
- app-network
volumes:
postgres_data:

View file

@ -30,20 +30,20 @@ if ! [[ "$SERVER_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
exit 1
fi
# Get the TURN password
echo -e "${YELLOW}Enter a password for the TURN server (press Enter for default 'dograh-turn-secret'):${NC}"
read -sp "> " TURN_PASSWORD
# Get the TURN secret
echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}"
read -sp "> " TURN_SECRET
echo ""
if [[ -z "$TURN_PASSWORD" ]]; then
TURN_PASSWORD="dograh-turn-secret"
echo -e "${BLUE}Using default TURN password${NC}"
if [[ -z "$TURN_SECRET" ]]; then
TURN_SECRET=$(openssl rand -hex 32)
echo -e "${BLUE}Generated random TURN secret${NC}"
fi
echo ""
echo -e "${GREEN}Configuration:${NC}"
echo -e " Server IP: ${BLUE}$SERVER_IP${NC}"
echo -e " TURN Password: ${BLUE}********${NC}"
echo -e " TURN Secret: ${BLUE}********${NC}"
echo ""
# Create project directory if it doesn't exist
@ -135,10 +135,9 @@ echo -e "${GREEN}✓ SSL certificates generated${NC}"
echo -e "${BLUE}[5/5] Creating environment file...${NC}"
cat > .env << ENV_EOF
# TURN Server Configuration
# TURN Server Configuration (time-limited credentials via TURN REST API)
TURN_HOST=$SERVER_IP
TURN_USERNAME=dograh
TURN_PASSWORD=$TURN_PASSWORD
TURN_SECRET=$TURN_SECRET
# Telemetry (set to false to disable)
ENABLE_TELEMETRY=true

View file

@ -1,22 +0,0 @@
/*
Route to provide TURN server configuration at runtime.
This allows OSS users to configure TURN servers via docker-compose.yaml
environment variables, since NEXT_PUBLIC_* keys are injected at build time.
*/
import { NextResponse } from 'next/server';
export async function GET() {
const host = process.env.TURN_HOST || '';
const username = process.env.TURN_USERNAME || '';
const password = process.env.TURN_PASSWORD || '';
// Only return enabled: true if all required fields are set
const enabled = !!(host && username && password);
return NextResponse.json({
enabled,
host,
username,
password,
});
}

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { client } from "@/client/client.gen";
import { validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import logger from '@/lib/logger';
@ -57,13 +58,9 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const useAudio = true;
const audioCodec = 'default';
// TURN server configuration fetched at runtime from /api/config/turn
const turnConfigRef = useRef<{
enabled: boolean;
host: string;
username: string;
password: string;
} | null>(null);
// TURN server credentials fetched at runtime from backend API
// Uses time-limited credentials (TURN REST API) for security
const turnCredentialsRef = useRef<TurnCredentialsResponse | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
@ -100,19 +97,16 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
iceServers.push({ urls: ['stun:stun.l.google.com:19302'] });
}
// Add TURN server if configured (fetched from /api/config/turn)
const turnConfig = turnConfigRef.current;
if (turnConfig?.enabled) {
// Add TURN server if credentials are available (time-limited credentials from backend)
const turnCredentials = turnCredentialsRef.current;
if (turnCredentials?.uris && turnCredentials.uris.length > 0) {
iceServers.push({
urls: [
`turn:${turnConfig.host}:3478`, // TURN over UDP
`turn:${turnConfig.host}:3478?transport=tcp`, // TURN over TCP
],
username: turnConfig.username,
credential: turnConfig.password
urls: turnCredentials.uris,
username: turnCredentials.username,
credential: turnCredentials.password
});
logger.info(`TURN server configured: ${turnConfig.host}:3478`);
logger.info(`TURN server configured with ${turnCredentials.uris.length} URIs, TTL: ${turnCredentials.ttl}s`);
}
const config: RTCConfiguration = {
@ -467,17 +461,24 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
setConnectionStatus('connecting');
try {
// Fetch TURN configuration at runtime
// Fetch time-limited TURN credentials from backend API
try {
const turnResponse = await fetch('/api/config/turn');
if (turnResponse.ok) {
turnConfigRef.current = await turnResponse.json();
if (turnConfigRef.current?.enabled) {
logger.info('TURN server enabled via runtime config');
}
const turnResponse = await getTurnCredentialsApiV1TurnCredentialsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnCredentialsRef.current.ttl}s`);
} else if (turnResponse.response.status === 503) {
// TURN not configured on server - this is OK, we'll use STUN only
logger.info('TURN server not configured, using STUN only');
} else {
logger.warn(`Failed to fetch TURN credentials: ${turnResponse.response.status}`);
}
} catch (e) {
logger.warn('Failed to fetch TURN config, continuing without TURN:', e);
logger.warn('Failed to fetch TURN credentials, continuing without TURN:', e);
}
// Validate API keys

File diff suppressed because one or more lines are too long

View file

@ -879,6 +879,16 @@ export type TriggerCallResponse = {
workflow_run_name: string;
};
/**
* Response model for TURN credentials.
*/
export type TurnCredentialsResponse = {
username: string;
password: string;
ttl: number;
uris: Array<string>;
};
/**
* Request schema for Twilio configuration.
*/
@ -4201,6 +4211,39 @@ export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses = {
export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse = GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses[keyof GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses];
export type GetTurnCredentialsApiV1TurnCredentialsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/turn/credentials';
};
export type GetTurnCredentialsApiV1TurnCredentialsGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetTurnCredentialsApiV1TurnCredentialsGetError = GetTurnCredentialsApiV1TurnCredentialsGetErrors[keyof GetTurnCredentialsApiV1TurnCredentialsGetErrors];
export type GetTurnCredentialsApiV1TurnCredentialsGetResponses = {
/**
* Successful Response
*/
200: TurnCredentialsResponse;
};
export type GetTurnCredentialsApiV1TurnCredentialsGetResponse = GetTurnCredentialsApiV1TurnCredentialsGetResponses[keyof GetTurnCredentialsApiV1TurnCredentialsGetResponses];
export type OptionsInitApiV1PublicEmbedInitOptionsData = {
body?: never;
path?: never;