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/);
});
});
+2
View File
@@ -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<TlsCert>('GET', '/api/system/tls'),
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'),
};
+30 -3
View File
@@ -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 }) {
<h2 className="font-medium">Phone setup files</h2>
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
<h2 className="font-medium">Encrypted SIP (TLS)</h2>
<p className="text-sm" aria-label="TLS certificate">
{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 ?? '…')}
</p>
{box(tlsOn, setTlsOn, 'Accept SIP over TLS')}
<p className="-mt-2 text-xs text-zinc-500">
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>
<Field label="TLS port">
{(p) => (
<input
{...p}
className={`${inputClass} max-w-32`}
inputMode="numeric"
value={tlsPort}
onChange={(e) => setTlsPort(e.target.value)}
/>
)}
</Field>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
+2 -1
View File
@@ -44,6 +44,7 @@ services:
- ./data/moh:/var/lib/asterisk/moh
- ./data/astdb:/var/lib/asterisk/db
- ./data/asterisk-logs:/var/log/asterisk
- ./data/tls:/var/lib/selfphoneng/tls:ro # SIP TLS cert (M11-T2): fullchain.pem + privkey.pem
api:
build:
@@ -68,7 +69,7 @@ services:
- ./data/sounds-custom:/var/lib/asterisk/sounds/custom
- ./data/moh:/var/lib/asterisk/moh
- ./data/backups:/var/lib/selfphoneng/backups
- ./data/tls:/var/lib/selfphoneng/tls
- ./data/tls:/var/lib/selfphoneng/tls:ro
caddy:
image: caddy:2
+5 -1
View File
@@ -149,6 +149,8 @@ bind = 0.0.0.0:{sip.tlsPort}
cert_file = /var/lib/selfphoneng/tls/fullchain.pem
priv_key_file = /var/lib/selfphoneng/tls/privkey.pem
method = tlsv1_2
verify_server = yes ; as built (M11-T2): check the provider (08 §2.1)
ca_list_file = /etc/ssl/certs/ca-certificates.crt
{natLines}
{/if}
@@ -168,7 +170,7 @@ Notes:
- The WebSocket bind address is ignored. The browser connects through Asterisk's HTTP server (`http.conf`).
If browser registration fails with a "no transport" error, change
`protocol = ws` to `protocol = wss` and fix the spec.
- The asterisk container needs `./data/tls:/var/lib/selfphoneng/tls:ro`. Add it to the compose file when TLS is first used (M11).
- The asterisk container needs `./data/tls:/var/lib/selfphoneng/tls:ro`. Added in M11-T2 (the api mounts it read-only too, as `TLS_DIR`).
- No `anonymous` endpoint is ever generated, so unauthenticated calls from the internet are rejected.
### 5.2 One extension (example: 101, Dan Doe, webrtc on)
@@ -319,6 +321,8 @@ For `auth_mode = 'ip'`, leave out the `-auth` and `-reg` sections and the `outbo
(and `from_user` when there is no username).
Skip disabled trunks entirely.
For TCP/TLS trunks, `server_uri`, `client_uri` and the AOR `contact` get `;transport=tcp|tls`.
As built (M11-T2): TLS trunks get `qualify_frequency = 0` (Asterisk 24 checks a qualify's certificate
against the IP, see 08 §2.1); their health comes from the registration.
`identify` resolves `match` when the config loads, and an unresolvable name is an ERROR that drops the
trunk's identify (verified), so the API refuses a server name that doesn't resolve, and a transport
that isn't turned on in Settings → SIP.
+2 -1
View File
@@ -407,9 +407,10 @@ Reports, history, system:
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`. "Trunk down" comes from live state in the browser |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2). "Trunk down" comes from live state in the browser |
| POST | `/api/settings/network/detect-public-ip` | |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
| GET | `/api/system/generated-files` | names + contents of generated files, **passwords masked** (debug view) |
| POST | `/api/system/config/apply` | force regenerate |
| POST | `/api/system/config/rollback` | |
+1 -1
View File
@@ -16,7 +16,7 @@ trunk fallback (same fields, no voip.ms-specific help text or API features).
- Allowed codecs: **G.711u (ulaw)**. Others optional.
- DTMF mode: **RFC 2833**
- NAT: **Yes**
- Encrypted SIP traffic (TLS/SRTP): optional. If on, set our trunk transport to TLS, port 5061, SRTP on.
- Encrypted SIP traffic (TLS/SRTP): optional. If on, set our trunk transport to TLS, port 5061, SRTP on. Needs Settings → Security → Accept SIP over TLS (a certificate in `data/tls`, 08 §2.1) first. voip.ms's certificate names every POP, so the trunk checks it (`verify_server`).
- CallerID Number: your main DID.
- International calling: leave **disabled** unless needed (toll-fraud protection on their side too).
2. **Choose a POP server** close to the PBX (e.g. `chicago1.voip.ms`). The sub-account registers to this server.
+19
View File
@@ -20,6 +20,25 @@
- Expose only 443/tcp, 5060/udp+tcp (5061/tcp if TLS), 10000–20000/udp.
- Admin guide includes an nftables example that limits 5060/5061 to voip.ms
server IPs (from the voip.ms wiki list, **verify**) plus `sip.extraAllowedNets`, for setups with no remote phones.
- As built (M11-T2), SIP over TLS:
- Certificate: `data/tls/fullchain.pem` + `privkey.pem`, mounted read-only into asterisk and the
api (`TLS_DIR`). `scripts/tls-cert.sh` copies them from the bundled Caddy's storage
(`data/caddy/caddy/certificates/*/<PUBLIC_HOSTNAME>/`) or from two given paths (certbot), owner
app UID, key 0600, and runs `module reload res_pjsip.so`. Asterisk 24 re-reads the cert and key on
that reload (same file names, and only when a file's mtime changed; verified, calls stay up), so
no restart. It does nothing when the
files are unchanged: run it daily from cron.
- Asterisk quietly leaves `transport-tls` out when the files are missing, so `PATCH /api/settings`
refuses to turn `sip.tlsEnabled` on unless both load and the key matches (node:crypto
`X509Certificate.checkPrivateKey`), and the dashboard warns (`tls_cert`) when TLS is on and the
cert is missing or expires within 14 days.
- `transport-tls` has `verify_server = yes` + `ca_list_file = /etc/ssl/certs/ca-certificates.crt`:
the provider's certificate is checked (voip.ms: Let's Encrypt, every POP in its SAN list,
checked 2026-10-19). Asterisk 24.0.0-rc2 checks a **qualify** OPTIONS against the IP instead of
the host name, which fails on voip.ms ("server identity does not match") and marks the contact
Unavailable, so TLS trunks get `qualify_frequency = 0` and Test skips the qualify; REGISTER and
INVITE verify the name correctly. Re-check on Asterisk upgrades.
- Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later.
### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`)
When true, the generator adds to every **desk** extension endpoint (not `-web`, not trunks):
+4
View File
@@ -103,3 +103,7 @@ export type SettingsDto = {
/** GET /api/system/warnings: shown as the dashboard banner (05 §2). */
export type Warning = { code: string; message: string; link: string };
/** GET /api/system/tls: the SIP TLS certificate in data/tls (M11-T2). */
export type TlsCert =
{ ok: true; subject: string; names: string[]; notAfter: string } | { ok: false; error: string };
+9
View File
@@ -208,3 +208,12 @@ survives a later config write.
(a wrong code refused, 10 recovery codes shown), signs out and back in with password then a code,
then with a recovery code (9 left), and turns it off with the password. Codes come from
`apps/api/src/auth/totp.ts` (node runs it directly).
`m11-t2.mjs <baseUrl> <adminEmail> <adminPassword> <tlsDir> <shotsDir>` (after `m1-t4.mjs`; start
the api with `TLS_DIR=<tlsDir>`, an empty folder) makes a throwaway CA with the image's openssl and
starts a fake voip.ms that only speaks TLS (`sp-live-tlsprov`, certificate for its IP). Settings →
Security first says there is no certificate, then shows the PBX's; the admin turns TLS on and
telephony restarts. A TLS + SRTP trunk must not register while the CA is untrusted (`verify_server`
logs "certificate is untrusted"), then registers once it is; a call carries `RTP/SAVP` with an
`a=crypto` key and audio both ways; a renewed PBX certificate is served after
`module reload res_pjsip.so` (what `scripts/tls-cert.sh` runs) while that call stays up.
+302
View File
@@ -0,0 +1,302 @@
// 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).
}
+1 -1
View File
@@ -84,7 +84,7 @@ if (cmd === 'up') {
await sleep(500);
console.log('asterisk up; AMI on 127.0.0.1:15038, secret m2-live-secret');
} else if (cmd === 'down') {
docker('rm', '-f', PHONES, AST, 'sp-live-provider', 'sp-live-phone101');
docker('rm', '-f', PHONES, AST, 'sp-live-provider', 'sp-live-tlsprov', 'sp-live-phone101');
docker('network', 'rm', NET);
} else if (cmd === 'check') {
const [base, email, pw, shotsDir] = args;
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Puts the SIP TLS certificate in data/tls and makes Asterisk use it (M11-T2). Asterisk re-reads
# the files on a res_pjsip reload: calls in progress stay up. Does nothing when they're unchanged,
# so it can run daily from cron to pick up renewals.
#
# Usage: scripts/tls-cert.sh the cert the bundled Caddy got for PUBLIC_HOSTNAME
# scripts/tls-cert.sh fullchain.pem privkey.pem any other (e.g. certbot's live/<host>/)
set -eu
cd "$(dirname "$0")/.."
if [ $# -eq 2 ]; then
cert=$1 key=$2
elif [ $# -eq 0 ]; then
host=$(sed -n 's/^PUBLIC_HOSTNAME=\([^ #]*\).*/\1/p' .env)
# Caddy keeps <host>.crt (full chain) and <host>.key per ACME issuer; take the newest.
dir=$(ls -dt data/caddy/caddy/certificates/*/"$host" 2>/dev/null | head -n 1)
if [ -z "$dir" ]; then
echo "No Caddy certificate for $host. Pass fullchain.pem and privkey.pem instead." >&2
exit 1
fi
cert=$dir/$host.crt key=$dir/$host.key
else
sed -n '7,8s/^# \{0,1\}//p' "$0" >&2
exit 2
fi
if cmp -s "$cert" data/tls/fullchain.pem && cmp -s "$key" data/tls/privkey.pem; then
exit 0
fi
uid=$(sed -n 's/^APP_UID=\([0-9]*\).*/\1/p' .env); uid=${uid:-1000}
gid=$(sed -n 's/^APP_GID=\([0-9]*\).*/\1/p' .env); gid=${gid:-1000}
# Asterisk and the api both run as the app user and only need to read these. Asterisk re-reads them
# only when their mtime changes: install (not cp -p) gives them a fresh one.
install -o "$uid" -g "$gid" -m 644 "$cert" data/tls/fullchain.pem.new
install -o "$uid" -g "$gid" -m 600 "$key" data/tls/privkey.pem.new
mv data/tls/fullchain.pem.new data/tls/fullchain.pem
mv data/tls/privkey.pem.new data/tls/privkey.pem
docker compose exec -T asterisk asterisk -rx 'module reload res_pjsip.so'
openssl x509 -noout -subject -enddate -in data/tls/fullchain.pem