mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: add FORCE_TURN_RELAY variable
This commit is contained in:
parent
97c6c6f122
commit
c0e8b631c4
12 changed files with 298 additions and 35 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
174
scripts/setup_local.sh
Executable 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 ""
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1812,6 +1812,14 @@ export type HealthResponse = {
|
|||
* Auth Provider
|
||||
*/
|
||||
auth_provider: string;
|
||||
/**
|
||||
* Turn Enabled
|
||||
*/
|
||||
turn_enabled: boolean;
|
||||
/**
|
||||
* Force Turn Relay
|
||||
*/
|
||||
force_turn_relay: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue