get correct expiry for multi-contact registraitons (#149)

This commit is contained in:
Sam Machin
2026-08-20 17:33:23 -04:00
committed by GitHub
parent f7aaf58ebd
commit ba5ce5269d
2 changed files with 96 additions and 3 deletions
+48 -3
View File
@@ -9,6 +9,7 @@ const {
JAMBONES_REGBOT_REGISTER_FAILURE_THRESHOLD
} = require('./config');
const {isValidDomainOrIP, isValidIPv4} = require('./utils');
const parseUri = require('drachtio-srf').parseUri;
const DEFAULT_EXPIRES = (parseInt(JAMBONES_REGBOT_DEFAULT_EXPIRES_INTERVAL) || 3600);
const MIN_EXPIRES = (parseInt(JAMBONES_REGBOT_MIN_EXPIRES_INTERVAL) || 90);
const FAILURE_RETRY_INTERVAL = (parseInt(JAMBONES_REGBOT_FAILURE_RETRY_INTERVAL) || 300);
@@ -17,6 +18,38 @@ const assert = require('assert');
const version = require('../package.json').version;
const useragent = JAMBONES_REGBOT_USER_AGENT || `Jambonz ${version}`;
/**
* A 200 OK to a REGISTER may echo back every Contact bound to the AoR, not just ours
* (e.g. when another device is registered against the same account). Per RFC 3261 10.2.4
* we must refresh based on the expires of *our own* binding, so locate the returned Contact
* whose user and host match the Contact we sent.
* Returns the parsed expires (a number) of our binding, or undefined if it can't be matched.
*/
const getOwnContactExpires = (contacts, ownContactUri) => {
let own;
try {
own = parseUri(ownContactUri);
} catch {
return undefined;
}
if (!own || !own.host) return undefined;
const ownUser = own.user;
const ownHost = own.host.toLowerCase();
for (const c of contacts) {
let u;
try {
u = parseUri(c.uri);
} catch {
continue;
}
if (u && u.user === ownUser && (u.host || '').toLowerCase() === ownHost &&
c.params && c.params.expires !== undefined) {
return parseInt(c.params.expires);
}
}
return undefined;
};
class Regbot {
constructor(logger, opts) {
this.logger = logger;
@@ -235,9 +268,18 @@ class Regbot {
}
if (res.has('Contact')) {
const contact = res.getParsedHeader('Contact');
if (contact.length > 0 && contact[0].params && contact[0].params.expires) {
expires = parseInt(contact[0].params.expires);
const contacts = res.getParsedHeader('Contact');
const ownExpires = getOwnContactExpires(contacts, `${scheme}:${contactAddress}`);
if (typeof ownExpires === 'number' && !isNaN(ownExpires)) {
// refresh based on our own binding's expires
expires = ownExpires;
} else if (contacts.length === 1 && contacts[0].params && contacts[0].params.expires) {
// single binding returned: safe to use even if the registrar rewrote the contact host
expires = parseInt(contacts[0].params.expires);
} else if (contacts.length > 1) {
this.logger.info({aor: this.aor, ipv4: this.ipv4, port: this.port},
// eslint-disable-next-line max-len
`200 OK returned ${contacts.length} contacts but none matched our own binding; using expires ${expires}`);
}
} else {
this.logger.debug({ aor: this.aor, ipv4: this.ipv4, port: this.port },
@@ -355,5 +397,8 @@ const dnsResolverSrv = async(logger, hostname, transport) => {
};
// exposed for unit testing
Regbot._getOwnContactExpires = getOwnContactExpires;
module.exports = Regbot;
+48
View File
@@ -149,6 +149,54 @@ test('configKey returns different strings when config differs', (t) => {
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',