feat: add FORCE_TURN_RELAY variable

This commit is contained in:
Abhishek Kumar 2026-05-11 15:42:41 +05:30
parent 97c6c6f122
commit c0e8b631c4
12 changed files with 298 additions and 35 deletions

View file

@ -133,6 +133,11 @@ 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"))
# Diagnostic flag: when true, strip all non-relay ICE candidates from the
# answer SDP so every media path must traverse the TURN server. Use for
# verifying TURN connectivity end-to-end; expect connection failures if
# TURN is misconfigured or unreachable.
FORCE_TURN_RELAY = os.getenv("FORCE_TURN_RELAY", "false").lower() == "true"
# OSS Email/Password Auth
OSS_JWT_SECRET = os.getenv("OSS_JWT_SECRET", "change-me-in-production")

View file

@ -66,11 +66,19 @@ class HealthResponse(BaseModel):
backend_api_endpoint: str
deployment_mode: str
auth_provider: str
turn_enabled: bool
force_turn_relay: bool
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
from api.constants import APP_VERSION, AUTH_PROVIDER, DEPLOYMENT_MODE
from api.constants import (
APP_VERSION,
AUTH_PROVIDER,
DEPLOYMENT_MODE,
FORCE_TURN_RELAY,
TURN_SECRET,
)
from api.utils.common import get_backend_endpoints
logger.debug("Health endpoint called")
@ -81,4 +89,6 @@ async def health() -> HealthResponse:
backend_api_endpoint=backend_endpoint,
deployment_mode=DEPLOYMENT_MODE,
auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET),
force_turn_relay=FORCE_TURN_RELAY,
)

View file

