/** * FreePBX Blacklist — NOT a MySQL table (verified on the PBX: `asterisk`/ * `asteriskcdrdb` have no `blacklist` table). The Blacklist module stores * entries directly in Asterisk's internal astdb, family `blacklist`, via AMI * `DBPut`/`DBDel` (see `Blacklist.class.php::numberAdd()` / * `Blacklist.class.php::numberDel()` on the PBX — this mirrors it exactly). * The dialplan's `BLACKLIST()` check reads astdb live on every inbound call, * so a DBPut takes effect immediately — no FreePBX "Apply Config" needed. * * `DBPut`/`DBDel` require the AMI `system` write-privilege class (confirmed * via `asterisk -rx "manager show command DBPut"` → Privilege: system,all). * The [logship] AMI account (manager_custom.conf on the PBX) was granted * `write=…,system` for this feature — deliberately NOT the `command` class * (that's what actually gates raw CLI exec via the `Command` AMI action). */ import { connectAmi, amiValue, type AmiClient } from './amiClient' import { randomBytes, randomUUID } from 'node:crypto' /** Same shape FreePBX's own form accepts; 'dest'/'blocked' are the module's own astdb keys * (blacklist destination / global on-off) — a caller number must never collide with those. */ export const isValidBlacklistNumber = (number: string): boolean => /^\+?\d{3,20}$/.test(number) && !['dest', 'blocked'].includes(number.toLowerCase()) // FreePBX's own "add number" form defaults an empty Description to a bare // '1' — but its GUI still treats the field as mandatory, so entries added // there always carry text. We synthesize one the same way (never blank), // optionally folding in the caller name/label for context. export const randomBlacklistDescription = (label?: string): string => { const tag = `LogShip ${randomBytes(3).toString('hex')}` const clean = String(label || '').trim().replace(/[\r\n]+/g, ' ').slice(0, 40) return (clean ? `${clean} - ${tag}` : tag).slice(0, 60) } const openAmi = async () => { const config = useRuntimeConfig() const cfg = (config as any).asterisk || {} if (!cfg.secret) throw createError({ statusCode: 503, statusMessage: 'Telefonanlage nicht konfiguriert' }) try { return await connectAmi({ host: cfg.host, port: cfg.port, username: cfg.username, secret: cfg.secret }) } catch (err: any) { throw createError({ statusCode: 502, statusMessage: 'Telefonanlage nicht erreichbar' }) } } /** Adds `number` to the FreePBX Blacklist. `description` must be non-empty. */ export const addToBlacklist = async (number: string, description: string): Promise => { const ami = await openAmi() try { const res = await ami.send('DBPut', { Family: 'blacklist', Key: number, Val: description }) if (amiValue(res, 'Response') !== 'Success') { throw createError({ statusCode: 502, statusMessage: amiValue(res, 'Message') || 'Sperren fehlgeschlagen' }) } } finally { ami.close() } } /** * Reads ONE blacklist entry on an open AMI session. `DBGet` only needs the `reporting` * class. Its `Response` packet is just an ack — `Success` = the key exists (= blocked), * `Error` + "Database entry not found" = not blocked (any OTHER error throws → the UI * shows "status unknown", never a false "not blocked"); the stored value (our description) * arrives separately as `Event: DBGetResponse` with the same ActionID, so a listener is * registered BEFORE sending. The value is optional garnish: a missing event never changes * the blocked verdict. */ const readEntry = async (ami: AmiClient, number: string): Promise<{ blocked: boolean; description: string | null }> => { const actionId = randomUUID() let off: () => void = () => {} const value = new Promise((resolve) => { const timer = setTimeout(() => resolve(null), 1500) timer.unref?.() off = ami.onEvent((evt) => { if (amiValue(evt, 'Event') === 'DBGetResponse' && amiValue(evt, 'ActionID') === actionId) { clearTimeout(timer) resolve(amiValue(evt, 'Val') || null) } }) }) try { const res = await ami.send('DBGet', { Family: 'blacklist', Key: number }, 5000, actionId) if (amiValue(res, 'Response') === 'Success') return { blocked: true, description: await value } // Only "Database entry not found" means NOT blocked. Any other error (permission denied, // unknown action, …) must not be shown as "not blocked" — surface it as "status unknown". const msg = amiValue(res, 'Message') if (/not found/i.test(msg)) return { blocked: false, description: null } throw createError({ statusCode: 502, statusMessage: msg || 'Sperrstatus konnte nicht gelesen werden' }) } finally { off() } } /** Blacklist status of several numbers over ONE AMI session (dashboard card: ≤ 10 per page). */ export const getBlacklistStatus = async (numbers: string[]): Promise> => { const out: Record = {} const list = [...new Set(numbers)].filter(isValidBlacklistNumber).slice(0, 50) if (!list.length) return out const ami = await openAmi() try { for (const n of list) out[n] = await readEntry(ami, n) } finally { ami.close() } return out } /** Removes `number` from the FreePBX Blacklist (mirrors Blacklist.class.php::numberDel()). */ export const removeFromBlacklist = async (number: string): Promise => { const ami = await openAmi() try { const res = await ami.send('DBDel', { Family: 'blacklist', Key: number }) if (amiValue(res, 'Response') !== 'Success') { throw createError({ statusCode: 502, statusMessage: amiValue(res, 'Message') || 'Entsperren fehlgeschlagen' }) } } finally { ami.close() } }