hey everyone, i have been battling with my webrtc project for a long time now, i need your help so i don't lose my job. I'm trying to set up WebRTC audio streaming with a dual-peer loopback setup using a FastAPI backend and a Coturn TURN server.
✅ TURN server is running on a VPS (Dockerized).
✅ Firewall ports 3478/udp, 3478/tcp, and 49152–65535/udp are open.
✅ Cloudflare proxy is OFF for the TURN domain (DNS-only).
✅ ICE gathering finishes successfully and TURN relay candidates are discovered.
❌ But the ICE connection never succeeds — it stays stuck in "checking" then "failed."
the ice connection never succeeds but when i run it on my local system, it works fine that's with local environment and all, but it seems to just fail when i run it from my vps, below is my python code:
"import os
import asyncio
import json
import logging
from fastapi import FastAPI, WebSocket
from aiortc import (
RTCPeerConnection,
RTCSessionDescription,
MediaStreamTrack,
RTCIceCandidate,
RTCConfiguration,
RTCIceServer,
)
from dotenv import load_dotenv
# ─── Load environment vars from .env ─────────────────────────────────────────
load_dotenv()
DOMAIN = os.getenv("DOMAIN", "127.0.0.1")
TURN_USER = os.getenv("TURN_USER")
TURN_PASS = os.getenv("TURN_PASS")
PORT = int(os.getenv("PORT", 8001))
# ─── Logging setup ────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("loopback")
# ─── FastAPI app ──────────────────────────────────────────────────────────────
app = FastAPI()
peer_in = None
peer_out = None
websocket_out = None
# ─── ICE configuration ─────────────────────────────────────────────────────────
config = RTCConfiguration(
iceServers=[
RTCIceServer(
urls=[f"turn:{DOMAIN}:3478?transport=udp"],
username=TURN_USER,
credential=TURN_PASS,
),
]
)
class AudioForwarder(MediaStreamTrack):
kind = "audio"
def __init__(self, source):
super().__init__() # don't forget this
self.track = source
async def recv(self):
frame = await self.track.recv()
return frame
@app.get("/")
def hello():
return "Hello World"
def parse_candidate(candidate_dict):
try:
parts = candidate_dict["candidate"].split()
return RTCIceCandidate(
foundation=parts[0].split(":")[1],
component=int(parts[1]),
protocol=parts[2].lower(),
priority=int(parts[3]),
ip=parts[4],
port=int(parts[5]),
type=parts[7],
sdpMid=candidate_dict.get("sdpMid"),
sdpMLineIndex=candidate_dict.get("sdpMLineIndex", 0),
)
except Exception as e:
logger.error("[ERROR] Failed to parse ICE candidate: %s", e)
return None
@app.websocket("/loopback/send")
async def loopback_sender(websocket: WebSocket):
global peer_in, peer_out, websocket_out
await websocket.accept()
pc = RTCPeerConnection(configuration=config)
peer_in = pc
logger.info("/loopback/send WebSocket accepted")
@pc.on("track")
async def on_track(track):
logger.info("[Sender] Received %s track", track.kind)
if track.kind == "audio":
forwarder = AudioForwarder(track)
# wait for peer_out to be ready
for _ in range(30):
if peer_out and peer_out.connectionState != "closed":
break
await asyncio.sleep(0.1)
if not peer_out or peer_out.connectionState == "closed":
return logger.error("[ERROR] peer_out not ready")
peer_out.addTrack(forwarder)
try:
offer = await peer_out.createOffer()
await peer_out.setLocalDescription(offer)
if websocket_out:
await websocket_out.send_json(
{
"type": "offer",
"data": {"type": offer.type, "sdp": offer.sdp},
}
)
logger.info("[Server] Sent loopback offer to /recv")
else:
logger.warning("[WARN] websocket_out is None")
except Exception as e:
logger.error("[ERROR] Failed to send offer to receiver: %s", e)
try:
while True:
msg = await websocket.receive_text()
data = json.loads(msg)
if data.get("type") == "offer":
await pc.setRemoteDescription(RTCSessionDescription(**data["data"]))
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await websocket.send_json(
{"type": "answer", "data": {"type": answer.type, "sdp": answer.sdp}}
)
logger.info("[Server] Sent answer to sender")
elif data.get("type") == "candidate":
cand = parse_candidate(data["data"])
if cand:
await pc.addIceCandidate(cand)
logger.info("[Server] Added ICE candidate (send)")
except Exception as e:
logger.error("[ERROR] Sender loop: %s", e)
finally:
await pc.close()
logger.info("[Server] Sender connection closed")
@app.websocket("/loopback/recv")
async def loopback_receiver(websocket: WebSocket):
global peer_out, websocket_out
await websocket.accept()
websocket_out = websocket
pc = RTCPeerConnection(configuration=config)
peer_out = pc
logger.info("/loopback/recv WebSocket accepted")
try:
while True:
msg = await websocket.receive_text()
data = json.loads(msg)
if data.get("type") == "answer":
await pc.setRemoteDescription(RTCSessionDescription(**data["data"]))
logger.info("[Server] Receiver set remote answer")
elif data.get("type") == "candidate":
cand = parse_candidate(data["data"])
if cand:
await pc.addIceCandidate(cand)
logger.info("[Server] Added ICE candidate (recv)")
except Exception as e:
logger.error("[ERROR] Receiver loop: %s", e)
finally:
await pc.close()
logger.info("[Server] Receiver connection closed")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=PORT, log_level="info")
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>WebRTC Dual Peer Loopback</title>
</head>
<body>
<h1>Loopback Audio Test</h1>
<button id="startBtn">Start Loopback</button>
<audio id="remoteAudio" autoplay controls></audio>
<script>
// ─── CONFIGURATION ───────────────────────────────────────────
const TURN_PORT = 3478;
const TURN_USER = 'sample_user'; //this is not the correct user or password
const TURN_PASS = 'sample_password';
// ─────────────────────────────────────────────────────────────
const iceServers = [
{
// Note: URLs must be an array, and we add ?transport=udp
urls: [`turn:${TURN_HOST}:${TURN_PORT}?transport=udp`],
username: TURN_USER,
credential: TURN_PASS
}
];
document.getElementById('startBtn').onclick = async () => {
const remoteAudio = document.getElementById('remoteAudio');
// Create two RTCPeerConnections with the same ICE config
const pcSend = new RTCPeerConnection({ iceServers });
const pcRecv = new RTCPeerConnection({ iceServers });
const wsSend = new WebSocket(`${SIGNALING_SERVER}/loopback/send`);
const wsRecv = new WebSocket(`${SIGNALING_SERVER}/loopback/recv`);
// ── ICE state logging ──────────────────────────────────────
pcSend.oniceconnectionstatechange = () =>
console.log("📡 pcSend ICE state:", pcSend.iceConnectionState);
pcRecv.oniceconnectionstatechange = () =>
console.log("📡 pcRecv ICE state:", pcRecv.iceConnectionState);
// ───────────────────────────────────────────────────────────
// ── pcRecv (PLAYBACK) ─────────────────────────────────────
pcRecv.ontrack = ({ streams: [stream] }) => {
if (remoteAudio.srcObject !== stream) {
remoteAudio.srcObject = stream;
console.log("✅ Receiving loopback audio");
}
};
wsRecv.onmessage = async (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === 'offer') {
await pcRecv.setRemoteDescription(new RTCSessionDescription(msg.data));
const answer = await pcRecv.createAnswer();
await pcRecv.setLocalDescription(answer);
wsRecv.send(JSON.stringify({ type: 'answer', data: answer }));
console.log("📥 pcRecv answered offer");
}
};
pcRecv.onicecandidate = (evt) => {
if (evt.candidate) {
wsRecv.send(JSON.stringify({ type: 'candidate', data: evt.candidate }));
}
};
// ───────────────────────────────────────────────────────────
// ── pcSend (MIC → SERVER) ─────────────────────────────────
wsSend.onopen = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => pcSend.addTrack(track, stream));
console.log("🎤 Added local audio track(s)");
const offer = await pcSend.createOffer();
await pcSend.setLocalDescription(offer);
wsSend.send(JSON.stringify({ type: 'offer', data: offer }));
console.log("📡 pcSend sent offer");
};
wsSend.onmessage = async (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === 'answer') {
await pcSend.setRemoteDescription(new RTCSessionDescription(msg.data));
console.log("✅ pcSend set remote answer");
}
};
pcSend.onicecandidate = (evt) => {
if (evt.candidate) {
wsSend.send(JSON.stringify({ type: 'candidate', data: evt.candidate }));
}
};
// ───────────────────────────────────────────────────────────
};
</script>
</body>