Free Click-to-Call to Your Call Center from the Website: Integrating WebRTC, SIP.js and FreePBX

Introduction

Customers can now call the call center straight from the company website, free of charge, with no phone credit and no extra app. They click a Call Us button, allow microphone access in the browser, and the call lands in the agent queue like any ordinary phone call.

The technology behind it is WebRTC, which is built into every modern browser. On the browser side we use the SIP.js library, and on the server side we use FreePBX (Asterisk). This article covers the architecture we use, three problems we ran into in practice, and how we solved them:

  1. How to tell callers apart when every web visitor shares the same SIP account.
  2. How to make the call center system, which only reads the Caller ID, still recognize each caller.
  3. How to stop the credentials embedded in the browser from being abused to make free calls anywhere.

Topology

A call travels from the browser to the WebRTC FreePBX, is forwarded over a trunk to the internal FreePBX, and then enters the call center queue.

Calls from the website can reach only one number: the call center Internet Public server Company internal network WebsiteVisitor's browserSIP.js, login ext 7000Display Name = ID WebRTC FreePBXWSS :8089, DTLS-SRTPMay only dial 8000Display Name to CID Internal FreePBXTrunk from WebRTCInbound Route 8000to call center Queue Call centerAgent receivesthe call withCaller ID = web ID The ext 7000 credentials are visible in the browser, so the destination lock sits on the WebRTC FreePBX (tinted box).

The WebRTC FreePBX is deliberately separated from the internal FreePBX. The WebRTC server faces the internet and acts only as the “front door” that accepts WSS connections from browsers. The internal server connected to the call center is never reached directly from the internet, and only accepts the trunk from the WebRTC server.

Problem 1: one SIP account for every visitor

We use one shared WebRTC extension for all web visitors and tell callers apart by their Display Name.

Out of the box, FreePBX can act as a WebRTC server, but every user needs their own extension (SIP account). That is fine for internal staff. For website visitors it is impractical: we do not know who will call, when, or how many of them. Creating accounts on the fly for each visitor would mean building an extra provisioning system.

The shortcut: every visitor logs in with the same extension, for example 7000. The consequence is that on the PBX side every call appears to come from 7000, so agents cannot tell the first caller from the second.

The fix is for each call from the browser to carry a unique Display Name. We decide its content on the website, for example:

  • a ticket number or customer number the visitor enters before pressing the call button;
  • a random session ID generated by JavaScript, such as WEB + 6 digits;
  • a mobile number the visitor types into a small form before calling.

In SIP.js, the Display Name is set through the displayName option of the UserAgent, so the From header of the INVITE looks like "WEB482913" <sip:7000@pbx-webrtc.example.com>.

Note: the browser only places outbound calls and does not REGISTER, so many visitors can call at the same time with the same extension.

Problem 2: the call center only reads the Caller ID

The Display Name is now unique, but our call center system only reads the Caller ID (number), not the name. As a result, every call still shows up as 7000 on the agent’s screen and in reports.

So we modified the dialplan on the WebRTC FreePBX: before the call is forwarded to the internal FreePBX, the Display Name value is copied into the Caller ID number. That way the call center receives a different number for each web caller.

The following code goes in /etc/asterisk/extensions_override_freepbx.conf (you can also use Admin → Config Edit):

[macro-user-callerid]
include => macro-user-callerid-custom
exten => lang-playback,1,GosubIf($[${DIALPLAN_EXISTS(macro-user-callerid,${CHANNEL(language)})}]?macro-user-callerid,${CHANNEL(language)},${ARG1}():macro-user-callerid,en,${ARG1}())
exten => lang-playback,n,Return()

exten => s,1,Set(TOUCH_MONITOR=${UNIQUEID})
exten => s,n,Set(CHANCONTEXT=${CUT(CHANNEL,@,2)})
exten => s,n,Set(CHANCONTEXT=${CUT(CHANCONTEXT,-,1)})
exten => s,n,Set(CHANEXTENCONTEXT=${CUT(CHANNEL,/,2)})
exten => s,n,Set(CHANEXTEN=${CUT(CHANEXTENCONTEXT,@,1)})
exten => s,n,Set(CALLERID(number)=${IF($["${CHANCONTEXT}" = "webcallback"]?${CHANEXTEN}:${CALLERID(number)})})

