Skip to content

Instantly share code, notes, and snippets.

@shtrom
Created November 3, 2020 11:44
Show Gist options
  • Save shtrom/6e1e0a1981741a23aa86488ea816b77c to your computer and use it in GitHub Desktop.
Save shtrom/6e1e0a1981741a23aa86488ea816b77c to your computer and use it in GitHub Desktop.
dial.php - A simplistic one-page protocol handler for tel: schemes, for FRITZ!Box and Mitel phones
<?php
/**
* dial.php - A simplistic one-page protocol handler for tel: schemes.
*
* Copyright (C) 2020 Olivier Mehani <[email protected]>
*
* This renders a simple HTML form allowing to call a number,
* and to register itself as a protocol handler.
*
* Multiple devices are supported (see the `$devices` array),
* of the following types:
* - fritzbox: FRITZ!Box for a user with permissions to settings, using http://fritz.box
* - myfritzbox: FRITZ!Box for a user without permissions to settings, using http://fritz.box
* - mitel: Mitel phones
*
* Tested with FRITZ!Box 7390 (firmware 06.86),
* and Mitel 5212 with the SIP firmware.
*
* SPDX-License-Identifier: GPL-2.0-or-later
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
/** @var array[] list of devices and their parameters
* - string name
* - string type [fritzbox, myfritzbox, mitel]
* - string host
* - string user
* - string pass
*
* <code>
* <?php
* $devices = [
* 'all' => [
* 'name' => 'All',
* 'type' => 'myfritzbox', # for unprivileged users
* 'host' => 'myfritz.box',
* 'user' => 'user',
* 'pass' => 'pass',
* ],
* ];
* ?>
* </code>
*/
$devices = [];
require_once('dial_devices.php');
/**
* Process call out instructions, and render a form allowing to call and to register a protocol handler.
*
* @param array[] $devices
*/
function main(array $devices) : void {
$args = [
'number' => FILTER_SANITIZE_ENCODED,
'device'=> [
'filter' => FILTER_SANITIZE_ENCODED,
'default' => 'all',
],
];
$messages = [];
$errors = [];
$number = '';
$params = filter_input_array(INPUT_GET, $args);
if (empty($params['device'])) {
$params['device'] = $args['device']['default'];
}
if (!empty($params['number'])) {
// Call
$number = preg_replace(['/^tel:/', '/ /'], ['', '', ], rawurldecode($params['number']));
}
if (!isset($devices[$params['device']])) {
$errors[] = 'Unknown device: ' . $params['device'];
} else if (!empty($number)) {
// Call
$device = $devices[$params['device']];
$ret = dial($number, $device);
$messages = array_merge($messages, $ret['messages']);
$errors = array_merge($errors, $ret['errors']);
}
// Render
echo htmlHead($messages, $errors);
echo dialForm($devices, $params['device'], $number);
echo htmlFoot();
}
/** Dial a number on a device
* @param string $number
* @param array $device
* @return array
*/
function dial(string $number, array $device) : array {
$ret = newRet("Calling {$number} from {$device['name']}");
switch($device['type']) {
case 'fritzbox':
$ret2 = fritzBoxDial($number, $device);
break;
case 'myfritzbox':
$ret2 = myFritzBoxDial($number, $device);
break;
case 'mitel':
$ret2 = mitelDial($number, $device);
break;
default:
$ret['errors'][] = 'Unknown device type for ' . $device['name'];
break;
}
return array_merge_recursive($ret, $ret2);
}
/** Dial a number on a FRITZ!Box, using an admin account
* @param string $number
* @param array $device
* @return array[errors, messages, info, response]
*/
function fritzBoxDial(string $number, array $device) : array {
$ret = newRet();
$baseUrl = "http://{$device['host']}";
$callEndpoint = '/fon_num/fonbook_list.lua';
$sidResponse = fritzBoxGetSid($baseUrl, $device['user'], $device['pass']);
$ret = array_merge_recursive($ret, $sidResponse);
if (!empty($ret['errors'])) {
return $ret;
}
$data = [
'sid' => $sidResponse['sid'],
'dial' => $number,
];
$callResponse = httpRequest($baseUrl . $callEndpoint, $data, 'GET');
$ret = array_merge_recursive($ret, $callResponse);
if (!empty($ret['errors'])) {
return $ret;
}
return $ret;
}
/** Get a FRITZ!Box Session ID, using an admin account
* @param string $baseUrl
* @param string $users
* @param string $password
* @return array[errors, messages, sid]
*/
function fritzBoxGetSid(string $baseUrl, string $user, string $password) : array {
$ret = newRet();
$sidEndpoint = '/login_sid.lua';
$challengeResponse = httpRequest($baseUrl . $sidEndpoint);
$ret = array_merge_recursive($ret, $challengeResponse);
if (!empty($ret['errors'])) {
return $ret;
}
$xml = simplexml_load_string($challengeResponse['response']);
$authPayload = [
'username' => $user,
'response' => fritzBoxToken($xml->Challenge, $password),
];
$sidResponse = httpRequest($baseUrl . $sidEndpoint, $authPayload);
$ret = array_merge_recursive($ret, $sidResponse);
if (!empty($ret['errors'])) {
return $ret;
}
$xml = simplexml_load_string($sidResponse['response']);
$ret['sid'] = (string)$xml->SID;
$canDial = false;
$canPhone = false;
foreach($xml->Rights->Name as $perm) {
switch ((string)$perm) {
case 'Dial';
$canDial = true;
break;
case 'Phone':
$canPhone = true;
break;
}
}
if(!$canDial) {
$ret['errors'][] = "Dial permission not granted for user {$user} at {$baseUrl}";
}
if(!$canPhone) {
$ret['errors'][] = "Phone permission not granted for user {$user} at {$baseUrl}";
}
return $ret;
}
/** Dial a number on a FRITZ!Box, using a non-admin account (at myfritz.box)
* @param string $number
* @param array $device
* @return array[errors, messages, info, response]
*/
function myFritzBoxDial(string $number, array $device) : array {
$ret = newRet();
$baseUrl = "http://{$device['host']}";
$callEndpoint = '/myfritz/areas/calls.lua';
$sidResponse = myFritzBoxGetSid($baseUrl, $device['user'], $device['pass']);
$ret = array_merge_recursive($ret, $sidResponse);
if (!empty($ret['errors'])) {
return $ret;
}
$headers = [
'Content-Type: application/x-www-form-urlencoded',
];
$data = [
'sid' => $sidResponse['sid'],
'number' => $number,
/* 'sid' => '93806323d7c93204', */
/* 'lang' => 'en', */
'ajax_id' => (string)rand(1000, 9999),
'cmd' => 'cn',
/* 'cid' => '14', */
'action' => 'dial',
/* 'no_sidrenew' => '', */
];
$callResponse = httpRequest($baseUrl . $callEndpoint, $data, 'POST', $headers);
$ret = array_merge_recursive($ret, $callResponse);
if (!empty($ret['errors'])) {
return $ret;
}
return $ret;
}
/** Get a FRITZ!Box Session ID, using a non-admin account (at myfritz.box).
*
* Challenge and SID have to be extracted with RegExps from the JS code.
*
* @param string $baseUrl
* @param string $users
* @param string $password
* @return array[errors, messages, sid]
*/
function myFritzBoxGetSid(string $baseUrl, string $user, string $password) : array {
$ret = newRet();
$sidEndpoint = '/myfritz';
$challengeResponse = httpRequest($baseUrl . $sidEndpoint);
$ret = array_merge_recursive($ret, $challengeResponse);
if (!empty($ret['errors'])) {
return $ret;
}
$matches = [];
if(1 !== preg_match('/challenge="([0-9a-f]+)"/', $challengeResponse['response'], $matches)) {
$ret['errors'][] = "Challenge not found at {$baseUrl}";
}
$headers = [
'Content-Type: application/x-www-form-urlencoded',
];
$authPayload = [
'username' => $user,
'response' => fritzBoxToken($matches[1], $password),
];
$sidResponse = httpRequest($baseUrl . $sidEndpoint, $authPayload, 'POST', $headers);
$ret = array_merge_recursive($ret, $sidResponse);
if (!empty($ret['errors'])) {
return $ret;
}
$matches = [];
if(1 !== preg_match('/sid=([0-9a-f]+)/', $sidResponse['response'], $matches)) {
$ret['errors'][] = "SID not found at {$baseUrl}";
}
$ret['sid'] = $matches[1];
return $ret;
}
/** Compute an authentication token from a FRITZ!Box challenge
* @param string $challenge
* @param string $password
* @return string token
*/
function fritzBoxToken(string $challenge, string $password) : string {
$magic = "{$challenge}-{$password}";
$magicU16le = iconv('UTF-8', 'UTF-16LE', $magic);
$magicHash = md5($magicU16le);
return "{$challenge}-{$magicHash}";
}
/** Dial a number on a Mitel phone
* @param string $number
* @param array $device
* @return array[errors, messages, info, response]
*/
function mitelDial(string $number, array $device) : array {
$url = "http://{$device['host']}/DialByURL";
$data = [
'sipurl' => $number,
'cmd1' => 'Dial',
'helpuprl' => 0,
];
$headers = [
'Content-Type: application/x-www-form-urlencoded',
];
return httpRequest($url, $data, 'POST', $headers, $device['user'], $device['pass']);
}
/** Make an HTTP request.
*
* - GET $data is appended as a query-sting to the $url.
* - POST $data is encoded according to the Content-Type in the headers, if provided.
*
* @param string $url
* @param array[] $data (default: [])
* @param string $method (default: GET)
* @param string[] $headers (default: [])
* @param ?string $user
* @param ?string $pass
* @return array[errors, messages, info, response]
*/
function httpRequest(
string $url,
array $data = [],
string $method = 'GET',
array $headers = [],
?string $user = null,
?string $pass = null
) : array {
$ret = newRet();
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
/* Encode the data according the the desired Content-Type */
foreach ($headers as $h) {
if (preg_match('/^Content-Type:/', $h)) {
if (preg_match('/application\/x-www-form-urlencoded$/', $h)) {
$data = http_build_query($data);
} else if (preg_match('/application\/json$/', $h)) {
$data = json_encode($data);
}
}
}
switch($method) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
break;
case 'GET':
$url .= '?' . http_build_query($data);
// fallthrough
default:
break;
}
curl_setopt($ch, CURLOPT_URL, $url);
if(!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
if(!empty($cookies)) {
curl_setopt($ch, CURLOPT_COOKIE, implode('; ', $cookies));
}
if (!is_null($user) || !is_null($pass)) {
curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
}
$response = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if($info['http_code'] !== 200) {
$ret['errors'][] = 'HTTP error from ' . $info['url'] . ': '. $info['http_code'];
}
$ret['response'] = $response;
$ret['info'] = $info;
return $ret;
}
/**
* Build a simple return array, with an optional message or error.
* @param ?string $message
* @param ?string $error
* @return array[errors, messages, info, response]
*/
function newRet(
?string $message = null,
?string $error = null
) : array {
$ret = [
'messages' => [],
'errors' => [],
];
if (!empty($message)) {
$ret['messages'] = $message;
}
if (!empty($error)) {
$ret['errors'][] = $error;
}
return $ret;
}
/** Build an HTML header.
* @param string[] $messages
* @param string[] $errors
* @return string
*/
function htmlHead(array $messages, array $errors) : string {
$title = 'SIP tel: handler';
$head = <<< HTML
<!doctype html>
<html>
<head>
<title>{$title}</title>
</head>
<body>
<h1>{$title}</h1>
HTML;
foreach($messages as $m) {
$head .= <<< HTML
<div class="info">Info: {$m}</div>
HTML;
}
foreach($errors as $e) {
$head .= <<< HTML
<div class="error">Error: {$e}</div>
HTML;
}
return $head;
}
/** Build an HTML dial form.
* @param array[] $devices
* @param ?string $selectedDevice
* @param ?string $number
* @return string
*/
function dialForm(
array $devices,
?string $selectedDevice = null,
?string $number = null
) : string {
$url = "?device={$selectedDevice}&number=%s";
$dialForm = <<< HTML
<form action="">
<label for="device">Device</label>
<select name="device">
HTML;
foreach ($devices as $device => $config) {
$selected = '';
if ($device === $selectedDevice) {
$selected = ' selected';
}
$dialForm .= <<< HTML
<option value="{$device}"{$selected}>{$config['name']}</option>
HTML;
}
$dialForm .= PHP_EOL . <<< HTML
</select>
<label for="number">Number</label>
<input name="number" value="{$number}">
<input type="submit" value="Dial">
</form>
<button
onclick="navigator.registerProtocolHandler('tel', '{$url}', 'Call using {$devices[$selectedDevice]['name']}');"
>Register handler</button>
<a href="tel:blah">Try it!</a>
HTML;
return $dialForm;
}
/** Build an HTML footer.
* @return string
*/
function htmlFoot() : string {
$foot = <<< HTML
</body>
</html>
HTML;
return $foot;
}
main($devices);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment