From 803859a1d2e88a29249ab767803cac16b3a99f18 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Mon, 19 Oct 2026 16:33:11 -0500 Subject: [PATCH] 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 --- apps/api/src/config.ts | 2 + .../expected/extensions_selfphoneng.conf | 19 ++ .../expected/pjsip_selfphoneng.conf | 61 ++++ .../__golden__/trunk-variants/snapshot.json | 13 +- apps/api/src/generator/pjsip.ts | 13 +- apps/api/src/live/state.ts | 11 +- apps/api/src/routes/settings.ts | 20 ++ apps/api/src/routes/trunks.ts | 15 +- apps/api/src/services/tls.ts | 33 ++ apps/api/test/asterisk/load-golden.mjs | 46 +++ apps/api/test/tls.test.ts | 133 ++++++++ apps/web/src/api/client.ts | 2 + apps/web/src/pages/admin/SettingsPage.tsx | 33 +- docker-compose.yml | 3 +- docs/spec/03-config-generator.md | 6 +- docs/spec/04-backend-api.md | 3 +- docs/spec/06-voipms.md | 2 +- docs/spec/08-security-ops.md | 19 ++ packages/shared/src/settings.ts | 4 + scripts/e2e/README.md | 9 + scripts/e2e/m11-t2.mjs | 302 ++++++++++++++++++ scripts/e2e/m2-live.mjs | 2 +- scripts/tls-cert.sh | 40 +++ 23 files changed, 772 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/services/tls.ts create mode 100644 apps/api/test/tls.test.ts create mode 100644 scripts/e2e/m11-t2.mjs create mode 100755 scripts/tls-cert.sh diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 911a638..1311be6 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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/.wav under it (03 sp-sub-record). */ diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf index c04e097..6dbcd1c 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf @@ -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() diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf index 25c5bbc..829fffe 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf @@ -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 diff --git a/apps/api/src/generator/__golden__/trunk-variants/snapshot.json b/apps/api/src/generator/__golden__/trunk-variants/snapshot.json index 1fe3bd3..c7c3f56 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/snapshot.json +++ b/apps/api/src/generator/__golden__/trunk-variants/snapshot.json @@ -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", diff --git a/apps/api/src/generator/pjsip.ts b/apps/api/src/generator/pjsip.ts index ca1cb71..8ac202e 100644 --- a/apps/api/src/generator/pjsip.ts +++ b/apps/api/src/generator/pjsip.ts @@ -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}`, diff --git a/apps/api/src/live/state.ts b/apps/api/src/live/state.ts index e524031..41c0cb7 100644 --- a/apps/api/src/live/state.ts +++ b/apps/api/src/live/state.ts @@ -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); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 968b768..874251e 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -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', diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts index 3568832..7844f4e 100644 --- a/apps/api/src/routes/trunks.ts +++ b/apps/api/src/routes/trunks.ts @@ -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 }; }); diff --git a/apps/api/src/services/tls.ts b/apps/api/src/services/tls.ts new file mode 100644 index 0000000..2122bf0 --- /dev/null +++ b/apps/api/src/services/tls.ts @@ -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 { + 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}` }; + } +} diff --git a/apps/api/test/asterisk/load-golden.mjs b/apps/api/test/asterisk/load-golden.mjs index c2973e9..ef48491 100644 --- a/apps/api/test/asterisk/load-golden.mjs +++ b/apps/api/test/asterisk/load-golden.mjs @@ -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 }); } } diff --git a/apps/api/test/tls.test.ts b/apps/api/test/tls.test.ts new file mode 100644 index 0000000..0d05b37 --- /dev/null +++ b/apps/api/test/tls.test.ts @@ -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 .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 's certificate and '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>; + let req: (method: string, url: string, payload?: unknown) => Promise; + 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/); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 319d857..1e2cd4b 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -69,6 +69,7 @@ import type { UserUpdate, VoicemailDto, WebRtcCreds, + TlsCert, Warning, } from '@selfphoneng/shared'; @@ -223,6 +224,7 @@ export const systemApi = { api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`), detectPublicIp: () => api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip), + tls: () => api('GET', '/api/system/tls'), setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'), completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'), }; diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index 90f0a2b..9f9dc81 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Security (phone ACL, provisioning). +// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Security (phone ACL, provisioning, SIP TLS). import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { NavLink, useParams } from 'react-router'; import { formatNumber, type SettingsDto } from '@selfphoneng/shared'; -import { didsApi, extensionsApi, settingsApi } from '../../api/client.ts'; +import { didsApi, extensionsApi, settingsApi, systemApi } from '../../api/client.ts'; import { SoundPicker } from '../../components/SoundPicker.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; @@ -376,9 +376,12 @@ function RecordingTab({ s }: { s: SettingsDto }) { ); } -/** Phone ACL (08 §2.2) and provisioning access (07 §2). */ +/** Phone ACL (08 §2.2), provisioning access (07 §2) and SIP over TLS (M11-T2). */ function SecurityTab({ s }: { s: SettingsDto }) { const save = useSave(); + const cert = useQuery({ queryKey: ['tls'], queryFn: systemApi.tls }); + const [tlsOn, setTlsOn] = useState(s['sip.tlsEnabled']); + const [tlsPort, setTlsPort] = useState(String(s['sip.tlsPort'])); const [restrict, setRestrict] = useState(s['sip.restrictPhonesToLocalNets']); const [extra, setExtra] = useState(s['sip.extraAllowedNets'].join(', ')); const [lanOnly, setLanOnly] = useState(s['provisioning.lanOnly']); @@ -399,6 +402,8 @@ function SecurityTab({ s }: { s: SettingsDto }) { 'sip.extraAllowedNets': extra.split(/[,\s]+/).filter(Boolean), 'provisioning.lanOnly': lanOnly, 'provisioning.enabled': provOn, + 'sip.tlsEnabled': tlsOn, + 'sip.tlsPort': Number(tlsPort), }); }} > @@ -424,6 +429,28 @@ function SecurityTab({ s }: { s: SettingsDto }) {

Phone setup files

{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')} {box(lanOnly, setLanOnly, 'Only to phones on the local networks')} +

Encrypted SIP (TLS)

+

+ {cert.data?.ok + ? `Certificate for ${cert.data.names.map((n) => n.replace(/^DNS:/, '')).join(', ') || cert.data.subject}, valid until ${new Date(cert.data.notAfter).toLocaleDateString()}.` + : (cert.data?.error ?? '…')} +

+ {box(tlsOn, setTlsOn, 'Accept SIP over TLS')} +

+ Needed for an encrypted voip.ms trunk. Turning it on or changing the port needs a telephony + restart. After renewing the certificate, run scripts/tls-cert.sh. +

+ + {(p) => ( + setTlsPort(e.target.value)} + /> + )} +