asterisk: 24.0.0-rc3, TLS trunks qualified again

rc3 checks a qualify's certificate against the host name, so TLS trunks get
qualify_frequency = 60 and Test qualifies them. The upgrade check and
pnpm test:asterisk pass on rc3. Spec 01's copy of the Dockerfile is in sync
again, and the version test now compares all of it.
This commit is contained in:
iamdoubz committed 2026-10-28 17:11:18 -05:00
1 parent a0121d4975
commit 0a18d5354d
11 files changed
+40 -40

No files matched your search

@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
[trunk-a1b2c3d4-0000-4000-8000-000000000004] [trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = aor type = aor
contact = sip:chicago1.voip.ms:5061;transport=tls contact = sip:chicago1.voip.ms:5061;transport=tls
qualify_frequency = 0 qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000004] [trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = endpoint type = endpoint
+3 -5
View File
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
section(`trunk-${id}`, [ section(`trunk-${id}`, [
'type = aor', 'type = aor',
`contact = sip:${target}`, `contact = sip:${target}`,
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so // Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and // (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health. 'qualify_frequency = 60',
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
]), ]),
section(`trunk-${id}`, [ section(`trunk-${id}`, [
'type = endpoint', 'type = endpoint',
+3 -5
View File
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */ /** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk( async testTrunk(
id: string, id: string,
{ waitMs = 1500, qualify = true } = {}, { waitMs = 1500 } = {},
): Promise<{ live: TrunkLive; rttMs: number | null }> { ): Promise<{ live: TrunkLive; rttMs: number | null }> {
if (qualify) { await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {}); await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
}
await this.refreshRegistrations(); await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id); const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id); const live = this.trunks.get(id);
+6 -9
View File
@@ -208,21 +208,18 @@ export async function trunkRoutes(app: FastifyInstance) {
if (!t.enabled) throw badRequest('Turn the trunk on first'); if (!t.enabled) throw badRequest('Turn the trunk on first');
if (!app.live.amiConnected) if (!app.live.amiConnected)
throw new HttpError(503, 'unavailable', 'Asterisk is not connected'); 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);
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
const reg = t.authMode === 'registration'; const reg = t.authMode === 'registration';
const detail = const detail =
reg && live.registration === 'registered' reg && live.registration === 'registered'
? `Registered with ${t.serverHost}` ? `Registered with ${t.serverHost}`
: reg && live.registration === 'rejected' : reg && live.registration === 'rejected'
? `${t.serverHost} rejected the username or password` ? `${t.serverHost} rejected the username or password`
: !reg && t.transport === 'tls' : !live.reachable
? `Can't check a TLS trunk without registration from here: make a test call.` ? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: !live.reachable : reg
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.` ? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: reg : `${t.serverHost} answers (IP authentication: no registration)`;
? `${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 }; return { registration: reg ? live.registration : 'unknown', rttMs, detail };
}); });
+4 -2
View File
@@ -15,12 +15,14 @@ describe('the pinned Asterisk', () => {
).toBe(VERIFIED_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( const spec = readFileSync(
new URL('../../../docs/spec/01-infrastructure.md', import.meta.url), new URL('../../../docs/spec/01-infrastructure.md', import.meta.url),
'utf8', 'utf8',
); );
expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``); 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);
}); });
}); });
+4 -4
View File
@@ -4,7 +4,7 @@
// `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this. // `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this.
import { readFileSync } from 'node:fs'; 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. */ /** The version asterisk/Dockerfile builds. */
export function pinnedAsterisk(): string { export function pinnedAsterisk(): string {
@@ -57,11 +57,11 @@ export const FACTS = {
where: 'services/cdr.ts utc() adds the Z itself', where: 'services/cdr.ts utc() adds the Z itself',
}, },
tlsQualifyByHostname: { tlsQualifyByHostname: {
expect: false, expect: true,
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP', what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
where: where:
'generator/pjsip.ts sets qualify_frequency = 0 on TLS trunks because of this (08 §2.1); ' + 'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' +
'24.0.0-rc3 says it fixes it ("Preserve target hostname for qualified OPTIONS destination")', '24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0',
}, },
} as const; } as const;
+5 -3
View File
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
expect(await warningCodes()).not.toContain('tls_cert'); 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', { const trunk = await req('POST', '/api/trunks', {
name: 'voip.ms TLS', name: 'voip.ms TLS',
serverHost: '127.0.0.1', serverHost: '127.0.0.1',
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
await t.app.generator.apply({ trigger: 'test' }); await t.app.generator.apply({ trigger: 'test' });
const { readFile } = await import('node:fs/promises'); const { readFile } = await import('node:fs/promises');
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8'); 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(/media_encryption = sdes/);
expect(conf).toMatch(/verify_server = yes/); expect(conf).toMatch(/verify_server = yes/);
t.live.amiConnected = true; t.live.amiConnected = true;
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`); const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
expect(test.statusCode).toBe(200); 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 }); const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
expect(off.statusCode).toBe(400); expect(off.statusCode).toBe(400);
expect(off.json().error.message).toMatch(/uses TLS/); expect(off.json().error.message).toMatch(/uses TLS/);
+1 -1
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
FROM debian:trixie-slim AS build FROM debian:trixie-slim AS build
ARG ASTERISK_VERSION=24.0.0-rc2 ARG ASTERISK_VERSION=24.0.0-rc3
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \ build-essential ca-certificates curl wget pkg-config autoconf automake libtool \
+5 -4
View File
@@ -142,10 +142,10 @@ missing or invalid, it logs a clear message and exits with code 1.
## 2. Asterisk image (`asterisk/Dockerfile`) ## 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 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 > **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 > 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 > 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 > 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 ```dockerfile
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
FROM debian:trixie-slim AS build FROM debian:trixie-slim AS build
ARG ASTERISK_VERSION=24.0.0-rc2 ARG ASTERISK_VERSION=24.0.0-rc3
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \ 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 --from=build /out/ /
COPY conf/ /etc/asterisk/ COPY conf/ /etc/asterisk/
COPY entrypoint.sh /entrypoint.sh 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 && chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
``` ```
+2 -2
View File
@@ -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). (and `from_user` when there is no username).
Skip disabled trunks entirely. Skip disabled trunks entirely.
For TCP/TLS trunks, `server_uri`, `client_uri` and the AOR `contact` get `;transport=tcp|tls`. 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 TLS trunks are qualified like the others (`qualify_frequency = 60`) since 24.0.0-rc3. On rc2 they
against the IP, see 08 §2.1); their health comes from the registration. 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 `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 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. that isn't turned on in Settings → SIP.
+6 -4
View File
@@ -34,10 +34,12 @@
cert is missing or expires within 14 days. cert is missing or expires within 14 days.
- `transport-tls` has `verify_server = yes` + `ca_list_file = /etc/ssl/certs/ca-certificates.crt`: - `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, 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 checked 2026-10-19). Asterisk 24.0.0-rc2 checked a **qualify** OPTIONS against the IP instead
the host name, which fails on voip.ms ("server identity does not match") and marks the contact of the host name, which failed on voip.ms ("server identity does not match") and marked the
Unavailable, so TLS trunks get `qualify_frequency = 0` and Test skips the qualify; REGISTER and contact Unavailable, so TLS trunks had `qualify_frequency = 0` and Test skipped the qualify.
INVITE verify the name correctly. Re-check on Asterisk upgrades. 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. - Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later.
### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`) ### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`)