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:
1 parent
38f39c9028
commit
996685e1ab
19 files changed
+531
-66
No files matched your search
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
Reference in new issue
Block a user