Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk with baresip: apps/api/test/asterisk/cnam-test.ts.
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
// 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<GeneratedFileName, string>;
|
|
|
|
// Each file's body; tasks fill these in (M2-T3 pjsip/rtp, M2-T4 dialplan, ...).
|
|
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => 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<AmiClient, 'isConnected' | 'command' | 'action'>;
|
|
/** /etc/asterisk/selfphoneng */
|
|
dir: string;
|
|
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
|
|
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<unknown> = Promise.resolve();
|
|
let timer: NodeJS.Timeout | undefined;
|
|
|
|
/** One at a time: each call waits for the previous one (process-wide mutex). */
|
|
function locked<T>(fn: () => Promise<T>): Promise<T> {
|
|
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<ApplyResult> {
|
|
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<GeneratedFileName, string | null>();
|
|
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<typeof createGenerator>;
|
|
|
|
/**
|
|
* 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<AmiClient, 'command'>,
|
|
now = Date.now(),
|
|
): Promise<boolean> {
|
|
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;
|
|
}
|