exten => s,n,Noop(Getting Display Name from SIP header)
exten => s,n,Set(fromdata=${PJSIP_HEADER(read,From)})
exten => s,n,Set(fromdata=${CUT(fromdata,\",2)})
exten => s,n,Set(MyDate=${STRFTIME(${EPOCH},Asia/Jakarta,%y%m%d%H%M%S000)})
exten => s,n,Set(fromdata=${IF($["${fromdata}" = ""]?${MyDate}:${fromdata})})
exten => s,n,Set(CALLERID(name)=${fromdata})
exten => s,n,Set(CALLERID(number)=${fromdata})

exten => s,n,Set(AMPUSER=${IF($["${AMPUSER}" = ""]?${CALLERID(number)}:${AMPUSER})})
exten => s,n,Set(HOTDESCKCHAN=${CUT(CHANNEL,/,2)})
exten => s,n,Set(HOTDESKEXTEN=${CUT(HOTDESCKCHAN,-,1)})
exten => s,n,Set(HOTDESKCALL=0)
exten => s,n,ExecIf($["${DB(EDEVICE/${HOTDESKEXTEN}/user)}"="DummyUser"]?Set(HOTDESKCALL=1))
exten => s,n,ExecIf($[${HOTDESKCALL}=1]?Set(CALLERID(name)=${DB(EDEVICE/${HOTDESKEXTEN}/location)}))
exten => s,n,GotoIf($["${CUT(CHANNEL,@,2):5:5}"="queue" | ${LEN(${AMPUSERCIDNAME})}]?report)
exten => s,n,ExecIf($["${REALCALLERIDNUM:1:2}" = ""]?Set(REALCALLERIDNUM=${CALLERID(number)}))
exten => s,n,Set(AMPUSER=${DB(DEVICE/${REALCALLERIDNUM}/user)})
exten => s,n,GotoIf($["${AMPUSER}" = "none"]?limit)

exten => s,n,Set(AMPUSERCIDNAME=${DB(AMPUSER/${AMPUSER}/cidname)})
exten => s,n,ExecIf($["${ARG2}" != "EXTERNAL" & ${DB_EXISTS(AMPUSER/${AMPUSER}/cidnum)} & "${AMPUSER}" != "${DB(AMPUSER/${AMPUSER}/cidnum)}"]?Set(__CIDMASQUERADING=TRUE))
exten => s,n,GotoIf($["${AMPUSERCIDNAME:1:2}" = ""]?report)
exten => s,n,Set(AMPUSERCID=${IF($["${ARG2}" != "EXTERNAL" & "${DB_EXISTS(AMPUSER/${AMPUSER}/cidnum)}" = "1"]?${DB_RESULT}:${AMPUSER})})
exten => s,n,Set(__DIAL_OPTIONS=${IF($["${DB_EXISTS(AMPUSER/${AMPUSER}/dialopts)}" = "1"]?${DB_RESULT}:${DIAL_OPTIONS})})
exten => s,n,Set(CALLERID(all)="${AMPUSERCIDNAME}" <${AMPUSERCID}>)
exten => s,n,ExecIf($["${DB(DEVICE/${ARG2}/tech)}"="custom"]?Set(CUSDIAL=${CUT(CUT(DB(DEVICE/${ARG2}/dial),/,2),@,1)}))
exten => s,n,ExecIf($["${DB(DEVICE/${ARG2}/tech)}"="custom" & "${DB(DEVICE/${CUSDIAL}/type)}" =""]?Set(CALLERID(all)=${IF($[${LEN(${DB(AMPUSER/${ARG2}/outboundcid)})}]?${DB(AMPUSER/${ARG2}/outboundcid)}:${CALLERID(all)})}))
exten => s,n,GotoIf($["${ARG1}"="LIMIT" & ${LEN(${AMPUSER})} & ${DB_EXISTS(AMPUSER/${AMPUSER}/concurrency_limit)} & ${DB(AMPUSER/${AMPUSER}/concurrency_limit)}>0 & ${GROUP_COUNT(${AMPUSER}@concurrency_limit)}>=${DB(AMPUSER/${AMPUSER}/concurrency_limit)}]?limit)
exten => s,n,ExecIf($["${ARG1}"="LIMIT" & ${LEN(${AMPUSER})}]?Set(GROUP(concurrency_limit)=${AMPUSER}))
exten => s,n(report),Noop(Macro depricated!! To keep the same line numbers)
exten => s,n,Noop(Macro depricated !!  To keep the same line numbers)
exten => s,n(report2),GotoIf($[ "${ARG1}" = "SKIPTTL" | "${ARG1}" = "LIMIT" ]?continue)
exten => s,n(report3),Set(__TTL=${IF($["foo${TTL}" = "foo"]?64:$[ ${TTL} - 1 ])})
exten => s,n,GotoIf($[ ${TTL} > 0 ]?continue)
exten => s,n,Wait(${RINGTIMER})
exten => s,n,Answer
exten => s,n,Wait(1)
exten => s,n,Gosub(macro-user-callerid,lang-playback,1(hook_0))
exten => s,n,Gosub(macro-hangupcall,s,1())
exten => s,n(macroerror),Noop(Macro Limit Reached. Aborting Call)
exten => s,n,Answer
exten => s,n,Wait(1)
exten => s,n,Gosub(macro-user-callerid,lang-playback,1(hook_2))
exten => s,n,Gosub(macro-hangupcall,s,1())
exten => s,n(limit),Answer
exten => s,n,Wait(1)
exten => s,n,Gosub(macro-user-callerid,lang-playback,1(hook_1))
exten => s,n,Gosub(macro-hangupcall,s,1())
exten => s,n,Congestion(20)
exten => s,n(continue),Set(CALLERID(number)=${CALLERID(number):0:40})
;exten => s,n,Set(CALLERID(name)=${CALLERID(name):0:40})

exten => s,n,Set(CALLERID(name)=${AMPUSERCIDNAME})
exten => s,n,GotoIf($["${CALLERID(name)}" = ""]?cnum)
exten => s,n,Set(CDR(cnam)=${CALLERID(name)})
exten => s,n(cnum),Set(CDR(cnum)=${CALLERID(num)})
exten => s,n(return),Return()
exten => s,n,Set(CHANNEL(language)=${MASTER_CHANNEL(CHANNEL(language))})

exten => h,1,Gosub(macro-hangupcall,s,1())

exten => en,1(hook_0),Playback(im-sorry&an-error-has-occurred&with&call-forwarding)
exten => en,n,Return()
exten => en,n(hook_1),Playback(beep&im-sorry&your&simul-call-limit-reached&goodbye)
exten => en,n,Return()
exten => en,n(hook_2),Playback(im-sorry&an-error-has-occurred)
exten => en,n,Return()

exten => ja,1(hook_0),Playback(im-sorry&call-forwarding&jp-no&an-error-has-occured)
exten => ja,n,Return()
exten => ja,n(hook_1),Playback(beep&im-sorry&simul-call-limit-reached)
exten => ja,n,Return()
exten => ja,n(hook_2),Playback(im-sorry&an-error-has-occured)
exten => ja,n,Return()

;--== end of [macro-user-callerid] ==--;

A few things to watch when writing the code above:

  • Validate the Display Name. The value is sent from the browser, so anyone can change it. Restrict it to letters and digits of a fixed length, and reject or replace anything invalid.
  • Use timestamp if Diplay Name empty. The value is set to use timestamp if Display Name empty with format yyyymmddhhmmss000 anf Asia/Jakarta timezone.
  • Use a format the call center understands. If the call center system only accepts digits, use a numeric session ID or add a special prefix so web calls are easy to spot in reports.
  • Keep the original value. It does no harm to store the original Display Name in a channel variable or the CDR for auditing.

Problem 3: credentials exposed in the browser

Our WebRTC extension may call one number only: the call center, whatever the browser sends.

The username and password of extension 7000 are embedded in the website’s JavaScript. Anyone can open Developer Tools, copy those credentials, and use a softphone to call other numbers through the company trunk for free. This is a classic toll fraud hole. Security must therefore not depend on code in the browser; the restriction has to be enforced on the server.

Step 1: create a dedicated context

By default a FreePBX extension uses the from-internal context, which can reach every extension and outbound route. We create a new context that knows only one destination. Example for /etc/asterisk/extensions_custom.conf (replace 8000 with your call center number):

[from-webrtc-callcenter]
; The only destination allowed: the call center number
exten => 8000,1,NoOp(Web call to call center from ${CALLERID(name)})
; >>> Paste the Caller ID code (Problem 2) here <<<
 same => n,Goto(from-internal,8000,1)

; Every other number is rejected and logged
exten => _X.,1,NoOp(REJECTED: WebRTC tried to call ${EXTEN})
 same => n,Playback(ss-noservice)
 same => n,Hangup()

exten => _*.,1,Hangup()

Because the destination 8000 is hard-coded (not taken from user input), the caller cannot divert the call to another number, even by editing the JavaScript or using their own softphone. Feature codes such as *97 are rejected as well.

Step 2: assign the context to the WebRTC extension

Open Applications → Extensions → 7000 → Advanced tab, then change the Context field from from-internal to from-webrtc-callcenter. Click Submit, then Apply Config.


Advanced tab of extension 7000, Context field set to from-webrtc-callcenter.

Step 3: an outbound route for the call center number only

On the WebRTC FreePBX, create Connectivity → Outbound Routes with a route named to-callcenter whose only dial pattern is 8000, pointing to the trunk towards the internal FreePBX. Do not create any other route that can reach the PSTN from this server.


Outbound Route to-callcenter, Dial Patterns tab containing 8000, Trunk Sequence containing the trunk to the internal FreePBX.

Step 4: lock it down on the internal FreePBX too

Defense in depth: on the internal FreePBX, calls from the WebRTC trunk enter the from-pstn context and have a single Inbound Route that leads to the call center Queue. Even if the WebRTC server is compromised, that trunk still cannot be used to dial out.

Additional hardening

LayerRecommended setting
ExtensionLong, random password; Max Contacts sized to agent capacity
FirewallOpen only the HTTPS/WSS ports and the RTP range; do not expose SIP UDP 5060 to the internet
Inter-PBX trunkAccept only the WebRTC FreePBX’s IP (IP whitelist)
MonitoringWatch for REJECTED log lines and enable fail2ban or Responsive Firewall

Setting up the WebRTC extension in FreePBX

WebRTC on FreePBX needs three things: a valid TLS certificate, a WSS transport in PJSIP, and a PJSIP extension configured for DTLS-SRTP. The steps below follow FreePBX 16/17; menu names may differ slightly in other versions.

1. Prepare an SSL certificate

Browsers only allow microphone access and secure WebSocket (wss://) when the certificate is valid. Open Admin → Certificate Management, create a Let’s Encrypt certificate for the WebRTC server’s domain (for example pbx-webrtc.example.com), and make it the Default.

2. Enable HTTPS and secure WebSocket

Open Settings → Advanced Settings and make sure Asterisk’s built-in HTTP server is enabled with TLS. Asterisk’s default WSS port is 8089, so the WebSocket address for SIP.js becomes wss://pbx-webrtc.example.com:8089/ws.

Then open Settings → Asterisk SIP Settings:

  • General SIP Settings tab: fill in External Address and Local Networks (you can use the Detect Network Settings button), and a STUN server such as stun:stun.l.google.com:19302;
  • SIP Settings [chan_pjsip] tab: enable the wss transport and select the certificate from step 1.

Asterisk chan_pjsip tab, wss transport enabled.

3. Create the PJSIP extension

Open Applications → Extensions → Add Extension → Add New SIP [chan_pjsip] Extension. On the General tab, fill in:

FieldExample value
User Extension7000
Display NameWeb Visitor (overridden by SIP.js)
Secreta long, random password

4. Configure the Advanced tab for WebRTC

This is the part that most often goes wrong. On the Advanced tab, set the following values:

FieldValue
Transport0.0.0.0-wss
Enable AVPFYes
Enable ICE SupportYes
Enable rtcp MuxYes
Media EncryptionDTLS-SRTP
Enable WebRTC defaultsYes
Contextfrom-webrtc-callcenter (see Problem 3)
Max Contactsthe number of visitors allowed to connect at once, if the browser sends REGISTER

Then in the DTLS section:

FieldValue
Enable DTLSYes
Use Certificatethe certificate from step 1
DTLS VerifyFingerprint
DTLS SetupAct/Pass

For codecs, enable at least opus and ulaw. Click Submit, then Apply Config.

There are reports on the FreePBX forum that the Enable WebRTC defaults option alone does not always turn on AVPF, ICE and RTCP Mux, so set those fields one by one as in the table above. To verify the result, run asterisk -rx "pjsip show endpoint 7000" and check that use_avpf, ice_support and rtcp_mux are true and media_encryption is dtls.

5. Open the firewall

Open ports 443 and 8089 (TCP) and the RTP range (default 10000-20000, UDP) from the internet to the WebRTC server. If you use the Sangoma Firewall, add the WebRTC/UCP service to the appropriate zone.

SIP.js example code for the website

The code below adds a call button to a web page. Each visitor gets a random 7-digit ID that is sent as the Display Name, and the call goes straight to the call center number 8000. The browser does not need to REGISTER because it only places outbound calls.

<button id="btnCall">Call the Call Center</button>
<button id="btnHangup" disabled>Hang Up</button>
<p id="status"></p>
<audio id="remoteAudio" autoplay></audio>

<script type="module">
import { UserAgent, Inviter, SessionState } from "https://cdn.jsdelivr.net/npm/sip.js@0.21.2/+esm";

const DOMAIN     = "pbx-webrtc.example.com";
const SERVER     = `wss://${DOMAIN}:8089/ws`;
const EXTENSION  = "7000";
const PASSWORD   = "REPLACE_WITH_SECRET";
const CALLCENTER = "8000";

// Unique ID per caller, used as the Display Name
const callerId = "9" + Math.floor(100000 + Math.random() * 900000);

const userAgent = new UserAgent({
  uri: UserAgent.makeURI(`sip:${EXTENSION}@${DOMAIN}`),
  displayName: callerId,
  authorizationUsername: EXTENSION,
  authorizationPassword: PASSWORD,
  transportOptions: { server: SERVER },
  sessionDescriptionHandlerFactoryOptions: {
    peerConnectionConfiguration: {
      iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
    }
  }
});

const btnCall   = document.getElementById("btnCall");
const btnHangup = document.getElementById("btnHangup");
const statusEl  = document.getElementById("status");
const audioEl   = document.getElementById("remoteAudio");
let session;

function playRemoteAudio(s) {
  const stream = new MediaStream();
  s.sessionDescriptionHandler.peerConnection.getReceivers()
    .forEach(r => r.track && stream.addTrack(r.track));
  audioEl.srcObject = stream;
  audioEl.play();
}

btnCall.onclick = async () => {
  btnCall.disabled = true;
  statusEl.textContent = "Connecting...";
  if (!userAgent.isConnected()) await userAgent.start();

  const target = UserAgent.makeURI(`sip:${CALLCENTER}@${DOMAIN}`);
  session = new Inviter(userAgent, target, {
    sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } }
  });

  session.stateChange.addListener(state => {
    if (state === SessionState.Established) {
      playRemoteAudio(session);
      statusEl.textContent = `Connected. Your call ID: ${callerId}`;
      btnHangup.disabled = false;
    }
    if (state === SessionState.Terminated) {
      statusEl.textContent = "Call ended.";
      btnCall.disabled = false;
      btnHangup.disabled = true;
    }
  });

  await session.invite();
};

btnHangup.onclick = () => {
  if (!session) return;
  if (session.state === SessionState.Established) session.bye();
  else if (session.state === SessionState.Establishing) session.cancel();
};
</script>

A few notes:

  • The ID is numeric. Because the call center only reads the Caller ID number, the ID is made of digits (prefix 9 + 6 digits). Adapt the format to your system, or replace it with a ticket number from a form.
  • Show the ID to the visitor. The visitor can read the ID to the agent, and the agent can match it against data on the website.
  • The page must be HTTPS. Browsers will not grant microphone access on a plain HTTP page.
  • The password is visible, by design. That is acceptable, because the restriction in Problem 3 means these credentials can only be used to call 8000.

Conclusion

With SIP.js and two FreePBX servers, customers can call the call center for free straight from the website, without a per-visitor account and without opening a toll fraud hole. It comes down to three decisions: one shared extension, a Display Name copied into the Caller ID, and a dialplan context that allows only one destination number.

Next steps worth considering: limiting the number of concurrent calls from the web, adding a CAPTCHA before the call button becomes active, and deploying a TURN server so calls still work from strict office networks.

References

Comments

Popular posts from this blog

VGA out for cubieboard

Video and Audio Streaming from A20SOM-EVB using crtmpserver

enable csi camera on A20-SOM EVB