Files
selfphoneng/scripts/e2e/m11-t2.mjs
T
iamdoubz 803859a1d2
ci / check (push) Successful in 58s
ci / images (push) Successful in 24s
sip: TLS transport with a checked certificate, TLS + SRTP trunks
The certificate lives in data/tls (fullchain.pem + privkey.pem), mounted
read-only into asterisk and the api. Asterisk quietly drops transport-tls
when the files are missing, so the api won't turn TLS on unless they load
and match, shows the certificate on Settings -> Security, and warns on the
dashboard when it expires within 14 days. scripts/tls-cert.sh copies it
from the bundled Caddy (or given paths) and reloads res_pjsip: Asterisk 24
re-reads a changed certificate on reload, calls stay up.

transport-tls checks the provider's certificate (verify_server). Asterisk
24 checks a qualify's certificate against the IP instead of the name, which
fails on voip.ms, so TLS trunks don't qualify; REGISTER and INVITE verify
the name fine.

Task: M11-T2
2026-10-19 16:33:11 -05:00

303 lines
12 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T2: SIP over TLS with SRTP against a fake voip.ms that only speaks TLS. A throwaway CA signs
// the PBX's and the provider's certificates. The admin turns TLS on in Settings → Security (the
// certificate shows), telephony restarts, and a TLS + SRTP trunk is created. While the CA isn't
// trusted the trunk must NOT register (verify_server); once it is, it registers, a call carries
// SRTP both ways (RTP/SAVP + a=crypto in the INVITE, packets counted), and a renewed PBX
// certificate goes live with a res_pjsip reload while that call stays up (scripts/tls-cert.sh).
// Runs on the m2-live setup after m1-t4.mjs; start the api with TLS_DIR=<tlsDir> (an empty dir).
// Usage: node scripts/e2e/m11-t2.mjs <baseUrl> <adminEmail> <adminPassword> <tlsDir> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PROVIDER = 'sp-live-tlsprov';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const SUB = { user: '123456_tls', pass: 'TlsProviderSecret42' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => {
const r = spawnSync('docker', a, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
};
const cli = (c, cmd) => docker('exec', c, 'asterisk', '-rx', cmd).out;
const asRoot = (c, sh) => docker('exec', '-u', '0', c, 'sh', '-c', sh);
/** A container's recent log without Asterisk's color codes. */
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
const logs = (c, since) => docker('logs', '--since', since, c).out.replace(/[[0-9;]*m/g, '');
const ipOf = (c) =>
docker('inspect', '-f', `{{(index .NetworkSettings.Networks "${NET}").IPAddress}}`, c).out;
async function until(what, fn, ms = 30000) {
for (const end = Date.now() + ms; Date.now() < end; await sleep(500)) if (await fn()) return;
throw new Error(`timed out: ${what}`);
}
const [base, email, pw, tlsDir, shotsDir] = process.argv.slice(2);
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: body && JSON.stringify(body),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
// 1. The provider: an Asterisk on the m2-live network, TLS added once its IP (the cert's name) is known.
const conf = mkdtempSync(join(tmpdir(), 'sp-tlsprov-'));
for (const f of [
'rtp_selfphoneng.conf',
'extensions_globals_selfphoneng.conf',
'voicemail_selfphoneng.conf',
'queues_selfphoneng.conf',
'musiconhold_selfphoneng.conf',
'confbridge_selfphoneng.conf',
])
writeFileSync(join(conf, f), '');
writeFileSync(
join(conf, 'extensions_selfphoneng.conf'),
'[provider-in]\nexten => _X.,1,Answer()\n same => n,Echo()\n',
);
writeFileSync(join(conf, 'pjsip_selfphoneng.conf'), '');
docker('rm', '-f', PROVIDER);
const run = docker(
'run',
'-d',
'--rm',
'--name',
PROVIDER,
'--network',
NET,
'-e',
'AMI_SECRET=provider',
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
);
assert.ok(run.ok, run.out);
await until(
'provider booted',
() => docker('exec', PROVIDER, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const providerIp = ipOf(PROVIDER);
const astIp = ipOf(AST);
console.log(`provider ${providerIp}, pbx ${astIp}`);
// 2. Certificates, made with the image's openssl: CA, provider (IP name), PBX now and "renewed".
const certs = mkdtempSync(join(tmpdir(), 'sp-tlscerts-'));
const mk = docker(
'run',
'--rm',
'-v',
`${certs}:/out`,
'--entrypoint',
'sh',
IMAGE,
'-c',
`set -e; cd /out
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj /CN=SelfPhoneNG-test-CA -keyout ca.key -out ca.crt 2>/dev/null
mk() { openssl req -newkey rsa:2048 -nodes -subj /CN=$1 -keyout $1.key -out $1.csr 2>/dev/null
echo "subjectAltName=$2" > $1.ext
openssl x509 -req -in $1.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days $3 -extfile $1.ext -out $1.crt 2>/dev/null; }
mk provider IP:${providerIp} 30; mk pbx1 DNS:pbx.test 30; mk pbx2 DNS:pbx.test 90
chmod 644 *`,
);
assert.ok(mk.ok, mk.out);
copyFileSync(join(certs, 'provider.crt'), join(conf, 'provider.crt'));
copyFileSync(join(certs, 'provider.key'), join(conf, 'provider.key'));
writeFileSync(
join(conf, 'pjsip_selfphoneng.conf'),
`[transport-tls]\ntype = transport\nprotocol = tls\nbind = 0.0.0.0:5061\n` +
`cert_file = /etc/asterisk/selfphoneng/provider.crt\npriv_key_file = /etc/asterisk/selfphoneng/provider.key\nmethod = tlsv1_2\n\n` +
`[${SUB.user}]\ntype = endpoint\ntransport = transport-tls\ncontext = provider-in\ndisallow = all\nallow = ulaw\n` +
`media_encryption = sdes\nauth = ${SUB.user}-auth\naors = ${SUB.user}\n\n` +
`[${SUB.user}-auth]\ntype = auth\nauth_type = userpass\nusername = ${SUB.user}\npassword = ${SUB.pass}\n\n` +
`[${SUB.user}]\ntype = aor\nmax_contacts = 1\nremove_existing = yes\n`,
);
cli(PROVIDER, 'module reload res_pjsip.so'); // it booted with no transports: adding one is fine
cli(PROVIDER, 'dialplan reload');
assert.match(
cli(PROVIDER, 'pjsip show transports'),
/transport-tls\s+tls/,
'provider listens on TLS',
);
/** The PBX certificate where the api (TLS_DIR) and Asterisk look, as tls-cert.sh puts it. */
function installPbxCert(name) {
copyFileSync(join(certs, `${name}.crt`), join(tlsDir, 'fullchain.pem'));
copyFileSync(join(certs, `${name}.key`), join(tlsDir, 'privkey.pem'));
asRoot(AST, 'mkdir -p /var/lib/selfphoneng/tls');
docker('cp', join(certs, `${name}.crt`), `${AST}:/var/lib/selfphoneng/tls/fullchain.pem`);
docker('cp', join(certs, `${name}.key`), `${AST}:/var/lib/selfphoneng/tls/privkey.pem`);
// Asterisk re-reads them only when the mtime changes; docker cp keeps the source's, and both
// certs were made in the same second. tls-cert.sh's install gives them a fresh one.
asRoot(AST, 'chmod 644 /var/lib/selfphoneng/tls/* && touch /var/lib/selfphoneng/tls/*');
}
/** The serial of the certificate the PBX presents on 5061, asked from the provider's container. */
const servedSerial = () =>
docker(
'exec',
PROVIDER,
'sh',
'-c',
`echo | openssl s_client -connect ${astIp}:5061 -servername pbx.test 2>/dev/null | openssl x509 -noout -serial`,
).out;
const serialOf = (name) =>
docker(
'run',
'--rm',
'-v',
`${certs}:/out`,
'--entrypoint',
'openssl',
IMAGE,
'x509',
'-noout',
'-serial',
'-in',
`/out/${name}.crt`,
).out;
const p = await launch({ shotsDir });
try {
// 3. Settings → Security: no certificate yet, then pbx1 shows; turn TLS on.
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`);
await p.goto(`${base}/admin/settings/security`);
await p.waitFor(`document.body.innerText.includes('No certificate')`, 'no certificate yet');
installPbxCert('pbx1');
await p.goto(`${base}/admin/settings/security`);
await p.waitFor(
`document.querySelector('[aria-label="TLS certificate"]')?.textContent.startsWith('Certificate for pbx.test')`,
'certificate shown',
);
await p.evaluate(`[...document.querySelectorAll('label')]
.find((l) => l.textContent.trim() === 'Accept SIP over TLS')
.querySelector('input').click()`);
await p.click('Save');
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'saved');
await p.shot('m11-t2-01-security');
assert.equal((await api('GET', '/api/settings'))['sip.tlsEnabled'], true);
// New transport: restart telephony (01 §4.3).
await sleep(1500);
await api('POST', '/api/system/asterisk/restart?now=true', {});
await until(
'restarted',
async () => {
const s = await api('GET', '/api/system/status').catch(() => null);
return s?.ami.connected && !s.restartRequired;
},
60000,
);
await until('transport-tls up', () =>
/transport-tls\s+tls/.test(cli(AST, 'pjsip show transports')),
);
assert.equal(servedSerial(), serialOf('pbx1'), 'PBX serves pbx1');
console.log('TLS on: PBX serves its certificate on 5061');
// 4. A TLS + SRTP trunk. The test CA isn't trusted yet: verify_server must refuse the provider.
const trunk = await api('POST', '/api/trunks', {
name: 'voip.ms TLS',
serverHost: providerIp,
serverPort: 5061,
transport: 'tls',
srtp: true,
username: SUB.user,
password: SUB.pass,
});
/** This trunk's registration line (earlier scripts may have other trunks registered). */
const registered = () =>
new RegExp(`trunk-${trunk.id}-reg\\S*\\s+\\S+\\s+Registered`).test(
cli(AST, 'pjsip show registrations'),
);
await until('verify error logged', () =>
/verify_log_result: Transport 'transport-tls'/.test(logs(AST, '60s')),
);
assert.ok(!registered(), 'untrusted provider: not registered');
assert.doesNotMatch(cli(PROVIDER, 'pjsip show contacts'), new RegExp(`${SUB.user}/sip:`));
console.log('untrusted provider certificate refused');
// Trust the CA (as for voip.ms's Let's Encrypt chain) and register.
docker('cp', join(certs, 'ca.crt'), `${AST}:/tmp/test-ca.crt`);
asRoot(AST, 'cat /tmp/test-ca.crt >> /etc/ssl/certs/ca-certificates.crt');
cli(AST, 'module reload res_pjsip.so');
cli(AST, `pjsip send register trunk-${trunk.id}-reg`);
await until('registered over TLS', registered, 60000);
const test = await api('POST', `/api/trunks/${trunk.id}/test`, {});
assert.equal(test.detail, `Registered with ${providerIp}`);
console.log('trunk registered over TLS:', test.detail);
// 5. A call through the trunk: SRTP offered and used, audio both ways (the provider echoes).
cli(PROVIDER, 'pjsip set logger on');
cli(
AST,
`channel originate PJSIP/15551234@trunk-${trunk.id} application Playback tt-monkeys&tt-monkeys&tt-monkeys`,
);
await until('call up on the provider', () =>
/PJSIP\/123456_tls-\S+\s+15551234@provider-in:\d+\s+Up/.test(
cli(PROVIDER, 'core show channels'),
),
);
const invite = logs(PROVIDER, '30s').split('INVITE sip:15551234@')[1] ?? '';
assert.match(invite, /m=audio \d+ RTP\/SAVP/, 'SDP offers RTP/SAVP');
assert.match(invite, /a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/, 'SDES key in the SDP');
assert.match(
logs(PROVIDER, '30s'),
new RegExp(`Received SIP request \\(\\d+ bytes\\) from TLS:${astIp.replace(/\./g, '\\.')}`),
);
await sleep(3000);
const rx = () =>
[
...cli(AST, 'pjsip show channelstats').matchAll(/ulaw\s+(\d+)\s+\d+\s+\S+\s+\S+\s+(\d+)/g),
].map((m) => [Number(m[1]), Number(m[2])]);
const [[recv, sent]] = rx();
assert.ok(recv > 50 && sent > 50, `RTP both ways: received ${recv}, sent ${sent}`);
console.log(`SRTP call up: ${recv} packets in, ${sent} out`);
// 6. Renewal: pbx2 goes live on a res_pjsip reload, the call stays up.
installPbxCert('pbx2');
cli(AST, 'module reload res_pjsip.so');
await until('pbx2 served', () => servedSerial() === serialOf('pbx2'));
assert.match(
cli(PROVIDER, 'core show channels'),
/PJSIP\/123456_tls-/,
'call survived the reload',
);
const tls = await api('GET', '/api/system/tls');
assert.ok(Date.parse(tls.notAfter) - Date.now() > 80 * 86_400_000, 'api sees the renewed cert');
const warn = (await api('GET', '/api/system/warnings')).items.map((w) => w.code);
assert.ok(!warn.includes('tls_cert'), 'no certificate warning');
console.log('renewed certificate live after reload, call still up');
cli(AST, 'channel request hangup all');
assert.deepEqual(p.consoleErrors, [], 'no console errors');
console.log('M11-T2: PASS');
} catch (e) {
await p.shot('m11-t2-FAILED').catch(() => {});
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
process.exitCode = 1;
} finally {
await p.close();
cli(PROVIDER, 'pjsip set logger off');
// The provider keeps running until m2-live.mjs down (see m3-t1.mjs).
}