diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf index bb72c93..9f1f4f9 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/pjsip_selfphoneng.conf @@ -140,7 +140,7 @@ 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 +qualify_frequency = 60 [trunk-a1b2c3d4-0000-4000-8000-000000000004] type = endpoint diff --git a/apps/api/src/generator/pjsip.ts b/apps/api/src/generator/pjsip.ts index eb49f32..131deec 100644 --- a/apps/api/src/generator/pjsip.ts +++ b/apps/api/src/generator/pjsip.ts @@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string { 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}`, + // Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name + // (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname). + 'qualify_frequency = 60', ]), section(`trunk-${id}`, [ 'type = endpoint', diff --git a/apps/api/src/live/state.ts b/apps/api/src/live/state.ts index 36a5cbc..4105ca3 100644 --- a/apps/api/src/live/state.ts +++ b/apps/api/src/live/state.ts @@ -213,12 +213,10 @@ 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, qualify = true } = {}, + { waitMs = 1500 } = {}, ): 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.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {}); + await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously await this.refreshRegistrations(); const rttMs = await this.refreshReachable(id); const live = this.trunks.get(id); diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts index 6bb3e2d..bf48ac3 100644 --- a/apps/api/src/routes/trunks.ts +++ b/apps/api/src/routes/trunks.ts @@ -208,21 +208,18 @@ 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'); - // 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 { live, rttMs } = await app.live.testTrunk(id); 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` - : !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)`; + : !live.reachable + ? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.` + : reg + ? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.` + : `${t.serverHost} answers (IP authentication: no registration)`; return { registration: reg ? live.registration : 'unknown', rttMs, detail }; }); diff --git a/apps/api/test/asterisk-version.test.ts b/apps/api/test/asterisk-version.test.ts index bf2a76c..80e7a27 100644 --- a/apps/api/test/asterisk-version.test.ts +++ b/apps/api/test/asterisk-version.test.ts @@ -15,12 +15,14 @@ describe('the pinned Asterisk', () => { ).toBe(VERIFIED_ASTERISK); }); - it('is the one spec 01 §2 names', () => { + it('is the one spec 01 §2 names, and its copy of the Dockerfile is the file', () => { const spec = readFileSync( new URL('../../../docs/spec/01-infrastructure.md', import.meta.url), 'utf8', ); expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``); - expect(spec).toContain(`ARG ASTERISK_VERSION=${pinnedAsterisk()}`); + const copy = /^## 2\. [\s\S]*?^```dockerfile\n([\s\S]*?)^```$/m.exec(spec)?.[1]; + const file = readFileSync(new URL('../../../asterisk/Dockerfile', import.meta.url), 'utf8'); + expect(copy, 'spec 01 §2 has a copy of asterisk/Dockerfile: keep it the same').toBe(file); }); }); diff --git a/apps/api/test/asterisk/verified.ts b/apps/api/test/asterisk/verified.ts index 9399cd8..7405325 100644 --- a/apps/api/test/asterisk/verified.ts +++ b/apps/api/test/asterisk/verified.ts @@ -4,7 +4,7 @@ // `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this. import { readFileSync } from 'node:fs'; -export const VERIFIED_ASTERISK = '24.0.0-rc2'; +export const VERIFIED_ASTERISK = '24.0.0-rc3'; /** The version asterisk/Dockerfile builds. */ export function pinnedAsterisk(): string { @@ -57,11 +57,11 @@ export const FACTS = { where: 'services/cdr.ts utc() adds the Z itself', }, tlsQualifyByHostname: { - expect: false, + expect: true, what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP', where: - 'generator/pjsip.ts sets qualify_frequency = 0 on TLS trunks because of this (08 §2.1); ' + - '24.0.0-rc3 says it fixes it ("Preserve target hostname for qualified OPTIONS destination")', + 'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' + + '24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0', }, } as const; diff --git a/apps/api/test/tls.test.ts b/apps/api/test/tls.test.ts index 0d05b37..48f64bc 100644 --- a/apps/api/test/tls.test.ts +++ b/apps/api/test/tls.test.ts @@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => { 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 () => { + it('a TLS trunk: qualified like the others (24.0.0-rc3), TLS stays on while it exists', async () => { const trunk = await req('POST', '/api/trunks', { name: 'voip.ms TLS', serverHost: '127.0.0.1', @@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => { 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(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 60\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([]); + expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([ + expect.objectContaining({ Endpoint: `trunk-${trunk.json().id}` }), + ]); const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false }); expect(off.statusCode).toBe(400); expect(off.json().error.message).toMatch(/uses TLS/); diff --git a/asterisk/Dockerfile b/asterisk/Dockerfile index e54a7cc..f02ce84 100644 --- a/asterisk/Dockerfile +++ b/asterisk/Dockerfile @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later FROM debian:trixie-slim AS build -ARG ASTERISK_VERSION=24.0.0-rc2 +ARG ASTERISK_VERSION=24.0.0-rc3 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential ca-certificates curl wget pkg-config autoconf automake libtool \ diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md index bc0cab3..48a38e2 100644 --- a/docs/spec/01-infrastructure.md +++ b/docs/spec/01-infrastructure.md @@ -142,10 +142,10 @@ missing or invalid, it logs a clear message and exits with code 1. ## 2. Asterisk image (`asterisk/Dockerfile`) Build Asterisk **24 LTS from source**. Pin an exact version: the newest 24.x release on the day you write it, recorded here in -the same commit. **Pinned: `24.0.0-rc2` (2026-10-17).** The file in the repo is the source of truth; this copy is kept in sync. +the same commit. **Pinned: `24.0.0-rc3` (2026-10-28).** The file in the repo is the source of truth; this copy is kept in sync. > **Asterisk 24 status (checked 2026-10-17):** Asterisk 24 is the LTS line, but -> only release candidates are published so far (24.0.0-rc2). Until 24.0.0 is final, +> only release candidates are published so far (24.0.0-rc3 on 2026-10-28). Until 24.0.0 is final, > develop against the newest 24.0.0-rcN. Release candidates may sit under a > different download folder than `releases/`, so check the directory listing. Switch to 24.0.0 (or newer > 24.x) as soon as it is final, and don't ship v1.0 on a release candidate. If a module, option @@ -164,7 +164,7 @@ that moved with what depends on it) and `pnpm test:asterisk`, change what moved, ```dockerfile # SPDX-License-Identifier: AGPL-3.0-or-later FROM debian:trixie-slim AS build -ARG ASTERISK_VERSION=24.0.0-rc2 +ARG ASTERISK_VERSION=24.0.0-rc3 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential ca-certificates curl wget pkg-config autoconf automake libtool \ @@ -209,7 +209,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=build /out/ / COPY conf/ /etc/asterisk/ COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \ +COPY sp-vm-pincheck /usr/local/bin/sp-vm-pincheck +RUN chmod +x /entrypoint.sh /usr/local/bin/sp-vm-pincheck && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \ && chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk ENTRYPOINT ["/entrypoint.sh"] ``` diff --git a/docs/spec/03-config-generator.md b/docs/spec/03-config-generator.md index ecd0ac0..6046461 100644 --- a/docs/spec/03-config-generator.md +++ b/docs/spec/03-config-generator.md @@ -322,8 +322,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. +TLS trunks are qualified like the others (`qualify_frequency = 60`) since 24.0.0-rc3. On rc2 they +had `qualify_frequency = 0`, because rc2 checked a qualify's certificate against the IP (08 §2.1). `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. diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md index 853f08e..876a6dc 100644 --- a/docs/spec/08-security-ops.md +++ b/docs/spec/08-security-ops.md @@ -34,10 +34,12 @@ 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. + checked 2026-10-19). Asterisk 24.0.0-rc2 checked a **qualify** OPTIONS against the IP instead + of the host name, which failed on voip.ms ("server identity does not match") and marked the + contact Unavailable, so TLS trunks had `qualify_frequency = 0` and Test skipped the qualify. + 24.0.0-rc3 fixed it ("Preserve target hostname for qualified OPTIONS destination"): TLS trunks + are qualified again, and the upgrade check (`tlsQualifyByHostname`, 01 §2) catches it if it + comes back. - Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later. ### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`)