Merge pull request 'Asterisk/24.0.0 rc3' (#9) from asterisk/24.0.0-rc3 into main
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
commit
6bd2aaec56
20 files changed
+492
-42
No files matched your search
@@ -5,6 +5,41 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.12 — 2026-10-28
|
||||
|
||||
**Before you update:** this version changes the database (spam reports); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
|
||||
**Probe key** below).
|
||||
|
||||
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
|
||||
many they answered and missed, talk time, calls and missed calls per day, and their busiest
|
||||
hours.
|
||||
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
|
||||
the spam check flagged the call, its finding is the reason; otherwise the person types one.
|
||||
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
|
||||
Dashboard counts them.
|
||||
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
|
||||
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
|
||||
a connected sign-in, and connecting an OpenID account only finishes in the browser that
|
||||
started it.
|
||||
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
|
||||
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
|
||||
its summary in their call report email or on their Contrack timeline.
|
||||
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
|
||||
- Changing the Contrack address asks for the company token again, and everyone reconnects
|
||||
their own Contrack token.
|
||||
- A directory account can only be swapped for another by an admin.
|
||||
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
|
||||
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
|
||||
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
|
||||
proxy must run on the probe's machine.
|
||||
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
|
||||
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
|
||||
on Asterisk doing before any version change.
|
||||
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
|
||||
([network](docs/admin-guide/network.md#crowdsec)).
|
||||
|
||||
## v0.0.11 — 2026-10-28
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.11), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.12), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A new Asterisk in asterisk/Dockerfile must go through the upgrade check first (verified.ts).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { VERIFIED_ASTERISK, pinnedAsterisk } from './asterisk/verified.ts';
|
||||
|
||||
describe('the pinned Asterisk', () => {
|
||||
it('is the version the upgrade check last passed on', () => {
|
||||
expect(
|
||||
pinnedAsterisk(),
|
||||
`asterisk/Dockerfile pins ${pinnedAsterisk()}, the upgrade check last passed on ` +
|
||||
`${VERIFIED_ASTERISK}. Build the image, run \`pnpm test:asterisk-upgrade\` and ` +
|
||||
'`pnpm test:asterisk`, act on what moved, then set VERIFIED_ASTERISK in ' +
|
||||
'apps/api/test/asterisk/verified.ts',
|
||||
).toBe(VERIFIED_ASTERISK);
|
||||
});
|
||||
|
||||
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()}\``);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Asterisk upgrade check (01 §2): boot the pinned image and measure each behaviour in verified.ts,
|
||||
// then list every one that moved and what depends on it. A second Asterisk plays a provider on
|
||||
// TLS with a certificate from our own CA for "provider.test", so the qualify check sees a real
|
||||
// host name. Needs Docker and the image built from asterisk/Dockerfile (ASTERISK_IMAGE, default
|
||||
// "selfphoneng-asterisk").
|
||||
// Usage: pnpm test:asterisk-upgrade
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { field, type AmiMessage } from '../../src/ami/events.ts';
|
||||
import { FACTS, VERIFIED_ASTERISK, pinnedAsterisk, type Fact } from './verified.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const GOLDEN = fileURLToPath(
|
||||
new URL('../../src/generator/__golden__/one-user/expected', import.meta.url),
|
||||
);
|
||||
const NET = 'sp-upgrade';
|
||||
const PBX = 'sp-upgrade-pbx';
|
||||
const PROVIDER = 'sp-upgrade-provider';
|
||||
const HOST = 'provider.test';
|
||||
const AMI_PORT = 15040;
|
||||
const AMI_SECRET = 'upgrade-test-secret';
|
||||
const SUB = { user: 'upgrade', pass: 'UpgradeSecret42' };
|
||||
const RISKY = ['app_system', 'func_shell', 'app_disa', 'res_agi', 'app_externalivr'];
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const log = (...a: unknown[]) => console.log('[upgrade-test]', ...a);
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
/** A CA, and a certificate from it for HOST: what the PBX trusts and the provider shows. */
|
||||
function certificates(dir: string) {
|
||||
const r = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${dir}:/out`,
|
||||
'--entrypoint',
|
||||
'sh',
|
||||
IMAGE,
|
||||
'-c',
|
||||
[
|
||||
'cd /out',
|
||||
'openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=sp-upgrade-ca -keyout ca.key -out ca.pem',
|
||||
`openssl req -newkey rsa:2048 -nodes -subj /CN=${HOST} -keyout privkey.pem -out host.csr`,
|
||||
`printf 'subjectAltName=DNS:${HOST}' > host.ext`,
|
||||
'openssl x509 -req -in host.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile host.ext -out fullchain.pem',
|
||||
'chmod 644 *',
|
||||
].join(' && '),
|
||||
);
|
||||
if (!r.ok) throw new Error(`certificates: ${r.out}`);
|
||||
}
|
||||
|
||||
const tlsTransport = (name: string, port: number, client: boolean) => `
|
||||
[${name}]
|
||||
type = transport
|
||||
protocol = tls
|
||||
bind = 0.0.0.0:${port}
|
||||
cert_file = /tls/fullchain.pem
|
||||
priv_key_file = /tls/privkey.pem
|
||||
method = tlsv1_2
|
||||
${client ? 'verify_server = yes\nca_list_file = /tls/ca.pem\n' : ''}`;
|
||||
|
||||
/** The provider: TLS only, one account that may register. */
|
||||
const providerPjsip = `${tlsTransport('transport-tls', 5061, false)}
|
||||
[${SUB.user}]
|
||||
type = endpoint
|
||||
context = default
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
auth = ${SUB.user}-auth
|
||||
aors = ${SUB.user}
|
||||
|
||||
[${SUB.user}-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = ${SUB.user}
|
||||
password = ${SUB.pass}
|
||||
|
||||
[${SUB.user}]
|
||||
type = aor
|
||||
max_contacts = 1
|
||||
remove_existing = yes
|
||||
`;
|
||||
|
||||
/**
|
||||
* Added to the one-user golden config: a trunk to the provider by name over TLS. The registration
|
||||
* is the control (REGISTER verifies the name on every version we know); the AOR's qualify is the
|
||||
* fact being measured.
|
||||
*/
|
||||
const pbxPjsip = `${tlsTransport('sp-upgrade-tls', 5062, true)}
|
||||
[sp-upgrade-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = ${SUB.user}
|
||||
password = ${SUB.pass}
|
||||
|
||||
[sp-upgrade-reg]
|
||||
type = registration
|
||||
transport = sp-upgrade-tls
|
||||
outbound_auth = sp-upgrade-auth
|
||||
server_uri = sip:${HOST}:5061;transport=tls
|
||||
client_uri = sip:${SUB.user}@${HOST}:5061;transport=tls
|
||||
contact_user = ${SUB.user}
|
||||
retry_interval = 5
|
||||
|
||||
[sp-upgrade-trunk]
|
||||
type = aor
|
||||
contact = sip:${HOST}:5061;transport=tls
|
||||
qualify_frequency = 5
|
||||
|
||||
[sp-upgrade-trunk]
|
||||
type = endpoint
|
||||
transport = sp-upgrade-tls
|
||||
outbound_auth = sp-upgrade-auth
|
||||
aors = sp-upgrade-trunk
|
||||
context = sp-upgrade-test
|
||||
`;
|
||||
|
||||
const pbxDialplan = `
|
||||
[sp-upgrade-test]
|
||||
exten => s,1,Answer()
|
||||
same => n,Hangup()
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const pinned = pinnedAsterisk();
|
||||
const tls = mkdtempSync(join(tmpdir(), 'sp-upgrade-tls-'));
|
||||
const pbxConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-pbx-'));
|
||||
const provConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-prov-'));
|
||||
cpSync(GOLDEN, pbxConf, { recursive: true });
|
||||
appendFileSync(join(pbxConf, 'pjsip_selfphoneng.conf'), pbxPjsip);
|
||||
appendFileSync(join(pbxConf, 'extensions_selfphoneng.conf'), pbxDialplan);
|
||||
for (const f of readdirSync(GOLDEN))
|
||||
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? providerPjsip : '');
|
||||
writeFileSync(
|
||||
join(pbxConf, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
username: 'selfphoneng',
|
||||
secret: AMI_SECRET,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
backoffMs: [500],
|
||||
});
|
||||
const asterisk = (name: string, conf: string, extra: string[]) =>
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
NET,
|
||||
...extra,
|
||||
'-e',
|
||||
`AMI_SECRET=${AMI_SECRET}`,
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
'-v',
|
||||
`${tls}:/tls:ro`,
|
||||
IMAGE,
|
||||
);
|
||||
const booted = (name: string) =>
|
||||
waitFor(
|
||||
`${name} to boot`,
|
||||
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
|
||||
);
|
||||
|
||||
const seen = {} as Record<Fact, boolean>;
|
||||
const problems: string[] = [];
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('network', 'create', NET);
|
||||
certificates(tls);
|
||||
const prov = asterisk(PROVIDER, provConf, ['--network-alias', HOST]);
|
||||
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
|
||||
await booted(PROVIDER);
|
||||
const pbx = asterisk(PBX, pbxConf, [
|
||||
'-p',
|
||||
`127.0.0.1:${AMI_PORT}:5038`,
|
||||
'-v',
|
||||
`${join(pbxConf, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
|
||||
]);
|
||||
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
|
||||
await booted(PBX);
|
||||
|
||||
const running = docker('exec', PBX, 'asterisk', '-V').out.trim();
|
||||
if (running !== `Asterisk ${pinned}`)
|
||||
throw new Error(
|
||||
`the image runs "${running}" but asterisk/Dockerfile pins ${pinned}: rebuild it ` +
|
||||
`(docker build -t ${IMAGE} asterisk)`,
|
||||
);
|
||||
log(`${running}, last verified on ${VERIFIED_ASTERISK}`);
|
||||
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
const cli = (cmd: string) => ami.command(cmd);
|
||||
|
||||
const modules = await Promise.all(RISKY.map((m) => cli(`module show like ${m}`)));
|
||||
seen.riskyModulesGone = modules.every((o) => /\b0 modules loaded/.test(o));
|
||||
const ep101 = await cli('pjsip show endpoint 101');
|
||||
seen.mwiSubscribeReplaces = /mwi_subscribe_replaces_unsolicited\s*:\s*yes/.test(ep101);
|
||||
// set_var lines show as their own rows: " __SP_DIRECTION : internal".
|
||||
seen.setVarKeepsUnderscores = /^\s*__SP_DIRECTION\s*:\s*internal\s*$/m.test(ep101);
|
||||
const commands = await cli('manager show commands');
|
||||
seen.voicemailMoveRemove =
|
||||
/\bVoicemailMove\b/.test(commands) && /\bVoicemailRemove\b/.test(commands);
|
||||
|
||||
// CDR: one Local call through the test context.
|
||||
const cdr = new Promise<AmiMessage>((r) => ami.once('event:Cdr', r));
|
||||
await cli('channel originate Local/s@sp-upgrade-test application Wait 1');
|
||||
const m = await Promise.race([
|
||||
cdr,
|
||||
sleep(15_000).then(() => {
|
||||
throw new Error('no Cdr event within 15 s of a call');
|
||||
}),
|
||||
]);
|
||||
seen.cdrLinkedId = !!field(m, 'LinkedID');
|
||||
const start = field(m, 'StartTime') ?? '';
|
||||
seen.cdrTimesUtcNoZone =
|
||||
/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(start) &&
|
||||
Math.abs(Date.parse(`${start.replace(' ', 'T')}Z`) - Date.now()) < 120_000;
|
||||
|
||||
// TLS: the registration must work first, or the qualify answer says nothing.
|
||||
await waitFor(
|
||||
'the TLS registration to the provider (the control)',
|
||||
async () => /sp-upgrade-reg\S*\s.*Registered/.test(await cli('pjsip show registrations')),
|
||||
45,
|
||||
).catch(async (e: Error) => {
|
||||
throw new Error(`${e.message}:\n${await cli('pjsip show registrations')}`);
|
||||
});
|
||||
let contacts = '';
|
||||
for (let i = 0; i < 20; i++) {
|
||||
contacts = await cli('pjsip show contacts');
|
||||
if (/sp-upgrade-trunk\/\S+\s.*\b(Avail|Unavail)/.test(contacts)) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
seen.tlsQualifyByHostname = /sp-upgrade-trunk\/\S+\s.*\bAvail\b/.test(contacts);
|
||||
if (!seen.tlsQualifyByHostname) {
|
||||
const why = docker('logs', PBX)
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
.out.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.split('\n');
|
||||
const line = contacts.split('\n').find((l) => l.includes('sp-upgrade-trunk'));
|
||||
log('qualify over TLS failed:', line);
|
||||
for (const l of why
|
||||
.filter((x) => /sp-upgrade-trunk|identity|EUNSUP|qualif/i.test(x))
|
||||
.slice(-4))
|
||||
log(l.trim());
|
||||
}
|
||||
|
||||
const listed = await ami.actionList({ Action: 'PJSIPShowContacts' });
|
||||
seen.staticContactsInShowContacts = listed.some((x) => x.Aor === 'sp-upgrade-trunk');
|
||||
const detail = await ami.actionList({
|
||||
Action: 'PJSIPShowEndpoint',
|
||||
Endpoint: 'sp-upgrade-trunk',
|
||||
});
|
||||
seen.endpointShowsContactStatus = detail.some(
|
||||
(x) => x.Event === 'ContactStatusDetail' && x.AOR === 'sp-upgrade-trunk',
|
||||
);
|
||||
} catch (e) {
|
||||
problems.push((e as Error).message);
|
||||
} finally {
|
||||
ami.stop();
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('network', 'rm', NET);
|
||||
for (const d of [tls, pbxConf, provConf]) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const moved = (Object.keys(FACTS) as Fact[]).filter(
|
||||
(k) => k in seen && seen[k] !== FACTS[k].expect,
|
||||
);
|
||||
for (const k of Object.keys(FACTS) as Fact[])
|
||||
console.log(
|
||||
`${!(k in seen) ? '? ' : moved.includes(k) ? 'MOVED' : 'ok '} ${k}: ${k in seen ? seen[k] : 'not measured'}`,
|
||||
);
|
||||
for (const k of moved)
|
||||
problems.push(
|
||||
`${k} was ${FACTS[k].expect} on ${VERIFIED_ASTERISK}, is ${seen[k]} on ${pinned}: ` +
|
||||
`${FACTS[k].what}. Depends on it: ${FACTS[k].where}`,
|
||||
);
|
||||
if (problems.length) {
|
||||
console.log('FAIL asterisk upgrade');
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
pinned === VERIFIED_ASTERISK
|
||||
? `ok asterisk ${pinned}: every fact as recorded`
|
||||
: `ok asterisk ${pinned}: nothing moved since ${VERIFIED_ASTERISK}; set VERIFIED_ASTERISK in verified.ts`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the code relies on Asterisk doing, as last measured by upgrade-test.ts, and on which
|
||||
// version. asterisk-version.test.ts fails when asterisk/Dockerfile pins another version: run
|
||||
// `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-rc3';
|
||||
|
||||
/** The version asterisk/Dockerfile builds. */
|
||||
export function pinnedAsterisk(): string {
|
||||
const file = readFileSync(new URL('../../../../asterisk/Dockerfile', import.meta.url), 'utf8');
|
||||
const v = /^ARG ASTERISK_VERSION=(\S+)$/m.exec(file)?.[1];
|
||||
if (!v) throw new Error('asterisk/Dockerfile has no ARG ASTERISK_VERSION');
|
||||
return v;
|
||||
}
|
||||
|
||||
/** `expect`: the answer on VERIFIED_ASTERISK. `where`: what depends on it, to change if it moves. */
|
||||
export const FACTS = {
|
||||
riskyModulesGone: {
|
||||
expect: true,
|
||||
what: 'app_system, func_shell, app_disa, res_agi and app_externalivr are not loaded',
|
||||
where: 'asterisk/conf/modules.conf noload lines (08 §6): a renamed module would load again',
|
||||
},
|
||||
mwiSubscribeReplaces: {
|
||||
expect: true,
|
||||
what: 'endpoints take mwi_subscribe_replaces_unsolicited',
|
||||
where: 'generator/pjsip.ts: without it a phone that SUBSCRIBEs for MWI gets a 500 (03 §5.2)',
|
||||
},
|
||||
setVarKeepsUnderscores: {
|
||||
expect: true,
|
||||
what: 'set_var = __SP_DIRECTION=… loads as "__SP_DIRECTION"',
|
||||
where: 'generator/pjsip.ts set_var lines; dialplan reads SP_DIRECTION on dialed legs (03 §5.2)',
|
||||
},
|
||||
staticContactsInShowContacts: {
|
||||
expect: false,
|
||||
what: 'PJSIPShowContacts lists static (contact =) AOR contacts',
|
||||
where: 'live/state.ts reads trunk reachability from PJSIPShowEndpoint instead (04 §5.1)',
|
||||
},
|
||||
endpointShowsContactStatus: {
|
||||
expect: true,
|
||||
what: 'PJSIPShowEndpoint has a ContactStatusDetail for a static contact',
|
||||
where: 'live/state.ts trunk reachability (04 §5.1)',
|
||||
},
|
||||
voicemailMoveRemove: {
|
||||
expect: true,
|
||||
what: 'the AMI actions VoicemailMove and VoicemailRemove exist',
|
||||
where: 'services/voicemail.ts mark read and delete (04 §5.6)',
|
||||
},
|
||||
cdrLinkedId: {
|
||||
expect: true,
|
||||
what: 'Cdr AMI events carry LinkedID (cdr_manager.conf mapping)',
|
||||
where: 'services/cdr.ts groups a call by it (01 §3.15, 04 §5.4)',
|
||||
},
|
||||
cdrTimesUtcNoZone: {
|
||||
expect: true,
|
||||
what: 'Cdr StartTime is "YYYY-MM-DD HH:MM:SS" in UTC, no zone',
|
||||
where: 'services/cdr.ts utc() adds the Z itself',
|
||||
},
|
||||
tlsQualifyByHostname: {
|
||||
expect: true,
|
||||
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
|
||||
where:
|
||||
'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;
|
||||
|
||||
export type Fact = keyof typeof FACTS;
|
||||
@@ -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/);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/web",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -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 \
|
||||
|
||||
@@ -89,7 +89,7 @@ needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next
|
||||
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
|
||||
|
||||
```sh
|
||||
v=v0.0.11 # the release
|
||||
v=v0.0.12 # the release
|
||||
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64
|
||||
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
|
||||
sha256sum --check --ignore-missing SHA256SUMS
|
||||
|
||||
@@ -142,20 +142,29 @@ 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
|
||||
> or dialplan function in this spec turns out to have changed in 24, follow the Asterisk 24
|
||||
> docs and update the spec (AGENTS rule 10).
|
||||
|
||||
**Changing the version** (owner, 2026-10-28). `apps/api/test/asterisk/verified.ts` lists what the
|
||||
code relies on Asterisk doing (the "checked on 24.0.0-rcN" notes in these specs, and the TLS
|
||||
qualify workaround in 08 §2.1), with the version it was measured on. `asterisk-version.test.ts`
|
||||
(part of `pnpm test`) fails while the Dockerfile pins another version or this section names
|
||||
another. To move: build the image, run `pnpm test:asterisk-upgrade` (boots it with the one-user
|
||||
golden config next to a TLS provider with its own CA, measures each fact, and lists every one
|
||||
that moved with what depends on it) and `pnpm test:asterisk`, change what moved, then set
|
||||
`VERIFIED_ASTERISK` and update this section.
|
||||
|
||||
```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 \
|
||||
@@ -200,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"]
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "selfphoneng",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -15,7 +15,8 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs && node --conditions=source apps/api/test/asterisk/call-test.ts && node --conditions=source apps/api/test/asterisk/trunk-test.ts && node --conditions=source apps/api/test/asterisk/voicemail-test.ts && node --conditions=source apps/api/test/asterisk/moh-test.ts && node --conditions=source apps/api/test/asterisk/rec-test.ts"
|
||||
"test:asterisk-upgrade": "node --conditions=source apps/api/test/asterisk/upgrade-test.ts",
|
||||
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs && node --conditions=source apps/api/test/asterisk/call-test.ts && node --conditions=source apps/api/test/asterisk/trunk-test.ts && node --conditions=source apps/api/test/asterisk/voicemail-test.ts && node --conditions=source apps/api/test/asterisk/moh-test.ts && node --conditions=source apps/api/test/asterisk/rec-test.ts && node --conditions=source apps/api/test/asterisk/upgrade-test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/shared",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
Reference in new issue
Block a user