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