Files
sbc-sip-sidecar/test/regbot-unit-test.js
T
Dave HortonandClaude Opus 5.5 f5c675952a feat: per-SBC registration Call-ID, and hand off the regbot role on AWS scale-in (#156)
The Call-ID of outbound registrations was the sip_gateway_sid, the same on
every SBC. When the regbot role moved to the other SBC, the registrar saw a
refresh of an existing binding from a different source address and Contact.
Some registrars 200 such a refresh without updating their routing, so inbound
calls to the registered trunk fail with 404 until the binding is recreated.
The Call-ID is now sip_gateway_sid@<sending SBC public IP>: stable across
refreshes and restarts of one SBC, new when the role moves, so a move looks
like a new registration. register_status also records the sending SBC as
sbcAddress.

With AWS_LIFECYCLE_DRAIN enabled the sidecar polls IMDS
autoscaling/target-lifecycle-state (the signal inbound drains on; detection
only, inbound completes the lifecycle hook). When the instance is being scaled
in, the regbot holder releases the lease while still running instead of after
the instance is gone; until now the draining SBC kept the registrations, so
carriers kept sending registration-trunk calls to an SBC that answers new
INVITEs with 503. It never claims the role back. Once another SBC has claimed
it, the draining SBC un-REGISTERs (Expires: 0) the bindings whose Contact
carries its own IP. Bindings with an AoR or realm Contact are left alone: the
new SBC sends the same Contact, and its REGISTER has already replaced ours.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:33:36 -04:00

450 lines
14 KiB
JavaScript

const test = require('tape');
const { EventEmitter } = require('events');
const clearModule = require('clear-module');
const Regbot = require('../lib/regbot');
const {
JAMBONES_LOGLEVEL,
} = require('../lib/config');
const opts = Object.assign({
timestamp: () => { return `, "time": "${new Date().toISOString()}"`; }
}, { level:JAMBONES_LOGLEVEL || 'info' });
const logger = require('pino')(opts);
test('Cannot create regbot with invalid sip_realm', (t) => {
try {
new Regbot(logger, {
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip:1.2.3.4',
protocol: 'udp',
});
t.fail('Regbot created with invalid sip_realm');
} catch (err) {
t.ok(err, 'Error received, regbot cannot be created with invalid sip_realm');
}
t.end();
});
test('Can create regbot with valid sip_realm', (t) => {
try {
new Regbot(logger, {
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: '1.2.3.4',
protocol: 'udp',
});
new Regbot(logger, {
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: '1.2.3.4:5060',
protocol: 'udp',
});
new Regbot(logger, {
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
});
new Regbot(logger, {
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com:5068',
protocol: 'udp',
});
t.ok('Regbot can be created with valid sip_realm');
} catch (err) {
t.fail('Regbot is not created with valid sip_realm');}
t.end();
});
test('configKey returns identical strings for identical config', (t) => {
const config = {
voip_carrier_sid: 'carrier-1',
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
account_sip_realm: 'example.com',
trunk_type: 'reg',
sip_gateway_sid: 'gw-1'
};
const rb1 = new Regbot(logger, config);
const rb2 = new Regbot(logger, config);
t.equal(rb1.configKey(), rb2.configKey(), 'identical config produces identical keys');
t.end();
});
test('static configKeyFromOpts matches instance configKey', (t) => {
const config = {
voip_carrier_sid: 'carrier-1',
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
account_sip_realm: 'example.com',
trunk_type: 'reg',
sip_gateway_sid: 'gw-1'
};
const rb = new Regbot(logger, config);
t.equal(Regbot.configKeyFromOpts(config), rb.configKey(),
'static method produces same key as instance method');
// also with from_user and from_domain overrides
const config2 = {...config, from_user: 'alice', from_domain: 'example.org'};
const rb2 = new Regbot(logger, config2);
t.equal(Regbot.configKeyFromOpts(config2), rb2.configKey(),
'static method matches instance with from_user/from_domain');
t.end();
});
test('configKey returns different strings when config differs', (t) => {
const base = {
voip_carrier_sid: 'carrier-1',
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
trunk_type: 'reg',
sip_gateway_sid: 'gw-1'
};
const baseKey = Regbot.configKeyFromOpts(base);
// each of these should produce a different key
const variants = [
{password: 'newpass'},
{username: 'other'},
{ipv4: '9.9.9.9'},
{port: 5080},
{sip_realm: 'other.com'},
{voip_carrier_sid: 'carrier-2'},
{sip_gateway_sid: 'gw-2'},
{from_user: 'override'},
{from_domain: 'custom.com'}
];
for (const override of variants) {
const key = Regbot.configKeyFromOpts({...base, ...override});
t.notEqual(key, baseKey, `changing ${Object.keys(override)[0]} produces a different key`);
}
t.end();
});
test('getOwnContactExpires: picks our own binding when registrar returns multiple contacts', (t) => {
const getOwnContactExpires = Regbot._getOwnContactExpires;
// reproduces EMF capture: AoR 9955 has another device registered against it (rinstance
// contact with a counting-down expires). Our own contact is always granted 300.
const contacts = [
{uri: 'sip:9955@192.168.1.50:15447;rinstance=D4077321', params: {expires: '18', received: '"sip:192.168.100.186:15447"'}},
{uri: 'sip:9955@example.com;transport=udp', params: {expires: '300'}}
];
t.equal(getOwnContactExpires(contacts, 'sip:9955@example.com'), 300,
'returns our own binding expires (300), not the other device countdown (18)');
// order should not matter
const reversed = [contacts[1], contacts[0]];
t.equal(getOwnContactExpires(reversed, 'sip:9955@example.com'), 300,
'matches our contact regardless of position in the header');
// host match is case-insensitive
t.equal(getOwnContactExpires(contacts, 'sip:9955@EXAMPLE.COM'), 300,
'host comparison is case-insensitive');
t.end();
});
test('getOwnContactExpires: returns undefined when our contact is absent', (t) => {
const getOwnContactExpires = Regbot._getOwnContactExpires;
const contacts = [
{uri: 'sip:9955@192.168.1.50:15447;rinstance=D4077321', params: {expires: '18'}}
];
t.equal(getOwnContactExpires(contacts, 'sip:9955@example.com'), undefined,
'no match -> undefined so caller can fall back to Expires header / single-contact');
t.equal(getOwnContactExpires([], 'sip:9955@example.com'), undefined,
'empty contact list -> undefined');
t.end();
});
test('getOwnContactExpires: single matching contact returns its expires', (t) => {
const getOwnContactExpires = Regbot._getOwnContactExpires;
// AoR 25337 in the same capture: only our binding present
const contacts = [
{uri: 'sip:25337@example.com;transport=udp', params: {expires: '300'}}
];
t.equal(getOwnContactExpires(contacts, 'sip:25337@example.com'), 300,
'single own binding resolves correctly');
t.end();
});
test('stopTimer clears timer without deleting gateways', (t) => {
const rb = new Regbot(logger, {
voip_carrier_sid: 'carrier-1',
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
});
// simulate a running timer
rb.timer = setTimeout(() => {}, 60000);
rb.addresses = ['1.2.3.4'];
rb.stopTimer();
t.equal(rb.timer, null, 'timer is cleared');
t.deepEqual(rb.addresses, ['1.2.3.4'], 'addresses are preserved');
t.equal(rb.retired, true, 'retired flag is set');
t.end();
});
test('stop sets retired flag', (t) => {
const rb = new Regbot(logger, {
voip_carrier_sid: 'carrier-1',
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
});
const srf = {
locals: {
realtimeDbHelpers: {
deleteEphemeralGateway: () => Promise.resolve()
}
}
};
rb.timer = setTimeout(() => {}, 60000);
rb.stop(srf);
t.equal(rb.retired, true, 'retired flag is set');
t.equal(rb.timer, null, 'timer is cleared');
t.end();
});
/* ---- re-registration / drachtio-reconnect recovery tests ---- */
const REGBOT_OPTS = {
voip_carrier_sid: 'carrier-1',
ipv4: '2.3.4.5',
port: 5060,
username: 'user',
password: 'password',
sip_realm: 'sip.server.com',
protocol: 'udp',
trunk_type: 'static_ip', // avoid the reg-trunk DNS/ephemeral-gateway path
sip_gateway_sid: 'gw-1'
};
// a mock srf whose request() records each REGISTER and returns a controllable fake req
function makeSrf() {
const state = { requests: [], statusUpdates: [] };
const srf = {
request: (uri, opts) => {
const req = new EventEmitter();
req.get = () => '';
req.uri = uri;
req.opts = opts;
state.requests.push(req);
return Promise.resolve(req);
},
locals: {
sbcPublicIpAddress: { udp: '203.0.113.1:5060' },
localSIPDomain: 'sbc.example.com',
writeAlerts: () => {},
realtimeDbHelpers: {
createEphemeralGateway: () => Promise.resolve(),
deleteEphemeralGateway: () => Promise.resolve()
},
dbHelpers: {
updateVoipCarriersRegisterStatus: (sid, s) => state.statusUpdates.push(s)
}
}
};
return { srf, state };
}
const ok200 = { status: 200, reason: 'OK', has: () => false, get: () => undefined, getParsedHeader: () => [] };
const tick = () => new Promise((resolve) => setImmediate(resolve));
test('reregister sends a fresh REGISTER and re-arms the timer on 200 OK', async(t) => {
const { srf, state } = makeSrf();
const rb = new Regbot(logger, REGBOT_OPTS);
// pretend a stale re-register timer is pending from before the drachtio restart
rb.timer = setTimeout(() => t.fail('stale timer should have been cleared'), 600000);
const prevEpoch = rb.epoch;
rb.reregister(srf);
await tick();
t.equal(state.requests.length, 1, 'a REGISTER was sent');
t.ok(rb.watchdog, 'response watchdog is armed while awaiting the response');
t.ok(rb.epoch > prevEpoch, 'attempt epoch advanced');
state.requests[0].emit('response', ok200);
await tick();
t.equal(rb.status, 'registered', 'status is registered after 200 OK');
t.equal(rb.watchdog, null, 'watchdog is cleared once the response arrives');
t.ok(rb.timer, 'next re-register timer is armed');
clearTimeout(rb.timer);
t.end();
});
test('reregister on a retired regbot is a no-op', async(t) => {
const { srf, state } = makeSrf();
const rb = new Regbot(logger, REGBOT_OPTS);
rb.retired = true;
rb.reregister(srf);
await tick();
t.equal(state.requests.length, 0, 'no REGISTER is sent for a retired regbot');
t.notOk(rb.timer, 'no timer is armed');
t.end();
});
test('a stale REGISTER response (superseded by a newer attempt) is ignored', async(t) => {
const { srf, state } = makeSrf();
const rb = new Regbot(logger, REGBOT_OPTS);
rb.register(srf); // attempt 1
await tick();
const req1 = state.requests[0];
rb.reregister(srf); // attempt 2 supersedes attempt 1
await tick();
const req2 = state.requests[1];
t.ok(req2 && req2 !== req1, 'a second REGISTER was sent by reregister');
// a late response to attempt 1 must not be acted on
req1.emit('response', ok200);
await tick();
t.notEqual(rb.status, 'registered', 'stale response did not mark the regbot registered');
t.ok(rb.watchdog, 'the current attempt watchdog is still armed after the stale response');
// the real response to attempt 2 is processed normally
req2.emit('response', ok200);
await tick();
t.equal(rb.status, 'registered', 'current attempt response is processed');
t.equal(rb.watchdog, null, 'watchdog cleared by the current response');
clearTimeout(rb.timer);
t.end();
});
test('watchdog marks fail and backs off when no SIP response arrives', async(t) => {
// reload regbot with a short response timeout so the watchdog fires quickly.
// NB: this leaves the require cache holding a fresh Regbot class; harmless here since
// nothing compares class identity, but restore the cache at the end for later suites.
process.env.JAMBONES_REGBOT_RESPONSE_TIMEOUT = '40';
clearModule('../lib/config');
clearModule('../lib/regbot');
const RegbotFresh = require('../lib/regbot');
const { srf, state } = makeSrf();
const rb = new RegbotFresh(logger, REGBOT_OPTS);
rb.register(srf); // no response is ever emitted
await tick();
t.equal(state.requests.length, 1, 'first REGISTER sent');
t.ok(rb.watchdog, 'watchdog armed while awaiting response');
await new Promise((resolve) => setTimeout(resolve, 90));
t.equal(state.requests.length, 1, 'watchdog does NOT re-send inline (no REGISTER storm)');
t.equal(rb.status, 'fail', 'watchdog marked the regbot failed');
t.ok(rb.timer, 'a retry timer (FAILURE_RETRY_INTERVAL backoff) was scheduled');
t.ok(state.statusUpdates.length >= 1, 'a fail status was written to the db');
// a late response for the timed-out attempt is ignored (epoch was bumped)
state.requests[0].emit('response', ok200);
await tick();
t.equal(rb.status, 'fail', 'late response after the watchdog fired is ignored');
// stop the retry and restore the module cache for later suites
rb.retired = true;
clearTimeout(rb.timer);
clearTimeout(rb.watchdog);
clearModule('../lib/config');
clearModule('../lib/regbot');
delete process.env.JAMBONES_REGBOT_RESPONSE_TIMEOUT;
t.end();
});
/* ---- per-SBC Call-ID and un-REGISTER ---- */
test('Call-ID is per gateway and per sending SBC, and register status records the SBC', async(t) => {
const a = makeSrf();
const b = makeSrf();
b.srf.locals.sbcPublicIpAddress = { udp: '198.51.100.7:5060' };
const rbA = new Regbot(logger, REGBOT_OPTS);
rbA.register(a.srf);
rbA.reregister(a.srf);
const rbB = new Regbot(logger, REGBOT_OPTS);
rbB.register(b.srf);
await tick();
const [first, refresh] = a.state.requests.map((r) => r.opts.headers['Call-ID']);
t.equal(first, 'gw-1@203.0.113.1', 'Call-ID is sip_gateway_sid@sbc-public-ip');
t.equal(refresh, first, 'Call-ID is stable across refreshes from the same SBC');
t.equal(b.state.requests[0].opts.headers['Call-ID'], 'gw-1@198.51.100.7',
'another SBC registering the same gateway uses a different Call-ID');
a.state.requests[1].emit('response', ok200);
await tick();
t.equal(JSON.parse(a.state.statusUpdates[0]).sbcAddress, '203.0.113.1:5060',
'register status records the sending SBC address');
[rbA, rbB].forEach((rb) => { rb.retired = true; clearTimeout(rb.timer); clearTimeout(rb.watchdog); });
t.end();
});
test('unregister removes a binding whose Contact carries our address', async(t) => {
const { srf, state } = makeSrf();
const rb = new Regbot(logger, {...REGBOT_OPTS, use_public_ip_in_contact: true});
t.equal(await rb.unregister(srf), true, 'un-REGISTER sent');
const {headers} = state.requests[0].opts;
t.equal(headers['Call-ID'], 'gw-1@203.0.113.1', 'same Call-ID as our registrations');
t.equal(headers['Expires'], 0, 'Expires: 0');
t.equal(headers['Contact'], '<sip:user@203.0.113.1:5060;transport=udp>;expires=0',
'the Contact we registered, with expires=0');
t.end();
});
test('unregister is a no-op when the Contact is shared with the other SBC', async(t) => {
const { srf, state } = makeSrf();
const rb = new Regbot(logger, REGBOT_OPTS);
t.equal(await rb.unregister(srf), false, 'nothing to remove');
t.equal(state.requests.length, 0, 'no un-REGISTER sent');
t.end();
});