diff --git a/CHANGELOG.md b/CHANGELOG.md index 2021276..1a878bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index a337fb1..a69ba60 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index 7974729..6f494f0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", 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 new file mode 100644 index 0000000..80e7a27 --- /dev/null +++ b/apps/api/test/asterisk-version.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/asterisk/upgrade-test.ts b/apps/api/test/asterisk/upgrade-test.ts new file mode 100644 index 0000000..ebfbe6d --- /dev/null +++ b/apps/api/test/asterisk/upgrade-test.ts @@ -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, 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; + 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((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(); diff --git a/apps/api/test/asterisk/verified.ts b/apps/api/test/asterisk/verified.ts new file mode 100644 index 0000000..7405325 --- /dev/null +++ b/apps/api/test/asterisk/verified.ts @@ -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; 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/apps/probe/package.json b/apps/probe/package.json index 35fc284..66dbefe 100644 --- a/apps/probe/package.json +++ b/apps/probe/package.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index aa1d0b6..d807f92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", 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/admin-guide/network.md b/docs/admin-guide/network.md index cf581b8..555decd 100644 --- a/docs/admin-guide/network.md +++ b/docs/admin-guide/network.md @@ -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 diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md index 2e0defe..48a38e2 100644 --- a/docs/spec/01-infrastructure.md +++ b/docs/spec/01-infrastructure.md @@ -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"] ``` 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`) diff --git a/package.json b/package.json index c0e3cac..dac2589 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/package.json b/packages/shared/package.json index f4c4140..ac3b4a3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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",