tSIP provisioning plugin

tSIP softphone plugin that fetches configuration from an HTTP(S) provisioning server and applies it via tSIP's own UpdateSettings() / UpdateButtons() Lua API (full or partial JSON merge - see tSIP Lua function reference/howto).

My earlier example of provisioning was based on Lua script and curl (https://tomeko.net/software/SIPclient/howto/provisioning.php). It is still valid, but using dedicated plugin for this purpose might be more elegant and robust.

This is not ready-to-distribute dll file but rather template helping with building your own provisioning setup. Every provisioning setup requires few decisions:

This may vary depending on number of softphones and/or whether provisioning runs over LAN or over the Internet. It is possible that for small, local installation no authorization would be needed at all (like with PnP auto provisioning for e.g. Yealink phones).

This dll/plugin can be build with Code::Blocks and 32-bit MinGW. Get codeblocks-25.03mingw-32bit-nosetup.zip (~430MB), extract it (~1.6GB required), start using CbLauncher.exe, set MinGW as default toolchain at first run.

tSIP-plugin-FreecnamOrg was used as a starting point when creating this plugin.

What it does

There is deliberately no GUI or config-file way to change the server address or credentials at runtime - see "Configuration" below. Less stuff accessible to end user = less stuff to break.

Identification request

The plugin identifies the calling PC so the server can decide what configuration to hand back. It sends a POST request with Content-Type: application/json and body like this:

{
   "pcName": "WORKSTATION-12",
   "userName": "jsmith",
   "ipAddress": "192.168.1.42",
   "macAddress": "AA:BB:CC:DD:EE:FF",
   "ipAddresses": ["192.168.1.42", "10.8.0.5"],
   "macAddresses": ["AA:BB:CC:DD:EE:FF"]
}

ipAddress/macAddress are the first usable adapter found (for servers that just want a single value). ipAddresses lists every IPv4 address bound to any adapter, excluding 0.0.0.0, loopback (127.0.0.0/8) and APIPA automatic-private addresses (169.254.0.0/16). macAddresses lists adapters that look like physical NICs, using a best-effort heuristic on the adapter description (virtual/VPN/tunnel/Bluetooth/loopback adapters are excluded where recognized) - there is no fully reliable "is this physical" API short of WMI, so a virtual adapter with an unrecognized name could still slip through.

Any field the OS can't determine is sent as an empty string or empty array.

Expected server response

200 OK with a JSON object. Both keys are optional; whichever are present get applied:

{
   "settings": {
      "uaConf": {
         "audioCfgRing": { "volume": 0.1 }
      }
   },
   "buttons": {
      "btnConf": [
         { "caption": "    REDIAL" }
      ]
   }
}

settings is passed directly to UpdateSettings(), buttons to UpdateButtons() Lua function - both merge into the existing configuration rather than replacing it, so the server only needs to send the fields it wants to change. Have I mentioned Yealink before? You might know how this works already.

Note: if you need updating only some of the buttons (e.g. leaving some of the GUI for user configuration), send empty objects as configuration of previous buttons. Might be a little nasty if you want to update only button #100, but JSONedit might be helpful with functions like cloning empty node 99 times and easy deleting of unused JSON nodes.

Any other HTTP status, a transport error, or invalid JSON is logged and left for the next scheduled/manual check - no partial or malformed configuration is ever applied.

Configuration

Edit ServerConfig.h and recompile:

#define PROVISIONING_URL "https://tomeko.net/software/SIPclient/provisioning/digest.php"
#define PROVISIONING_POLL_INTERVAL_HOURS 24
#define AUTH_MODE AUTH_MODE_DIGEST   // or AUTH_MODE_BEARER / AUTH_MODE_NONE

Two authorization examples are provided:

My example build points to https://tomeko.net/software/SIPclient/provisioning/digest.php. Hosted on ovh - it also required adding .htaccess in the same folder, it looks like Authorization was stripped by default. Configuration set by this example changes randomly ring volume and sets caption of first button to REDIAL (random_number).

When using TLS on Windows 7: installing KB3140245 might be required (but I apparently had it already).

Bearer authentication in practice

With AUTH_MODE_BEARER and AUTH_BEARER_TOKEN "s3cr3t-deploy-token", every provisioning check sends a plain request - no challenge/response round trip, unlike Digest:

POST /tsip/config HTTP/1.1
Host: provisioning.example.com
Content-Type: application/json
Authorization: Bearer s3cr3t-deploy-token
Content-Length: 118

{"pcName":"WORKSTATION-12","userName":"jsmith","ipAddress":"192.168.1.42","macAddress":"AA:BB:CC:DD:EE:FF", ...}

Server side, this is just a header check ahead of the normal handler, e.g. in Python/Flask:

import random

EXPECTED_TOKEN = "s3cr3t-deploy-token"

@app.route("/tsip/config", methods=["POST"])
def provisioning():
    auth = request.headers.get("Authorization", "")
    if auth != f"Bearer {EXPECTED_TOKEN}":
        return "", 401
    identity = request.get_json()
    # identity["pcName"], identity["macAddresses"], etc. decide what to return
    # random values here just make it obvious in testing that a fresh
    # response was actually applied on each check
    volume = round(random.uniform(0.05, 0.5), 2)
    counter = random.randint(1, 999)
    return jsonify({
        "settings": {"uaConf": {"audioCfgRing": {"volume": volume}}},
        "buttons": {"btnConf": [{"caption": f"    REDIAL ({counter})"}]},
    })

Or plain PHP (runnable version: server-examples/bearer.php):

<?php
$expectedToken = 's3cr3t-deploy-token';

$headers = getallheaders();
if (($headers['Authorization'] ?? '') !== "Bearer $expectedToken") {
    http_response_code(401);
    exit;
}

$identity = json_decode(file_get_contents('php://input'), true);
// $identity['pcName'], $identity['macAddresses'], etc. decide what to return

// random values here just make it obvious in testing that a fresh
// response was actually applied on each check
$volume = round(random_int(5, 50) / 100, 2);
$counter = random_int(1, 999);

header('Content-Type: application/json');
echo json_encode([
    'settings' => ['uaConf' => ['audioCfgRing' => ['volume' => $volume]]],
    'buttons'  => ['btnConf' => [['caption' => "    REDIAL ($counter)"]]],
]);

(getallheaders() needs the Authorization header actually reaching PHP - under Apache + mod_php this normally works, but some setups strip it; if $headers['Authorization'] comes back empty, add SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 to your Apache config, or read $_SERVER['HTTP_AUTHORIZATION'] / $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] directly under PHP-FPM.)

