6 Commits
Author SHA1 Message Date
iamdoubz 10b499e263 Merge pull request 'Security/freepbx campaign' (#10) from security/freepbx-campaign into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 4s
release / images (release) Successful in 59s
Reviewed-on: #10
2026-10-29 18:48:11 -05:00
iamdoubz 34f443385d release: version 0.0.13
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 4s
2026-10-29 18:40:30 -05:00
iamdoubz f435b475ed repo: ignore voip.ms's API example bundle
ci / check (push) Successful in 1m19s
ci / images (push) Successful in 1m30s
2026-10-29 18:35:06 -05:00
iamdoubz 1979a02827 voipms: IP restriction on the setup checklist, getCDR checked against voip.ms's examples 2026-10-29 18:35:06 -05:00
iamdoubz 08bc611dec security: fraud alerts (M11-T10), admin access spec (M11-T9) 2026-10-29 18:35:06 -05:00
iamdoubz 63286a1954 asterisk: AMI limited to the classes we use, no shell-running modules in the image 2026-10-29 18:35:06 -05:00
37 changed files with 847 additions and 49 deletions

No files matched your search

+1
View File
@@ -39,3 +39,4 @@
**/*.key
**/*.log
**/*.pid
docs/voip.ms-api
+2
View File
@@ -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/
+20
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+55 -2
View File
@@ -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
View File
@@ -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);
+239
View File
@@ -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 };
}
+3 -5
View File
@@ -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);
+2 -4
View File
@@ -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,
+2 -4
View File
@@ -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',
+18
View File
@@ -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;
}
+2 -4
View File
@@ -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;
+2 -4
View File
@@ -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',
+2 -4
View File
@@ -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);
+231
View File
@@ -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);
});
});
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+36
View File
@@ -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&apos;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')}
+5
View File
@@ -591,6 +591,11 @@ function VoipmsChecklist() {
<em>G.711u</em>, DTMF <em>RFC 2833</em>, NAT <em>Yes</em>, CallerID number = your main
number, international calling <em>off</em> unless you need it.
</li>
<li>
<strong>Turn on IP restriction</strong> (edit the sub-account → Security) and allow only
this server&apos;s public IP. A stolen password then can&apos;t make calls from anywhere
else. If your public IP changes, update it there too.
</li>
<li>
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
one below.
+2
View File
@@ -28,7 +28,9 @@ RUN ./configure --with-pjproject-bundled --with-jansson-bundled \
&& mkdir -p /out/usr/share/selfphoneng \
&& mv /out/var/lib/asterisk/moh /out/usr/share/selfphoneng/moh-opsound
# Debian's /var/run is a symlink to /run; a real directory here breaks the COPY below.
# Modules that run host commands go: AMI's ModuleLoad (class system) loads them past noload (08 §8).
RUN rm -rf /out/var/run \
&& for m in app_system func_shell app_disa res_agi app_externalivr; do rm /out/usr/lib/asterisk/modules/$m.so; done \
&& mkdir -p /out/etc/asterisk \
&& cp configs/samples/indications.conf.sample /out/etc/asterisk/indications.conf
+8 -2
View File
@@ -9,6 +9,12 @@ timestampevents = yes
secret = ${AMI_SECRET}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.255
read = all
write = all
; Only the classes the api uses (08 §8). An action needs any one of its classes, so `system`
; still allows GetConfig and ModuleLoad; config (UpdateConfig, CreateConfig) is what this drops.
; Events: system (ContactStatus, Registry), call (channels, bridges, parking, ExtensionStatus,
; MessageWaiting), agent (queues), user (UserEvent, VoicemailPasswordChange), cdr, dialplan (VarSet, Newexten).
read = system,call,agent,user,cdr,dialplan
; Actions: system (DB*, PJSIP*), call (Redirect, Hangup, ParkedCalls), command, originate,
; agent (QueuePause), user (VoicemailMove, VoicemailRemove).
write = system,call,command,originate,agent,user
writetimeout = 5000
+2 -1
View File
@@ -51,7 +51,8 @@ noload = chan_websocket.so
noload = res_adsi.so
noload = app_getcpeid.so
noload = app_adsiprog.so
; can run host commands; nothing of ours uses them, but a dialplan injection would reach for them (08 §6)
; can run host commands; nothing of ours uses them, but a dialplan injection would reach for them (08 §6).
; Our image doesn't have them at all (Dockerfile, 08 §8); these lines cover a custom build.
noload = app_system.so
noload = func_shell.so
noload = app_disa.so
+1 -1
View File
@@ -142,7 +142,7 @@ backup.
3. Run `./scripts/install.sh`. It keeps the existing `.env`.
4. Run `./scripts/restore.sh <backup>`.
5. If the public IP changed: update it on **Settings → Network**, change your DNS record and any
IP-based settings at voip.ms (IP authentication, API whitelist), then
IP-based settings at voip.ms (IP restriction, IP authentication, API whitelist), then
`docker compose restart asterisk`.
6. Desk phones fetch their setup from the host name, so they follow the DNS change.
+4 -1
View File
@@ -89,7 +89,7 @@ needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
```sh
v=v0.0.12 # the release
v=v0.0.13 # the release
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
@@ -345,6 +345,9 @@ Someone with a stolen SIP password calling expensive international numbers is th
real money. Keep:
- international calling **off** at voip.ms and in **Outbound rules** unless you need it;
- **IP restriction** on the voip.ms sub-account, allowing only your server's public IP: a stolen
trunk password used from another server skips every limit below
([voip.ms setup](voipms.md#1-in-the-voipms-portal));
- the voip.ms **balance alerts and spending limits** on;
- the **desk phone network limit** above on if your phones are all in the office;
- the **outside calls at once** limits (Settings → Security) as low as your office needs: per
+5
View File
@@ -18,6 +18,11 @@ then; if a menu name below doesn't match, look for the closest one.
- **International calling:** leave it **off** unless you need it. It's your best protection
against toll fraud if a password ever leaks.
- **Encrypted SIP traffic (TLS/SRTP):** optional; see [SIP over TLS](#sip-over-tls).
- **IP restriction** (edit the sub-account → Security): turn it **on** and allow only your
server's public IP. voip.ms then refuses outgoing calls from anywhere else, so a stolen
sub-account password can't be used from the thief's own server, where none of SelfPhoneNG's
limits apply. It needs a fixed public IP: if yours changes, outgoing calls fail until you
update it here (SelfPhoneNG emails admins when it sees the change).
2. **Pick a POP server** near the PBX, e.g. `chicago1.voip.ms`. Write it down: the trunk and every
number must use the same one.
3. **Point each number at the sub-account** (DID Numbers → Manage DIDs → edit the number):
+7 -2
View File
@@ -192,7 +192,9 @@ RUN ./configure --with-pjproject-bundled --with-jansson-bundled \
&& mkdir -p /out/usr/share/selfphoneng \
&& mv /out/var/lib/asterisk/moh /out/usr/share/selfphoneng/moh-opsound
# Debian's /var/run is a symlink to /run; a real directory here breaks the COPY below.
# Modules that run host commands go: AMI's ModuleLoad (class system) loads them past noload (08 §8).
RUN rm -rf /out/var/run \
&& for m in app_system func_shell app_disa res_agi app_externalivr; do rm /out/usr/lib/asterisk/modules/$m.so; done \
&& mkdir -p /out/etc/asterisk \
&& cp configs/samples/indications.conf.sample /out/etc/asterisk/indications.conf
@@ -369,10 +371,13 @@ timestampevents = yes
secret = ${AMI_SECRET}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.255
read = all
write = all
read = system,call,agent,user,cdr,dialplan
write = system,call,command,originate,agent,user
writetimeout = 5000
```
Only the classes the api uses (owner, 2026-10-29; the list per action and event is in 08 §8).
The Asterisk tests mount this template with only `bindaddr` and `permit` opened up
(`test/asterisk/manager-conf.ts`), so a missing class fails them.
### 3.5 `http.conf` (WebSocket for the browser softphone)
```ini
+3
View File
@@ -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 |
+4 -1
View File
@@ -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 |
+8
View File
@@ -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.
+20 -2
View File
@@ -19,6 +19,13 @@ trunk fallback (same fields, no voip.ms-specific help text or API features).
- Encrypted SIP traffic (TLS/SRTP): optional. If on, set our trunk transport to TLS, port 5061, SRTP on. Needs Settings → Security → Accept SIP over TLS (a certificate in `data/tls`, 08 §2.1) first. voip.ms's certificate names every POP, so the trunk checks it (`verify_server`).
- CallerID Number: your main DID.
- International calling: leave **disabled** unless needed (toll-fraud protection on their side too).
- IP restriction (owner, 2026-10-29; edit the sub-account → Security, **verify** the label):
**on**, allowing only the PBX's public IP. voip.ms then refuses outgoing calls from anywhere
else, so a stolen sub-account password can't be used from the thief's own server, which skips
every limit the PBX has (08 §8). Whether it also refuses a REGISTER from elsewhere (which would
take incoming calls) is unverified. Needs a fixed public IP: when it changes, outgoing calls
fail until the list is updated (`publicIpWatch` mails admins, 04 §8). IP authentication is
the same protection without a password.
2. **Choose a POP server** close to the PBX (e.g. `chicago1.voip.ms`). The sub-account registers to this server.
3. **Point each DID at the sub-account** (DID Numbers → Manage DIDs → edit):
- Routing: **SIP/IAX → the sub-account**
@@ -83,14 +90,25 @@ GET https://voip.ms/api/v1/rest.php
- Messages over 160 characters: use `method=sendMMS` (`message` up to 2048 chars **(verify limit)**),
plus `media1`…`media3` URLs for pictures. We split nothing ourselves.
- Numbers: voip.ms wants 10 digits for NANP, so strip the leading `1`.
- The API password goes in a query string, so **never log the request URL**.
- The API password goes in a query string, so **never log the request URL**. voip.ms now also
offers a Bearer token (owner, 2026-10-29); we keep `api_username`/`api_password` for now.
- Rate limit our sends to 1 per second per DID (queue in memory).
- Other methods used: `getDIDsInfo` (DID import), `getServersInfo` (server dropdown),
`getSMS` (optional polling fallback). All behind `voipmsApi(method, params)` with a 15 s timeout.
- `getBalance` (owner, 2026-10-21: the balance on Reports): `balance.current_balance`, a string
like `"12.3456"` in the account's currency **(verify the shape and that the API login needs
nothing more, with a real account)**. Each Reports load asks once per voip.ms trunk that has an
API login.
API login. The fraud alerts ask every 15 minutes too (08 §8.3).
- `getCDR` (owner, 2026-10-29: fraud alerts, 08 §8.3): `date_from`, `date_to` (YYYY-MM-DD, at most
92 days), `answered`, `noanswer`, `busy`, `failed` (`1` each), `timezone=0` (times come back in
UTC, no zone suffix; left out, they're in the account's own zone, which no API call reports),
`account` (the sub-account). Answer `cdr: [{date: "2026-09-16 19:14:35", callerid, destination,
description, account, disposition, duration, seconds, rate, total, uniqueid}]`; no calls is
status `no_cdr`. voip.ms's example bundle (`docs/voip.ms-api`: `server.wsdl`,
`Examples REST for PHP/07.CDR.php`) confirms the request, `no_cdr` and the fields `date`,
`callerid`, `destination`, `description`, `duration`, `seconds`, `rate`, `total`.
`account`, `disposition` and `uniqueid` in the answer are **(verify)**; without them the
check still works (the request already names the sub-account).
### 5.2 Receiving
Endpoint: `GET|POST /hooks/voipms/sms/:token`. `:token` is a 32-char random value stored
+134 -1
View File
@@ -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.
+2
View File
@@ -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.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "selfphoneng",
"version": "0.0.12",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/shared",
"version": "0.0.12",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+5
View File
@@ -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),
+2 -4
View File
@@ -15,6 +15,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
import { testManagerConf } from '../../apps/api/test/asterisk/manager-conf.ts';
const NET = 'sp-live';
const AST = 'sp-live-ast';
@@ -33,10 +34,7 @@ if (cmd === 'up') {
mkdirSync(conf, { recursive: true });
for (const d of [vm, sounds, recs, moh]) if (d) mkdirSync(d, { recursive: true });
const dir = mkdtempSync(join(tmpdir(), 'sp-live-'));
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());
// The browser softphone (m5-*) runs on the host: SIP-over-WebSocket and a small RTP range are
// published on 127.0.0.1, and the api maps the container's ICE candidate to 127.0.0.1.
writeFileSync(