dialplan: build the dial string per contact; iOS Linphone's push params broke Dial

Its Contact holds pn-prid=...:voip&...:remote, and PJSIP_DIAL_CONTACTS passes that '&' through,
so Dial rejected the whole device list and the call dropped for every device of the user.
sp-sub-contacts walks the AOR's contacts, drops pn-* params and FILTERs the rest. call-test
registers a phone with such a Contact and checks it is rung.
This commit is contained in:
iamdoubz committed 2026-10-20 16:34:45 -05:00
1 parent 38f39c9028
commit 996685e1ab
19 files changed
+531 -66

No files matched your search

+32 -2
View File
@@ -22,6 +22,7 @@ const RECORD = process.argv.includes('--record')
const NET = 'sp-test';
const AST = 'sp-test-ast';
const PHONES = 'sp-test-phones';
const PN_PHONE = 'sp-test-pn-phone';
const AMI_PORT = 15038;
const AMI_SECRET = 'call-test-secret';
const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' };
@@ -142,7 +143,7 @@ async function main() {
backoffMs: [500],
});
try {
docker('rm', '-f', AST, PHONES);
docker('rm', '-f', AST, PHONES, PN_PHONE);
docker('network', 'create', NET);
const run = docker(
'run',
@@ -236,6 +237,30 @@ async function main() {
).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
else log('102-web over WebSocket from 127.0.0.1: registered');
// 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device.
const pn = docker(
'run',
'-d',
'--rm',
'--name',
PN_PHONE,
'--network',
NET,
'-v',
`${join(import.meta.dirname, 'pn-phone.mjs')}:/pn-phone.mjs:ro`,
'node:24-trixie-slim',
'node',
'/pn-phone.mjs',
AST,
'101',
PASS['101'],
);
if (!pn.ok) throw new Error(`pn phone: ${pn.out}`);
for (let i = 0; i < 20 && !/REGISTER SIP\/2.0 200/.test(docker('logs', PN_PHONE).out); i++)
await sleep(500);
if (!/REGISTER SIP\/2.0 200/.test(docker('logs', PN_PHONE).out))
throw new Error(`pn phone did not register: ${docker('logs', PN_PHONE).out}`);
log('101 also registered with iOS Linphone push params');
// Real list responses with registered phones, for the live-state fixture.
recorder?.write(
'Action: PJSIPShowContacts\r\nActionID: rec-2\r\n\r\n' +
@@ -263,6 +288,11 @@ async function main() {
break;
}
if (!/Up!Dial/.test(channels)) throw new Error(`call did not connect:\n${channels}`);
// Rung too, without its pn-* params; the rest of its Contact kept.
const pnInvite = /^INVITE .*$/m.exec(docker('logs', PN_PHONE).out)?.[0] ?? 'no INVITE';
if (!/;x-test=keep-me$/.test(pnInvite) || /pn-/.test(pnInvite))
problems.push(`push-params phone: ${pnInvite}`);
else log(`push-params phone rung: ${pnInvite}`);
log(`call up, holding ${CALL_SECONDS} s`);
await sleep(CALL_SECONDS * 1000);
@@ -307,7 +337,7 @@ async function main() {
${docker('exec', PHONES, 'tail', '-n', '25', `/phones/phone-${e}/log`).out}`);
recorder?.destroy();
ami.stop();
docker('rm', '-f', PHONES, AST);
docker('rm', '-f', PHONES, PN_PHONE, AST);
docker('network', 'rm', NET);
rmSync(dir, { recursive: true, force: true });
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A phone that registers like iOS Linphone: push params holding '&' in its Contact. Answers
// qualify OPTIONS, prints the Request-URI of each INVITE ("INVITE <uri>") and turns it down (486),
// so the real phone on the same extension takes the call. call-test.ts runs it.
// Usage: node pn-phone.mjs <asterisk host> <user> <password>
import { createHash, randomUUID } from 'node:crypto';
import { createSocket } from 'node:dgram';
import { lookup } from 'node:dns/promises';
import { networkInterfaces } from 'node:os';
const [astHost, user, pass] = process.argv.slice(2);
const md5 = (s) => createHash('md5').update(s).digest('hex');
const ast = (await lookup(astHost)).address;
const me = Object.values(networkInterfaces())
.flat()
.find((i) => i.family === 'IPv4' && !i.internal).address;
const PORT = 5080;
const uri = `sip:${astHost}`;
const callId = randomUUID();
const tag = randomUUID().slice(0, 8);
const contact =
`<sip:${user}@${me}:${PORT};pn-prid=AAAA1111:voip&bbbb2222:remote;pn-provider=apns;` +
`pn-param=ABCD1234.org.linphone.phone.voip&remote;x-test=keep-me>`;
const sock = createSocket('udp4');
const send = (msg) => sock.send(msg, 5060, ast);
const register = (cseq, auth) =>
[
`REGISTER ${uri} SIP/2.0`,
`Via: SIP/2.0/UDP ${me}:${PORT};rport;branch=z9hG4bK${randomUUID().slice(0, 12)}`,
'Max-Forwards: 70',
`From: <sip:${user}@${astHost}>;tag=${tag}`,
`To: <sip:${user}@${astHost}>`,
`Call-ID: ${callId}`,
`CSeq: ${cseq} REGISTER`,
`Contact: ${contact}`,
'Expires: 300',
...(auth ? [`Authorization: ${auth}`] : []),
'Content-Length: 0',
'',
'',
].join('\r\n');
/** A response to a request: its Via, From, Call-ID and CSeq, and a To tag. */
const reply = (req, status) => {
const keep = req.split('\r\n').filter((l) => /^(Via|From|To|Call-ID|CSeq):/i.test(l));
const lines = keep.map((l) => (/^To:/i.test(l) && !/;tag=/.test(l) ? `${l};tag=${tag}` : l));
return [`SIP/2.0 ${status}`, ...lines, 'Content-Length: 0', '', ''].join('\r\n');
};
sock.on('message', (buf) => {
const msg = buf.toString();
const first = msg.split('\r\n')[0];
const challenge = /WWW-Authenticate: Digest (.*)/i.exec(msg)?.[1];
if (first.includes(' 401 ') && challenge) {
const p = Object.fromEntries(
[...challenge.matchAll(/(\w+)="?([^",]*)"?/g)].map((m) => [m[1], m[2]]),
);
const cnonce = randomUUID().slice(0, 8);
const ha1 = md5(`${user}:${p.realm}:${pass}`);
const ha2 = md5(`REGISTER:${uri}`);
const response = md5(`${ha1}:${p.nonce}:00000001:${cnonce}:auth:${ha2}`);
send(
register(
2,
`Digest username="${user}", realm="${p.realm}", nonce="${p.nonce}", uri="${uri}", ` +
`response="${response}", algorithm=MD5, qop=auth, nc=00000001, cnonce="${cnonce}"` +
(p.opaque ? `, opaque="${p.opaque}"` : ''),
),
);
} else if (first.startsWith('SIP/2.0 ')) console.log(`REGISTER ${first}`);
else if (first.startsWith('OPTIONS ')) send(reply(msg, '200 OK'));
else if (first.startsWith('INVITE ')) {
console.log(first.split(' ').slice(0, 2).join(' '));
send(reply(msg, '486 Busy Here'));
}
});
sock.bind(PORT, () => send(register(1)));