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:
- how to identify end user (PC MAC? Windows user name? IP?) and map it to phone numbers
- how to build and deploy provisioning server / data source
- how to authorize provisioning request
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
- On
Connect()(tSIP startup) and then everyPROVISIONING_POLL_INTERVAL_HOURShours (default 24) while tSIP keeps running, the plugin POSTs a small JSON identification document to the configured server. - If the server responds
200 OKwith a JSON object containing a"settings"and/or"buttons"key, the plugin serializes that value back to JSON and passes it to tSIP'sUpdateSettings()/UpdateButtons()Lua functions, which merge it into the running configuration. - A "Check provisioning now" tray menu item lets the user (or a helpdesk script) trigger an immediate re-check without waiting for the schedule or restarting tSIP.
- The tray "Provisioning plugin" settings dialog (Devices settings -> Show settings) shows the outcome of the last check.
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:
AUTH_MODE_BEARER- sends a staticAuthorization: Bearer <token>header on every request. SetAUTH_BEARER_TOKEN.AUTH_MODE_DIGEST- HTTP Digest authentication (RFC 7616). SetAUTH_DIGEST_USERNAME/AUTH_DIGEST_PASSWORD; WinHTTP performs the challenge/response handshake automatically after the server's initial401reply.
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:
- The token is baked into the compiled DLL as a plain string, so anyone with
the binary can recover it with
strings provisioning.dllor a debugger - Bearer here authenticates "a genuine copy of this plugin build", not a secret that survives redistribution. Use per-deployment tokens (one per customer/site build) if you need to be able to revoke a single site without affecting others. - There's no challenge/response, so the token is sent on every single
request, including ones a passive network observer could see - this is
exactly why
https://(nothttp://) is assumed forPROVISIONING_URL. - Rotating the token means recompiling and redeploying the DLL to every client; Digest (username/password checked against a server-side store) is easier to rotate centrally if that matters for your deployment.
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
- 2026.10.03 tSIP-plugin-provisioning_0_1.zip
Back to tSIP softphone