DEV Community

Артем eurodoo (Eurodoo.com)
Артем eurodoo (Eurodoo.com)

Posted on AI-assisted

How We Added Browser Calling to Our Open-Source CRM with Asterisk and WebRTC

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
Enter fullscreen mode Exit fullscreen mode

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=yes in 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–20000 by 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:

  1. Security policy. Our CSP only allows connections to our own domain.
  2. Flexibility. The PBX address is stored in the CRM settings, so admins can change it without editing nginx.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 sip subprotocol
  • [ ] 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:

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)