Created
November 3, 2020 11:44
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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