From 1841110ef7a4a78fd571d276626ca7cb5423ad5f Mon Sep 17 00:00:00 2001 From: Sam Machin Date: Thu, 9 Jul 2026 16:30:50 +0200 Subject: [PATCH] add server discovery to options (#142) --- README.md | 19 +++++++++++ lib/config.js | 6 +++- lib/options.js | 22 ++++++++++++ lib/server-control/index.js | 67 +++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 lib/server-control/index.js diff --git a/README.md b/README.md index 0c083d5..d5cadc2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,25 @@ Configuration is provided via environment variables: |ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes| |JAMBONES_REGBOT_DEFAULT_EXPIRES_INTERVAL| default expire value for outbound registration in seconds (default 3600) |no| |JAMBONES_REGBOT_MIN_EXPIRES_INTERVAL| minimum expire value for outbound registration in seconds (default 30) |no| +|JAMBONES_SERVER_CONTROL| set to a truthy value ('1', 'true', 'yes') to enable server-control features such as topology discovery via OPTIONS (see below) |no| + +## Server control + +When `JAMBONES_SERVER_CONTROL` is enabled, the SBC exposes additional server-control features. + +### Topology discovery + +An OPTIONS request carrying the header `X-Jambonz-Discover: true` (from any IP, including external ones) is answered with a `200 OK` whose JSON body lists the current cluster topology read from redis: + +```json +{ + "featureServers": ["10.0.0.10:5060"], + "sipServers": ["1.2.3.4"], + "rtpServers": ["10.0.0.20"] +} +``` + +`featureServers`, `sipServers` and `rtpServers` are the IPs (feature servers include the port) of the active feature servers, SIP servers and RTP servers respectively. When `JAMBONES_SERVER_CONTROL` is not set, the discovery header is ignored and OPTIONS behaves as normal. ## CLI Management diff --git a/lib/config.js b/lib/config.js index 2c04dfa..13f2ec6 100644 --- a/lib/config.js +++ b/lib/config.js @@ -45,6 +45,9 @@ const JAMBONES_REGBOT_USER_AGENT = process.env.JAMBONES_REGBOT_USER_AGENT ; const JAMBONES_REGBOT_FAILURE_RETRY_INTERVAL = process.env.JAMBONES_REGBOT_FAILURE_RETRY_INTERVAL; const JAMBONES_REGBOT_REGISTER_FAILURE_THRESHOLD = process.env.JAMBONES_REGBOT_REGISTER_FAILURE_THRESHOLD; +/* Server control - external topology discovery and other server-control features (disabled unless truthy) */ +const JAMBONES_SERVER_CONTROL = process.env.JAMBONES_SERVER_CONTROL; + module.exports = { JAMBONES_MYSQL_HOST, JAMBONES_MYSQL_USER, @@ -81,5 +84,6 @@ module.exports = { REGISTER_RESPONSE_REMOVE, JAMBONES_REGBOT_USER_AGENT, JAMBONES_REGBOT_FAILURE_RETRY_INTERVAL, - JAMBONES_REGBOT_REGISTER_FAILURE_THRESHOLD + JAMBONES_REGBOT_REGISTER_FAILURE_THRESHOLD, + JAMBONES_SERVER_CONTROL }; diff --git a/lib/options.js b/lib/options.js index 39211f7..3f08ddf 100644 --- a/lib/options.js +++ b/lib/options.js @@ -1,5 +1,6 @@ const debug = require('debug')('jambonz:sbc-options-handler'); const { isDrained } = require('./cli/feature-server-config'); +const serverControl = require('./server-control'); const { EXPIRES_INTERVAL, CHECK_EXPIRES_INTERVAL, @@ -111,6 +112,27 @@ module.exports = ({srf, logger}) => { return async(req, res) => { + /* server-control: topology discovery request (X-Jambonz-Discover: true). + Deliberately answerable from any (external) IP - it is gated only by the + JAMBONES_SERVER_CONTROL env var and the presence of the discovery header. */ + if (serverControl.isEnabled() && serverControl.isDiscoverRequest(req)) { + try { + const topology = await serverControl.discoverServers(req.srf); + logger.info({source_address: req.source_address, topology}, + 'responding to X-Jambonz-Discover OPTIONS request'); + res.send(200, { + body: JSON.stringify(topology), + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (err) { + logger.error({err}, 'Error handling discovery OPTIONS'); + res.send(503); + } + return req.srf.endSession(req); + } + /* OPTIONS ping from internal FS or RTP server? */ const internal = req.has('X-FS-Status') || req.has('X-RTP-Status'); if (!internal) { diff --git a/lib/server-control/index.js b/lib/server-control/index.js new file mode 100644 index 0000000..a008f23 --- /dev/null +++ b/lib/server-control/index.js @@ -0,0 +1,67 @@ +const debug = require('debug')('jambonz:sbc-server-control'); +const { JAMBONES_SERVER_CONTROL, JAMBONES_CLUSTER_ID } = require('../config'); + +/* SIP header a client sets (X-Jambonz-Discover: true) to request a topology discovery response */ +const DISCOVER_HEADER = 'X-Jambonz-Discover'; + +/** + * Whether the server-control features are enabled. + * Gated behind the JAMBONES_SERVER_CONTROL env var; treats the usual truthy + * strings ('1', 'true', 'yes') as enabled, everything else as disabled. + */ +const isEnabled = () => { + if (!JAMBONES_SERVER_CONTROL) return false; + return /^(1|true|yes)$/i.test(`${JAMBONES_SERVER_CONTROL}`.trim()); +}; + +/* redis set names holding the active servers of each type for this cluster */ +const _setNames = () => { + const prefix = JAMBONES_CLUSTER_ID || 'default'; + return { + featureServers: `${prefix}:active-fs`, + sipServers: `${prefix}:active-sip`, + rtpServers: `${prefix}:active-rtp` + }; +}; + +/** + * Return true if the request is a discovery request, i.e. it carries the + * `X-Jambonz-Discover: true` header. Header presence/value is checked + * case-insensitively. + */ +const isDiscoverRequest = (req) => { + if (!req.has(DISCOVER_HEADER)) return false; + return /^true$/i.test(`${req.get(DISCOVER_HEADER)}`.trim()); +}; + +/** + * Read the current cluster topology from redis: the IPs of the feature + * servers, SIP servers and RTP servers. + * @param {object} srf - drachtio srf instance (uses srf.locals.retrieveSet) + * @returns {Promise<{featureServers: string[], sipServers: string[], rtpServers: string[]}>} + */ +const discoverServers = async(srf) => { + const { retrieveSet } = srf.locals; + const { featureServers, sipServers, rtpServers } = _setNames(); + + const [fs, sip, rtp] = await Promise.all([ + retrieveSet(featureServers), + retrieveSet(sipServers), + retrieveSet(rtpServers) + ]); + + const topology = { + featureServers: fs || [], + sipServers: sip || [], + rtpServers: rtp || [] + }; + debug({topology}, 'discovered cluster topology from redis'); + return topology; +}; + +module.exports = { + DISCOVER_HEADER, + isEnabled, + isDiscoverRequest, + discoverServers +};