Things worth keeping in mind for this mode:

Digest authentication in practice

With AUTH_MODE_DIGEST, the first request has no Authorization header; the server must reject it with a challenge, which WinHTTP then answers automatically on a second, identical request - the plugin code doesn't do anything special beyond calling WinHttpSetCredentials():

POST /tsip/config HTTP/1.1
Host: provisioning.example.com
Content-Type: application/json

{"pcName":"WORKSTATION-12", ...}

< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Digest realm="tSIP provisioning", qop="auth",
<     nonce="a1b2c3...", opaque="d4e5f6..."

POST /tsip/config HTTP/1.1
Host: provisioning.example.com
Content-Type: application/json
Authorization: Digest username="jsmith", realm="tSIP provisioning",
    nonce="a1b2c3...", uri="/tsip/config", qop=auth, nc=00000001,
    cnonce="...", response="...", opaque="d4e5f6..."

{"pcName":"WORKSTATION-12", ...}

PHP has no built-in Digest helper, so the server has to parse the header and verify the response hash itself (runnable version: server-examples/digest.php):

<?php
$realm = 'tSIP provisioning';
$users = ['REPLACE_WITH_USERNAME' => 'REPLACE_WITH_PASSWORD']; // must match AUTH_DIGEST_USERNAME/PASSWORD

function digestChallenge($realm) {
    header('WWW-Authenticate: Digest realm="' . $realm . '",qop="auth",' .
        'nonce="' . uniqid() . '",opaque="' . md5($realm) . '"');
    http_response_code(401);
    exit;
}

$authHeader = getallheaders()['Authorization'] ?? '';
if (strpos($authHeader, 'Digest') !== 0) {
    digestChallenge($realm);
}

preg_match_all('@(\w+)=(?:"([^"]+)"|([^,]+))@', $authHeader, $matches, PREG_SET_ORDER);
$data = [];
foreach ($matches as $m) {
    $data[$m[1]] = $m[2] !== '' ? $m[2] : $m[3];
}
foreach (['username', 'nonce', 'uri', 'nc', 'cnonce', 'qop', 'response'] as $key) {
    if (!isset($data[$key])) digestChallenge($realm);
}
if (!isset($users[$data['username']])) {
    digestChallenge($realm);
}

$ha1 = md5($data['username'] . ':' . $realm . ':' . $users[$data['username']]);
$ha2 = md5($_SERVER['REQUEST_METHOD'] . ':' . $data['uri']);
$expected = md5($ha1 . ':' . $data['nonce'] . ':' . $data['nc'] . ':' .
    $data['cnonce'] . ':' . $data['qop'] . ':' . $ha2);

if (!hash_equals($expected, $data['response'])) {
    digestChallenge($realm);
}

$identity = json_decode(file_get_contents('php://input'), true);

// random values here just make it obvious in testing that a fresh
// response was actually applied on each check
$volume = round(random_int(5, 50) / 100, 2);
$counter = random_int(1, 999);

header('Content-Type: application/json');
echo json_encode([
    'settings' => ['uaConf' => ['audioCfgRing' => ['volume' => $volume]]],
    'buttons'  => ['btnConf' => [['caption' => "    REDIAL ($counter)"]]],
]);

This minimal version accepts any server-generated nonce without tracking it, so it doesn't detect replay of an old request or enforce nonce expiry/one-time nc use - fine for a low-value internal provisioning endpoint, but a production-grade implementation should keep a short-lived server-side nonce store (reject unknown/expired/reused nonces) rather than trusting uniqid() alone.

Set PROVISIONING_VALIDATE_TLS to 0 only for testing against a self-signed certificate - never ship a build with TLS validation disabled.

Example server

server-examples/bearer.php and server-examples/digest.php are complete, runnable versions of the snippets above (drop either one on a PHP-enabled web server and point PROVISIONING_URL at it for a quick end-to-end test). Both are demo-quality only - see the caveats noted in "Configuration" above.

Deployment

Build, then copy the resulting DLL (preferably Release, smaller version) into tSIP's phone subdirectory (the project's post-build step already does this for local Debug/Release builds next to the tSIP repo checkout). tSIP lists it under Settings -> Plugins/phones as an additional plugin to enable. You can also ship tSIP together with this plugin and minimal initial configuration file (deleted everything except for list of enabled plugins and enabled provisioning.dll).

Releases

Back to tSIP softphone