We're building FARA CRM, a free open-source CRM on FastAPI + React. One feature users asked for again and again: click a phone number in the CRM and talk right in the browser, through the Asterisk / FreePBX the company already has.
That means no desktop softphone on every laptop, no SIP settings typed into Zoiper, and no paid cloud PBX.
This post walks through how we built it. The whole stack is free and open source:
- Asterisk / FreePBX: the PBX you probably already run
- JsSIP (MIT): the SIP client in the browser
- coturn: the TURN relay for difficult networks
- FastAPI: a small proxy between the browser and the PBX
How it fits together
Browser (JsSIP) ──WSS: SIP only──▶ FastAPI /ws/sip ──WS──▶ Asterisk
│ ▲
└──────────── RTP / DTLS-SRTP (audio) ─────────────┘
directly, or via coturn if NAT gets in the way
The key idea: only SIP signaling goes through the backend. SIP messages are small text commands like "register", "call" and "hang up". The audio goes straight between the browser and Asterisk, and falls back to the TURN relay when a direct path isn't possible. Your web server never carries voice traffic.
Step 1: prepare FreePBX
A browser can't talk to a regular SIP extension. It only speaks encrypted WebRTC media. If you skip this step, calls fail with 488 Not Acceptable Here, which JsSIP reports as Incompatible SDP.
What to set up:
-
Enable WebRTC on the extension (
webrtc=yesin PJSIP). This one flag turns on everything the browser needs: DTLS encryption, ICE and AVPF. - Create a separate extension for the browser if the employee also has a desk phone. The two need different media settings.
-
Set up WSS with a real certificate (Let's Encrypt is fine). Browsers refuse self-signed certificates here. The default port is TCP
8089. -
Open the RTP ports to the internet: UDP
10000–20000by default. - Serve the CRM over HTTPS. Browsers only give microphone access to secure pages.
Step 2: proxy SIP through your backend
JsSIP could connect to wss://pbx.example.com:8089/ws directly, but we route it through our own backend. There are three reasons:
- Security policy. Our CSP only allows connections to our own domain.
- Flexibility. The PBX address is stored in the CRM settings, so admins can change it without editing nginx.
- Access control. The proxy checks that the user really owns a line on that PBX.
With FastAPI the proxy stays short:
import asyncio
from fastapi import WebSocket
from websockets.asyncio.client import connect as ws_connect
from websockets.typing import Subprotocol
@router.websocket("/ws/sip")
async def sip_ws_proxy(websocket: WebSocket):
user = await authenticate(websocket.query_params.get("token"))
connector_id = int(websocket.query_params.get("connector") or 0)
# let users reach only the PBX where they have a line
if connector_id not in await user_lines(user):
await websocket.accept()
await websocket.close(1008, "No line on this PBX")
return
pbx_url = await get_pbx_ws_url(connector_id)
# JsSIP and Asterisk use the 'sip' subprotocol, so echo it back
await websocket.accept(subprotocol="sip")
async with ws_connect(pbx_url, subprotocols=[Subprotocol("sip")]) as pbx:
await pipe(websocket, pbx) # forward frames both ways until one side closes
pipe() is two small loops. One reads from the browser and sends to the PBX, the other does the reverse. The full version is linked at the end.
One detail matters here: accept(subprotocol="sip"). If you leave it out, the browser closes the connection right after connecting and nothing explains why.
Step 3: the softphone in the browser
On the frontend, JsSIP takes a few lines to set up:
import JsSIP from 'jssip';
const socket = new JsSIP.WebSocketInterface(
`wss://crm.example.com/ws/sip?token=${token}&connector=${connectorId}`
);
const ua = new JsSIP.UA({
sockets: [socket],
uri: `sip:${extension}@${realm}`,
password,
register: true,
});
ua.start();
After that, making a call is one method:
ua.call(`sip:${number}@${realm}`, {
mediaConstraints: { audio: true, video: false },
pcConfig: { iceServers }, // STUN/TURN from Step 4
});
Incoming calls arrive through the newRTCSession event, and you answer them with session.answer(). In FARA CRM the dialer sits as a button in the header. When the employee answers a call, the client's card opens automatically.
Step 4: add a TURN relay
With the steps above, calls work in most places. In some offices and on some mobile networks, though, they connect "sometimes". Strict NAT or blocked UDP prevents the browser and Asterisk from reaching each other directly. That's what a TURN relay solves: when there's no direct path, audio goes through it.
We ship coturn in the same docker-compose.yml as the CRM, so it starts along with everything else.
Temporary credentials. Creating relay accounts for every employee would be a pain. coturn instead accepts short-lived credentials signed with a shared secret. The backend generates them on request:
import base64, hashlib, hmac, time
def make_turn_credentials(secret: str, ttl: int, user_id: int):
expires_at = int(time.time()) + ttl
username = f"{expires_at}:{user_id}"
password = base64.b64encode(
hmac.new(secret.encode(), username.encode(), hashlib.sha1).digest()
).decode()
return username, password
The browser gets the relay address over both UDP and TCP. TCP is what helps in corporate networks where UDP is blocked.
Lock it down. A TURN relay forwards traffic, so make sure it can't reach your internal network. The minimum coturn config looks like this:
use-auth-secret
no-cli
no-tcp-relay
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
If your Asterisk is in the same private network as the relay, allow its address explicitly, for example --allowed-peer-ip=192.168.1.10.
Checklist
- [ ] FreePBX extension with
webrtc=yes - [ ] WSS on a trusted certificate; TCP 8089 and UDP 10000–20000 open
- [ ] CRM served over HTTPS
- [ ] SIP proxy on the backend that echoes the
sipsubprotocol - [ ] JsSIP registered through that proxy
- [ ] coturn with temporary credentials and private networks blocked
The code
All of this runs in production in FARA CRM, and the source is open. If you're building something similar, these files are a good place to start:
- SIP WebSocket proxy:
chat_phone/routers/sip.py - JsSIP softphone:
fara_sip_phone/useSipPhone.ts - TURN credentials:
chat/turn.py - coturn config:
docker/turnserver.conf
Have you connected a browser to Asterisk? Tell me in the comments what your setup looks like and what gave you the most trouble.
Top comments (0)