// SPDX-License-Identifier: AGPL-3.0-or-later // DB → Asterisk config (03). render() is pure; apply() writes changed files and reloads Asterisk. import { createHash } from 'node:crypto'; import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { FastifyBaseLogger } from 'fastify'; import type { AmiClient } from '../ami/client.ts'; import type { Db } from '../db/client.ts'; import { eq } from 'drizzle-orm'; import { generatorRuns, settings } from '../db/schema.ts'; import { getSetting, setSetting } from '../services/settings.ts'; import { resendPauses } from '../services/queues.ts'; import { loadSnapshot, type Snapshot } from './load.ts'; import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts'; import { STATIC_DIALPLAN } from './dialplan/static.ts'; import { renderGlobals, renderIvrDirect, renderLocalNumbers } from './dialplan/local.ts'; import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts'; import { renderOutbound } from './dialplan/outbound.ts'; import { renderEmergency } from './dialplan/emergency.ts'; import { renderRingGroups } from './dialplan/ringgroups.ts'; import { renderQueueDialplan } from './dialplan/queues.ts'; import { renderQueues } from './queues.ts'; import { renderMoh } from './moh.ts'; import { renderConfbridge, renderConferences } from './dialplan/conferences.ts'; import { renderPaging } from './dialplan/paging.ts'; import { renderIvrs } from './dialplan/ivrs.ts'; import { renderTimeConditions } from './dialplan/timeconditions.ts'; import { renderAnnouncements } from './dialplan/announcements.ts'; import { renderVoicemail } from './voicemail.ts'; export { renderTransports }; export const HEADER = '; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.\n'; /** Generated file → the AMI Command that makes Asterisk load it (03 §3). Order = reload order. */ export const RELOADS = { 'pjsip_selfphoneng.conf': 'module reload res_pjsip.so', 'rtp_selfphoneng.conf': 'module reload res_rtp_asterisk.so', 'extensions_globals_selfphoneng.conf': 'dialplan reload', 'extensions_selfphoneng.conf': 'dialplan reload', 'voicemail_selfphoneng.conf': 'voicemail reload', 'queues_selfphoneng.conf': 'queue reload all', 'musiconhold_selfphoneng.conf': 'moh reload', 'confbridge_selfphoneng.conf': 'module reload app_confbridge.so', } as const; export type GeneratedFileName = keyof typeof RELOADS; export const GENERATED_FILES = Object.keys(RELOADS) as GeneratedFileName[]; export type RenderedFiles = Record; // Each file's body; tasks fill these in (M2-T3 pjsip/rtp, M2-T4 dialplan, ...). const BODIES: Partial string>> = { 'pjsip_selfphoneng.conf': renderPjsip, 'rtp_selfphoneng.conf': renderRtp, 'musiconhold_selfphoneng.conf': renderMoh, 'extensions_globals_selfphoneng.conf': renderGlobals, 'voicemail_selfphoneng.conf': renderVoicemail, 'queues_selfphoneng.conf': renderQueues, 'confbridge_selfphoneng.conf': renderConfbridge, 'extensions_selfphoneng.conf': (snap) => [ STATIC_DIALPLAN, renderLocalNumbers(snap), renderRingGroups(snap), renderQueueDialplan(snap), renderConferences(snap), renderPaging(snap), renderIvrs(snap), renderIvrDirect(snap), renderTimeConditions(snap), renderAnnouncements(snap), renderTrunkEntries(snap), renderDids(snap), renderOutbound(snap), renderEmergency(snap), ] .filter(Boolean) .join('\n'), }; /** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */ export function render(snap: Snapshot): RenderedFiles { return Object.fromEntries( GENERATED_FILES.map((f) => [f, HEADER + (BODIES[f]?.(snap) ?? '')]), ) as RenderedFiles; } export type ApplyResult = { ok: boolean; error: string | null; filesChanged: GeneratedFileName[]; commands: string[]; restartRequired: boolean; }; export type GeneratorDeps = { db: Db; ami: Pick; /** /etc/asterisk/selfphoneng */ dir: string; log: Pick; onApplied?: (r: ApplyResult) => void; debounceMs?: number; /** RTP_PORT_START/END (config.ts). */ rtpPorts?: { start: number; end: number }; /** The caller-name lookup (11 §6 C4). */ callerNames?: { url: string; token: string }; }; const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); export function createGenerator(deps: GeneratorDeps) { const { db, ami, dir, log } = deps; let chain: Promise = Promise.resolve(); let timer: NodeJS.Timeout | undefined; /** One at a time: each call waits for the previous one (process-wide mutex). */ function locked(fn: () => Promise): Promise { const run = chain.then(fn); chain = run.catch(() => undefined); return run; } async function writeAtomic(name: string, content: string) { const path = join(dir, name); await writeFile(`${path}.tmp`, content, { mode: 0o640 }); await rename(`${path}.tmp`, path); } async function sendReloads( commands: string[], ): Promise<{ sent: string[]; error: string | null }> { if (!ami.isConnected()) return { sent: [], error: 'ami-disconnected' }; const sent: string[] = []; for (const c of commands) { try { await ami.command(c); sent.push(c); } catch (e) { return { sent, error: `${c}: ${(e as Error).message}` }; } } return { sent, error: null }; } async function record(trigger: string, r: ApplyResult, t0: number) { await db.insert(generatorRuns).values({ trigger, filesChanged: r.filesChanged, commands: r.commands, ok: r.ok, error: r.error, durationMs: Math.round(performance.now() - t0), }); log[r.ok ? 'info' : 'error']( { trigger, filesChanged: r.filesChanged, commands: r.commands, error: r.error }, 'generator apply', ); deps.onApplied?.(r); } async function doApply(trigger: string, force: boolean): Promise { const t0 = performance.now(); const r: ApplyResult = { ok: true, error: null, filesChanged: [], commands: [], restartRequired: false, }; try { const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts, callerNames: deps.callerNames, }; const files = render(snap); await mkdir(join(dir, '.prev'), { recursive: true }); const current = new Map(); for (const f of GENERATED_FILES) { current.set(f, await readFile(join(dir, f), 'utf8').catch(() => null)); if (current.get(f) !== files[f]) r.filesChanged.push(f); } if (r.filesChanged.length === 0 && !force) { await record(trigger, r, t0); return r; } if (r.filesChanged.length) { // Keep exactly one previous version for rollback. for (const [f, content] of current) if (content !== null) await writeFile(join(dir, '.prev', f), content, { mode: 0o640 }); for (const f of r.filesChanged) await writeAtomic(f, files[f]); } // PJSIP transports can't be reloaded safely (01 §4.3). The first apply ever (no stored hash) // only adds transports to an Asterisk that booted with none, so a reload is fine then. const hash = sha256(renderTransports(snap)); const prevHash = await getSetting(db, 'system.transportHash'); const transportsChanged = prevHash !== null && prevHash !== hash; if (prevHash !== hash) await setSetting(db, 'system.transportHash', hash); if (transportsChanged) await setSetting(db, 'system.restartRequired', true); r.restartRequired = await getSetting(db, 'system.restartRequired'); const reloadFor = force ? GENERATED_FILES : r.filesChanged; const commands = [...new Set(reloadFor.map((f) => RELOADS[f]))].filter( (c) => !(transportsChanged && c === 'module reload res_pjsip.so'), ); const { sent, error } = await sendReloads(commands); r.commands = sent; if (sent.includes(RELOADS['queues_selfphoneng.conf'])) await resendPauses(db, ami); if (error) Object.assign(r, { ok: false, error }); } catch (e) { Object.assign(r, { ok: false, error: (e as Error).message }); } await record(trigger, r, t0); return r; } return { dir, apply: (opts: { trigger: string; force?: boolean }) => locked(() => doApply(opts.trigger, opts.force ?? false)), /** Debounced (1 s by default): many quick changes become one apply. */ requestApply(trigger: string) { clearTimeout(timer); timer = setTimeout(() => { void locked(() => doApply(trigger, false)); }, deps.debounceMs ?? 1000); }, /** POST /api/system/config/rollback: put .prev back and reload everything. */ rollback: () => locked(async () => { const t0 = performance.now(); const r: ApplyResult = { ok: true, error: null, filesChanged: [], commands: [], restartRequired: false, }; for (const f of GENERATED_FILES) { const prev = join(dir, '.prev', f); const restored = await copyFile(prev, join(dir, `${f}.tmp`)).then( () => true, () => false, ); if (restored) { await rename(join(dir, `${f}.tmp`), join(dir, f)); r.filesChanged.push(f); } } if (r.filesChanged.length === 0) Object.assign(r, { ok: false, error: 'no previous version' }); else { const { sent, error } = await sendReloads([...new Set(Object.values(RELOADS))]); r.commands = sent; if (sent.includes(RELOADS['queues_selfphoneng.conf'])) await resendPauses(db, ami); if (error) Object.assign(r, { ok: false, error }); } r.restartRequired = await getSetting(db, 'system.restartRequired'); await record('rollback', r, t0); return r; }), /** Cancels a pending debounced apply (shutdown, tests). */ cancelPending: () => clearTimeout(timer), }; } export type Generator = ReturnType; /** * On an AMI (re)connect: if Asterisk started after the change that needed a restart, it has * loaded the new transports, so the restart is no longer required (01 §4.3). */ export async function clearRestartIfRestarted( db: Db, ami: Pick, now = Date.now(), ): Promise { if (!(await getSetting(db, 'system.restartRequired'))) return false; const uptime = /System uptime:\s*(\d+)/.exec(await ami.command('core show uptime seconds'))?.[1]; const [row] = await db .select({ at: settings.updatedAt }) .from(settings) .where(eq(settings.key, 'system.restartRequired')); if (uptime === undefined || !row) return false; if (now - Number(uptime) * 1000 <= row.at.getTime()) return false; await setSetting(db, 'system.restartRequired', false); return true; }