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
This commit is contained in:
1 parent
8c750c67ca
commit
803859a1d2
23 files changed
+772
-19
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user