<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Артем eurodoo (Eurodoo.com)</title>
    <description>The latest articles on DEV Community by Артем eurodoo (Eurodoo.com) (@eurodoo).</description>
    <link>https://dev.to/eurodoo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4138323%2F3b73b7b0-21f4-4ac9-8efa-c12c3a4f58a7.png</url>
      <title>DEV Community: Артем eurodoo (Eurodoo.com)</title>
      <link>https://dev.to/eurodoo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eurodoo"/>
    <language>en</language>
    <item>
      <title>How We Added Browser Calling to Our Open-Source CRM with Asterisk and WebRTC</title>
      <dc:creator>Артем eurodoo (Eurodoo.com)</dc:creator>
      <pubDate>Tue, 22 Sep 2026 21:44:11 +0000</pubDate>
      <link>https://dev.to/eurodoo/how-we-added-browser-calling-to-our-open-source-crm-with-asterisk-and-webrtc-1dm5</link>
      <guid>https://dev.to/eurodoo/how-we-added-browser-calling-to-our-open-source-crm-with-asterisk-and-webrtc-1dm5</guid>
      <description>&lt;p&gt;We're building &lt;a href="https://github.com/shurshilov/faracrm" rel="noopener noreferrer"&gt;FARA CRM&lt;/a&gt;, a free open-source CRM on FastAPI + React. One feature users asked for again and again: &lt;em&gt;click a phone number in the CRM and talk right in the browser, through the Asterisk / FreePBX the company already has.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That means no desktop softphone on every laptop, no SIP settings typed into Zoiper, and no paid cloud PBX.&lt;/p&gt;

&lt;p&gt;This post walks through how we built it. The whole stack is free and open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asterisk / FreePBX&lt;/strong&gt;: the PBX you probably already run&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JsSIP&lt;/strong&gt; (MIT): the SIP client in the browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;coturn&lt;/strong&gt;: the TURN relay for difficult networks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt;: a small proxy between the browser and the PBX&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Browser (JsSIP) ──WSS: SIP only──▶ FastAPI /ws/sip ──WS──▶ Asterisk
        │                                                  ▲
        └──────────── RTP / DTLS-SRTP (audio) ─────────────┘
                     directly, or via coturn if NAT gets in the way
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key idea: &lt;strong&gt;only SIP signaling goes through the backend.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: prepare FreePBX
&lt;/h2&gt;

&lt;p&gt;A browser can't talk to a regular SIP extension. It only speaks encrypted WebRTC media. If you skip this step, calls fail with &lt;code&gt;488 Not Acceptable Here&lt;/code&gt;, which JsSIP reports as &lt;code&gt;Incompatible SDP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What to set up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable WebRTC on the extension&lt;/strong&gt; (&lt;code&gt;webrtc=yes&lt;/code&gt; in PJSIP). This one flag turns on everything the browser needs: DTLS encryption, ICE and AVPF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a separate extension for the browser&lt;/strong&gt; if the employee also has a desk phone. The two need different media settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up WSS with a real certificate&lt;/strong&gt; (Let's Encrypt is fine). Browsers refuse self-signed certificates here. The default port is TCP &lt;code&gt;8089&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open the RTP ports&lt;/strong&gt; to the internet: UDP &lt;code&gt;10000–20000&lt;/code&gt; by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve the CRM over HTTPS.&lt;/strong&gt; Browsers only give microphone access to secure pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: proxy SIP through your backend
&lt;/h2&gt;

&lt;p&gt;JsSIP could connect to &lt;code&gt;wss://pbx.example.com:8089/ws&lt;/code&gt; directly, but we route it through our own backend. There are three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Security policy.&lt;/strong&gt; Our CSP only allows connections to our own domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexibility.&lt;/strong&gt; The PBX address is stored in the CRM settings, so admins can change it without editing nginx.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control.&lt;/strong&gt; The proxy checks that the user really owns a line on that PBX.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With FastAPI the proxy stays short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebSocket&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;websockets.asyncio.client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;connect&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ws_connect&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;websockets.typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Subprotocol&lt;/span&gt;


