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

+2
View File
@@ -21,6 +21,8 @@ const EnvSchema = z.object({
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
/** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
/** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
/** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
/** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */
@@ -233,6 +233,24 @@ exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
same => n(blocked),Hangup(21)
; ─── Calls from trunk "voip.ms encrypted"
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000004]
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000004,unknown,1)
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
same => n,Set(CDR(SP_DIRECTION)=inbound)
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
same => n(blocked),Hangup(21)
[sp-inbound]
; anything else
exten => _[0-9a-z].,1,Hangup()
@@ -298,4 +316,5 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000001)
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000002)
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000004)
same => n,Hangup()
@@ -21,6 +21,16 @@ type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
cert_file = /var/lib/selfphoneng/tls/fullchain.pem
priv_key_file = /var/lib/selfphoneng/tls/privkey.pem
method = tlsv1_2
verify_server = yes
ca_list_file = /etc/ssl/certs/ca-certificates.crt
[transport-ws]
type = transport
protocol = ws
@@ -102,3 +112,54 @@ dtmf_mode = rfc4733
type = identify
endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000002
match = example.net
; ── trunk "voip.ms encrypted" ──
[trunk-a1b2c3d4-0000-4000-8000-000000000004-auth]
type = auth
auth_type = userpass
username = 123456_tls
password = TlsSecret42
[trunk-a1b2c3d4-0000-4000-8000-000000000004-reg]
type = registration
transport = transport-tls
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000004-auth
server_uri = sip:chicago1.voip.ms:5061;transport=tls
client_uri = sip:123456_tls@chicago1.voip.ms:5061;transport=tls
contact_user = 123456_tls
retry_interval = 60
forbidden_retry_interval = 600
fatal_retry_interval = 600
max_retries = 10000
expiration = 300
line = yes
endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = aor
contact = sip:chicago1.voip.ms:5061;transport=tls
qualify_frequency = 0
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = endpoint
transport = transport-tls
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000004
disallow = all
allow = ulaw
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000004-auth
aors = trunk-a1b2c3d4-0000-4000-8000-000000000004
from_user = 123456_tls
from_domain = chicago1.voip.ms
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
media_encryption = sdes
[trunk-a1b2c3d4-0000-4000-8000-000000000004-identify]
type = identify
endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
match = chicago1.voip.ms
@@ -1,6 +1,7 @@
{
"settings": {
"sip.tcpEnabled": true
"sip.tcpEnabled": true,
"sip.tlsEnabled": true
},
"trunks": [
{
@@ -26,6 +27,16 @@
"codecs": ["g722", "ulaw"],
"maxChannels": 4
},
{
"id": "a1b2c3d4-0000-4000-8000-000000000004",
"name": "voip.ms encrypted",
"serverHost": "chicago1.voip.ms",
"serverPort": 5061,
"transport": "tls",
"srtp": true,
"username": "123456_tls",
"password": "TlsSecret42"
},
{
"id": "a1b2c3d4-0000-4000-8000-000000000003",
"name": "Old provider",
+12 -1
View File
@@ -59,6 +59,9 @@ export function renderTransports(snap: Snapshot): string {
'cert_file = /var/lib/selfphoneng/tls/fullchain.pem',
'priv_key_file = /var/lib/selfphoneng/tls/privkey.pem',
'method = tlsv1_2',
// Trunks: check the provider's certificate, or TLS only hides the call from bystanders.
'verify_server = yes',
'ca_list_file = /etc/ssl/certs/ca-certificates.crt',
...nat,
]),
// The bind address is ignored: browsers arrive through Asterisk's HTTP server (01 §3.5).
@@ -214,7 +217,15 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
]),
);
out.push(
section(`trunk-${id}`, ['type = aor', `contact = sip:${target}`, 'qualify_frequency = 60']),
section(`trunk-${id}`, [
'type = aor',
`contact = sip:${target}`,
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
]),
section(`trunk-${id}`, [
'type = endpoint',
`transport = ${transport}`,
+8 -3
View File
@@ -211,9 +211,14 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
}
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk(id: string, waitMs = 1500): Promise<{ live: TrunkLive; rttMs: number | null }> {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
async testTrunk(
id: string,
{ waitMs = 1500, qualify = true } = {},
): Promise<{ live: TrunkLive; rttMs: number | null }> {
if (qualify) {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
}
await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id);
+20
View File
@@ -20,6 +20,7 @@ import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
import { notifyEmergency } from '../services/emergency.ts';
import { readTlsCert } from '../services/tls.ts';
import type { SendMail } from '../services/mail.ts';
declare module 'fastify' {
@@ -112,6 +113,10 @@ export async function settingsRoutes(app: FastifyInstance) {
}
await checkRefs(db, values);
const before = await getAllSettings(db);
if (values['sip.tlsEnabled'] === true && !before['sip.tlsEnabled']) {
const cert = await readTlsCert(app.config.TLS_DIR);
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
}
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
const changed = Object.keys(values).filter(
@@ -197,6 +202,9 @@ export async function settingsRoutes(app: FastifyInstance) {
return { ok: true };
});
/** The SIP TLS certificate, for Settings → Security (M11-T2). */
app.get('/api/system/tls', () => readTlsCert(app.config.TLS_DIR));
/** Things an admin should fix, for the dashboard banner (05 §2). */
app.get('/api/system/warnings', async (): Promise<{ items: Warning[] }> => {
const items: Warning[] = [];
@@ -224,6 +232,18 @@ export async function settingsRoutes(app: FastifyInstance) {
link: '/admin/numbers',
});
}
if (s['sip.tlsEnabled']) {
const cert = await readTlsCert(app.config.TLS_DIR);
const days = cert.ok ? (Date.parse(cert.notAfter) - Date.now()) / 86_400_000 : 0;
if (days < 14)
items.push({
code: 'tls_cert',
message: cert.ok
? `The SIP TLS certificate ${days < 0 ? 'has expired' : `expires in ${Math.ceil(days)} days`}. Run scripts/tls-cert.sh after renewing it.`
: `SIP over TLS is on, but: ${cert.error}.`,
link: '/admin/settings/security',
});
}
if (!s.smtp)
items.push({
code: 'smtp_missing',
+9 -6
View File
@@ -208,18 +208,21 @@ export async function trunkRoutes(app: FastifyInstance) {
if (!t.enabled) throw badRequest('Turn the trunk on first');
if (!app.live.amiConnected)
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
const { live, rttMs } = await app.live.testTrunk(id);
// No OPTIONS over TLS (see the trunk's aor in generator/pjsip.ts).
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
const reg = t.authMode === 'registration';
const detail =
reg && live.registration === 'registered'
? `Registered with ${t.serverHost}`
: reg && live.registration === 'rejected'
? `${t.serverHost} rejected the username or password`
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
: !reg && t.transport === 'tls'
? `Can't check a TLS trunk without registration from here: make a test call.`
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
});
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The SIP TLS certificate (M11-T2): TLS_DIR/fullchain.pem + privkey.pem, the same files Asterisk's
// transport-tls reads. Asterisk drops the transport without a word when they're missing, so the
// api checks them before TLS is turned on and the dashboard warns before they expire.
import { X509Certificate, createPrivateKey } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { TlsCert } from '@selfphoneng/shared';
export async function readTlsCert(dir: string): Promise<TlsCert> {
let pem: string, key: string;
try {
[pem, key] = await Promise.all([
readFile(join(dir, 'fullchain.pem'), 'utf8'),
readFile(join(dir, 'privkey.pem'), 'utf8'),
]);
} catch {
return { ok: false, error: 'No certificate: put fullchain.pem and privkey.pem in data/tls' };
}
try {
const cert = new X509Certificate(pem); // the first one in the chain is ours
if (!cert.checkPrivateKey(createPrivateKey(key)))
return { ok: false, error: "privkey.pem doesn't belong to the certificate in fullchain.pem" };
return {
ok: true,
subject: cert.subject.replace(/\n/g, ', '),
names: (cert.subjectAltName ?? '').split(', ').filter(Boolean),
notAfter: new Date(cert.validTo).toISOString(),
};
} catch (e) {
return { ok: false, error: `Can't read the certificate: ${(e as Error).message}` };
}
}
+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/);
});
});