Files
sbc-sip-sidecar/test/regbot-handoff-test.js
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

115 lines
4.5 KiB
JavaScript

const test = require('tape');
const { EventEmitter } = require('events');
const Regbot = require('../lib/regbot');
const sipTrunkRegister = require('../lib/sip-trunk-register');
const { JAMBONES_LOGLEVEL } = require('../lib/config');
const logger = require('pino')({ level: JAMBONES_LOGLEVEL || 'info' });
const FAST = { pollMs: 5, timeoutMs: 100, graceMs: 0 };
function makeSrf({ active = true, successorAfterMs } = {}) {
const state = { keys: {}, deleted: [], requests: [] };
const regbotKey = 'default:regbot-token';
state.keys[regbotKey] = active ? 'me' : 'other';
const srf = {
request: (uri, opts) => {
const req = new EventEmitter();
req.opts = opts;
state.requests.push(req);
return Promise.resolve(req);
},
locals: {
regbot: { myToken: 'me', active },
sbcPublicIpAddress: { udp: '203.0.113.1:5060' },
realtimeDbHelpers: {
retrieveKey: (k) => Promise.resolve(state.keys[k]),
deleteKey: (k) => {
state.deleted.push(k);
delete state.keys[k];
if (successorAfterMs !== undefined) {
setTimeout(() => { state.keys[k] = 'successor'; }, successorAfterMs);
}
return Promise.resolve(true);
}
}
}
};
return { srf, state };
}
function addRegbot(overrides) {
const rb = new Regbot(logger, Object.assign({
voip_carrier_sid: 'carrier-1', ipv4: '2.3.4.5', port: 5060, username: 'user', password: 'pw',
sip_realm: 'sip.server.com', protocol: 'udp', trunk_type: 'static_ip', sip_gateway_sid: 'gw-1'
}, overrides));
rb.status = 'registered';
rb.timer = setTimeout(() => {}, 600000);
sipTrunkRegister._addForTest(rb);
return rb;
}
test('handoff: an SBC that is not the regbot holder only marks itself draining', async(t) => {
sipTrunkRegister._resetForTest();
const { srf, state } = makeSrf({ active: false });
await sipTrunkRegister.handoff(logger, srf, FAST);
t.equal(srf.locals.regbot.draining, true, 'marked draining so it never claims the role');
t.deepEqual(state.deleted, [], 'did not touch the lease');
t.end();
});
test('handoff: releases the role, then un-registers only bindings that point at this SBC', async(t) => {
sipTrunkRegister._resetForTest();
const { srf, state } = makeSrf({ successorAfterMs: 20 });
const ipContact = addRegbot({ use_public_ip_in_contact: true, sip_gateway_sid: 'gw-ip' });
const sharedContact = addRegbot({ sip_gateway_sid: 'gw-realm', username: 'other' });
await sipTrunkRegister.handoff(logger, srf, FAST);
t.equal(srf.locals.regbot.active, false, 'no longer the regbot holder');
t.equal(srf.locals.regbot.draining, true, 'marked draining');
t.deepEqual(state.deleted, ['default:regbot-token'], 'released the lease');
t.ok(ipContact.retired && !ipContact.timer && sharedContact.retired && !sharedContact.timer,
'refresh timers stopped');
t.equal(state.requests.length, 1, 'one un-REGISTER sent');
t.equal(state.requests[0].opts.headers['Call-ID'], 'gw-ip@203.0.113.1',
'for the binding whose Contact carries our address');
t.equal(state.requests[0].opts.headers['Expires'], 0, 'with Expires: 0');
t.end();
});
test('handoff: with no successor, bindings are left to expire', async(t) => {
sipTrunkRegister._resetForTest();
const { srf, state } = makeSrf();
addRegbot({ use_public_ip_in_contact: true });
await sipTrunkRegister.handoff(logger, srf, FAST);
t.deepEqual(state.deleted, ['default:regbot-token'], 'released the lease');
t.equal(state.requests.length, 0, 'no un-REGISTER sent');
t.end();
});
test('aws-lifecycle: fires once when IMDS target-lifecycle-state reads Terminated', async(t) => {
const realFetch = global.fetch;
const states = ['InService', 'InService', 'Terminated', 'Terminated'];
const calls = [];
global.fetch = (url, opts) => {
calls.push({url, opts});
const body = url.endsWith('/api/token') ? 'tok' : states.shift() || 'Terminated';
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(body) });
};
let fired = 0;
require('../lib/aws-lifecycle')(logger, () => fired++, { interval: 5 });
await new Promise((resolve) => setTimeout(resolve, 100));
global.fetch = realFetch;
t.equal(fired, 1, 'scale-in callback fired exactly once');
const get = calls.find((c) => c.url.endsWith('autoscaling/target-lifecycle-state'));
t.equal(get.opts.headers['X-aws-ec2-metadata-token'], 'tok', 'IMDSv2 session token presented');
t.equal(calls[0].opts.method, 'PUT', 'token fetched with PUT');
t.end();
});