&lt;span class="nd"&gt;@router.websocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ws/sip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sip_ws_proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;connector_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# let users reach only the PBX where they have a line
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;connector_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;user_lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1008&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No line on this PBX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;pbx_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_pbx_ws_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connector_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# JsSIP and Asterisk use the 'sip' subprotocol, so echo it back
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subprotocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;ws_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pbx_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprotocols&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Subprotocol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pbx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pbx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# forward frames both ways until one side closes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pipe()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;One detail matters here: &lt;code&gt;accept(subprotocol="sip")&lt;/code&gt;. If you leave it out, the browser closes the connection right after connecting and nothing explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the softphone in the browser
&lt;/h2&gt;

&lt;p&gt;On the frontend, JsSIP takes a few lines to set up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;JsSIP&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jssip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;JsSIP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebSocketInterface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`wss://crm.example.com/ws/sip?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;connector=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;connectorId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;JsSIP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sockets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`sip:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, making a call is one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`sip:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;mediaConstraints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;pcConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;iceServers&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// STUN/TURN from Step 4&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Incoming calls arrive through the &lt;code&gt;newRTCSession&lt;/code&gt; event, and you answer them with &lt;code&gt;session.answer()&lt;/code&gt;. In FARA CRM the dialer sits as a button in the header. When the employee answers a call, the client's card opens automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: add a TURN relay
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;TURN relay&lt;/strong&gt; solves: when there's no direct path, audio goes through it.&lt;/p&gt;

&lt;p&gt;We ship &lt;strong&gt;coturn&lt;/strong&gt; in the same &lt;code&gt;docker-compose.yml&lt;/code&gt; as the CRM, so it starts along with everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporary credentials.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;make_turn_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;
    &lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser gets the relay address over both UDP and TCP. TCP is what helps in corporate networks where UDP is blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock it down.&lt;/strong&gt; A TURN relay forwards traffic, so make sure it can't reach your internal network. The minimum coturn config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;use-auth-secret&lt;/span&gt;
&lt;span class="err"&gt;no-cli&lt;/span&gt;
&lt;span class="err"&gt;no-tcp-relay&lt;/span&gt;
&lt;span class="py"&gt;denied-peer-ip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.0.0.0-10.255.255.255&lt;/span&gt;
&lt;span class="py"&gt;denied-peer-ip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;172.16.0.0-172.31.255.255&lt;/span&gt;
&lt;span class="py"&gt;denied-peer-ip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.168.0.0-192.168.255.255&lt;/span&gt;
&lt;span class="py"&gt;denied-peer-ip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;127.0.0.0-127.255.255.255&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your Asterisk is in the same private network as the relay, allow its address explicitly, for example &lt;code&gt;--allowed-peer-ip=192.168.1.10&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] FreePBX extension with &lt;code&gt;webrtc=yes&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] WSS on a trusted certificate; TCP 8089 and UDP 10000–20000 open&lt;/li&gt;
&lt;li&gt;[ ] CRM served over HTTPS&lt;/li&gt;
&lt;li&gt;[ ] SIP proxy on the backend that echoes the &lt;code&gt;sip&lt;/code&gt; subprotocol&lt;/li&gt;
&lt;li&gt;[ ] JsSIP registered through that proxy&lt;/li&gt;
&lt;li&gt;[ ] coturn with temporary credentials and private networks blocked&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SIP WebSocket proxy: &lt;a href="https://github.com/shurshilov/faracrm/blob/master/backend/base/crm/chat_phone/routers/sip.py" rel="noopener noreferrer"&gt;&lt;code&gt;chat_phone/routers/sip.py&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;JsSIP softphone: &lt;a href="https://github.com/shurshilov/faracrm/blob/master/frontend/src/fara_sip_phone/useSipPhone.ts" rel="noopener noreferrer"&gt;&lt;code&gt;fara_sip_phone/useSipPhone.ts&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TURN credentials: &lt;a href="https://github.com/shurshilov/faracrm/blob/master/backend/base/crm/chat/turn.py" rel="noopener noreferrer"&gt;&lt;code&gt;chat/turn.py&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;coturn config: &lt;a href="https://github.com/shurshilov/faracrm/blob/master/docker/turnserver.conf" rel="noopener noreferrer"&gt;&lt;code&gt;docker/turnserver.conf&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you connected a browser to Asterisk? Tell me in the comments what your setup looks like and what gave you the most trouble.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>opensource</category>
      <category>python</category>
      <category>react</category>
    </item>
  </channel>
</rss>