@ -28,7 +28,7 @@ from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
from starlette.websockets import WebSocketState
from api.constants import ENVIRONMENT
from api.constants import ENVIRONMENT, FORCE_TURN_RELAY
from api.db import db_client
from api.db.models import UserModel
from api.enums import Environment
@ -78,21 +78,54 @@ def is_private_ip_candidate(candidate_str: str) -> bool:
def filter_outbound_sdp(sdp: str) -> str:
"""Strip a=candidate lines with private/CGNAT IPs from an outbound answer SDP.
"""Strip ICE candidates from an outbound answer SDP based on env config.
aiortc gathers host candidates from every interface on the box, including
Docker bridges (172.17.0.1, 172.18.0.1). Advertising those to the browser
causes coturn "peer IP X denied" errors when the browser asks TURN to
permit them. No-op in LOCAL so docker-compose dev keeps working.
Two filters apply:
1. In non-LOCAL environments, drop host candidates with private/CGNAT IPs.
aiortc gathers host candidates from every interface on the box, including
Docker bridges (172.17.0.1, 172.18.0.1). Advertising those to the browser
causes coturn "peer IP X denied" errors when the browser asks TURN to
permit them.
2. When FORCE_TURN_RELAY is set, drop every non-relay candidate so the
only path the browser can use is via TURN. Lets you verify TURN
connectivity end-to-end if TURN is broken, the call simply fails.
"""
if ENVIRONMENT == Environment.LOCAL.value:
if ENVIRONMENT == Environment.LOCAL.value and not FORCE_TURN_RELAY:
return sdp
lines = sdp.split("\r\n")
filtered = [
line
for line in lines
if not (line.startswith("a=candidate:") and is_private_ip_candidate(line[2:]))
]
filtered: List[str] = []
dropped_non_relay = 0
kept_relay = 0
for line in lines:
if line.startswith("a=candidate:"):
candidate_str = line[2:]
if FORCE_TURN_RELAY and " typ relay" not in candidate_str:
dropped_non_relay += 1
continue
if ENVIRONMENT != Environment.LOCAL.value and is_private_ip_candidate(
candidate_str
):
continue
if FORCE_TURN_RELAY:
kept_relay += 1
filtered.append(line)
if FORCE_TURN_RELAY:
if kept_relay == 0:
logger.warning(
"FORCE_TURN_RELAY is on but the answer SDP has no relay candidates "
f"(dropped {dropped_non_relay} non-relay). TURN may be unreachable; "
"the connection will fail."
)
else:
logger.info(
f"FORCE_TURN_RELAY: kept {kept_relay} relay candidates, "
f"dropped {dropped_non_relay} non-relay"
)
return "\r\n".join(filtered)

View file

@ -80,7 +80,7 @@ services:
- shared-tmp:/tmp
environment:
# Core application config
ENVIRONMENT: "local"
ENVIRONMENT: "${ENVIRONMENT:-local}"
LOG_LEVEL: "INFO"
# Replace this environment variable if you are using a custom
@ -196,7 +196,7 @@ services:
image: coturn/coturn:4.8.0
container_name: coturn
restart: unless-stopped
profiles: ["remote"]
profiles: ["remote", "local-turn"]
ports:
- "3478:3478/udp"
- "3478:3478/tcp"

File diff suppressed because one or more lines are too long

174
scripts/setup_local.sh Executable file
View file

@ -0,0 +1,174 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Local Setup ║"
echo "║ Local docker deployment, optional TURN server ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# Ask whether to enable coturn (skip prompt if ENABLE_COTURN is already set)
if [[ -z "$ENABLE_COTURN" ]]; then
echo -e "${YELLOW}Enable coturn (TURN server) for WebRTC NAT traversal? [y/N]:${NC}"
read -p "> " ENABLE_COTURN_INPUT
if [[ "$ENABLE_COTURN_INPUT" =~ ^[Yy] ]]; then
ENABLE_COTURN=true
else
ENABLE_COTURN=false
fi
fi
if [[ "$ENABLE_COTURN" == "true" ]]; then
# Get the host browsers/peers will use to reach the TURN server
if [[ -z "$TURN_HOST" ]]; then
echo -e "${YELLOW}Enter the host browsers should use to reach TURN (press Enter for 127.0.0.1):${NC}"
read -p "> " TURN_HOST
fi
TURN_HOST="${TURN_HOST:-127.0.0.1}"
# Validate that TURN_HOST is either an IP or a hostname (basic check)
if ! [[ "$TURN_HOST" =~ ^[A-Za-z0-9.-]+$ ]]; then
echo -e "${RED}Error: TURN host must be an IP address or hostname${NC}"
exit 1
fi
# Get the TURN secret (skip prompt if TURN_SECRET is already set)
if [[ -z "$TURN_SECRET" ]]; then
echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}"
read -sp "> " TURN_SECRET
echo ""
fi
if [[ -z "$TURN_SECRET" ]]; then
TURN_SECRET=$(openssl rand -hex 32)
echo -e "${BLUE}Generated random TURN secret${NC}"
fi
fi
# Telemetry opt-out (default: true)
ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}"
# Container registry (defaults to the public OSS registry)
REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}"
echo ""
echo -e "${GREEN}Configuration:${NC}"
echo -e " Coturn: ${BLUE}$ENABLE_COTURN${NC}"
if [[ "$ENABLE_COTURN" == "true" ]]; then
echo -e " TURN Host: ${BLUE}$TURN_HOST${NC}"
echo -e " TURN Secret: ${BLUE}********${NC}"
fi
echo -e " Telemetry: ${BLUE}$ENABLE_TELEMETRY${NC}"
echo -e " Registry: ${BLUE}$REGISTRY${NC}"
echo ""
# Download compose file (skip when DOGRAH_SKIP_DOWNLOAD=1 — e.g. local repo testing).
TOTAL_STEPS=2
if [[ "$ENABLE_COTURN" == "true" ]]; then
TOTAL_STEPS=3
fi
if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then
echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}"
curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml
echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}"
else
echo -e "${BLUE}[1/$TOTAL_STEPS] Using docker-compose.yaml in current directory${NC}"
fi
# Generate turnserver.conf if coturn is enabled
if [[ "$ENABLE_COTURN" == "true" ]]; then
echo -e "${BLUE}[2/$TOTAL_STEPS] Creating TURN server configuration...${NC}"
cat > turnserver.conf << TURN_EOF
# Coturn TURN Server - Docker Configuration (local)
# Auto-generated by setup_local.sh
# Listener ports
listening-port=3478
tls-listening-port=5349
# Relay port range
min-port=49152
max-port=49200
# Network - external IP for NAT traversal
external-ip=$TURN_HOST
# Realm
realm=dograh.com
# Authentication (TURN REST API with time-limited credentials)
use-auth-secret
static-auth-secret=$TURN_SECRET
# Security
fingerprint
no-cli
no-multicast-peers
# Logging
log-file=stdout
TURN_EOF
echo -e "${GREEN}✓ turnserver.conf created${NC}"
fi
# Generate .env
ENV_STEP=$TOTAL_STEPS
echo -e "${BLUE}[$ENV_STEP/$TOTAL_STEPS] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32)
cat > .env << ENV_EOF
# Container registry for Dograh images
REGISTRY=$REGISTRY
# JWT secret for OSS authentication
OSS_JWT_SECRET=$OSS_JWT_SECRET
# Telemetry (set to false to disable)
ENABLE_TELEMETRY=$ENABLE_TELEMETRY
ENV_EOF
if [[ "$ENABLE_COTURN" == "true" ]]; then
cat >> .env << ENV_EOF
# TURN Server Configuration (time-limited credentials via TURN REST API)
TURN_HOST=$TURN_HOST
TURN_SECRET=$TURN_SECRET
ENV_EOF
fi
echo -e "${GREEN}✓ .env file created${NC}"
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete! ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Files created in ${BLUE}$(pwd)${NC}:"
echo " - docker-compose.yaml"
echo " - .env"
if [[ "$ENABLE_COTURN" == "true" ]]; then
echo " - turnserver.conf"
fi
echo ""
if [[ "$ENABLE_COTURN" == "true" ]]; then
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
echo ""
echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}"
else
echo -e "${YELLOW}To start Dograh, run:${NC}"
echo ""
echo -e " ${BLUE}docker compose up --pull always${NC}"
fi
echo ""
echo -e "${YELLOW}Your application will be available at:${NC}"
echo ""
echo -e " ${BLUE}http://localhost:3010${NC}"
echo ""

View file

@ -204,6 +204,9 @@ echo -e "${BLUE}[6/6] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32)
cat > .env << ENV_EOF
# Change environment from local to production so that coturn filters local IPs
ENVIRONMENT=production
# Backend API endpoint (public URL the backend uses to build webhook/embed links)
BACKEND_API_ENDPOINT=https://$SERVER_IP

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.orthnRzifa
# timestamp: 2026-05-08T10:32:18+00:00
# filename: dograh-openapi-XXXXXX.json.1hhdI3e7bZ
# timestamp: 2026-05-11T10:11:56+00:00
from __future__ import annotations

View file

@ -12,6 +12,8 @@ export async function GET() {
let apiVersion = "unknown";
let deploymentMode = "oss";
let authProvider = "local";
let turnEnabled = false;
let forceTurnRelay = false;
try {
const response = await healthApiV1HealthGet();
@ -20,6 +22,8 @@ export async function GET() {
apiVersion = data.version;
deploymentMode = data.deployment_mode;
authProvider = data.auth_provider;
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
}
} catch {
apiVersion = "unavailable";
@ -30,5 +34,7 @@ export async function GET() {
api: apiVersion,
deploymentMode,
authProvider,
turnEnabled,
forceTurnRelay,
});
}

View file

@ -4,6 +4,7 @@ import { client } from "@/client/client.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
import { sdpFilterCodec } from "../utils";
@ -48,6 +49,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const [isStarting, setIsStarting] = useState(false);
const [feedbackMessages, setFeedbackMessages] = useState<FeedbackMessage[]>([]);
const initialContext = initialContextVariables || {};
const { config: appConfig } = useAppConfig();
const {
audioInputs,
@ -123,6 +125,16 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
iceServers
};
// Diagnostic: when the backend is started with FORCE_TURN_RELAY=true,
// restrict the browser to relay-only candidates so media must traverse
// TURN. Lets you verify TURN connectivity end-to-end — a TURN
// misconfiguration surfaces as an ICE failure instead of silently
// falling back to host/srflx.
if (appConfig?.forceTurnRelay) {
config.iceTransportPolicy = 'relay';
logger.info('FORCE_TURN_RELAY is on — restricting browser ICE to relay candidates only');
}
const pc = new RTCPeerConnection(config);
// Set up ICE candidate trickling
@ -528,24 +540,30 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
setConnectionStatus('connecting');
try {
// Fetch time-limited TURN credentials from backend API
try {
const turnResponse = await getTurnCredentialsApiV1TurnCredentialsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.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}`);
// Fetch time-limited TURN credentials from backend API only if the
// server reports a TURN server is configured. Skipping the request
// avoids a 503 on OSS local deployments that don't run coturn.
if (appConfig?.turnEnabled === false) {
logger.info('TURN server disabled in app config, using STUN only');
} else {
try {
const turnResponse = await getTurnCredentialsApiV1TurnCredentialsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.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 credentials, continuing without TURN:', e);
}
} catch (e) {
logger.warn('Failed to fetch TURN credentials, continuing without TURN:', e);
}
// Validate API keys

View file

@ -1812,6 +1812,14 @@ export type HealthResponse = {
* Auth Provider
*/
auth_provider: string;
/**
* Turn Enabled
*/
turn_enabled: boolean;
/**
* Force Turn Relay
*/
force_turn_relay: boolean;
};
/**

View file

@ -7,6 +7,8 @@ interface AppConfig {
apiVersion: string;
deploymentMode: string;
authProvider: string;
turnEnabled: boolean;
forceTurnRelay: boolean;
}
interface AppConfigContextType {
@ -19,6 +21,8 @@ const defaultConfig: AppConfig = {
apiVersion: 'unknown',
deploymentMode: 'oss',
authProvider: 'local',
turnEnabled: false,
forceTurnRelay: false,
};
const AppConfigContext = createContext<AppConfigContextType>({
@ -39,6 +43,8 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
apiVersion: data.api || 'unknown',
deploymentMode: data.deploymentMode || 'oss',
authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
});
})
.catch(() => {