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/);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Executable
+40
@@ -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
|
||||
Reference in new issue
Block a user