mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
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:
parent
7e438ad049
commit
bf972fcfec
13 changed files with 470 additions and 86 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
163
api/routes/turn_credentials.py
Normal file
163
api/routes/turn_credentials.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue