security: fraud alerts (M11-T10), admin access spec (M11-T9)
This commit is contained in:
1 parent
63286a1954
commit
08bc611dec
11 files changed
+731
-5
No files matched your search
@@ -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<Voip
|
||||
});
|
||||
}
|
||||
|
||||
export type VoipmsCall = {
|
||||
at: Date;
|
||||
destination: string;
|
||||
account: string;
|
||||
disposition: string;
|
||||
seconds: number;
|
||||
total: string;
|
||||
uniqueid: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
|
||||
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
|
||||
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
|
||||
*/
|
||||
export async function getCdr(
|
||||
baseUrl: string,
|
||||
creds: VoipmsCreds,
|
||||
q: { from: string; to: string; account: string },
|
||||
): Promise<VoipmsCall[]> {
|
||||
let r: Record<string, unknown>;
|
||||
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).
|
||||
|
||||
+14
-1
@@ -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);
|
||||
|
||||
@@ -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/<number>@trunk-<id>,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<void>;
|
||||
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<string, number>();
|
||||
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<string, { at: number; v: number }[]>();
|
||||
const balanceMailed = new Map<string, number>();
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, unknown>[] = [];
|
||||
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<ReturnType<typeof createTestApp>>;
|
||||
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
|
||||
@@ -814,6 +817,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
'provisioning.enabled': provOn,
|
||||
'calls.maxPerExtension': Number(perExt),
|
||||
'calls.maxTotal': Number(total),
|
||||
'alerts.riskyCalls': riskyCalls,
|
||||
'alerts.providerCalls': providerCalls,
|
||||
'alerts.balanceDropPerHour': Number(balanceDrop),
|
||||
'sip.udpPort': Number(sipPort),
|
||||
'sip.tcpEnabled': tcpOn,
|
||||
'sip.tlsEnabled': tlsOn,
|
||||
@@ -873,6 +879,36 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<h2 className="font-medium">Fraud alerts</h2>
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Emails to every admin. The last two need the voip.ms API login on the trunk (Trunks).
|
||||
</p>
|
||||
{box(riskyCalls, setRiskyCalls, 'Calls to international, premium and Caribbean numbers')}
|
||||
{box(
|
||||
providerCalls,
|
||||
setProviderCalls,
|
||||
"Calls voip.ms billed that this phone system didn't make",
|
||||
)}
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
Checked every 15 minutes. That's what a stolen trunk password looks like: the calls
|
||||
never pass through this server, so no limit here can stop them.
|
||||
</p>
|
||||
<Field
|
||||
label="voip.ms balance drop in an hour"
|
||||
hint="In your account's currency. 0 = no email."
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={`${inputClass} max-w-32`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={balanceDrop}
|
||||
onChange={(e) => setBalanceDrop(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<h2 className="font-medium">Phone setup files</h2>
|
||||
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
|
||||
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
|
||||
|
||||
@@ -684,6 +684,9 @@ with a zod schema per key and a default. Missing rows return the default.
|
||||
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
|
||||
| `calls.maxPerExtension` | number | 3 | outside calls going out at once per extension (the caller, or whose forward it is); 0 = no limit (08 §1) |
|
||||
| `calls.maxTotal` | number | 10 | outside calls going out at once in total; 0 = no limit. 911 counts toward neither |
|
||||
| `alerts.riskyCalls` | boolean | true | email admins about calls to international, premium and Caribbean numbers (08 §8.3) |
|
||||
| `alerts.providerCalls` | boolean | true | email admins about calls voip.ms billed that this PBX didn't make (08 §8.3) |
|
||||
| `alerts.balanceDropPerHour` | number | 10 | email admins when the voip.ms balance drops more than this in an hour; 0 = off (08 §8.3) |
|
||||
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
|
||||
| `provisioning.enabled` | boolean | true | |
|
||||
| `system.restartRequired` | boolean | false | set by generator |
|
||||
|
||||
@@ -64,7 +64,8 @@ an admin (`requireAdmin` refuses them). See `13-mobile.md` §2 and §4.
|
||||
|
||||
### 2.4 Guards
|
||||
Fastify `preHandler` hooks in `auth/guards.ts`:
|
||||
- `requireAuth`: sets `req.user`.
|
||||
- `requireAuth`: sets `req.user`. An admin outside `auth.adminAllowedNets` is set as a user
|
||||
(`req.adminElsewhere`), and `auth.requireAdminTotp` adds `totp_setup_required` (08 §8.2, M11-T9).
|
||||
- `requireAdmin`.
|
||||
- `requireSelfOrAdmin(paramName)`: for `/api/me/*` and per-user resources.
|
||||
|
||||
@@ -481,6 +482,8 @@ Provisioning and inbound hooks (outside `/api`, no session):
|
||||
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
|
||||
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
|
||||
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
|
||||
| `riskyCallWatch` | every 60 s | new outside calls to risky numbers → email admins (08 §8.3) |
|
||||
| `providerWatch` | every 15 min | voip.ms calls the PBX didn't make, and a balance drop → email admins (08 §8.3) |
|
||||
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
|
||||
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
|
||||
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
|
||||
|
||||
@@ -59,6 +59,14 @@ to the next one. Steps can be skipped where noted.
|
||||
2. Company name, timezone (default from the browser), language.
|
||||
3. Network: "Detect public IP", LAN IP (prefilled from `/api/system/status` if detectable), local nets.
|
||||
A big note about SIP ALG and port forwarding (01 §4.2) with a checkbox "I've done this".
|
||||
3a. Admin access (owner, 2026-10-29; 08 §8.2, M11-T9), skippable. Both switches start on:
|
||||
- "Admin pages only from these networks" (`auth.adminAllowedNets`), the list prefilled with
|
||||
step 3's local nets, plus the address in use now when it's outside them (else the save
|
||||
would be refused with `would_lock_out`), marked "the address you're using now: remove it
|
||||
if it changes".
|
||||
- "Require two-step sign-in for admins" (`auth.requireAdminTotp`): turning it on sets up the
|
||||
wizard admin's own two-step sign-in right here (QR, one code, recovery codes), so
|
||||
`totp_first` is met. Skipping that setup leaves the switch off.
|
||||
4. Email (SMTP), skippable. Test button.
|
||||
5. Trunk: voip.ms form (06 §2), with the voip.ms portal checklist next to it. "Test" must show Registered before Next is enabled. Skippable for now ("I'll add it later").
|
||||
6. Phone numbers: Import from voip.ms (if API creds were given) or add manually.
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
| SIP scanners / password guessing | "friendly-scanner", sipvicious hitting 5060 | Generated 24-char SIP passwords; no anonymous endpoint; trunks matched by source address only (`identify_by = ip`, M11-T8); fail2ban/CrowdSec on `security.log`; optional phone ACL (§2.2), browser endpoints included; firewall allow-list |
|
||||
| **Toll fraud** (the expensive one) | Stolen SIP creds used to call premium international numbers | International route disabled by default; per-user outbound class; outside calls at once per extension (3) and in total (10), Settings → Security; trunk `max_channels`; voip.ms-side international block + balance limits (06 §1); forward targets checked against class; dialed numbers `FILTER`ed before the route's Goto (M11-T8: a "," skipped the class check) |
|
||||
| Web credential stuffing | Login brute force | argon2id, rate limits, audit log, TOTP for admins (M11) |
|
||||
| Admin panel bugs | FreePBX 2025–26: an auth bypass + SQL injection, then webshells and stolen trunk passwords (§8) | Admin pages only from listed networks, two-step sign-in required for admins (§8.2, M11-T9) |
|
||||
| Voicemail PIN guessing | Calling *98 and trying PINs | 6-digit default PIN, `maxlogins=3`, reject trivial PINs (`000000`, `123456`, the extension number repeated). As built (M11-T1): also for PINs changed from the phone's voicemail menu: `externpasscheck = /usr/local/bin/sp-vm-pincheck` (asterisk image, same rules, a unit test keeps it in step with `isTrivialPin`), `minpassword = 4`; the api stores accepted ones from AMI `VoicemailPasswordChange` (`services/vmpin.ts`), otherwise the next config write would put the old PIN back |
|
||||
| Provisioning leaks | Fetching `/prov/<mac>.cfg` to steal SIP passwords | Per-device Basic auth, 404 on unknown MAC, rate limit + temporary IP block, optional LAN-only |
|
||||
| Dialplan injection | Name `x); System(...)`; dialed `011…,4` or `…&PJSIP/…` | Sanitizers (03 §2), no `System()` anywhere; at call time, `FILTER` on every `${EXTEN}` from a phone that reaches an app's arguments (M11-T8) |
|
||||
| SSRF via webhooks | Webhook to `http://127.0.0.1:5038` | Private IP block by default (04 §9), NAT64/6to4 included |
|
||||
| Stolen backup | Backup file copied | Backups exclude generated plaintext configs; DB secrets are encrypted with `APP_SECRET` (kept in `.env`, not in the backup); mode 0600 |
|
||||
| AMI exposure | AMI reachable from network | Bound to 127.0.0.1, `permit` 127.0.0.1 only, random secret |
|
||||
| AMI exposure | AMI reachable from network | Bound to 127.0.0.1, `permit` 127.0.0.1 only, random secret; only the classes the api uses, and no module in the image that runs host commands (§8.1) |
|
||||
|
||||
## 2. Required controls
|
||||
|
||||
@@ -276,3 +277,135 @@ encrypted provider tokens; LDAP filter escaping and no empty binds; the caller-n
|
||||
for Asterisk on this machine with its token; phonebook XML and timeline HTML escaped, report
|
||||
emails plain text; AI and Contrack addresses literal local IPs with redirects refused; the spam
|
||||
provider key kept out of errors; every new raw SQL parameterized; no secret in any GET.
|
||||
|
||||
## 8. After the FreePBX campaign (owner, 2026-10-29)
|
||||
|
||||
INJ3CTOR3 got into internet-facing FreePBX through its admin panel: CVE-2025-57819 (Endpoint
|
||||
module, an auth bypass + SQL injection, no sign-in needed) and CVE-2025-64328 (filestore, command
|
||||
injection after sign-in). It left PHP webshells, cron jobs and backdoor accounts, then copied out
|
||||
the trunk, AMI, database and mail passwords. With the trunk password it registers to the provider
|
||||
from its own machine, so the calls skip the PBX entirely. Our stack has no PHP and no shell in
|
||||
reach of the web (§6, §7). Two changes follow.
|
||||
|
||||
### 8.1 AMI: only the classes we use (as built)
|
||||
|
||||
| Class | Actions the api sends | Events the api reads |
|
||||
|---|---|---|
|
||||
| `system` | DBPut, DBDel, DBDelTree, PJSIPShowContacts, PJSIPShowEndpoint, PJSIPShowRegistrationsOutbound, PJSIPQualify, CoreShowChannels | ContactStatus, Registry |
|
||||
| `call` | Redirect, Hangup, ParkedCalls, ExtensionStateList | Newchannel, Newstate, NewCallerid, NewConnectedLine, BridgeEnter/Leave, Hold/Unhold, Hangup, ParkedCall, ParkedCallTimeOut, ParkedCallGiveUp, UnParkedCall, ExtensionStatus, MessageWaiting |
|
||||
| `command` | Command (reloads, `core show uptime`, `core restart`, `pjsip show transports`) | |
|
||||
| `originate` | Originate | |
|
||||
| `agent` | QueuePause | QueueCaller*, QueueMember*, AgentConnect |
|
||||
| `user` | VoicemailMove, VoicemailRemove | UserEvent, VoicemailPasswordChange |
|
||||
| `cdr` | | Cdr |
|
||||
| `dialplan` | | VarSet, Newexten |
|
||||
|
||||
Ping, Login, Logoff and QueueStatus need no class. Action classes come from `manager show command`
|
||||
on 24.0.0-rc3; event classes from Asterisk's AMI event docs.
|
||||
|
||||
Measured on 24.0.0-rc3: an action is allowed when the user has **any one** of its classes. So
|
||||
`system`, which DB* and PJSIP* need, still allows GetConfig (any config file, trunk passwords
|
||||
included) and ModuleLoad, and ModuleLoad loaded `func_shell.so` past `noload` in modules.conf
|
||||
(`Command: module load` does the same). Class limits are no barrier (Asterisk's docs say not to
|
||||
rely on them). What dropping the rest takes away is `config`: UpdateConfig and CreateConfig, a
|
||||
way to write a trunk into a file the admin panel never shows. The barrier is the image itself:
|
||||
`asterisk/Dockerfile` deletes `app_system`, `func_shell`, `app_disa`, `res_agi` and
|
||||
`app_externalivr`, so nothing is left for ModuleLoad to load. The AMI secret is still the whole
|
||||
key (Originate is enough for toll fraud), so AMI stays on 127.0.0.1.
|
||||
|
||||
The Asterisk tests mount the real template with only `bindaddr` and `permit` opened
|
||||
(`test/asterisk/manager-conf.ts`), so a class the api needs and the template lacks fails them.
|
||||
|
||||
### 8.2 Admin access (to build: M11-T9)
|
||||
|
||||
Both FreePBX bugs were reached through an admin panel open to the internet, and Sangoma's first
|
||||
advice was to block it. SelfPhoneNG serves the admin pages, the user portal and the browser
|
||||
phone's WebSocket on one host and port, so a firewall can't keep one out and let the others in. Two settings on
|
||||
Settings → Security, both off by default (today's behaviour):
|
||||
|
||||
**`auth.adminAllowedNets`**: CIDR list, IPv4 and IPv6 (`z.union([z.cidrv4(), z.cidrv6()])`).
|
||||
Empty means admin from anywhere.
|
||||
- `requireAuth` (`auth/guards.ts`), once `req.user` is set: an admin whose `req.ip` is outside the
|
||||
list, and isn't 127.0.0.1 or ::1, gets `req.user = { ...user, role: 'user' }` and
|
||||
`req.adminElsewhere = true`, the same way a paired phone is never an admin (13 §2). Every role
|
||||
check after that sees a user: `requireAdmin`, recordings (`role === 'admin'`), the OpenID
|
||||
`next` default. Sessions and API tokens alike (a token acts as its admin, 04 §2.3).
|
||||
- Matching: node:net `BlockList`, as `/prov` does (`isLocal` in `routes/provisioning.ts`, which
|
||||
becomes the shared `inNets(ip, nets)`), with `::ffff:`-mapped IPv4 unwrapped. The list is read
|
||||
with `getSetting` only when the user is an admin (one row by key).
|
||||
- `/api/live` (`live/hub.ts`) reads the session itself: `admin` at connect uses the same rule
|
||||
with the socket's `req.ip`. The 30 s re-check compares the same way and closes with 4401 when
|
||||
the list changes under it.
|
||||
- Two-step routes (`/api/auth/totp/*`) check the stored role, not the downgraded one: an admin
|
||||
away from the office can still look after their own sign-in.
|
||||
- Signing in works from anywhere. An admin outside the list gets their own portal (My phone,
|
||||
voicemail, calls). `GET /api/auth/me` adds `adminElsewhere: true`, and the web app shows
|
||||
one line where the admin menu would be: "Admin pages open only from an allowed network
|
||||
(Settings → Security)."
|
||||
- 127.0.0.1 and ::1 always count as allowed. That's the way back in when the list is wrong:
|
||||
`ssh -L 8080:127.0.0.1:8080 <server>`, then `http://localhost:8080` (browsers keep `Secure`
|
||||
cookies on localhost). The admin guide shows it.
|
||||
- `PATCH /api/settings` refuses, like `sip.tlsEnabled` (§2.1):
|
||||
- 409 `would_lock_out` when the new list isn't empty and the request's own address isn't in it
|
||||
(loopback counts as in);
|
||||
- 409 `trust_proxy_off` when `req.ip` is loopback and the request has `X-Forwarded-For`: behind
|
||||
a proxy without `TRUST_PROXY=true` every request looks local and the list would do nothing.
|
||||
- The form offers "Add the local networks" (`network.localNets`) and the address you're using now.
|
||||
Its help text: integrations calling admin routes with an API token need their address listed
|
||||
too; a VPN into the office network is the usual answer for working away.
|
||||
- Diagnostics (§2.6): a warning when the list is empty ("Admin pages open from anywhere");
|
||||
otherwise the list.
|
||||
|
||||
**`auth.requireAdminTotp`**: boolean.
|
||||
- When on, an admin without two-step sign-in (`totpSecretEnc` null) whose session came from a
|
||||
password or the directory gets a session that works like `must_change_password` (04 §2.2):
|
||||
every route outside `/api/auth/*` → 403 `totp_setup_required`, and the web app sends them to
|
||||
My account → Two-step sign-in. The check reads the user row on each request, so the session
|
||||
turns normal once two-step is on. `mustChange` in `auth/sessions.ts` grows this case, so
|
||||
`/api/live` follows it too.
|
||||
- Exempt: OpenID sessions (`sessions.method = 'oidc'`, the provider does the second factor, 12)
|
||||
and API tokens (made by an admin who signed in).
|
||||
- Turning it on needs the saving admin to have two-step on (409 `totp_first`).
|
||||
- "Turn off for a lost phone" (`POST /api/users/:id/reset-two-step`) still works: that admin is
|
||||
sent to set it up again at their next sign-in.
|
||||
|
||||
Tests (each fails without its rule): an admin outside the list gets 403 on an admin route and
|
||||
200 on `/api/me`, from 127.0.0.1 and from inside it is an admin (IPv4, IPv6, `::ffff:`); an API
|
||||
token the same; `/api/live` gives `admin: false` outside; a recording of someone else refused
|
||||
outside; `would_lock_out` and `trust_proxy_off`; `totp_setup_required` until two-step is on,
|
||||
then 200; an OpenID session exempt; `totp_first`.
|
||||
|
||||
Setup (owner, 2026-10-29): the first-run wizard offers both, switched on, with the list
|
||||
prefilled from the local networks (05 §2.1 step 3a). The settings' own defaults stay off, so an
|
||||
update changes nothing on an existing install.
|
||||
|
||||
### 8.3 Fraud alerts (owner, 2026-10-29; M11-T10)
|
||||
|
||||
Until now an admin learned of toll fraud from voip.ms's balance alert or the bill. Fraud with a
|
||||
stolen trunk password leaves no call in our records at all: the calls never touch the PBX. Three
|
||||
checks, each an email to every admin plus a `warning` system event; all three are on by default
|
||||
and on Settings → Security, "Fraud alerts". In `services/fraud.ts`, run from `jobs.ts`.
|
||||
|
||||
- **`riskyCallWatch`** (every minute; `alerts.riskyCalls`): outside calls the PBX placed, new
|
||||
since the last run (`calls.created_at`), to a number `riskyDestination` calls risky: `011…`
|
||||
(international), area code 900 or exchange 976 (premium), and the NANP area codes outside the
|
||||
US and Canada, which are dialed like a domestic call but billed at international rates and are
|
||||
the usual one-ring-scam targets: 242 246 264 268 284 345 441 473 649 658 664 721 758 767 784
|
||||
809 829 849 868 869 876 **(verify the list)**. One email per run listing the calls. The
|
||||
cursor starts when the api starts: calls made while it was down have no row anyway (their CDR
|
||||
events are lost).
|
||||
- **`providerCallCheck`** (every 15 minutes; `alerts.providerCalls`; voip.ms trunks with an API
|
||||
login, 06 §5.1): voip.ms's call records for the trunk's sub-account (`getCDR`,
|
||||
`account = trunks.username`, `timezone = 0` so times are UTC) from 2 hours to 10 minutes ago.
|
||||
Records to one of our DIDs are incoming and skipped. Every other record must match an
|
||||
outbound `calls` row: same last 10 digits of the number, started within 5 minutes. One that
|
||||
doesn't is a call voip.ms billed that this PBX didn't make: the email lists them and says to
|
||||
change the sub-account password, turn on its IP restriction (06 §1), and that a call made while
|
||||
the api was restarting, or by another device on the same sub-account, also shows here. Each
|
||||
voip.ms record is reported once (by `uniqueid`, time and number, kept in memory for 3 hours).
|
||||
- **`balanceWatch`** (every 15 minutes, same API logins; `alerts.balanceDropPerHour`, 0 = off):
|
||||
the balance (`getBalance`) more than that amount below its highest in the last hour → one email
|
||||
per hour. It catches what the call check can't: another sub-account, or one made with a stolen
|
||||
API password. A deposit only raises the balance, so it never alerts.
|
||||
|
||||
An API error is logged (`warn`), not emailed: Reports already shows why the balance can't be read.
|
||||
@@ -265,6 +265,8 @@ As built: all four tasks done and tested with simulated phones (`scripts/e2e/m10
|
||||
**M11-T6** Reports page (05 §2).
|
||||
**M11-T7** `docs/admin-guide/`: install, voip.ms setup with screenshots, NAT/SIP ALG, firewall + fail2ban/CrowdSec, E911 duties, recording law notice, troubleshooting (08 §3.3), updating, backup/restore.
|
||||
**M11-T8** Security review: run through 08 §1 line by line, and get a review from a separate agent that didn't write the code. Fix findings.
|
||||
**M11-T9** (owner, 2026-10-29) Admin access: `auth.adminAllowedNets` and `auth.requireAdminTotp` (08 §8.2), Settings → Security, setup wizard step 3a (05 §2.1), Diagnostics line, admin guide (the SSH way back in).
|
||||
**M11-T10** (owner, 2026-10-29) Fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
|
||||
✅ An outside tester follows only the admin guide and gets inbound/outbound calls working on a fresh VM.
|
||||
|
||||
---
|
||||
|
||||
@@ -103,6 +103,11 @@ export const SettingsSchemas = {
|
||||
// Toll fraud (08 §1): outside calls going out at once, per extension and in total. 0 = no limit.
|
||||
'calls.maxPerExtension': z.number().int().min(0).max(100).default(3),
|
||||
'calls.maxTotal': z.number().int().min(0).max(1000).default(10),
|
||||
// Fraud alerts (08 §8.3): emails to admins.
|
||||
'alerts.riskyCalls': z.boolean().default(true),
|
||||
'alerts.providerCalls': z.boolean().default(true),
|
||||
/** In the voip.ms account's currency; 0 = off. */
|
||||
'alerts.balanceDropPerHour': z.number().min(0).max(100000).default(10),
|
||||
'provisioning.lanOnly': z.boolean().default(false),
|
||||
'provisioning.enabled': z.boolean().default(true),
|
||||
'system.restartRequired': z.boolean().default(false),
|
||||
|
||||
Reference in new issue
Block a user