sip: TLS transport with a checked certificate, TLS + SRTP trunks
ci / check (push) Successful in 58s
ci / images (push) Successful in 24s

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
This commit is contained in:
iamdoubz committed 2026-10-19 16:33:11 -05:00
1 parent 8c750c67ca
commit 803859a1d2
23 files changed
+772 -19

No files matched your search

+46
View File
@@ -53,6 +53,47 @@ async function check(name) {
const mohConf = readFileSync(join(tmp, 'musiconhold_selfphoneng.conf'), 'utf8');
for (const [, d] of mohConf.matchAll(/^directory = \/var\/lib\/asterisk\/moh\/(.+)$/gm))
mkdirSync(join(moh, d));
// transport-tls needs a certificate, or Asterisk quietly leaves it out: make a throwaway one.
const tls = mkdtempSync(join(tmpdir(), `sp-golden-tls-`));
const wantTls = /^\[transport-tls\]$/m.test(
readFileSync(join(tmp, 'pjsip_selfphoneng.conf'), 'utf8'),
);
if (wantTls) {
const cert = docker(
'run',
'--rm',
'-v',
`${tls}:/out`,
'--entrypoint',
'openssl',
IMAGE,
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-days',
'1',
'-subj',
'/CN=golden.test',
'-keyout',
'/out/privkey.pem',
'-out',
'/out/fullchain.pem',
);
if (!cert.ok) throw new Error(`making a test certificate failed: ${cert.out}`);
docker(
'run',
'--rm',
'-v',
`${tls}:/out`,
'--entrypoint',
'chmod',
IMAGE,
'644',
'/out/privkey.pem',
);
}
const run = docker(
'run',
'-d',
@@ -65,6 +106,8 @@ async function check(name) {
`${tmp}:/etc/asterisk/selfphoneng`,
'-v',
`${moh}:/var/lib/asterisk/moh`,
'-v',
`${tls}:/var/lib/selfphoneng/tls:ro`,
IMAGE,
);
if (!run.ok) throw new Error(`docker run failed: ${run.out}`);
@@ -78,6 +121,8 @@ async function check(name) {
await sleep(500);
const { endpoints, contexts, mailboxes, queues } = expectations(tmp);
if (wantTls && !/transport-tls\s+tls/.test(cli('pjsip show transports').out))
problems.push('transport-tls not loaded');
const shown = cli('pjsip show endpoints').out;
for (const e of endpoints)
if (!new RegExp(`Endpoint:\\s+${e}[\\s/]`).test(shown))
@@ -106,6 +151,7 @@ async function check(name) {
docker('rm', '-f', NAME);
rmSync(tmp, { recursive: true, force: true });
rmSync(moh, { recursive: true, force: true });
rmSync(tls, { recursive: true, force: true });
}
}
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T2: the SIP TLS certificate check, turning TLS on, and the expiry warning.
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { readTlsCert } from '../src/services/tls.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const hasOpenssl = spawnSync('openssl', ['version']).status === 0;
const work = mkdtempSync(join(tmpdir(), 'sp-tls-certs-'));
/** A self-signed cert + key named <name>.crt/.key, valid for `days`. */
function makeCert(name: string, days: number) {
const r = spawnSync(
'openssl',
['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', String(days)]
.concat([
'-subj',
`/CN=${name}.example.com`,
'-addext',
`subjectAltName=DNS:${name}.example.com`,
])
.concat(['-keyout', join(work, `${name}.key`), '-out', join(work, `${name}.crt`)]),
{ env: { ...process.env, MSYS_NO_PATHCONV: '1' } }, // Git Bash's openssl mangles /CN=
);
if (r.status !== 0) throw new Error(String(r.stderr));
}
/** Puts <cert>'s certificate and <key>'s key where the api (and Asterisk) look. */
function install(dir: string, cert: string, key = cert) {
copyFileSync(join(work, `${cert}.crt`), join(dir, 'fullchain.pem'));
copyFileSync(join(work, `${key}.key`), join(dir, 'privkey.pem'));
}
describe.skipIf(!hasOpenssl)('readTlsCert', () => {
beforeAll(() => {
makeCert('pbx', 365);
makeCert('other', 365);
});
it('missing files, a key that does not match, a good pair', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sp-tls-'));
expect(await readTlsCert(dir)).toMatchObject({ ok: false, error: /No certificate/ });
install(dir, 'pbx', 'other');
expect(await readTlsCert(dir)).toMatchObject({ ok: false, error: /doesn't belong/ });
install(dir, 'pbx');
const c = await readTlsCert(dir);
expect(c).toMatchObject({
ok: true,
subject: 'CN=pbx.example.com',
names: ['DNS:pbx.example.com'],
});
if (c.ok) expect(Date.parse(c.notAfter) - Date.now()).toBeGreaterThan(300 * 86_400_000);
rmSync(dir, { recursive: true });
});
});
describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
const dir = mkdtempSync(join(tmpdir(), 'sp-tls-'));
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
const warningCodes = async () =>
(await req('GET', '/api/system/warnings')).json().items.map((w: { code: string }) => w.code);
beforeAll(async () => {
makeCert('soon', 5);
makeCert('pbx', 365);
t = await createTestApp(undefined, { TLS_DIR: dir });
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
});
afterAll(async () => {
await t?.close();
rmSync(dir, { recursive: true });
rmSync(work, { recursive: true, force: true });
});
it("won't turn on without a certificate", async () => {
const r = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': true });
expect(r.statusCode).toBe(400);
expect(r.json().error.message).toMatch(/Can't turn on TLS. No certificate/);
expect((await req('GET', '/api/system/tls')).json()).toMatchObject({ ok: false });
});
it('turns on with one, warns when it expires within 14 days', async () => {
install(dir, 'soon');
const r = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': true });
expect(r.statusCode).toBe(200);
expect(r.json()['sip.tlsEnabled']).toBe(true);
expect((await req('GET', '/api/system/tls')).json()).toMatchObject({
ok: true,
subject: 'CN=soon.example.com',
});
expect(await warningCodes()).toContain('tls_cert');
install(dir, 'pbx'); // renewed
expect(await warningCodes()).not.toContain('tls_cert');
});
it('a TLS trunk: no qualify, Test does not send OPTIONS, TLS stays on while it exists', async () => {
const trunk = await req('POST', '/api/trunks', {
name: 'voip.ms TLS',
serverHost: '127.0.0.1',
serverPort: 5061,
transport: 'tls',
srtp: true,
username: '123456_pbx',
password: 'pw',
});
expect(trunk.statusCode).toBe(201);
await t.app.generator.apply({ trigger: 'test' });
const { readFile } = await import('node:fs/promises');
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 0\n/);
expect(conf).toMatch(/media_encryption = sdes/);
expect(conf).toMatch(/verify_server = yes/);
t.live.amiConnected = true;
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
expect(test.statusCode).toBe(200);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([]);
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
expect(off.statusCode).toBe(400);
expect(off.json().error.message).toMatch(/uses TLS/);
});
});