diff --git a/.dockerignore b/.dockerignore index 5413f68..650ee6a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,3 +39,4 @@ **/*.key **/*.log **/*.pid +docs/voip.ms-api diff --git a/.gitignore b/.gitignore index 339527b..7cf644c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ coverage/ *.pid # The mobile app is its own repo (13), checked out here beside the PBX. mobile/ +# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference. +docs/voip.ms-api/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a878bc..7104c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.13 — 2026-10-29 + +**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose: +`sudo scripts/update.sh v0.0.13`. + +Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used +for toll fraud: + +- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system + calls an international or premium number (including Caribbean area codes, which are dialed like + a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system + didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the + voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud + alerts**. +- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to + turn it on for the sub-account, with only this server's public IP. A stolen trunk password is + then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)). +- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no + longer has the modules that run commands on the server. + ## v0.0.12 — 2026-10-28 **Before you update:** this version changes the database (spam reports); the api backs up first, diff --git a/README.md b/README.md index a69ba60..ed27171 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.12), for testing. See [what's left for v1](docs/v1-checklist.md). +**Status:** pre-1.0 (v0.0.13), 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 6f494f0..278cb14 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@selfphoneng/api", - "version": "0.0.12", + "version": "0.0.13", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/api/src/integrations/voipms/client.ts b/apps/api/src/integrations/voipms/client.ts index d8c3712..0332eff 100644 --- a/apps/api/src/integrations/voipms/client.ts +++ b/apps/api/src/integrations/voipms/client.ts @@ -2,8 +2,8 @@ // voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with // `status` = "success" or an error code. The password is in the query string, so the URL is never // logged or put in an error. -// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet -// (voip.ms's API docs need a login); check them with the owner's account and fix here. +// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet; +// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account. import { normalizeNumber } from '@selfphoneng/shared'; export type VoipmsCreds = { username: string; password: string }; @@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise { + let r: Record; + try { + r = await voipmsApi(baseUrl, creds, 'getCDR', { + date_from: q.from, + date_to: q.to, + answered: '1', + noanswer: '1', + busy: '1', + failed: '1', + timezone: '0', + account: q.account, + }); + } catch (e) { + if (e instanceof VoipmsError && e.status === 'no_cdr') return []; + throw e; + } + return list(r.cdr).flatMap((c) => { + const at = new Date(`${str(c.date).replace(' ', 'T')}Z`); + if (Number.isNaN(at.getTime())) return []; + return [ + { + at, + destination: str(c.destination), + account: str(c.account), + disposition: str(c.disposition), + seconds: Number(str(c.seconds)) || 0, + total: str(c.total), + uniqueid: str(c.uniqueid), + }, + ]; + }); +} + /** * The account's balance (getBalance → balance.current_balance, in the account's currency). * (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1). diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts index 9d5ad43..4e9c8db 100644 --- a/apps/api/src/jobs.ts +++ b/apps/api/src/jobs.ts @@ -31,6 +31,7 @@ import { logCallsToContrack } from './contacts/timeline.ts'; import { checkDirectory } from './sso/directory.ts'; import { checkOidcSessions } from './sso/oidcCheck.ts'; import { logEvent } from './services/events.ts'; +import { createFraudWatch } from './services/fraud.ts'; import { spamQuotaCheck } from './spam/check.ts'; import { ftcSync } from './spam/ftc.ts'; import { deliverDue } from './services/webhooks.ts'; @@ -130,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) { export function createJobs(d: JobDeps) { const now = d.now ?? (() => new Date()); const tz = () => getSetting(d.db, 'general.timezone'); + const fraud = createFraudWatch({ + db: d.db, + voipmsUrl: d.config.VOIPMS_API_URL, + mail: (subject, text) => mailAdmins(d, subject, text), + log: d.log, + now, + }); // Holidays roll over at midnight (03 §6.10): render again when the local date changes. // Retention runs then too (and at start), not at 02:00. @@ -195,7 +203,8 @@ export function createJobs(d: JobDeps) { `Public IP changed to ${ip}`, `The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` + 'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' + - 'Phones and the provider may need the new IP if they were set up with the old one.', + 'Phones and the provider may need the new IP if they were set up with the old one ' + + '(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).', ); }; @@ -296,6 +305,8 @@ export function createJobs(d: JobDeps) { publicIpWatch, trunkWatch, diskWatch, + riskyCallWatch: fraud.riskyCallWatch, + providerWatch: fraud.providerWatch, callReports, contactsWarm, contrackLog, @@ -321,6 +332,8 @@ export function startJobs(d: JobDeps): () => void { run('trunkWatch', j.trunkWatch); if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch); if (minute % 60 === 0) run('diskWatch', j.diskWatch); + run('riskyCallWatch', j.riskyCallWatch); + if (minute % 15 === 0) run('providerWatch', j.providerWatch); run('callReports', j.callReports); if (minute % 5 === 0) run('contactsWarm', j.contactsWarm); run('contrackLog', j.contrackLog); diff --git a/apps/api/src/services/fraud.ts b/apps/api/src/services/fraud.ts new file mode 100644 index 0000000..9277af9 --- /dev/null +++ b/apps/api/src/services/fraud.ts @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Fraud alerts (08 §8.3): risky calls the PBX placed, voip.ms calls it didn't place, and a +// balance that drops fast. Each is an email to every admin and a warning event. +import { and, eq, gt, gte, isNotNull, like, lte } from 'drizzle-orm'; +import { formatNumber } from '@selfphoneng/shared'; +import type { Db } from '../db/client.ts'; +import { calls, cdr, dids, trunks } from '../db/schema.ts'; +import { decrypt } from '../crypto/secrets.ts'; +import { + getBalance, + getCdr, + VoipmsError, + type VoipmsCall, + type VoipmsCreds, +} from '../integrations/voipms/client.ts'; +import { logEvent } from './events.ts'; +import { getSetting } from './settings.ts'; + +/** NANP area codes outside the US and Canada: dialed like a home call, billed like abroad. (verify) */ +const ABROAD_NANP = new Set( + '242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876'.split(' '), +); + +/** Why a dialed number is worth an email, or null. 10 digits count as 1 + them. */ +export function riskyDestination(dialed: string): string | null { + const n = /^[2-9]\d{9}$/.test(dialed) ? `1${dialed}` : dialed; + if (n.startsWith('011')) return 'international'; + const m = /^1(\d{3})(\d{3})\d{4}$/.exec(n); + if (!m) return null; + if (m[1] === '900' || m[2] === '976') return 'premium'; + if (ABROAD_NANP.has(m[1]!)) return 'international rates (outside the US and Canada)'; + return null; +} + +/** The number a trunk Dial sent (`PJSIP/@trunk-,120`), or null. */ +export const trunkDialNumber = (lastdata: string | null): string | null => + /^PJSIP\/([^@]+)@trunk-/.exec(lastdata ?? '')?.[1] ?? null; + +const tail = (n: string) => n.replace(/\D/g, '').slice(-10); +const MIN = 60_000; +const day = (d: Date) => d.toISOString().slice(0, 10); + +export type FraudDeps = { + db: Db; + voipmsUrl: string; + mail: (subject: string, text: string) => Promise; + log: { warn(o: object, m: string): void }; + now: () => Date; +}; + +export function createFraudWatch(d: FraudDeps) { + const when = async (at: Date) => + new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'short', + timeZone: await getSetting(d.db, 'general.timezone'), + }).format(at); + const warn = (code: string, source: string, message: string) => + logEvent(d.db, { severity: 'warning', code, source, message }); + + // Calls are read 10 s behind the clock, so a row whose insert is still committing isn't skipped. + // ponytail: the cursor starts at api start; a call made while the api was down has no row anyway. + let since = new Date(d.now().getTime() - 10_000); + const riskyCallWatch = async () => { + const until = new Date(d.now().getTime() - 10_000); + const rows = await d.db + .select() + .from(calls) + .where( + and( + eq(calls.direction, 'outbound'), + gt(calls.createdAt, since), + lte(calls.createdAt, until), + ), + ) + .orderBy(calls.createdAt); + since = until; + if (!(await getSetting(d.db, 'alerts.riskyCalls'))) return; + const lines: string[] = []; + for (const c of rows) { + const why = riskyDestination(c.toNumber ?? ''); + if (why) + lines.push( + `${await when(c.startedAt)} from ${c.fromNumber ?? '?'} to ${formatNumber(c.toNumber!)}: ` + + `${why}, ${c.status}, ${c.talkSeconds} s talking`, + ); + } + if (!lines.length) return; + await warn( + 'fraud.risky_call', + 'Fraud alerts', + `${lines.length} call(s) to international or premium numbers`, + ); + await d.mail( + `Calls to risky numbers (${lines.length})`, + 'The phone system placed calls to numbers that can cost a lot:\n\n' + + `${lines.join('\n')}\n\n` + + "If you don't recognise them, someone may have a phone's password: change it " + + '(Extensions → the extension → New SIP password) and check Outbound rules.\n' + + 'These emails can be turned off on Settings → Security → Fraud alerts.', + ); + }; + + // voip.ms records already reported, for 3 hours (08 §8.3). + const reported = new Map(); + async function callCheck(t: { name: string; username: string }, creds: VoipmsCreds) { + const now = d.now().getTime(); + for (const [id, at] of reported) if (at < now - 180 * MIN) reported.delete(id); + const from = new Date(now - 120 * MIN); + const to = new Date(now - 10 * MIN); + // uniqueid isn't in voip.ms's examples (06 §5.1), so the time and number make the key too. + const key = (r: VoipmsCall) => `${r.uniqueid}|${r.at.getTime()}|${r.destination}`; + const records = ( + await getCdr(d.voipmsUrl, creds, { from: day(from), to: day(to), account: t.username }) + ).filter((r) => r.at >= from && r.at <= to && !reported.has(key(r))); + if (!records.length) return; + const ours = new Set((await d.db.select({ n: dids.number }).from(dids)).map((x) => tail(x.n))); + // Every call the PBX sent to a trunk, forwards and 911 included (03 §6.12, §6.14). + const sent = ( + await d.db + .select({ at: cdr.startAt, data: cdr.lastdata }) + .from(cdr) + .where( + and( + like(cdr.lastdata, 'PJSIP/%@trunk-%'), + gte(cdr.startAt, new Date(from.getTime() - 5 * MIN)), + lte(cdr.startAt, new Date(to.getTime() + 5 * MIN)), + ), + ) + ).flatMap((r) => { + const n = trunkDialNumber(r.data); + return n && r.at ? [{ n: tail(n), at: r.at.getTime() }] : []; + }); + const strange: VoipmsCall[] = []; + for (const r of records) { + reported.set(key(r), r.at.getTime()); + if (r.account && r.account !== t.username) continue; + if (ours.has(tail(r.destination))) continue; // incoming, to one of our numbers + const i = sent.findIndex( + (s) => s.n === tail(r.destination) && Math.abs(s.at - r.at.getTime()) <= 5 * MIN, + ); + if (i >= 0) sent.splice(i, 1); + else strange.push(r); + } + if (!strange.length) return; + const lines = await Promise.all( + strange.map( + async (r) => + `${await when(r.at)} to ${r.destination}: ${r.disposition.toLowerCase() || '?'}, ` + + `${r.seconds} s, cost ${r.total}`, + ), + ); + await warn( + 'fraud.provider_call', + `Trunk ${t.name}`, + `voip.ms has ${strange.length} call(s) on ${t.username} that this phone system didn't make`, + ); + await d.mail( + `voip.ms calls this phone system didn't make (${strange.length})`, + `voip.ms billed these calls on sub-account ${t.username} (trunk ${t.name}), and this ` + + 'phone system has no record of making them:\n\n' + + `${lines.join('\n')}\n\n` + + 'Someone else may have the sub-account password. At voip.ms: change the sub-account ' + + "password (then paste it on Trunks), and turn on its IP restriction with this server's " + + 'public IP. Check the voip.ms call records too.\n' + + 'A call made while SelfPhoneNG was restarting, or by another device using the same ' + + 'sub-account, also shows up here.\n' + + 'These emails can be turned off on Settings → Security → Fraud alerts.', + ); + } + + // Balance samples per API login (one account can have several trunks), the last hour's. + const balances = new Map(); + const balanceMailed = new Map(); + async function balanceCheck(creds: VoipmsCreds, limit: number) { + const now = d.now().getTime(); + const v = await getBalance(d.voipmsUrl, creds); + const samples = [ + ...(balances.get(creds.username) ?? []).filter((s) => s.at >= now - 60 * MIN), + { at: now, v }, + ]; + balances.set(creds.username, samples); + const high = Math.max(...samples.map((s) => s.v)); + if (high - v <= limit || (balanceMailed.get(creds.username) ?? 0) > now - 60 * MIN) return; + balanceMailed.set(creds.username, now); + const drop = (high - v).toFixed(2); + await warn( + 'fraud.balance_drop', + 'Fraud alerts', + `The voip.ms balance dropped ${drop} in an hour`, + ); + await d.mail( + `voip.ms balance dropped ${drop} in an hour`, + `The voip.ms balance of ${creds.username} went from ${high.toFixed(2)} to ${v.toFixed(2)} ` + + `within the last hour, more than the ${limit} set on Settings → Security → Fraud alerts.\n\n` + + "If that isn't your own calling, check the call records at voip.ms, change the " + + 'sub-account and API passwords, and turn on IP restriction on the sub-account.', + ); + } + + const providerWatch = async () => { + const checkCalls = await getSetting(d.db, 'alerts.providerCalls'); + const limit = await getSetting(d.db, 'alerts.balanceDropPerHour'); + if (!checkCalls && !limit) return; + const rows = await d.db + .select({ + name: trunks.name, + username: trunks.username, + apiUser: trunks.voipmsApiUsername, + apiPass: trunks.voipmsApiPasswordEnc, + }) + .from(trunks) + .where( + and( + eq(trunks.enabled, true), + eq(trunks.provider, 'voipms'), + isNotNull(trunks.voipmsApiUsername), + isNotNull(trunks.voipmsApiPasswordEnc), + ), + ); + const balanceAsked = new Set(); + for (const t of rows) { + const creds = { username: t.apiUser!, password: decrypt(t.apiPass!) }; + try { + if (checkCalls && t.username) + await callCheck({ name: t.name, username: t.username }, creds); + if (limit && !balanceAsked.has(creds.username)) { + balanceAsked.add(creds.username); + await balanceCheck(creds, limit); + } + } catch (e) { + if (!(e instanceof VoipmsError)) throw e; + d.log.warn({ trunk: t.name, status: e.status }, 'voip.ms fraud check failed'); + } + } + }; + + return { riskyCallWatch, providerWatch }; +} diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts index 312eae6..53246a4 100644 --- a/apps/api/test/asterisk/call-test.ts +++ b/apps/api/test/asterisk/call-test.ts @@ -13,6 +13,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared'; import { AmiClient } from '../../src/ami/client.ts'; import { GENERATED_FILES, render } from '../../src/generator/index.ts'; import type { Snapshot } from '../../src/generator/load.ts'; +import { testManagerConf } from './manager-conf.ts'; const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30); @@ -126,11 +127,8 @@ async function main() { spawnSync('mkdir', ['-p', conf]); const files = render(snapshot()); for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]); - // Test-only manager.conf: reachable through the published port (the real one is 127.0.0.1-only). - writeFileSync( - join(dir, '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', - ); + // The real manager.conf, reachable through the published port (test/asterisk/manager-conf.ts). + writeFileSync(join(dir, 'manager.conf.template'), testManagerConf()); phoneConfig(dir, '101', 5070, 440); phoneConfig(dir, '102', 5072, 880); diff --git a/apps/api/test/asterisk/cdr-record.ts b/apps/api/test/asterisk/cdr-record.ts index c697d9d..9254c1f 100644 --- a/apps/api/test/asterisk/cdr-record.ts +++ b/apps/api/test/asterisk/cdr-record.ts @@ -18,6 +18,7 @@ import type { AmiMessage } from '../../src/ami/events.ts'; import { GENERATED_FILES, render } from '../../src/generator/index.ts'; import type { Snapshot, SnapExtension } from '../../src/generator/load.ts'; import { providerDialplan, providerPjsip } from './provider.ts'; +import { testManagerConf } from './manager-conf.ts'; const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url)); @@ -243,10 +244,7 @@ async function main() { ); } const runPhones = phoneFiles(dir); - writeFileSync( - join(dir, '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', - ); + writeFileSync(join(dir, 'manager.conf.template'), testManagerConf()); const ami = new AmiClient({ host: '127.0.0.1', port: AMI_PORT, diff --git a/apps/api/test/asterisk/cnam-test.ts b/apps/api/test/asterisk/cnam-test.ts index af2600d..4fd4978 100644 --- a/apps/api/test/asterisk/cnam-test.ts +++ b/apps/api/test/asterisk/cnam-test.ts @@ -17,6 +17,7 @@ import { AmiClient } from '../../src/ami/client.ts'; import { GENERATED_FILES, render } from '../../src/generator/index.ts'; import type { Snapshot } from '../../src/generator/load.ts'; import { providerPjsip } from './provider.ts'; +import { testManagerConf } from './manager-conf.ts'; const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const NET = 'sp-cnam'; @@ -223,10 +224,7 @@ async function main() { ); } phoneFiles(dir); - writeFileSync( - join(dir, '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', - ); + writeFileSync(join(dir, 'manager.conf.template'), testManagerConf()); const problems: string[] = []; const ami = new AmiClient({ host: '127.0.0.1', diff --git a/apps/api/test/asterisk/manager-conf.ts b/apps/api/test/asterisk/manager-conf.ts new file mode 100644 index 0000000..a3ae73e --- /dev/null +++ b/apps/api/test/asterisk/manager-conf.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The real manager.conf.template, read/write classes included, so a class the api needs and the +// template lacks fails the tests. Only opened up for the host to reach AMI through the published +// port: the real one is 127.0.0.1-only. +import { readFileSync } from 'node:fs'; + +export function testManagerConf(): string { + const real = readFileSync( + new URL('../../../../asterisk/conf/manager.conf.template', import.meta.url), + 'utf8', + ); + const open = real + .replace(/^bindaddr = 127\.0\.0\.1$/m, 'bindaddr = 0.0.0.0') + .replace(/^permit = 127\.0\.0\.1\/255\.255\.255\.255$/m, 'permit = 0.0.0.0/0.0.0.0'); + if (open.includes('127.0.0.1')) + throw new Error('manager.conf.template changed: update manager-conf.ts'); + return open; +} diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts index 036d190..4bbf2b8 100644 --- a/apps/api/test/asterisk/trunk-test.ts +++ b/apps/api/test/asterisk/trunk-test.ts @@ -17,6 +17,7 @@ import { GENERATED_FILES, render } from '../../src/generator/index.ts'; import type { Snapshot } from '../../src/generator/load.ts'; import { providerDialplan, providerPjsip } from './provider.ts'; +import { testManagerConf } from './manager-conf.ts'; const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const RECORD = process.argv.includes('--record') @@ -200,10 +201,7 @@ async function main() { ); } phoneFiles(dir); - writeFileSync( - join(dir, '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', - ); + writeFileSync(join(dir, 'manager.conf.template'), testManagerConf()); const problems: string[] = []; let recorder: Socket | undefined; diff --git a/apps/api/test/asterisk/upgrade-test.ts b/apps/api/test/asterisk/upgrade-test.ts index ebfbe6d..77e9249 100644 --- a/apps/api/test/asterisk/upgrade-test.ts +++ b/apps/api/test/asterisk/upgrade-test.ts @@ -13,6 +13,7 @@ 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'; +import { testManagerConf } from './manager-conf.ts'; const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const GOLDEN = fileURLToPath( @@ -146,10 +147,7 @@ async function main() { 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', - ); + writeFileSync(join(pbxConf, 'manager.conf.template'), testManagerConf()); const ami = new AmiClient({ host: '127.0.0.1', diff --git a/apps/api/test/asterisk/voicemail-test.ts b/apps/api/test/asterisk/voicemail-test.ts index 801e4c2..6ca3ed8 100644 --- a/apps/api/test/asterisk/voicemail-test.ts +++ b/apps/api/test/asterisk/voicemail-test.ts @@ -11,6 +11,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared'; import { AmiClient } from '../../src/ami/client.ts'; import { GENERATED_FILES, render } from '../../src/generator/index.ts'; import type { Snapshot } from '../../src/generator/load.ts'; +import { testManagerConf } from './manager-conf.ts'; const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const NET = 'sp-vm'; @@ -111,10 +112,7 @@ async function main() { spawnSync('mkdir', ['-p', conf]); const files = render(snapshot()); for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]); - writeFileSync( - join(dir, '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', - ); + writeFileSync(join(dir, 'manager.conf.template'), testManagerConf()); phoneConfig(dir, '101', 5070); phoneConfig(dir, '102', 5072); diff --git a/apps/api/test/fraud.test.ts b/apps/api/test/fraud.test.ts new file mode 100644 index 0000000..e24d181 --- /dev/null +++ b/apps/api/test/fraud.test.ts @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// M11-T10: fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop. +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { calls, cdr, dids } from '../src/db/schema.ts'; +import { createFraudWatch, riskyDestination, trunkDialNumber } from '../src/services/fraud.ts'; +import { setSetting } from '../src/services/settings.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +describe('riskyDestination', () => { + it('flags international, premium and NANP numbers billed like abroad', () => { + expect(riskyDestination('01144207946095')).toBe('international'); + expect(riskyDestination('19005551234')).toBe('premium'); + expect(riskyDestination('13129765555')).toBe('premium'); // exchange 976 + expect(riskyDestination('18765551234')).toMatch(/international rates/); // Jamaica + expect(riskyDestination('8095551234')).toMatch(/international rates/); // 10 digits + expect(riskyDestination('13125551234')).toBeNull(); + expect(riskyDestination('18005551234')).toBeNull(); + expect(riskyDestination('101')).toBeNull(); + }); + + it('reads the number out of a trunk Dial', () => { + expect(trunkDialNumber('PJSIP/13125550000@trunk-7d3e9a52,120')).toBe('13125550000'); + expect(trunkDialNumber('PJSIP/911@trunk-7d3e9a52')).toBe('911'); + expect(trunkDialNumber('PJSIP/101,30')).toBeNull(); + expect(trunkDialNumber(null)).toBeNull(); + }); +}); + +describe.skipIf(!hasTestDb)('fraud watches', () => { + const at = (iso: string) => new Date(iso); + let clock = Date.parse('2026-10-29T12:00:00Z'); + let balance = '100.00'; + const cdrs: Record[] = []; + const seen: URLSearchParams[] = []; + const fake = createServer((req, res) => { + const q = new URL(req.url!, 'http://x').searchParams; + seen.push(q); + res.setHeader('content-type', 'application/json'); + const m = q.get('method'); + res.end( + JSON.stringify( + m === 'getBalance' + ? { status: 'success', balance: { current_balance: balance } } + : m === 'getCDR' + ? cdrs.length + ? { status: 'success', cdr: cdrs } + : { status: 'no_cdr' } + : { status: 'invalid_method' }, + ), + ); + }); + let t: Awaited>; + let req: (m: string, url: string, payload?: unknown) => Promise; + let trunkId: string; + let url: string; + const mails: { subject: string; text: string }[] = []; + const watch = () => + createFraudWatch({ + db: t.db, + voipmsUrl: url, + mail: async (subject, text) => void mails.push({ subject, text }), + log: { warn() {} }, + now: () => new Date(clock), + }); + + beforeAll(async () => { + await new Promise((r) => fake.listen(0, '127.0.0.1', r)); + url = `http://127.0.0.1:${(fake.address() as AddressInfo).port}/api/v1/rest.php`; + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + const cookie = await t.login(ADMIN.email, ADMIN.password); + req = (method, u, payload) => + t.app.inject({ + method: method as 'GET', + url: u, + payload: payload as object, + headers: { ...H, cookie }, + }); + const r = await req('POST', '/api/trunks', { + name: 'voip.ms', + serverHost: '127.0.0.1', + username: '123456_pbx', + password: 'Sub-account pw!', + }); + trunkId = r.json().id; + await req('PATCH', `/api/trunks/${trunkId}`, { + voipmsApiUsername: 'me@example.com', + voipmsApiPassword: 'api-pw', + }); + await t.db.insert(dids).values({ + number: '13125551000', + trunkId, + destination: { type: 'hangup' }, + } as typeof dids.$inferInsert); + }); + afterAll(async () => { + fake.close(); + await t.close(); + }); + + it('riskyCallWatch emails each risky outside call once, and not when turned off', async () => { + const w = watch(); + mails.length = 0; + const call = (linkedid: string, to: string, created: string) => ({ + linkedid, + direction: 'outbound' as const, + status: 'answered' as const, + startedAt: at(created), + endedAt: at(created), + fromNumber: '101', + toNumber: to, + talkSeconds: 125, + createdAt: at(created), + }); + await t.db + .insert(calls) + .values([ + call('r1', '01144207946095', '2026-10-29T12:00:20Z'), + call('r2', '18765551234', '2026-10-29T12:00:30Z'), + call('r3', '13125551234', '2026-10-29T12:00:40Z'), + ]); + clock = Date.parse('2026-10-29T12:01:00Z'); + await w.riskyCallWatch(); + expect(mails.map((m) => m.subject)).toEqual(['Calls to risky numbers (2)']); + expect(mails[0]!.text).toMatch(/from 101 to 01144207946095: international, answered/); + expect(mails[0]!.text).toMatch(/\(876\) 555-1234: international rates/); + expect(mails[0]!.text).not.toMatch(/\(312\) 555-1234/); + clock += 60_000; + await w.riskyCallWatch(); // nothing new + expect(mails).toHaveLength(1); + + await setSetting(t.db, 'alerts.riskyCalls', false); + await t.db.insert(calls).values(call('r4', '19005551234', '2026-10-29T12:01:30Z')); + clock += 60_000; + await w.riskyCallWatch(); + expect(mails).toHaveLength(1); + await setSetting(t.db, 'alerts.riskyCalls', true); + }); + + it('providerWatch reports voip.ms calls the PBX never sent to the trunk, once', async () => { + await setSetting(t.db, 'alerts.balanceDropPerHour', 0); + const w = watch(); + mails.length = 0; + clock = Date.parse('2026-10-29T14:00:00Z'); + // The PBX sent one call to the trunk at 13:00 (a forward would look the same). + await t.db.insert(cdr).values({ + uniqueid: 'u1', + linkedid: 'u1', + startAt: at('2026-10-29T13:00:05Z'), + lastdata: `PJSIP/13125550000@trunk-${trunkId},120`, + raw: {}, + }); + const rec = (uniqueid: string, date: string, destination: string) => ({ + date, + callerid: '"Office" <3125551000>', + destination, + description: 'Outgoing', + account: '123456_pbx', + disposition: 'ANSWERED', + duration: '00:03:00', + seconds: '180', + rate: '0.25', + total: '0.7500', + uniqueid, + }); + cdrs.push( + rec('v1', '2026-10-29 13:00:09', '3125550000'), // ours + rec('v2', '2026-10-29 13:10:00', '3125551000'), // incoming, to our DID + rec('v3', '2026-10-29 13:20:00', '01144207946095'), // not ours + rec('v4', '2026-10-29 11:00:00', '01144207946096'), // before the window + { ...rec('v5', '2026-10-29 13:30:00', '01144207946097'), account: '123456_other' }, + ); + await w.providerWatch(); + const q = seen.findLast((s) => s.get('method') === 'getCDR')!; + expect( + Object.fromEntries(['date_from', 'date_to', 'timezone', 'account'].map((k) => [k, q.get(k)])), + ).toEqual({ + date_from: '2026-10-29', + date_to: '2026-10-29', + timezone: '0', + account: '123456_pbx', + }); + expect(mails.map((m) => m.subject)).toEqual([ + "voip.ms calls this phone system didn't make (1)", + ]); + expect(mails[0]!.text).toMatch(/to 01144207946095: answered, 180 s, cost 0\.7500/); + expect(mails[0]!.text).not.toMatch(/3125550000|3125551000|946096|946097/); + clock += 15 * 60_000; + await w.providerWatch(); // v3 again: already reported + expect(mails).toHaveLength(1); + + await setSetting(t.db, 'alerts.providerCalls', false); + cdrs.push(rec('v6', '2026-10-29 14:00:00', '01144207946098')); + clock += 15 * 60_000; + await w.providerWatch(); + expect(mails).toHaveLength(1); + await setSetting(t.db, 'alerts.providerCalls', true); + cdrs.length = 0; + }); + + it('balanceWatch emails once an hour when the balance drops more than the limit', async () => { + await setSetting(t.db, 'alerts.providerCalls', false); + await setSetting(t.db, 'alerts.balanceDropPerHour', 10); + const w = watch(); + mails.length = 0; + clock = Date.parse('2026-10-29T16:00:00Z'); + balance = '100.00'; + await w.providerWatch(); + clock += 15 * 60_000; + balance = '95.00'; + await w.providerWatch(); + expect(mails).toEqual([]); + clock += 15 * 60_000; + balance = '85.00'; + await w.providerWatch(); + expect(mails.map((m) => m.subject)).toEqual(['voip.ms balance dropped 15.00 in an hour']); + clock += 15 * 60_000; + balance = '70.00'; + await w.providerWatch(); // within the hour of the last email + expect(mails).toHaveLength(1); + clock += 60 * 60_000; + balance = '200.00'; // a deposit + await w.providerWatch(); + expect(mails).toHaveLength(1); + await setSetting(t.db, 'alerts.providerCalls', true); + }); +}); diff --git a/apps/probe/package.json b/apps/probe/package.json index 66dbefe..189e35d 100644 --- a/apps/probe/package.json +++ b/apps/probe/package.json @@ -1,6 +1,6 @@ { "name": "@selfphoneng/probe", - "version": "0.0.12", + "version": "0.0.13", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index d807f92..572e4d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@selfphoneng/web", - "version": "0.0.12", + "version": "0.0.13", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index 1022c26..0d81159 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -796,6 +796,9 @@ function SecurityTab({ s }: { s: SettingsDto }) { const [provOn, setProvOn] = useState(s['provisioning.enabled']); const [perExt, setPerExt] = useState(String(s['calls.maxPerExtension'])); const [total, setTotal] = useState(String(s['calls.maxTotal'])); + const [riskyCalls, setRiskyCalls] = useState(s['alerts.riskyCalls']); + const [providerCalls, setProviderCalls] = useState(s['alerts.providerCalls']); + const [balanceDrop, setBalanceDrop] = useState(String(s['alerts.balanceDropPerHour'])); const box = (checked: boolean, set: (v: boolean) => void, label: string) => (