20 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
iamdoubz 6bd2aaec56 Merge pull request 'Asterisk/24.0.0 rc3' (#9) from asterisk/24.0.0-rc3 into main
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 48s
release / images (release) Successful in 2m10s
Reviewed-on: #9
2026-10-28 17:23:19 -05:00
iamdoubz 80e97b4118 release: version 0.0.12
ci / check (push) Successful in 1m25s
ci / images (push) Successful in 2m35s
ci / check (pull_request) Successful in 1m13s
ci / images (pull_request) Successful in 5s
2026-10-28 17:13:31 -05:00
iamdoubz 0a18d5354d asterisk: 24.0.0-rc3, TLS trunks qualified again
rc3 checks a qualify's certificate against the host name, so TLS trunks get
qualify_frequency = 60 and Test qualifies them. The upgrade check and
pnpm test:asterisk pass on rc3. Spec 01's copy of the Dockerfile is in sync
again, and the version test now compares all of it.
2026-10-28 17:11:18 -05:00
iamdoubz a0121d4975 test: Asterisk upgrade check
verified.ts records what the code relies on Asterisk doing and the version it
was measured on; pnpm test fails while the Dockerfile pins another one.
pnpm test:asterisk-upgrade boots the image next to a TLS provider and lists
every behaviour that moved and what depends on it.
2026-10-28 17:01:26 -05:00
iamdoubz 678d45e4f9 Merge pull request 'Security/review 2' (#8) from security/review-2 into main
ci / check (push) Successful in 1m14s
ci / images (push) Successful in 53s
Reviewed-on: #8
2026-10-28 16:59:55 -05:00
iamdoubz 60348cf0c5 security: a key for the network test probe (08 §7 #9)
ci / check (push) Successful in 1m13s
ci / images (push) Successful in 4m2s
ci / check (pull_request) Successful in 1m42s
ci / images (pull_request) Successful in 28s
Settings → Network makes a key, shown once and stored encrypted; the api sends
it to the probe as a Bearer token, and a new probe address drops it. A probe
with PROBE_KEY only answers that key. Without one it stays open, and
Diagnostics strongly recommends setting it.
2026-10-28 16:28:23 -05:00
iamdoubz 958405651b docs: security review 2 (08 §7) 2026-10-28 16:01:57 -05:00
iamdoubz db244b116a security: recordings, webhooks, Contrack tokens, probe (08 §7 #3 #4 #6 #7)
A phone that only rang no longer gets a coworker's recording, transcript or
summary. The voicemail webhook leaves the transcript out. The saved Contrack
token only goes to its own address. The probe trusts X-Forwarded-For from a
local proxy only.
2026-10-28 16:01:56 -05:00
iamdoubz 1db5aa22dc security: sign-in by email and connecting accounts (08 §7 #1 #2 #5 #8)
Matching by email skips admins and anyone with a connected account, in both
directions. A connected directory account is only swapped by an admin, and
an OpenID connect finishes only in the browser whose session asked.
2026-10-28 16:01:56 -05:00
iamdoubz 8a1f17099d Merge pull request 'Chore/qol' (#7) from chore/qol into main
ci / check (push) Successful in 1m17s
ci / images (push) Successful in 5s
Reviewed-on: #7
2026-10-28 15:18:49 -05:00
iamdoubz a75113b6ac web: report spam from my calls, reports on Blocked numbers
ci / check (push) Successful in 1m24s
ci / images (push) Successful in 25s
ci / check (pull_request) Successful in 1m15s
ci / images (pull_request) Successful in 7s
Users report an outside call that rang them: the spam check's line when it
said medium or high, else a reason they type. Admins block or dismiss each
report on Blocked numbers, and the Dashboard counts them. Migration 0015 adds
spam_reports.
2026-10-28 15:07:05 -05:00
iamdoubz 2bc7d5cde5 web: my reports, the calls made to me
Users get a Reports tab: the admin summary limited to calls that rang or were
answered by them. Answered and talk time are the calls they picked up.
2026-10-28 13:39:33 -05:00
iamdoubz 19ca4ffc91 docs: v1 checklist ticks, DID in the Request-URI
ci / check (push) Successful in 1m12s
ci / images (push) Successful in 30s
The outside install, restores, the week on the owner's server, Portainer
updates and both registries are done. The mobile app is after v1.
2026-10-28 13:28:42 -05:00
iamdoubz 1bc0fe8811 docs: CrowdSec rule for slow SIP scanners
ci / check (push) Successful in 1m21s
ci / images (push) Successful in 22s
The collection's asterisk_bf and asterisk_user_enum hold 5 failures per
address and drain one every 10 s, so a scanner trying every 20 s (two
failures per try: InvalidAccountID, ChallengeResponseFailed) drains as
fast as it fills and is never banned. Seen on a live install: hours of
INVITEs from one address, no decision.

network.md: a local scenario, selfphoneng/asterisk-slow-bf (10 failures,
one drained every 5 min), a table of how it differs from the stock rule,
a whitelist for remote offices and phones, how to read cscli metrics,
and cscli explain with a log line (pointing --file at the acquisition
YAML parses the YAML itself and fails on every line).
2026-10-28 10:56:40 -05:00
89 changed files with 7522 additions and 277 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/
+55
View File
@@ -5,6 +5,61 @@ 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,
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
**Probe key** below).
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
many they answered and missed, talk time, calls and missed calls per day, and their busiest
hours.
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
the spam check flagged the call, its finding is the reason; otherwise the person types one.
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
Dashboard counts them.
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
a connected sign-in, and connecting an OpenID account only finishes in the browser that
started it.
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
its summary in their call report email or on their Contrack timeline.
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
- Changing the Contrack address asks for the company token again, and everyone reconnects
their own Contrack token.
- A directory account can only be swapped for another by an admin.
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
proxy must run on the probe's machine.
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
on Asterisk doing before any version change.
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
([network](docs/admin-guide/network.md#crowdsec)).
## v0.0.11 — 2026-10-28
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
+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.11), 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.11",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+8 -4
View File
@@ -18,6 +18,7 @@ import {
import { todayIn } from '../generator/load.ts';
import { getSetting } from '../services/settings.ts';
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** A recorded call waits this long for its summary before it's logged without one. */
const SUMMARY_WAIT_MS = 30 * 60_000;
@@ -141,11 +142,13 @@ export async function logCallsToContrack(d: {
.limit(500);
// One entry per call and person (a person can be in a call as caller, rang and answered).
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
for (const r of rows) {
const k = `${r.call.id}|${r.userId}`;
const out = r.role === 'caller' || todo.get(k)?.out === true;
todo.set(k, { ...r, out });
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
todo.set(k, { ...r, out, heard });
}
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
let logged = 0;
@@ -169,7 +172,8 @@ export async function logCallsToContrack(d: {
await record(null);
continue;
}
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
const recorded = r.heard && r.call.recordingId !== null;
const wantSummary = r.withSummary && summariesOn && recorded;
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
continue; // the transcript job hasn't summarized it yet
const e = timelineEntry(
@@ -180,7 +184,7 @@ export async function logCallsToContrack(d: {
toNumber: r.call.toNumber,
talkSeconds: r.call.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
recorded: r.call.recordingId !== null,
recorded,
},
wantSummary ? r.summary : null,
);
@@ -0,0 +1,11 @@
CREATE TABLE "spam_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"number" text NOT NULL,
"reason" text NOT NULL,
"reported_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
);
--> statement-breakpoint
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -106,6 +106,13 @@
"when": 1793127210386,
"tag": "0014_spam_check",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1793212840276,
"tag": "0015_spam_reports",
"breakpoints": true
}
]
}
+13
View File
@@ -670,6 +670,19 @@ export const ftcComplaints = pgTable(
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
export const spamReports = pgTable(
'spam_reports',
{
id: id(),
number: text().notNull(),
reason: text().notNull(),
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
...timestamps,
},
(t) => [unique().on(t.number, t.reportedBy)],
);
export const callExtensions = pgTable(
'call_extensions',
{
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = aor
contact = sip:chicago1.voip.ms:5061;transport=tls
qualify_frequency = 0
qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = endpoint
+3 -5
View File
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
section(`trunk-${id}`, [
'type = aor',
`contact = sip:${target}`,
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
'qualify_frequency = 60',
]),
section(`trunk-${id}`, [
'type = endpoint',
+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);
+3 -5
View File
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk(
id: string,
{ waitMs = 1500, qualify = true } = {},
{ waitMs = 1500 } = {},
): Promise<{ live: TrunkLive; rttMs: number | null }> {
if (qualify) {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
}
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id);
+28 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
// entry checks (03 §6.4). Admin only.
// entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
import { desc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
@@ -9,9 +9,10 @@ import {
formatNumber,
type BlockedDto,
type ListResponse,
type SpamReportDto,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { blockedNumbers } from '../db/schema.ts';
import { blockedNumbers, spamReports, users } from '../db/schema.ts';
import { conflict, notFound } from '../errors.ts';
import { setBlock } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
throw e;
}
await mirror(b.number, true);
await db.delete(spamReports).where(eq(spamReports.number, b.number));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
});
return { ok: true };
});
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
items: (
await db
.select({ r: spamReports, name: users.displayName })
.from(spamReports)
.leftJoin(users, eq(users.id, spamReports.reportedBy))
.orderBy(desc(spamReports.createdAt))
).map(({ r, name }) => ({
id: r.id,
number: r.number,
reason: r.reason,
reportedBy: name,
createdAt: r.createdAt.toISOString(),
})),
nextCursor: null,
}));
app.delete('/api/spam-reports/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
if (!r) throw notFound('Already handled');
return { ok: true };
});
}
+56 -3
View File
@@ -1,11 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm';
// /api/me/spam-reports: Report spam on a call that rang me (05 §4).
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import {
CallFilterSchema,
SpamReportCreateSchema,
formatNumber,
normalizeNumber,
type CallDetailDto,
type CallDto,
type ListResponse,
@@ -14,9 +18,18 @@ import {
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
import {
blockedNumbers,
callExtensions,
calls,
cdr,
dids,
extensions,
spamReports,
} from '../db/schema.ts';
import { contactNames } from '../contacts/book.ts';
import { badRequest, notFound } from '../errors.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
import { getSetting } from '../services/settings.ts';
const answerer = alias(extensions, 'answerer');
@@ -216,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
const out = mine.includes('caller');
return {
...c,
// Only rang (a ring group): someone else's conversation (08 §7 #3).
recordingId: mine.some((r) => ON_THE_CALL.includes(r)) ? c.recordingId : null,
myRole: out ? 'out' : 'in',
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
};
@@ -224,4 +239,42 @@ export async function callRoutes(app: FastifyInstance) {
};
},
);
/** The admins see it on Blocked numbers and block or dismiss it. */
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
const body = SpamReportCreateSchema.parse(req.body);
const [c] = await db
.select({ number: calls.fromNumber })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(
and(
eq(calls.id, body.callId),
eq(calls.direction, 'inbound'),
eq(extensions.userId, req.user!.id),
ne(callExtensions.role, 'caller'),
),
)
.limit(1);
if (!c) throw notFound('Not a call from outside that rang you');
let number: string;
try {
number = normalizeNumber(c.number ?? '');
} catch {
throw badRequest("This caller's number was hidden, so it can't be blocked");
}
const [blocked] = await db
.select({ id: blockedNumbers.id })
.from(blockedNumbers)
.where(eq(blockedNumbers.number, number));
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
const [r] = await db
.insert(spamReports)
.values({ number, reason: body.reason, reportedBy: req.user!.id })
.onConflictDoNothing()
.returning();
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
return reply.code(201).send({ ok: true });
});
}
+3 -1
View File
@@ -270,8 +270,10 @@ export async function contactRoutes(app: FastifyInstance) {
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
// The saved token only for the saved address: another one gets none (08 §7 #7).
const same = saved?.url === b.url.replace(/\/+$/, '');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
b.companyToken || (same && saved.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
+21 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
import { and, eq, gt, isNull, ne, notExists } from 'drizzle-orm';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { decrypt, encrypt } from '../crypto/secrets.ts';
@@ -15,7 +15,7 @@ import {
} from '../errors.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
import { createSession, setSessionCookie } from '../auth/sessions.ts';
import { COOKIE, createSession, getSessionUser, setSessionCookie } from '../auth/sessions.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, verifiedEmail } from '../sso/claims.ts';
@@ -146,7 +146,13 @@ export async function oidcRoutes(app: FastifyInstance) {
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
if (connecting) {
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in, and
// only in that browser: a planted sp_oidc can't connect someone else's account (08 §7 #8).
const here = req.cookies[COOKIE]
? await getSessionUser(db, req.cookies[COOKIE], false)
: null;
if (here?.sessionId !== connecting)
return fail('expired', 'connect finished in another browser than the one that asked');
const [sess] = await db
.select({ userId: sessions.userId, email: users.email })
.from(sessions)
@@ -186,7 +192,8 @@ export async function oidcRoutes(app: FastifyInstance) {
let userId = owner?.userId;
if (!userId) {
// A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
// A user without a password or any connected account yet, by verified email (12 §3.3
// step 2). Never an admin: a provider account with their email isn't them (08 §7 #2).
const v = verifiedEmail(r.claims, s.trustEmail);
const [u] = v
? await db
@@ -196,13 +203,12 @@ export async function oidcRoutes(app: FastifyInstance) {
and(
eq(users.email, v),
isNull(users.passwordHash),
ne(users.role, 'admin'),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
),
.where(eq(userIdentities.userId, users.id)),
),
),
)
@@ -322,8 +328,15 @@ export async function oidcRoutes(app: FastifyInstance) {
recordLoginFailure(req.ip, login);
throw badRequest('The directory refused that username or password');
}
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
const r = await connectDirectory(db, req.log, req.user!.id, entry, req.ip);
if (r === 'taken')
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
if (r === 'connected_other')
throw new HttpError(
409,
'connected_other',
'Another directory account is connected. Ask an admin to disconnect it first',
);
return { ok: true };
});
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
+12 -2
View File
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
import { badRequest, forbidden, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { sideFiles } from '../ai/calls.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** The recording and, when call transcripts left them, its two side files. */
const removeFiles = async (dir: string, filePath: string) => {
@@ -109,7 +110,10 @@ export async function recordingRoutes(app: FastifyInstance) {
},
);
/** Admins, or a participant: their extension is on the call this recording belongs to. */
/**
* Admins, or who was on the call: their extension made or answered it. Not a phone that only
* rang (a ring group, a queue): that's a coworker's conversation (08 §7 #3).
*/
async function mayHear(user: { id: string; role: string }, recordingId: string) {
if (user.role === 'admin') return;
const [mine] = await db
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
.where(
and(
eq(calls.recordingId, recordingId),
eq(extensions.userId, user.id),
inArray(callExtensions.role, ON_THE_CALL),
),
)
.limit(1);
if (!mine) throw forbidden('Not your call');
}
+39 -13
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
import { and, asc, eq, sql } from 'drizzle-orm';
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
// but /api/me/reports/summary: the same summary of the calls made to me.
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import {
CALL_DIRECTIONS,
@@ -10,12 +11,12 @@ import {
type ProviderBalanceDto,
type ReportSummary,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import { calls, queues, trunks } from '../db/schema.ts';
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
import { badRequest } from '../errors.ts';
import { badRequest, notFound } from '../errors.ts';
import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts';
import { getSetting } from '../services/settings.ts';
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
export async function reportRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const admin = { preHandler: requireAdmin };
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
const { tz, from, to } = await range(db, req.query);
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
const { tz, from, to } = await range(db, query);
const where = and(
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
toExt
? inArray(
calls.id,
db
.select({ id: callExtensions.callId })
.from(callExtensions)
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
)
: undefined,
);
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
const answered = toExt
? sql`${calls.answeredByExtensionId} = ${toExt}`
: sql`${calls.status} = 'answered'`;
const local = sql`(${calls.startedAt} at time zone ${tz})`;
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
db
.select({
calls: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
answered: sql<number>`count(*) filter (where ${answered})::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
lineIn: sql<number>`round(${lineIn})::int`,
lineOut: sql<number>`${lineOut}::int`,
avgTalk: sql<
number | null
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
})
.from(calls)
.where(where),
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
},
hours,
};
}
app.get('/api/reports/summary', admin, (req) => summary(req.query));
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
const [ext] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
return summary(req.query, ext.id);
});
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
const rows = await db
.select()
.from(trunks)
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
return { items, nextCursor: null };
});
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number));
return {
+42 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
import { eq, inArray } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
import { eq, inArray, isNotNull } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -26,7 +27,7 @@ import {
type Warning,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { dids, extensions, trunks, users } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
@@ -63,6 +64,7 @@ const READ_ONLY = new Set<string>([
'spam.state',
'sms.webhookToken',
'setup.completed',
'network.probeKeyEnc',
]);
async function toDto(db: Db): Promise<SettingsDto> {
@@ -72,10 +74,12 @@ async function toDto(db: Db): Promise<SettingsDto> {
'auth.oidc': oidc,
'auth.ldap': ldap,
spam,
'network.probeKeyEnc': probeKey,
...rest
} = await getAllSettings(db);
return {
...rest,
'network.hasProbeKey': probeKey !== null,
spam: {
...spam,
ipqs: spam.ipqs && { hasKey: true },
@@ -257,6 +261,7 @@ export async function settingsRoutes(app: FastifyInstance) {
app.patch('/api/settings', async (req) => {
const body = z.record(z.string(), z.unknown()).parse(req.body);
const values: Record<string, unknown> = {};
let contrackMoved = false;
for (const [key, raw] of Object.entries(body)) {
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
throw badRequest(`${key} can't be changed here`);
@@ -287,8 +292,13 @@ export async function settingsRoutes(app: FastifyInstance) {
} catch (err) {
throw badRequest(`Contrack address: ${(err as Error).message}`);
}
// The saved tokens only go back to the address they were typed for (08 §7 #7).
const url = input?.url.replace(/\/+$/, '');
const moved = !!old && !!input && old.url !== url;
if (moved && old.companyTokenEnc && !input.companyToken && !input.clearCompanyToken)
throw badRequest('The Contrack address changed: enter the company token again');
values[key] = input && {
url: input.url.replace(/\/+$/, ''),
url: url!,
publicUrl: input.publicUrl,
companyTokenEnc: input.companyToken
? encrypt(input.companyToken)
@@ -296,6 +306,7 @@ export async function settingsRoutes(app: FastifyInstance) {
? ''
: (old?.companyTokenEnc ?? ''),
};
if (moved) contrackMoved = true;
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
@@ -349,8 +360,17 @@ export async function settingsRoutes(app: FastifyInstance) {
const cert = await readTlsCert(app.config.TLS_DIR);
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
}
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
values['network.probeKeyEnc'] = null;
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
if (contrackMoved)
await db
.update(users)
.set({ contrackTokenEnc: null })
.where(isNotNull(users.contrackTokenEnc));
// A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
const changed = Object.keys(values).filter(
@@ -474,6 +494,25 @@ export async function settingsRoutes(app: FastifyInstance) {
},
);
/**
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
* the probe address set now; the old key stops working on the next test.
*/
app.post('/api/settings/network/probe-key', async (req) => {
if (!(await getSetting(db, 'network.probeUrl')))
throw badRequest('Set the probe address first');
const key = randomBytes(32).toString('base64url');
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'settings',
summary: 'Made a new network test probe key',
});
return { key };
});
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
+6 -9
View File
@@ -208,21 +208,18 @@ export async function trunkRoutes(app: FastifyInstance) {
if (!t.enabled) throw badRequest('Turn the trunk on first');
if (!app.live.amiConnected)
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
// No OPTIONS over TLS (see the trunk's aor in generator/pjsip.ts).
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
const { live, rttMs } = await app.live.testTrunk(id);
const reg = t.authMode === 'registration';
const detail =
reg && live.registration === 'registered'
? `Registered with ${t.serverHost}`
: reg && live.registration === 'rejected'
? `${t.serverHost} rejected the username or password`
: !reg && t.transport === 'tls'
? `Can't check a TLS trunk without registration from here: make a test call.`
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
});
+6 -3
View File
@@ -138,6 +138,9 @@ export async function sendCallReports(d: {
aiDoneAt: recordings.aiDoneAt,
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
// Made or answered it: only rang means a coworker's conversation (08 §7 #3).
heard: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} in ('caller', 'answered'))`,
})
.from(calls)
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
@@ -177,9 +180,9 @@ export async function sendCallReports(d: {
toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
recorded: r.c.recordingId !== null,
summary: r.summary,
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
recorded: r.heard && r.c.recordingId !== null,
summary: r.heard ? r.summary : null,
summaryPending: r.heard && r.c.recordingId !== null && r.aiDoneAt === null,
}));
const withSummaries =
e.callReportSummaries &&
+6
View File
@@ -14,6 +14,12 @@ import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/
import { durationMs } from '../media/audio.ts';
type CdrRow = typeof cdr.$inferSelect;
/**
* The call_extensions roles of someone who was on the call, not just rung: they may hear its
* recording and read its transcript and summary (08 §7 #3).
*/
export const ON_THE_CALL = ['caller', 'answered'];
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
+23 -6
View File
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import type { Config } from '../config.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
!s['network.probeUrl']
? check(
'probe',
'Network test probe',
'skip',
'None set, so nothing is tested from outside your network.',
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
),
)
: s['network.probeKeyEnc']
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
: check(
'probe',
'Network test probe',
'warn',
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
),
);
return out;
}
@@ -289,14 +298,20 @@ export async function networkTest(
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
let keyRefused = false;
const key = s['network.probeKeyEnc'];
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
...(key && { authorization: `Bearer ${decrypt(key)}` }),
},
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
keyRefused = r.status === 401;
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
// Datagrams sent last arrive just after the answer.
@@ -316,7 +331,9 @@ export async function networkTest(
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
keyRefused
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
: 'Check the probe runs and is reachable from this server, then try again.',
),
],
};
+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 };
}
+6 -5
View File
@@ -121,11 +121,12 @@ async function transcribeMessage(d: VoicemailDeps, row: Row): Promise<string | n
async function announce(d: VoicemailDeps, box: Box, found: Row) {
const transcript = await transcribeMessage(d, found);
const row = { ...found, transcript };
// Every mailbox, with or without a user (the live message is only for the user's own).
void enqueueWebhook(d.db, 'voicemail.new', {
...toVoicemailDto(row),
extension: box.number,
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
// Every mailbox, with or without a user (the live message is only for the user's own). Not
// the transcript: webhooks go off this server, and transcripts never do (10 §1, 08 §7 #6).
const { transcript: _words, ...hook } = toVoicemailDto(row);
void enqueueWebhook(d.db, 'voicemail.new', { ...hook, extension: box.number }).catch(
(err: unknown) => d.log.error({ err }, 'webhook enqueue failed'),
);
if (box.userId)
d.live.emit('message', {
type: 'voicemail.new',
+27 -8
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
import { and, eq, notExists } from 'drizzle-orm';
import { deleteUserSessions } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
@@ -22,7 +22,21 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
// Nobody with another way in either: an OpenID user's account is theirs to connect (08 §7 #1).
const [byEmail] = await db
.select()
.from(users)
.where(
and(
eq(users.email, e.email),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(eq(userIdentities.userId, users.id)),
),
),
);
return byEmail && byEmailOk(byEmail) ? byEmail : null;
}
@@ -35,19 +49,27 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
const byEmailOk = (u: UserRow) =>
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
/** Connect a directory account the person proved (My settings, 12 §4.3). */
/**
* Connect a directory account the person proved (My settings, 12 §4.3). Not another one in place
* of the one they have: the leaver check follows it, so swapping is an admin's job (08 §7 #5).
*/
export async function connectDirectory(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
e: DirectoryEntry,
ip: string,
): Promise<'connected' | 'taken'> {
): Promise<'connected' | 'taken' | 'connected_other'> {
const [owner] = await db
.select({ userId: userIdentities.userId })
.from(userIdentities)
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (owner && owner.userId !== userId) return 'taken';
const [mine] = await db
.select({ subject: userIdentities.subject })
.from(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
if (mine && mine.subject !== e.id) return 'connected_other';
await db.transaction(async (tx) => {
await tx
.delete(userIdentities)
@@ -101,11 +123,8 @@ export async function directorySignIn(
entityId: u.id,
summary: `${u.email} connected directory account ${e.dn}`,
});
} else if (mine.subject !== e.id) {
// Their email matches, but another directory account is already theirs: not this one.
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
return null;
} else
// Found by this account's id (directoryUserFor never matches by email someone connected).
await db
.update(userIdentities)
.set({ email: e.email, lastUsedAt: new Date() })
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A new Asterisk in asterisk/Dockerfile must go through the upgrade check first (verified.ts).
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { VERIFIED_ASTERISK, pinnedAsterisk } from './asterisk/verified.ts';
describe('the pinned Asterisk', () => {
it('is the version the upgrade check last passed on', () => {
expect(
pinnedAsterisk(),
`asterisk/Dockerfile pins ${pinnedAsterisk()}, the upgrade check last passed on ` +
`${VERIFIED_ASTERISK}. Build the image, run \`pnpm test:asterisk-upgrade\` and ` +
'`pnpm test:asterisk`, act on what moved, then set VERIFIED_ASTERISK in ' +
'apps/api/test/asterisk/verified.ts',
).toBe(VERIFIED_ASTERISK);
});
it('is the one spec 01 §2 names, and its copy of the Dockerfile is the file', () => {
const spec = readFileSync(
new URL('../../../docs/spec/01-infrastructure.md', import.meta.url),
'utf8',
);
expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``);
const copy = /^## 2\. [\s\S]*?^```dockerfile\n([\s\S]*?)^```$/m.exec(spec)?.[1];
const file = readFileSync(new URL('../../../asterisk/Dockerfile', import.meta.url), 'utf8');
expect(copy, 'spec 01 §2 has a copy of asterisk/Dockerfile: keep it the same').toBe(file);
});
});
+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;
+309
View File
@@ -0,0 +1,309 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Asterisk upgrade check (01 §2): boot the pinned image and measure each behaviour in verified.ts,
// then list every one that moved and what depends on it. A second Asterisk plays a provider on
// TLS with a certificate from our own CA for "provider.test", so the qualify check sees a real
// host name. Needs Docker and the image built from asterisk/Dockerfile (ASTERISK_IMAGE, default
// "selfphoneng-asterisk").
// Usage: pnpm test:asterisk-upgrade
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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(
new URL('../../src/generator/__golden__/one-user/expected', import.meta.url),
);
const NET = 'sp-upgrade';
const PBX = 'sp-upgrade-pbx';
const PROVIDER = 'sp-upgrade-provider';
const HOST = 'provider.test';
const AMI_PORT = 15040;
const AMI_SECRET = 'upgrade-test-secret';
const SUB = { user: 'upgrade', pass: 'UpgradeSecret42' };
const RISKY = ['app_system', 'func_shell', 'app_disa', 'res_agi', 'app_externalivr'];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const docker = (...args: string[]) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const log = (...a: unknown[]) => console.log('[upgrade-test]', ...a);
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
for (let i = 0; i < seconds; i++) {
if (await fn()) return;
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
/** A CA, and a certificate from it for HOST: what the PBX trusts and the provider shows. */
function certificates(dir: string) {
const r = docker(
'run',
'--rm',
'-v',
`${dir}:/out`,
'--entrypoint',
'sh',
IMAGE,
'-c',
[
'cd /out',
'openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=sp-upgrade-ca -keyout ca.key -out ca.pem',
`openssl req -newkey rsa:2048 -nodes -subj /CN=${HOST} -keyout privkey.pem -out host.csr`,
`printf 'subjectAltName=DNS:${HOST}' > host.ext`,
'openssl x509 -req -in host.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile host.ext -out fullchain.pem',
'chmod 644 *',
].join(' && '),
);
if (!r.ok) throw new Error(`certificates: ${r.out}`);
}
const tlsTransport = (name: string, port: number, client: boolean) => `
[${name}]
type = transport
protocol = tls
bind = 0.0.0.0:${port}
cert_file = /tls/fullchain.pem
priv_key_file = /tls/privkey.pem
method = tlsv1_2
${client ? 'verify_server = yes\nca_list_file = /tls/ca.pem\n' : ''}`;
/** The provider: TLS only, one account that may register. */
const providerPjsip = `${tlsTransport('transport-tls', 5061, false)}
[${SUB.user}]
type = endpoint
context = default
disallow = all
allow = ulaw
auth = ${SUB.user}-auth
aors = ${SUB.user}
[${SUB.user}-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[${SUB.user}]
type = aor
max_contacts = 1
remove_existing = yes
`;
/**
* Added to the one-user golden config: a trunk to the provider by name over TLS. The registration
* is the control (REGISTER verifies the name on every version we know); the AOR's qualify is the
* fact being measured.
*/
const pbxPjsip = `${tlsTransport('sp-upgrade-tls', 5062, true)}
[sp-upgrade-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[sp-upgrade-reg]
type = registration
transport = sp-upgrade-tls
outbound_auth = sp-upgrade-auth
server_uri = sip:${HOST}:5061;transport=tls
client_uri = sip:${SUB.user}@${HOST}:5061;transport=tls
contact_user = ${SUB.user}
retry_interval = 5
[sp-upgrade-trunk]
type = aor
contact = sip:${HOST}:5061;transport=tls
qualify_frequency = 5
[sp-upgrade-trunk]
type = endpoint
transport = sp-upgrade-tls
outbound_auth = sp-upgrade-auth
aors = sp-upgrade-trunk
context = sp-upgrade-test
`;
const pbxDialplan = `
[sp-upgrade-test]
exten => s,1,Answer()
same => n,Hangup()
`;
async function main() {
const pinned = pinnedAsterisk();
const tls = mkdtempSync(join(tmpdir(), 'sp-upgrade-tls-'));
const pbxConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-pbx-'));
const provConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-prov-'));
cpSync(GOLDEN, pbxConf, { recursive: true });
appendFileSync(join(pbxConf, 'pjsip_selfphoneng.conf'), pbxPjsip);
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'), testManagerConf());
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
username: 'selfphoneng',
secret: AMI_SECRET,
logger: { info() {}, warn() {}, error() {}, debug() {} },
backoffMs: [500],
});
const asterisk = (name: string, conf: string, extra: string[]) =>
docker(
'run',
'-d',
'--rm',
'--name',
name,
'--network',
NET,
...extra,
'-e',
`AMI_SECRET=${AMI_SECRET}`,
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
'-v',
`${tls}:/tls:ro`,
IMAGE,
);
const booted = (name: string) =>
waitFor(
`${name} to boot`,
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const seen = {} as Record<Fact, boolean>;
const problems: string[] = [];
try {
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'create', NET);
certificates(tls);
const prov = asterisk(PROVIDER, provConf, ['--network-alias', HOST]);
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
await booted(PROVIDER);
const pbx = asterisk(PBX, pbxConf, [
'-p',
`127.0.0.1:${AMI_PORT}:5038`,
'-v',
`${join(pbxConf, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
]);
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
await booted(PBX);
const running = docker('exec', PBX, 'asterisk', '-V').out.trim();
if (running !== `Asterisk ${pinned}`)
throw new Error(
`the image runs "${running}" but asterisk/Dockerfile pins ${pinned}: rebuild it ` +
`(docker build -t ${IMAGE} asterisk)`,
);
log(`${running}, last verified on ${VERIFIED_ASTERISK}`);
ami.start();
await new Promise((r) => ami.once('connected', r));
const cli = (cmd: string) => ami.command(cmd);
const modules = await Promise.all(RISKY.map((m) => cli(`module show like ${m}`)));
seen.riskyModulesGone = modules.every((o) => /\b0 modules loaded/.test(o));
const ep101 = await cli('pjsip show endpoint 101');
seen.mwiSubscribeReplaces = /mwi_subscribe_replaces_unsolicited\s*:\s*yes/.test(ep101);
// set_var lines show as their own rows: " __SP_DIRECTION : internal".
seen.setVarKeepsUnderscores = /^\s*__SP_DIRECTION\s*:\s*internal\s*$/m.test(ep101);
const commands = await cli('manager show commands');
seen.voicemailMoveRemove =
/\bVoicemailMove\b/.test(commands) && /\bVoicemailRemove\b/.test(commands);
// CDR: one Local call through the test context.
const cdr = new Promise<AmiMessage>((r) => ami.once('event:Cdr', r));
await cli('channel originate Local/s@sp-upgrade-test application Wait 1');
const m = await Promise.race([
cdr,
sleep(15_000).then(() => {
throw new Error('no Cdr event within 15 s of a call');
}),
]);
seen.cdrLinkedId = !!field(m, 'LinkedID');
const start = field(m, 'StartTime') ?? '';
seen.cdrTimesUtcNoZone =
/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(start) &&
Math.abs(Date.parse(`${start.replace(' ', 'T')}Z`) - Date.now()) < 120_000;
// TLS: the registration must work first, or the qualify answer says nothing.
await waitFor(
'the TLS registration to the provider (the control)',
async () => /sp-upgrade-reg\S*\s.*Registered/.test(await cli('pjsip show registrations')),
45,
).catch(async (e: Error) => {
throw new Error(`${e.message}:\n${await cli('pjsip show registrations')}`);
});
let contacts = '';
for (let i = 0; i < 20; i++) {
contacts = await cli('pjsip show contacts');
if (/sp-upgrade-trunk\/\S+\s.*\b(Avail|Unavail)/.test(contacts)) break;
await sleep(1000);
}
seen.tlsQualifyByHostname = /sp-upgrade-trunk\/\S+\s.*\bAvail\b/.test(contacts);
if (!seen.tlsQualifyByHostname) {
const why = docker('logs', PBX)
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
.out.replace(/\x1b\[[0-9;]*m/g, '')
.split('\n');
const line = contacts.split('\n').find((l) => l.includes('sp-upgrade-trunk'));
log('qualify over TLS failed:', line);
for (const l of why
.filter((x) => /sp-upgrade-trunk|identity|EUNSUP|qualif/i.test(x))
.slice(-4))
log(l.trim());
}
const listed = await ami.actionList({ Action: 'PJSIPShowContacts' });
seen.staticContactsInShowContacts = listed.some((x) => x.Aor === 'sp-upgrade-trunk');
const detail = await ami.actionList({
Action: 'PJSIPShowEndpoint',
Endpoint: 'sp-upgrade-trunk',
});
seen.endpointShowsContactStatus = detail.some(
(x) => x.Event === 'ContactStatusDetail' && x.AOR === 'sp-upgrade-trunk',
);
} catch (e) {
problems.push((e as Error).message);
} finally {
ami.stop();
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'rm', NET);
for (const d of [tls, pbxConf, provConf]) rmSync(d, { recursive: true, force: true });
}
const moved = (Object.keys(FACTS) as Fact[]).filter(
(k) => k in seen && seen[k] !== FACTS[k].expect,
);
for (const k of Object.keys(FACTS) as Fact[])
console.log(
`${!(k in seen) ? '? ' : moved.includes(k) ? 'MOVED' : 'ok '} ${k}: ${k in seen ? seen[k] : 'not measured'}`,
);
for (const k of moved)
problems.push(
`${k} was ${FACTS[k].expect} on ${VERIFIED_ASTERISK}, is ${seen[k]} on ${pinned}: ` +
`${FACTS[k].what}. Depends on it: ${FACTS[k].where}`,
);
if (problems.length) {
console.log('FAIL asterisk upgrade');
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
console.log(
pinned === VERIFIED_ASTERISK
? `ok asterisk ${pinned}: every fact as recorded`
: `ok asterisk ${pinned}: nothing moved since ${VERIFIED_ASTERISK}; set VERIFIED_ASTERISK in verified.ts`,
);
}
await main();
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What the code relies on Asterisk doing, as last measured by upgrade-test.ts, and on which
// version. asterisk-version.test.ts fails when asterisk/Dockerfile pins another version: run
// `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this.
import { readFileSync } from 'node:fs';
export const VERIFIED_ASTERISK = '24.0.0-rc3';
/** The version asterisk/Dockerfile builds. */
export function pinnedAsterisk(): string {
const file = readFileSync(new URL('../../../../asterisk/Dockerfile', import.meta.url), 'utf8');
const v = /^ARG ASTERISK_VERSION=(\S+)$/m.exec(file)?.[1];
if (!v) throw new Error('asterisk/Dockerfile has no ARG ASTERISK_VERSION');
return v;
}
/** `expect`: the answer on VERIFIED_ASTERISK. `where`: what depends on it, to change if it moves. */
export const FACTS = {
riskyModulesGone: {
expect: true,
what: 'app_system, func_shell, app_disa, res_agi and app_externalivr are not loaded',
where: 'asterisk/conf/modules.conf noload lines (08 §6): a renamed module would load again',
},
mwiSubscribeReplaces: {
expect: true,
what: 'endpoints take mwi_subscribe_replaces_unsolicited',
where: 'generator/pjsip.ts: without it a phone that SUBSCRIBEs for MWI gets a 500 (03 §5.2)',
},
setVarKeepsUnderscores: {
expect: true,
what: 'set_var = __SP_DIRECTION=… loads as "__SP_DIRECTION"',
where: 'generator/pjsip.ts set_var lines; dialplan reads SP_DIRECTION on dialed legs (03 §5.2)',
},
staticContactsInShowContacts: {
expect: false,
what: 'PJSIPShowContacts lists static (contact =) AOR contacts',
where: 'live/state.ts reads trunk reachability from PJSIPShowEndpoint instead (04 §5.1)',
},
endpointShowsContactStatus: {
expect: true,
what: 'PJSIPShowEndpoint has a ContactStatusDetail for a static contact',
where: 'live/state.ts trunk reachability (04 §5.1)',
},
voicemailMoveRemove: {
expect: true,
what: 'the AMI actions VoicemailMove and VoicemailRemove exist',
where: 'services/voicemail.ts mark read and delete (04 §5.6)',
},
cdrLinkedId: {
expect: true,
what: 'Cdr AMI events carry LinkedID (cdr_manager.conf mapping)',
where: 'services/cdr.ts groups a call by it (01 §3.15, 04 §5.4)',
},
cdrTimesUtcNoZone: {
expect: true,
what: 'Cdr StartTime is "YYYY-MM-DD HH:MM:SS" in UTC, no zone',
where: 'services/cdr.ts utc() adds the Z itself',
},
tlsQualifyByHostname: {
expect: true,
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
where:
'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' +
'24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0',
},
} as const;
export type Fact = keyof typeof FACTS;
+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);
+69 -4
View File
@@ -1,25 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5).
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5); users' Report spam (05 §4).
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { callExtensions, calls, extensions } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { syncAstDb } from '../src/services/astdb.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let req: (
method: string,
url: string,
payload?: unknown,
as?: string,
) => Promise<LightMyRequestResponse>;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
req = (method, url, payload, as = cookie) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
headers: { ...H, cookie: as },
});
});
afterAll(() => t?.close());
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
});
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
});
it('Report spam: only on an outside call that rang me; Block clears it, Dismiss drops it', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
await t.addUser('pat@example.com', 'pat-password-123');
const [ext] = await t.db
.insert(extensions)
.values({
number: '103',
name: 'Sam',
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: sam.id,
})
.returning();
const call = async (linkedid: string, from: string | null, role: string) => {
const [c] = await t.db
.insert(calls)
.values({
linkedid,
direction: 'inbound',
status: 'answered',
fromNumber: from,
startedAt: new Date(),
endedAt: new Date(),
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: ext!.id, role });
return c!.id;
};
const robo = await call('r1', '13125550142', 'rang');
const hidden = await call('r2', null, 'answered');
const samC = await t.login('sam@example.com', 'sam-password-123');
const patC = await t.login('pat@example.com', 'pat-password-123');
const report = (callId: string, as: string, reason = 'Car warranty robocall') =>
req('POST', '/api/me/spam-reports', { callId, reason }, as);
expect((await report(robo, patC)).statusCode).toBe(404); // not a call that rang Pat
expect((await report(robo, samC, ' ')).statusCode).toBe(400); // no reason
expect((await report(hidden, samC)).statusCode).toBe(400); // no number to block
expect((await report(robo, samC)).statusCode).toBe(201);
expect((await report(robo, samC)).statusCode).toBe(409); // once each
expect((await req('GET', '/api/spam-reports', undefined, samC)).statusCode).toBe(403);
const [r] = (await req('GET', '/api/spam-reports')).json().items;
expect(r).toMatchObject({
number: '13125550142',
reason: 'Car warranty robocall',
reportedBy: 'sam@example.com',
});
expect((await req('DELETE', `/api/spam-reports/${r.id}`)).statusCode).toBe(200);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(201); // dismissed: may report again
const b = await req('POST', '/api/blocked-numbers', { number: r.number, reason: r.reason });
expect(b.statusCode).toBe(201);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(409); // already blocked
});
});
+39
View File
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
await run('2026-10-23T13:06:00Z');
expect(t.mails[1]!.text).not.toContain('quote');
});
it('no recording or summary of a call that only rang my phone (08 §7 #3)', async () => {
await dan('PATCH', '/api/me', { callReportSummaries: true });
const [rec] = await t.db
.insert(recordings)
.values({
linkedid: 'r4',
filePath: '2026/10/24/r4.wav',
startedAt: new Date('2026-10-24T15:00:00Z'),
summary: 'Secret pricing for Acme',
aiDoneAt: new Date(),
})
.returning();
const [c] = await t.db
.insert(calls)
.values({
linkedid: 'c4', // the ring group rang Dan; a coworker answered and talked
direction: 'inbound',
status: 'answered',
startedAt: new Date('2026-10-24T15:00:00Z'),
endedAt: new Date('2026-10-24T15:05:00Z'),
fromNumber: '13125557777',
talkSeconds: 300,
recordingId: rec!.id,
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: extId, role: 'rang' });
await t.db
.update(extensions)
.set({ callReportSentAt: new Date('2026-10-24T13:00:00Z') })
.where(eq(extensions.id, extId));
t.mails.length = 0;
expect(await run('2026-10-25T13:06:00Z')).toBe(1);
const text = t.mails[0]!.text;
expect(text).toContain('Incoming from (312) 555-7777');
expect(text).not.toContain('Secret pricing');
expect(text).not.toContain('recorded');
expect(text).not.toContain('Summary not ready');
});
});
+44
View File
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
fake.state.readOnly.clear();
expect(await run()).toBe(0);
expect(jane).toHaveLength(8);
// Only rang Dan (a coworker took it): on his timeline, but not its recording or summary.
const [k12] = await t.db
.insert(calls)
.values(call('k12', 1.5, { recordingId: done!.id }))
.returning();
await t.db
.insert(callExtensions)
.values({ callId: k12!.id, extensionId: ext!.id, role: 'rang' });
forgetBook(danId);
expect(await run()).toBe(1);
expect(jane.at(-1)!.content).not.toMatch(/recorded|Acme/);
expect(jane.at(-1)).not.toHaveProperty('actionItem');
});
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
@@ -521,4 +534,35 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
it('the saved tokens only go to the address they were saved for (08 §7 #7)', async () => {
// Another machine on the network that answers like Contrack, and notes what it's sent.
const heard: string[] = [];
const other = createServer((req, res) => {
heard.push(String(req.headers.authorization ?? ''));
res.setHeader('content-type', 'application/json');
res.end('[]');
});
await new Promise<void>((ok) => other.listen(0, '127.0.0.1', ok));
const otherUrl = `http://127.0.0.1:${(other.address() as AddressInfo).port}`;
try {
await admin('POST', '/api/contacts/contrack/test', { url: otherUrl });
expect(heard.join(' ')).not.toContain('ctk_company');
// A new address needs its token typed again…
const kept = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: otherUrl },
});
expect(kept.statusCode).toBe(400);
expect(kept.json().error.message).toMatch(/token again/);
// …and people's own tokens are for the old one: they connect again.
const moved = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: otherUrl, companyToken: 'ctk_new_place' },
});
expect(moved.statusCode, moved.body).toBe(200);
const [d] = await t.db.select().from(users).where(eq(users.id, danId));
expect(d!.contrackTokenEnc).toBeNull();
} finally {
other.close();
}
});
});
+51
View File
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
401,
);
});
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
const cookie = await t.login(ADMIN.email, ADMIN.password);
const as = (method: string, url: string, payload?: object) =>
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
// No key yet: the inside checks say to make one.
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
const g = await as('POST', '/api/settings/network/probe-key');
expect(g.statusCode, g.body).toBe(200);
const { key } = g.json() as { key: string };
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
const s = await as('GET', '/api/settings');
expect(s.json()['network.hasProbeKey']).toBe(true);
expect(s.body).not.toContain(key);
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
400,
);
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
let auth: string | null = null;
await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
auth = new Headers(init.headers).get('authorization');
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(auth).toBe(`Bearer ${key}`);
// The probe refusing it says what to do.
const refused = await networkTest(
{
...deps,
fetchProbe: (async () =>
new Response(JSON.stringify({ error: 'a key is needed' }), {
status: 401,
})) as unknown as typeof fetch,
},
'quick',
);
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
// Another address: the key was for the old probe, so it's gone.
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
});
});
+35 -2
View File
@@ -1,12 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
// directory that's down counting as a failed sign-in (§4.2).
// directory that's down counting as a failed sign-in (§4.2). 08 §7: no OpenID user by email, no
// swapping the connected account.
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { directorySignIn } from '../src/sso/directory.ts';
import { connectDirectory, directorySignIn } from '../src/sso/directory.ts';
import type { DirectoryEntry } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -52,6 +53,38 @@ describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
}
});
it('never connects by email someone who signs in with OpenID (08 §7 #1)', async () => {
// A directory that lets people edit their own mail: Mallory's entry, the victim's email.
const victim = await t.addUser('oidc-only@example.com', 'unused-pw-2');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, victim.id));
await t.db
.insert(userIdentities)
.values({ userId: victim.id, kind: 'oidc', subject: 'https://idp victim-sub' });
expect(await directorySignIn(t.db, log, entry(victim.email), '127.0.0.1')).toBeNull();
expect(await connected(victim.id)).toBe(1); // the OpenID account only
});
it('a connected directory account is only swapped by an admin (08 §7 #5)', async () => {
const u = await t.addUser('swapper@example.com', 'swapper-password-1');
expect(await connectDirectory(t.db, log, u.id, entry(u.email), '127.0.0.1')).toBe('connected');
const [first] = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
// The same account again is fine; another one (a shared mailbox) is not.
expect(
await connectDirectory(
t.db,
log,
u.id,
{ ...entry(u.email), id: first!.subject },
'127.0.0.1',
),
).toBe('connected');
expect(await connectDirectory(t.db, log, u.id, entry('shared@example.com'), '127.0.0.1')).toBe(
'connected_other',
);
const now = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
expect(now.map((i) => i.subject)).toEqual([first!.subject]);
});
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
+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);
});
});
+50
View File
@@ -207,6 +207,56 @@ for (const alg of ['RS256', 'ES256'] as const)
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
});
it('never matches by email an admin, or someone with a directory account (08 §7 #2)', async () => {
const [boss, dir] = await t.db
.insert(users)
.values([
{ email: 'diradmin@example.com', displayName: 'Boss', passwordHash: null, role: 'admin' },
{ email: 'diruser@example.com', displayName: 'Dir', passwordHash: null },
])
.returning();
await t.db
.insert(userIdentities)
.values({ userId: dir!.id, kind: 'ldap', subject: 'dir-user-id' });
for (const u of [boss!, dir!]) {
idp.st.user = { sub: `u-${u.email}`, email: u.email, email_verified: true };
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
expect(session(callback!)).toBeNull();
}
});
it('connect finishes only in the browser whose session asked (08 §7 #8)', async () => {
// Mallory starts Connect, then plants her sp_oidc cookie in Vic's browser and sends him on.
await t.addUser('mallory@example.com', 'mallory-password-1');
const [m] = await t.db.select().from(users).where(eq(users.email, 'mallory@example.com'));
await t.addUser('vic@example.com', 'vic-password-123');
const mallory = await t.login('mallory@example.com', 'mallory-password-1');
const vic = await t.login('vic@example.com', 'vic-password-123');
idp.st.user = { sub: 'u-vic', email: 'vic@example.com', email_verified: true };
for (const theirs of [vic, '']) {
const start = await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: mallory },
payload: {},
});
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const atIdp = await fetch((start.json() as { url: string }).url, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
const callback = await t.app.inject({
url: back.pathname + back.search,
headers: { cookie: [`sp_oidc=${pending.value}`, theirs].filter(Boolean).join('; ') },
});
expect(errorOf(callback)).toBe('expired');
const ids = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, m!.id));
expect(ids).toEqual([]);
}
});
it('trustEmail matches an unverified email', async () => {
await t.db
.insert(users)
+24 -2
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
// people on the call; delete and retention remove file and row.
// people on the call (not a phone that only rang, 08 §7 #3); delete and retention remove file and row.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { AmiMessage } from '../src/ami/events.ts';
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
import { callExtensions, calls, cdr, extensions, recordings, users } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { cleanupRecordings } from '../src/routes/recordings.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
};
});
afterAll(() => t?.close());
const bobId = async () =>
(await t.db.select().from(users).where(eq(users.email, 'bob@example.com')))[0]!.id;
it('the recording is linked to its call, with size and length', async () => {
const [c] = await t.db.select().from(calls);
@@ -91,6 +93,26 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
});
it('a phone that only rang (ring group, queue) gets neither audio nor transcript (08 §7 #3)', async () => {
const [bob] = await t.db
.insert(extensions)
.values({
number: '102',
name: 'Bob',
userId: await bobId(),
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
})
.returning();
const [c] = await t.db.select().from(calls);
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: bob!.id, role: 'rang' });
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
expect((await as.bob('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(403);
const mine = (await as.bob('GET', '/api/me/calls')).json();
expect(mine.items).toEqual([expect.objectContaining({ id: c!.id, recordingId: null })]);
expect((await as.alice('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(200);
});
it('retention deletes recordings past their days; delete removes file and link', async () => {
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
+74 -2
View File
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
// /api/me/reports/summary, the calls made to me.
import type { LightMyRequestResponse } from 'fastify';
import { inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, queueCalls, queues } from '../src/db/schema.ts';
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
});
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
const [mine, other] = await t.db
.insert(extensions)
.values(
(['103', '104'] as const).map((number) => ({
number,
name: number,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: number === '103' ? sam.id : null,
})),
)
.returning();
await t.db.insert(calls).values({
linkedid: 'g',
direction: 'internal',
status: 'answered',
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
endedAt: new Date('2026-05-05T15:00:45Z'),
talkSeconds: 40,
answeredByExtensionId: mine!.id,
});
const ids = Object.fromEntries(
(
await t.db
.select({ id: calls.id, linkedid: calls.linkedid })
.from(calls)
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
).map((c) => [c.linkedid, c.id]),
);
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
await t.db
.update(calls)
.set({ answeredByExtensionId: other!.id })
.where(inArray(calls.id, [ids.a!]));
await t.db.insert(callExtensions).values([
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
]);
const cookie = await t.login('sam@example.com', 'sam-password-123');
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
]);
expect(s.totals).toEqual({
calls: 3,
answered: 1,
missed: 1,
avgTalkSeconds: 40,
talkSeconds: 40,
outsideSeconds: { inbound: 95, outbound: 0 },
});
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
// The company summary is unchanged by who rang.
expect(
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
).toBe(2);
});
});
+2 -2
View File
@@ -117,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 47 tables', async () => {
it('creates all 48 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 47 });
expect(r.rows[0]).toEqual({ n: 48 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+5 -3
View File
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
expect(await warningCodes()).not.toContain('tls_cert');
});
it('a TLS trunk: no qualify, Test does not send OPTIONS, TLS stays on while it exists', async () => {
it('a TLS trunk: qualified like the others (24.0.0-rc3), TLS stays on while it exists', async () => {
const trunk = await req('POST', '/api/trunks', {
name: 'voip.ms TLS',
serverHost: '127.0.0.1',
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
await t.app.generator.apply({ trigger: 'test' });
const { readFile } = await import('node:fs/promises');
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 0\n/);
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 60\n/);
expect(conf).toMatch(/media_encryption = sdes/);
expect(conf).toMatch(/verify_server = yes/);
t.live.amiConnected = true;
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
expect(test.statusCode).toBe(200);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([]);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([
expect.objectContaining({ Endpoint: `trunk-${trunk.json().id}` }),
]);
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
expect(off.statusCode).toBe(400);
expect(off.json().error.message).toMatch(/uses TLS/);
+11 -1
View File
@@ -7,7 +7,7 @@ import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared';
import { extensions, voicemailMessages } from '../src/db/schema.ts';
import { extensions, voicemailMessages, webhookDeliveries, webhooks } from '../src/db/schema.ts';
import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -175,11 +175,21 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
const url = `http://127.0.0.1:${(stt.address() as AddressInfo).port}/v1`;
await setSetting(t.db, 'ai.stt', { url, model: 'whisper' });
await setSetting(t.db, 'ai.voicemailTranscripts', true);
await t.db
.insert(webhooks)
.values({ url: 'https://hooks.example.com/x', secretEnc: 'x', events: ['voicemail.new'] });
try {
leave('INBOX', 3, '1792300700-00000004', '<104>');
await w.scan('101');
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
expect(r.transcript).toBe('Please call me.');
// A webhook goes off this server: the words stay here (10 §1, 08 §7 #6).
const [hook] = await t.db
.select()
.from(webhookDeliveries)
.where(eq(webhookDeliveries.event, 'voicemail.new'));
expect((hook!.payload as { data: object }).data).toMatchObject({ id: r.id });
expect((hook!.payload as { data: object }).data).not.toHaveProperty('transcript');
expect(t.mails.at(-1)!.text).toContain(
'What they said (automatic, may have mistakes):\nPlease call me.',
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.11",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+14 -3
View File
@@ -1,10 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
// without one anyone who finds the probe can use it.
import { createProbe } from './server.ts';
const port = Number(process.env.PORT ?? 8080);
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
const trustProxy = process.env.TRUST_PROXY === 'true';
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
const key = process.env.PROBE_KEY || undefined;
const probe = createProbe({ trustProxy, key });
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
if (!key)
console.warn(
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
'(Settings → Network → Generate key) and set PROBE_KEY.',
);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
+44 -3
View File
@@ -1,12 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits,
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
// answers who sends it (#9).
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
import {
createServer as httpServer,
type IncomingMessage,
type Server as HttpServer,
} from 'node:http';
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runTest } from './checks.ts';
import { createProbe, parseCheck } from './server.ts';
import { clientIp, createProbe, parseCheck } from './server.ts';
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
const listen = <T extends Server | HttpServer>(s: T) =>
@@ -165,3 +171,38 @@ describe('probe server', () => {
target.close();
});
});
describe('the probe key', () => {
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
const key = 'k3y'.repeat(15);
const probe = await listen(createProbe({ key }));
const post = (authorization?: string) =>
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
});
expect((await post()).status).toBe(401);
expect((await post('Bearer wrong')).status).toBe(401);
expect((await post(`Bearer ${key}x`)).status).toBe(401);
expect((await post(`Bearer ${key}`)).status).toBe(200);
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
probe.close();
});
});
describe('the address that asked', () => {
const req = (peer: string, xff?: string) =>
({
headers: xff ? { 'x-forwarded-for': xff } : {},
socket: { remoteAddress: peer },
}) as unknown as IncomingMessage;
it('takes X-Forwarded-For only from the local proxy (08 §7 #4)', () => {
// Reached straight from the internet: the header is whatever the caller wrote.
expect(clientIp(req('203.0.113.5', '198.51.100.9'), true)).toBe('203.0.113.5');
for (const proxy of ['127.0.0.1', '::ffff:127.0.0.1', '::1'])
expect(clientIp(req(proxy, '10.0.0.1, 198.51.100.9'), true)).toBe('198.51.100.9');
expect(clientIp(req('::ffff:203.0.113.5', '198.51.100.9'), false)).toBe('203.0.113.5');
});
});
+26 -8
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
// from being aimed at someone else; each address gets a few checks per 10 minutes.
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
import { createHash, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { isIP } from 'node:net';
import { runTest, type Result, type Test } from './checks.ts';
@@ -9,6 +11,8 @@ import { runTest, type Result, type Test } from './checks.ts';
export type ProbeOptions = {
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
trustProxy?: boolean;
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
key?: string;
/** Checks per address per window. */
maxChecks?: number;
windowMs?: number;
@@ -48,15 +52,27 @@ export function parseCheck(body: unknown): { host: string; tests: Test[] } | str
return { host: b.host, tests };
}
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
/**
* The address to test. X-Forwarded-For only counts when the request came from a proxy on this
* machine: reached directly, the header is whatever the caller wrote (08 §7 #4).
*/
export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
const peer = req.socket.remoteAddress!.replace(/^::ffff:/, '');
const xff = req.headers['x-forwarded-for'];
const raw =
trustProxy && typeof xff === 'string'
? xff.split(',').at(-1)!.trim()
: req.socket.remoteAddress!;
return raw.replace(/^::ffff:/, '');
const local = peer === '127.0.0.1' || peer === '::1';
return trustProxy && local && typeof xff === 'string'
? xff
.split(',')
.at(-1)!
.trim()
.replace(/^::ffff:/, '')
: peer;
};
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
const sameKey = (a: string, b: string) =>
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let size = 0;
@@ -78,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
}
export function createProbe(opts: ProbeOptions = {}): Server {
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
const { trustProxy = false, key, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
// ponytail: in-process memory; addresses past the window are swept on each check.
const seen = new Map<string, number[]>();
const allow = (ip: string, now = Date.now()) => {
@@ -95,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
if (req.method !== 'POST' || req.url !== '/v1/check')
return send(res, 404, { error: 'not found' });
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
return send(res, 401, { error: 'this probe needs its key' });
const ip = clientIp(req, trustProxy);
void readJson(req)
.then(async (body) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/web",
"version": "0.0.11",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+10
View File
@@ -26,6 +26,8 @@ import type {
AnnouncementUpdate,
BlockedCreate,
BlockedDto,
SpamReportCreate,
SpamReportDto,
CallDetailDto,
CallDto,
CallFilter,
@@ -293,6 +295,9 @@ export const systemApi = {
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
detectPublicIp: () =>
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
/** A new key for the network test probe, shown this once (08 §7 #9). */
probeKey: () =>
api<{ key: string }>('POST', '/api/settings/network/probe-key').then((r) => r.key),
tls: () => api<TlsCert>('GET', '/api/system/tls'),
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
networkTest: (mode: 'quick' | 'complete') =>
@@ -306,6 +311,9 @@ export const systemApi = {
export const meApi = {
calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
reportSpam: (r: SpamReportCreate) => api<{ ok: true }>('POST', '/api/me/spam-reports', r),
/** The summary of the calls made to me. */
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
get: () => api<MeDto>('GET', '/api/me'),
/** 14 §5.1: only for a caller ringing me now. */
callerCheck: (number: string) =>
@@ -505,6 +513,8 @@ export const blockedApi = {
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`),
reports: () => api<ListResponse<SpamReportDto>>('GET', '/api/spam-reports').then((r) => r.items),
dismiss: (id: string) => api<{ ok: true }>('DELETE', `/api/spam-reports/${id}`),
};
/** Filters as a query string (empty values left out). */
+47 -3
View File
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings.
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings. Users'
// Report spam waits at the top for Block or Dismiss.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { blockedApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { Alert, Button, Field, Panel, inputClass } from '../../components/ui.tsx';
export function BlockedPage() {
const qc = useQueryClient();
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
const [number, setNumber] = useState('');
const [reason, setReason] = useState('');
@@ -24,6 +26,16 @@ export function BlockedPage() {
mutationFn: (id: string) => blockedApi.remove(id),
onSuccess: refresh,
});
// Blocking a number clears every report of it (api).
const block = useMutation({
mutationFn: (r: { number: string; reason: string }) => blockedApi.create(r),
onSuccess: refresh,
});
const dismiss = useMutation({
mutationFn: (id: string) => blockedApi.dismiss(id),
onSuccess: refresh,
});
const error = add.error ?? remove.error ?? block.error ?? dismiss.error ?? reports.error;
return (
<section className="flex flex-col gap-4">
@@ -32,6 +44,38 @@ export function BlockedPage() {
Calls from these numbers are rejected before any phone rings. Emergency calls you make are
never affected.
</p>
{!!reports.data?.length && (
<Panel title="Reported as spam">
<ul className="divide-y divide-line">
{reports.data.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
<span className="font-mono">{formatNumber(r.number)}</span>
<span className="min-w-0 flex-1">
{r.reason}
<span className="block text-xs text-muted">
{r.reportedBy ?? 'A former user'}, {new Date(r.createdAt).toLocaleString()}
</span>
</span>
<Button
variant="primary"
aria-label={`Block ${formatNumber(r.number)}`}
disabled={block.isPending}
onClick={() => block.mutate({ number: r.number, reason: r.reason })}
>
Block
</Button>
<Button
variant="ghost"
aria-label={`Dismiss the report of ${formatNumber(r.number)}`}
onClick={() => dismiss.mutate(r.id)}
>
Dismiss
</Button>
</li>
))}
</ul>
</Panel>
)}
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
@@ -66,7 +110,7 @@ export function BlockedPage() {
Block
</Button>
</form>
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>}
{error && <Alert>{error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
+14 -3
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles, parked calls and extensions online.
// queue tiles, parked calls and extensions online, spam reports waiting.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { blockedApi, liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx';
@@ -31,6 +31,11 @@ export function DashboardPage() {
const onCall = list.filter((e) =>
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
).length;
const reports = useQuery({
queryKey: ['blocked', 'reports'],
queryFn: blockedApi.reports,
refetchInterval: 30_000,
});
return (
<section className="flex flex-col gap-6">
@@ -40,7 +45,7 @@ export function DashboardPage() {
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
)}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
</Card>
@@ -61,6 +66,12 @@ export function DashboardPage() {
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
</span>
</Card>
<Card title="Spam reports">
<Link to="/admin/blocked" className="hover:underline">
{reports.data?.length ?? '…'}
<span className="sr-only"> waiting on Blocked numbers</span>
</Link>
</Card>
</div>
<QueueTiles />
+90 -61
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
// day, missed per day, busiest hours, queue table.
// day, missed per day, busiest hours, queue table. `mine` (05 §4): the calls made to me, without
// the company's outside minutes, balance and queues.
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
import { reportsApi } from '../../api/client.ts';
import { meApi, reportsApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { fmtDuration } from './CallsPage.tsx';
@@ -116,18 +117,23 @@ function Heatmap({ hours }: { hours: number[][] }) {
);
}
export function ReportsPage() {
export function ReportsPage({ mine = false }: { mine?: boolean }) {
const [draft, setDraft] = useState<ReportRange>({});
const [range, setRange] = useState<ReportRange>({});
const summary = useQuery({
queryKey: ['reports', 'summary', range],
queryFn: () => reportsApi.summary(range),
queryKey: [mine ? 'me' : 'reports', 'summary', range],
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
});
const queues = useQuery({
queryKey: ['reports', 'queues', range],
queryFn: () => reportsApi.queues(range),
enabled: !mine,
});
const balance = useQuery({
queryKey: ['reports', 'balance'],
queryFn: reportsApi.balance,
enabled: !mine,
});
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
const s = summary.data;
const date = (k: 'from' | 'to', label: string) => (
<Field label={label}>
@@ -145,7 +151,8 @@ export function ReportsPage() {
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Reports</h1>
<h1 className="text-xl font-semibold">{mine ? 'My reports' : 'Reports'}</h1>
{mine && <p className="text-sm text-muted">Calls that rang you, not the ones you made.</p>}
<form
aria-label="Dates"
className="flex flex-wrap items-end gap-3"
@@ -174,19 +181,33 @@ export function ReportsPage() {
{(
[
['Calls', s.totals.calls, null],
['Answered', s.totals.answered, null],
[
mine ? 'You answered' : 'Answered',
s.totals.answered,
mine ? 'not the ones a coworker picked up' : null,
],
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
[
'Average talk time',
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
null,
],
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
[
'Outside minutes',
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
'Talk time',
fmtMinutes(s.totals.talkSeconds),
mine ? null : 'people talking, inside calls too',
],
...(mine
? []
: ([
[
'Outside minutes',
fmtMinutes(
s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound,
),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
],
] as const)),
...(balance.data ?? []).map(
(b) =>
[
@@ -204,12 +225,18 @@ export function ReportsPage() {
</div>
))}
</dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars
label="Outside minutes per day"
label="Calls per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
segments={mine ? SERIES.filter(([k]) => k !== 'outbound') : SERIES}
/>
{!mine && (
<Bars
label="Outside minutes per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
/>
)}
<Bars
label="Missed per day"
days={s.days}
@@ -222,53 +249,55 @@ export function ReportsPage() {
</>
)}
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-muted">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
{!mine && (
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-muted">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</thead>
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</section>
);
}
+90 -1
View File
@@ -505,6 +505,11 @@ function NetworkTab({ s }: { s: SettingsDto }) {
const [watch, setWatch] = useState(s['network.publicIpWatch']);
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
const qc = useQueryClient();
const makeKey = useMutation({
mutationFn: systemApi.probeKey,
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
});
return (
<form
className="flex flex-col gap-4"
@@ -555,13 +560,61 @@ function NetworkTab({ s }: { s: SettingsDto }) {
/>
)}
</Field>
{s['network.probeUrl'] && (
<section
aria-labelledby="probe-key-h"
className="flex flex-col gap-2 rounded-lg border border-line p-3 text-sm"
>
<h3 id="probe-key-h" className="font-medium">
Probe key
</h3>
{s['network.hasProbeKey'] ? (
<p className="text-muted">Set: the probe only answers this server.</p>
) : (
<p className="text-amber-800 dark:text-amber-200">
Not set: anyone who finds your probe can use it. Strongly recommended: generate a key
and set it on the probe.
</p>
)}
{makeKey.data ? (
<>
<p>
Set this on the probe (its compose file or service), then restart it. It&rsquo;s
shown only now.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 rounded bg-subtle p-2 font-mono text-xs break-all select-all">
PROBE_KEY={makeKey.data}
</code>
<Button
type="button"
onClick={() => void navigator.clipboard.writeText(makeKey.data)}
>
Copy
</Button>
</div>
</>
) : (
<div>
<Button type="button" onClick={() => makeKey.mutate()} disabled={makeKey.isPending}>
{s['network.hasProbeKey'] ? 'Generate a new key' : 'Generate key'}
</Button>
</div>
)}
<p className="text-xs text-muted">
A new probe address drops the key: generate one for the new probe.
</p>
</section>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
{(save.error ?? detect.error ?? makeKey.error) && (
<Alert>{(save.error ?? detect.error ?? makeKey.error)!.message}</Alert>
)}
</form>
);
}
@@ -743,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)} />
@@ -761,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,
@@ -820,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.
+11 -1
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Reports, Settings, People, and the
// softphone.
import {
BookUser,
ChartColumn,
House,
Info,
LogOut,
@@ -30,6 +32,7 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
import { ReportsPage } from '../admin/ReportsPage.tsx';
const link = 'text-link underline';
@@ -124,6 +127,12 @@ export function MePage({ user }: { user: AuthUser }) {
</span>
)}
</NavLink>
{user.extension && (
<NavLink to="/me/reports" className={tab} onClick={close}>
<ChartColumn className={icon} />
Reports
</NavLink>
)}
<NavLink to="/me/settings" className={tab} onClick={close}>
<Settings className={icon} />
Settings
@@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Route path="calls" element={<MyCallsPage />} />
<Route path="voicemail" element={<VoicemailPage />} />
<Route path="contacts" element={<ContactsPage />} />
<Route path="reports" element={<ReportsPage mine />} />
<Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} />
</Routes>
+66 -4
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
import { useInfiniteQuery } from '@tanstack/react-query';
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts", "Report
// spam".
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import type { MyCallDto } from '@selfphoneng/shared';
import { meApi, recordingsApi } from '../../api/client.ts';
import { Alert, Button } from '../../components/ui.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { softphone } from '../../softphone/SoftphoneEngine.ts';
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx';
import { SpamNote } from '../../components/SpamNote.tsx';
import { SpamNote, spamLine } from '../../components/SpamNote.tsx';
/** The other side of the call, from where I sit. */
export const otherParty = (c: MyCallDto) =>
@@ -30,6 +31,64 @@ export function useCallBack() {
return { call, note };
}
/** To the admins' Blocked numbers: the spam check's line when it flagged the call, else my words. */
function ReportSpam({ c }: { c: MyCallDto }) {
const flagged = c.spam && c.spam.risk !== 'low' ? spamLine(c.spam) : null;
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const send = useMutation({
mutationFn: (r: string) => meApi.reportSpam({ callId: c.id, reason: r }),
});
if (send.isSuccess)
return (
<span role="status" className="text-muted">
Reported to your admin
</span>
);
return (
<>
{open ? (
<form
aria-label={`Report ${fmtNumber(c.fromNumber)} as spam`}
className="flex w-full flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
send.mutate(reason);
}}
>
<Field label="Why is it spam?">
{(p) => (
<input
{...p}
className={inputClass}
maxLength={200}
placeholder="Robocall about my car warranty"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={!reason.trim() || send.isPending}>
Report
</Button>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
</form>
) : (
<Button
variant="ghost"
disabled={send.isPending}
onClick={() => (flagged ? send.mutate(flagged) : setOpen(true))}
>
Report spam
</Button>
)}
{send.error && <Alert>{send.error.message}</Alert>}
</>
);
}
export function MyCallsPage() {
const list = useInfiniteQuery({
queryKey: ['me', 'calls'],
@@ -98,6 +157,9 @@ export function MyCallsPage() {
)}
<StatusBadge status={c.status} />
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
{c.myRole === 'in' && c.direction === 'inbound' && other.number && !known && (
<ReportSpam c={c} />
)}
</li>
);
})}
+3 -1
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM debian:trixie-slim AS build
ARG ASTERISK_VERSION=24.0.0-rc2
ARG ASTERISK_VERSION=24.0.0-rc3
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \
@@ -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
+6 -2
View File
@@ -6,8 +6,9 @@
# docker compose -f deploy/probe/compose.yml up -d → http://<this machine>:8080
#
# Host networking, so it sees the real address that asks (Docker's published ports can hide it).
# Behind your own HTTPS reverse proxy instead: set PORT to a local port, TRUST_PROXY=true, and have
# the proxy set X-Forwarded-For. The SIP checks connect out from here, so no other ports are needed.
# Behind your own HTTPS reverse proxy on this machine instead: set PORT to a local port,
# TRUST_PROXY=true (it then listens on 127.0.0.1 only; HOST changes that), and have the proxy set
# X-Forwarded-For. The SIP checks connect out from here, so no other ports are needed.
services:
probe:
# Built by each release, like the api and asterisk images (pick your registry and version).
@@ -17,6 +18,9 @@ services:
environment:
PORT: '8080'
TRUST_PROXY: 'false'
# Strongly recommended: SelfPhoneNG → Settings → Network → Generate key, paste it here, and
# the probe only answers your server. Empty: anyone who finds the probe can use it.
PROBE_KEY: ''
logging:
driver: json-file
options: { max-size: 10m, max-file: '3' }
+5 -1
View File
@@ -12,7 +12,11 @@ Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/selfphoneng-probe
Environment=PORT=8080
# Behind your own HTTPS reverse proxy that sets X-Forwarded-For: PORT to a local one, and
# Strongly recommended: SelfPhoneNG → Settings → Network → Generate key, then uncomment and paste
# it; the probe then only answers your server.
#Environment=PROBE_KEY=
# Behind your own HTTPS reverse proxy on this machine that sets X-Forwarded-For: PORT to a local
# one, and TRUST_PROXY=true (it then listens on 127.0.0.1 only; HOST changes that).
#Environment=TRUST_PROXY=true
Restart=always
RestartSec=5
+4 -2
View File
@@ -72,8 +72,10 @@ on, each recorded call is also recorded one side at a time, and a minute or two
- the two one-side files are deleted. The normal recording stays.
On the **Recordings** page each call shows its summary, **Show transcript**, and the search box
finds calls by what was said. People who were on a call can read its transcript too, like they can
play its recording, and get its summary in their call report email (My settings → Call report).
finds calls by what was said. People who made or answered a call can read its transcript too, like
they can play its recording, and get its summary in their call report email (My settings → Call
report). Someone whose phone only rang (a ring group or queue a coworker answered) can't: it's
someone else's conversation.
- Only calls that are recorded are written out (user record modes, phone numbers, queues and
conference rooms set to record), and only those recorded while it's on.
+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.
+110 -2
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.11 # 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
@@ -103,7 +103,8 @@ sudo ufw allow 8080/tcp
(On GitHub, the downloads are at `https://github.com/iamdoubz/selfphoneng/releases/download/$v/…`.)
To try it first without the service, run `./selfphoneng-probe-linux-x64` and open
`http://<machine>:8080/healthz`. `PORT` changes the port; behind your own HTTPS reverse proxy
that sets X-Forwarded-For, also set `TRUST_PROXY=true`.
on the same machine that sets X-Forwarded-For, also set `TRUST_PROXY=true`. The probe then only
listens on 127.0.0.1 (`HOST` changes it) and only believes X-Forwarded-For from there.
**With Docker:**
@@ -117,6 +118,14 @@ against someone else, and each address gets a few tests per 10 minutes. Its SIP
OPTIONS request; if you run fail2ban or CrowdSec, add the probe's address to their allow list so a
test never counts toward a ban.
**Give the probe a key (strongly recommended).** Without one, anyone who finds your probe can use
it to test their own address, and spend its few tests per 10 minutes. After saving the address,
click **Generate key** under it, copy the `PROBE_KEY=…` line, and add it to the probe: under
`environment:` in `deploy/probe/compose.yml` (`PROBE_KEY: '…'`), or as an `Environment=PROBE_KEY=…`
line in the service file (`sudo systemctl edit selfphoneng-probe`). Restart the probe. From then on
it only answers this server, and System → Diagnostics stops warning about it. The key is shown only
once; a new probe address drops it, so generate one for the new probe.
## Firewall
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the
@@ -228,6 +237,102 @@ labels:
and run `systemctl reload crowdsec`.
#### Slow scanners: add a second rule
The collection's rules (`crowdsecurity/asterisk_bf`, and `asterisk_user_enum` for many different
usernames from one address) hold 5 failed sign-ins per address and let one go every 10 seconds.
An address is banned only after 6 failures in about a minute. That stops fast password guessers,
but not the common SIP scanners that space their tries out. Each try writes two failures
(`InvalidAccountID`, then `ChallengeResponseFailed`), and the scanner comes back every 20 seconds
or slower, so the count drains as fast as it fills and the scanner is never banned. You see it as
`log_failed_request ... No matching endpoint found` / `Failed to authenticate` in the Asterisk log,
from the same address, for hours.
Add a slower rule next to the stock ones, `/etc/crowdsec/scenarios/selfphoneng-asterisk-slow.yaml`:
```yaml
type: leaky
name: selfphoneng/asterisk-slow-bf
description: "Asterisk failed sign-ins spread over time (slow SIP scanners)"
filter: evt.Meta.log_type == 'asterisk_failed_auth'
groupby: evt.Meta.source_ip
capacity: 10
leakspeed: 5m
blackhole: 5m
labels:
service: asterisk
confidence: 3
spoofable: 0
classification:
- attack.T1110
behavior: "sip:bruteforce"
label: "Asterisk slow bruteforce"
remediation: true
```
```sh
systemctl reload crowdsec
cscli scenarios list | grep selfphoneng # listed, marked local
```
It counts the same failures from the same parser; only the bucket is different:
| | Stock: `crowdsecurity/asterisk_bf` | This rule: `selfphoneng/asterisk-slow-bf` |
| ----------------- | ----------------------------------------- | ------------------------------------------------ |
| Holds | 5 failures | 10 failures |
| Lets one go every | 10 seconds | 5 minutes |
| Bans after | 6 failures within about a minute | 11 failures, at more than one per 5 minutes |
| A scanner every… | 20 s: never banned | 20 s: banned after about 2 minutes |
| Comes from | the CrowdSec hub (`cscli hub upgrade`) | this file; change it as you like |
Keep both: the stock rule still bans fast guessers within a minute. How long a ban lasts is set by
CrowdSec's profiles, the same for both (4 hours by default, `/etc/crowdsec/profiles.yaml`).
fail2ban's settings above (5 failures in 10 minutes) already catch slow scanners.
The catch: one of your own phones with an old password, signing in from outside your networks,
adds up failures too and is banned after about 11 tries. CrowdSec's standard whitelist
(`crowdsecurity/whitelists`) skips private addresses, so phones in the office are safe. Add the public address of any other office or remote worker
to a whitelist, `/etc/crowdsec/parsers/s02-enrich/selfphoneng-whitelist.yaml`:
```yaml
name: selfphoneng/whitelist
description: "Our offices and remote phones"
whitelist:
reason: "our offices"
ip:
- "198.51.100.7"
```
#### Checking CrowdSec
```sh
cscli metrics show acquisition parsers scenarios
```
- **Acquisition:** `security.log`'s _Lines read_ goes up as Asterisk writes. _Lines parsed_ is
lower, and that's normal: only failed sign-ins are parsed, other security events (challenges
sent, successful sign-ins) are left out.
- **Scenarios:** _Overflows_ for `asterisk_bf` and `selfphoneng/asterisk-slow-bf` is the number of
bans each has made.
To follow one log line through the parser and the rules, give `cscli explain` a line from
`security.log`:
```sh
cscli explain --type asterisk \
--log "$(grep ChallengeResponseFailed /opt/selfphoneng/data/asterisk-logs/security.log | tail -1)"
```
Point `--file` at the log itself, never at `acquis.d/selfphoneng.yaml`: that parses the YAML's own
lines as if they were log lines, and every one shows as a parser failure.
To ban an address now, and to see the bans:
```sh
cscli decisions add --ip 203.0.113.5 --duration 7d --reason "SIP scanner"
cscli decisions list
```
## Web sign-in attacks
The web sign-in is rate limited. Failed attempts are written to the api's log as
@@ -240,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):
+21 -6
View File
@@ -142,20 +142,29 @@ missing or invalid, it logs a clear message and exits with code 1.
## 2. Asterisk image (`asterisk/Dockerfile`)
Build Asterisk **24 LTS from source**. Pin an exact version: the newest 24.x release on the day you write it, recorded here in
the same commit. **Pinned: `24.0.0-rc2` (2026-10-17).** The file in the repo is the source of truth; this copy is kept in sync.
the same commit. **Pinned: `24.0.0-rc3` (2026-10-28).** The file in the repo is the source of truth; this copy is kept in sync.
> **Asterisk 24 status (checked 2026-10-17):** Asterisk 24 is the LTS line, but
> only release candidates are published so far (24.0.0-rc2). Until 24.0.0 is final,
> only release candidates are published so far (24.0.0-rc3 on 2026-10-28). Until 24.0.0 is final,
> develop against the newest 24.0.0-rcN. Release candidates may sit under a
> different download folder than `releases/`, so check the directory listing. Switch to 24.0.0 (or newer
> 24.x) as soon as it is final, and don't ship v1.0 on a release candidate. If a module, option
> or dialplan function in this spec turns out to have changed in 24, follow the Asterisk 24
> docs and update the spec (AGENTS rule 10).
**Changing the version** (owner, 2026-10-28). `apps/api/test/asterisk/verified.ts` lists what the
code relies on Asterisk doing (the "checked on 24.0.0-rcN" notes in these specs, and the TLS
qualify workaround in 08 §2.1), with the version it was measured on. `asterisk-version.test.ts`
(part of `pnpm test`) fails while the Dockerfile pins another version or this section names
another. To move: build the image, run `pnpm test:asterisk-upgrade` (boots it with the one-user
golden config next to a TLS provider with its own CA, measures each fact, and lists every one
that moved with what depends on it) and `pnpm test:asterisk`, change what moved, then set
`VERIFIED_ASTERISK` and update this section.
```dockerfile
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM debian:trixie-slim AS build
ARG ASTERISK_VERSION=24.0.0-rc2
ARG ASTERISK_VERSION=24.0.0-rc3
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \
@@ -183,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
@@ -200,7 +211,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=build /out/ /
COPY conf/ /etc/asterisk/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \
COPY sp-vm-pincheck /usr/local/bin/sp-vm-pincheck
RUN chmod +x /entrypoint.sh /usr/local/bin/sp-vm-pincheck && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \
&& chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk
ENTRYPOINT ["/entrypoint.sh"]
```
@@ -359,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
+11
View File
@@ -480,6 +480,14 @@ create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDa
create index on ftc_complaints (number);
create index on ftc_complaints (reported_at);
create table spam_reports ( -- a user's Report spam (05 §4), until an admin blocks or dismisses it
id uuid primary key default gen_random_uuid(),
number text not null, -- normalized, from the call
reason text not null, -- the spam check's line, or the user's words
reported_by uuid references users(id) on delete set null,
unique (number, reported_by)
);
create table call_extensions ( -- which extensions were involved (for per-user history)
call_id uuid not null references calls(id) on delete cascade,
extension_id uuid not null references extensions(id) on delete cascade,
@@ -676,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 |
+2 -2
View File
@@ -322,8 +322,8 @@ For `auth_mode = 'ip'`, leave out the `-auth` and `-reg` sections and the `outbo
(and `from_user` when there is no username).
Skip disabled trunks entirely.
For TCP/TLS trunks, `server_uri`, `client_uri` and the AOR `contact` get `;transport=tcp|tls`.
As built (M11-T2): TLS trunks get `qualify_frequency = 0` (Asterisk 24 checks a qualify's certificate
against the IP, see 08 §2.1); their health comes from the registration.
TLS trunks are qualified like the others (`qualify_frequency = 60`) since 24.0.0-rc3. On rc2 they
had `qualify_frequency = 0`, because rc2 checked a qualify's certificate against the IP (08 §2.1).
`identify` resolves `match` when the config loads, and an unresolvable name is an ERROR that drops the
trunk's identify (verified), so the API refuses a server name that doesn't resolve, and a transport
that isn't turned on in Settings → SIP.
+13 -3
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.
@@ -286,7 +287,9 @@ As built (M7-T1):
outside); otherwise `src`. An outbound call has no `answered_by` (no extension answered).
- **recording** (M7-T3): `sp-sub-record` sets `CDR(SP_REC)`; the file (relative, under
`RECORDINGS_DIR`) becomes a `recordings` row (size, ffprobe length) linked to the call.
`/api/recordings/:id/audio` serves it to admins and to users whose extension is on the call.
`/api/recordings/:id/audio` serves it to admins and to users whose extension made or answered
the call (`call_extensions` role `caller` or `answered`), not to a phone that only rang (08 §7
#3); `/api/me/calls` shows such a call without its `recordingId`.
Retention runs once a day (and at start) in the same day-change check as the nightly regenerate.
- Aggregation runs 5 s after a linkedid's last Cdr event; on every AMI connect, linkedids older
than a minute without a `calls` row are aggregated (instead of the nightly job).
@@ -383,6 +386,8 @@ Auth & self:
| GET | `/api/me/softphone-qr` | user | `{url, qr, expiresAt}`: Linphone setup link and its QR (PNG data URL), 24 h (07 §5) |
| GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` |
| GET | `/api/me/calls` | user | own call history (via `call_extensions`) |
| GET | `/api/me/reports/summary?from&to` | user | `/api/reports/summary` of the calls that rang or were answered by me; `answered` and talk time are the ones I picked up |
| POST | `/api/me/spam-reports` {callId, reason} | user | Report spam on an outside call that rang me; the number comes from the call. 404 not my call, 400 hidden number, 409 already blocked or already reported by me |
| GET | `/api/me/voicemail` | user | list |
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
@@ -416,6 +421,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
| `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) |
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 |
| `/api/blocked-numbers` | |
| `/api/spam-reports` | GET (with the reporter's name) and DELETE (Dismiss). Blocking a number deletes its reports |
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) or vCard (11 §3.1) |
| `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
| `/api/api-tokens` | POST returns the token once |
@@ -440,6 +446,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
| POST | `/api/system/warnings/spam_quota/dismiss` | hides `spam_quota` until IPQualityScore's credits run out again (14 §3) |
| POST | `/api/settings/network/detect-public-ip` | |
| POST | `/api/settings/network/probe-key` | a new key for the network test probe, `{ key }` shown this once, stored encrypted in `network.probeKeyEnc` (never in GET: `network.hasProbeKey`); 400 without a probe address. Diagnostics sends it as `Authorization: Bearer`; a new `network.probeUrl` drops it (08 §7 #9) |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
| GET | `/api/system/generated-files` | names + contents of generated files, **passwords masked** (debug view) |
@@ -475,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 |
@@ -530,7 +539,8 @@ Events: `call.started`, `call.answered`, `call.ended`, `voicemail.new`, `sms.rec
`sms.sent`, `emergency.call`, `trunk.registration_changed`, `extension.registration_changed`.
- Payload: `{ "id": "<uuid>", "event": "call.ended", "createdAt": "...", "data": {...} }`.
`data` uses the same shapes as the REST API.
`data` uses the same shapes as the REST API, except `voicemail.new` has no `transcript`: a
webhook leaves the server and transcripts don't (10 §1, 08 §7 #6).
- Headers: `Content-Type: application/json`, `X-SelfPhoneNG-Event`,
`X-SelfPhoneNG-Timestamp` (unix seconds),
`X-SelfPhoneNG-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.
+11 -2
View File
@@ -46,7 +46,7 @@ Left sidebar navigation, in this order:
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) |
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | |
| Blocked numbers | `/admin/blocked` | Users' Report spam on top (number, reason, who, when; Block or Dismiss); their count is a Dashboard tile |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`, `spam.quotaExhausted`/`spam.quotaRestored` (14 §3). Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
@@ -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.
@@ -102,9 +110,10 @@ Implementation notes (M3):
| Page | Path | Contents |
|---|---|---|
| Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list. M4 ships only new voicemails; the rest arrives with DND/forwarding (M6), call history (M7) and presence (M5-T3) |
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts, Report spam on an outside call that rang me (the spam check's line when it said medium or high, else a reason I type) |
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
| Reports | `/me/reports` | The admin Reports' summary of the calls made to me (rang or answered me, not the ones I made): calls, answered by me, missed, talk time, calls and missed per day, busiest hours. No outside minutes, balance or queues. Shown only with an extension |
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) |
+23 -4
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**
@@ -54,8 +61,9 @@ voip.ms Asterisk PJSIP wiki: registration + auth + aor + endpoint + identify,
which means the **Request-URI user is the 10-digit DID** (no leading 1).
- The dialplan (03 §6.4) normalizes it to 11 digits and checks `sp-did-exists`. If that fails,
it falls back to the user part of the `To:` header. That covers either behavior.
- **M3 acceptance check (verify):** run `pjsip set logger on`, call the DID from a
mobile phone, and confirm which header carries the DID. Record the finding here.
- **Checked (owner, 2026-10-21):** a mobile → DID call came in with the DID as the Request-URI
user, and the dialplan routed it to the digital receptionist. The `To:` fallback stays for
other providers.
- Inbound caller ID arrives as 10 digits for North American callers. The dialplan normalizes it to 11.
## 4. Outbound calls
@@ -82,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
+178 -6
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
@@ -34,10 +35,12 @@
cert is missing or expires within 14 days.
- `transport-tls` has `verify_server = yes` + `ca_list_file = /etc/ssl/certs/ca-certificates.crt`:
the provider's certificate is checked (voip.ms: Let's Encrypt, every POP in its SAN list,
checked 2026-10-19). Asterisk 24.0.0-rc2 checks a **qualify** OPTIONS against the IP instead of
the host name, which fails on voip.ms ("server identity does not match") and marks the contact
Unavailable, so TLS trunks get `qualify_frequency = 0` and Test skips the qualify; REGISTER and
INVITE verify the name correctly. Re-check on Asterisk upgrades.
checked 2026-10-19). Asterisk 24.0.0-rc2 checked a **qualify** OPTIONS against the IP instead
of the host name, which failed on voip.ms ("server identity does not match") and marked the
contact Unavailable, so TLS trunks had `qualify_frequency = 0` and Test skipped the qualify.
24.0.0-rc3 fixed it ("Preserve target hostname for qualified OPTIONS destination"): TLS trunks
are qualified again, and the upgrade check (`tlsQualifyByHostname`, 01 §2) catches it if it
comes back.
- Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later.
### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`)
@@ -117,8 +120,12 @@ how to fix it.
works on Gitea's runner, where the job's checkout can't be bind-mounted).
`deploy/probe/selfphoneng-probe.service` runs it under systemd with `DynamicUser` and a
read-only system. About 45 MB of memory.
- With `PROBE_KEY` set the probe only answers `Authorization: Bearer <key>` (401 otherwise,
compared in constant time); without it, it answers anyone and logs a warning at start. The key
is made in Settings → Network and sent by the api only, never the browser (08 §7 #9).
- `POST /v1/check {host, tests}`: the probe tests **only the address the request came from** (the
last X-Forwarded-For entry with `TRUST_PROXY=true`), so it can't be aimed at anyone else; 20
last X-Forwarded-For entry with `TRUST_PROXY=true`, believed only from a proxy on 127.0.0.1
or ::1, where the probe then listens by default; 08 §7 #4), so it can't be aimed at anyone else; 20
tests at most, 10 checks per address per 10 minutes, 4 s per test.
- Quick: HTTP 80, HTTPS 443 `/healthz` (status and certificate), SIP OPTIONS over UDP (and TCP
when on) to `sip.udpPort`. Complete adds SIP over TLS (when on), a WebSocket upgrade on `/ws`
@@ -237,3 +244,168 @@ Fixed after the review (owner, 2026-10-21):
exist; with credentials it counts as a failure (`devices.test.ts`).
- The login limiter forgets keys past every window, swept at most once a minute
(`login-limiter.test.ts`).
## 7. Security review 2 (2026-10-28)
Everything since §6 (`8d5475c..`): contacts and Contrack, single sign-on, call reports, local AI,
spam check, the mobile app's pairing, my reports and spam reports. Two passes again: one against
the code by the author, and an independent review agent (read-only, told to prove each finding
end to end). Each fix came with a test that failed before it and passes now. Nothing Critical or
High.
| # | Severity | Finding | Fix | Proof |
|---|---|---|---|---|
| 1 | Medium | A directory sign-in matched by email a user who signs in with OpenID only. Where people can edit their own `mail`, Mallory set hers to the victim's and her own directory password opened the victim's account | the email match also needs no connected account of any kind (12 §4.3 step 2) | `directory-signin.test.ts` |
| 2 | Medium | An OpenID sign-in matched by verified email an admin, or a user who signs in through the directory (no password, no OpenID account yet): a provider account with that email got their session, admin included | not an admin, and no connected account of any kind (12 §3.3 step 2) | `oidc.test.ts`, both key types |
| 3 | Medium, privacy | Everyone whose phone **rang** counted as on the call: a ring group or queue member who didn't answer could play a coworker's recording, read its transcript, and got its summary in their call report email and on their Contrack timeline. The audio part predates §6 | `ON_THE_CALL` (`services/cdr.ts`): only the `caller` and `answered` roles, in `mayHear`, `/api/me/calls` (no `recordingId`), the call report and the timeline | `recordings.test.ts`, `call-reports.test.ts`, `contrack.test.ts` |
| 4 | Low | The probe with `TRUST_PROXY=true` believed any X-Forwarded-For and listened on every address: reached directly, anyone could have it test any address and ports | X-Forwarded-For only from 127.0.0.1 or ::1; `HOST` defaults to 127.0.0.1 with `TRUST_PROXY=true` (§2.6) | `probe.test.ts` |
| 5 | Low | A user could swap their directory account for another one they know the password of (a shared mailbox): the leaver check then followed the wrong account | `POST /api/me/identities/ldap` → 409 `connected_other` when a different one is connected; an admin disconnects first (12 §4.3) | `directory-signin.test.ts` |
| 6 | Low, privacy | The `voicemail.new` webhook carried the transcript, though webhooks leave the server and 10 §1 says transcripts don't | the webhook has no `transcript` (04 §9) | `voicemail.test.ts` |
| 7 | Low | Contrack's saved company token went to any address: Test with another URL, or saving a new URL, sent it there (SMTP, LDAP and OpenID already refused) | the saved token only for the saved address; a new address needs it typed again and clears everyone's own token (11 §4.3) | `contrack.test.ts` |
| 8 | Low | The OpenID connect callback wasn't tied to the browser that asked: a planted `sp_oidc` (a sibling subdomain can set it) connected the victim's provider account to the attacker's user | the callback's own session must be the one in `sp_oidc`, else `expired` (12 §3.3) | `oidc.test.ts`, both key types |
| 9 | Low | Anyone who found a probe could use it (owner's idea after #4): to test their own address, and spend its per-address limits | an optional key: Settings → Network → Generate key, set as the probe's `PROBE_KEY`; the api sends it as a Bearer token, encrypted at rest, dropped when the probe address changes. Without a key the probe stays open (owner, 2026-10-28) and Diagnostics warns, strongly recommending one (§2.6) | `probe.test.ts`, `diagnostics.test.ts` |
The independent agent found 1, 2, 4, 5 and 8, the author 3; both found 6 and 7; 9 was the
owner's idea. The agent had
checked the recording rule and called it sound: it's the same rule as the audio, which §6 also
passed.
Checked and sound (both passes): every new route's guard and ownership check (contacts and
sharing, spam reports, my reports, caller check, mobile devices); a paired phone is never an admin
and can't make pairing codes, and codes are single-use; OpenID PKCE, state, nonce, `safeNext` and
encrypted provider tokens; LDAP filter escaping and no empty binds; the caller-name lookup only
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
@@ -47,7 +47,7 @@ is logged and the message goes out without it.
or timing out: the run stops and the call is retried next minute.
- API: `GET /api/recordings` has `summary`, `hasTranscript`, `aiError`, and `?q=` (ILIKE on the
transcript and summary, `%`/`_` taken literally). `GET /api/recordings/:id/transcript`: admins
and the people on the call, like the audio. Deleting a recording, and retention, delete its side
and the people on the call (made or answered it, not only rang), like the audio. Deleting a recording, and retention, delete its side
files too.
## 4. Next (in order)
+3
View File
@@ -190,6 +190,9 @@ against the pinned version's `openapi.json`.
checked by reading contacts).
- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
"Add to company contacts" work), write-only like the SMTP password (`hasCompanyToken` in GET).
Like it, the saved token only goes to the address it was saved for: Test with another address
sends none, and saving a new address needs the token typed again (400). A new address also
clears everyone's own token: they connect again (08 §7 #7).
The admin makes that account in Contrack (e.g. `company@…`) and fills it like any other.
- Contrack tokens expire in 90 days unless made with **Never**: the guide and the forms say to pick
Never (or a year).
+14 -8
View File
@@ -199,11 +199,12 @@ Failed callbacks count in the IP login limiter (04 §2.2).
Signing in, in this order:
1. A `user_identities` row (`oidc`, `"<iss> <sub>"`) → its user.
2. Else, a user with that email (case-insensitive) who has **no password and no OpenID account
yet** (made by an admin in `oidc` mode, or whose password an admin removed), **and the email is
verified** (`email_verified === true`, or `trustEmail`) → connect it now (step 3's row) and sign
in. Users who have a password are never matched by email: they connect from My account, so a
provider where people can set their own email can't hand over an account.
2. Else, a user with that email (case-insensitive) who is **not an admin** and has **no password
and no connected account of any kind yet** (made by an admin in `oidc` mode, or whose password
an admin removed), **and the email is verified** (`email_verified === true`, or `trustEmail`)
→ connect it now (step 3's row) and sign in. Users who have a password, an admin, or someone
who signs in through the directory are never matched by email: they connect from My account,
so a provider where people can set their own email can't hand over an account (08 §7 #2).
3. Else `not_connected`.
Connecting (from My account, already signed in, any mode but `password`):
@@ -211,6 +212,9 @@ Connecting (from My account, already signed in, any mode but `password`):
- After the callback, the session's user gets the `user_identities` row (email and groups as
sent). Refused (`taken`) when that `iss sub` belongs to another user; a user who already has one
replaces it (confirm in the UI). Other sessions stay. Audit `sso_connected`.
- The callback must arrive in the browser whose session asked: its `sp_session` is the session in
`sp_oidc`, else `expired`. A planted `sp_oidc` (from a sibling subdomain) can't connect the
victim's provider account to someone else's user (08 §7 #8).
- **Disconnect** (`DELETE /api/me/identities/oidc`; admins also on Users,
`DELETE /api/users/:id/identities/oidc`): ends that user's `oidc` sessions. Refused (409
`last_way_in`) when it would leave them no way to sign in in the current mode (no password, or
@@ -376,11 +380,13 @@ A user can have both a SelfPhoneNG password and a directory account: either pass
1. A `user_identities` row (`ldap`, id) → its user. Their email here follows the directory's when
it changes (unless another user has it: kept, logged).
2. Else, a user with the directory email who is **not an admin** and has **no password or only the
temporary one** from Users (`must_change_password`) → connect it (add the row) and sign in,
2. Else, a user with the directory email who is **not an admin**, has **no password or only the
temporary one** from Users (`must_change_password`) and **no connected account of any kind**
(an OpenID user's account is theirs, 08 §7 #1) → connect it (add the row) and sign in,
audit `ldap_connected`. Anyone else connects on My settings → Sign-in → **Connect directory
account** (`POST /api/me/identities/ldap`, `{ login, password }`, checked by the directory and
counted by the login limiter; 409 `taken` when another user has it). Security audit,
counted by the login limiter; 409 `taken` when another user has it, 409 `connected_other` when
they already have a different one: an admin disconnects it first, 08 §7 #5). Security audit,
2026-10-26: a directory where people can edit their own `mail` (common OpenLDAP access rules)
must not hand out the admin's account.
3. Else → 401 "Wrong email or password" (no account is made, §1), with `login_failed` audited as
+13 -15
View File
@@ -8,13 +8,15 @@ Tick a box only with proof: a date and what was seen.
- [x] Milestones M0–M11 built and tested (simulated phones and a simulated voip.ms).
- [x] Security review (M11-T8), with an independent review (08 §6).
- [x] Second security review of everything after it, with an independent review; all eight
findings fixed with tests (08 §7, 2026-10-28).
## Real voip.ms account (M3)
- [x] The trunk shows **Registered** in the UI (2026-10-21: green, voip.ms, the sub-account at
its POP).
- [x] A mobile → DID call rings a phone. 2026-10-21: DID → digital receptionist → browser
phone; the DID came in the Request-URI. Still to do: write that down in 06 §3.
phone; the DID came in the Request-URI (written down in 06 §3).
- [x] An outbound call shows the DID as caller ID on the mobile (2026-10-21, owner).
- [ ] Write down in 06 §4 whether per-call caller ID (Remote-Party-ID) is honored.
@@ -31,18 +33,14 @@ On hold until the owner has the phones (2026-10-21).
## Someone else installs it (M11)
- [ ] An outside tester follows only the admin guide and gets inbound and outbound calls working
on a fresh VM.
- [ ] A backup restores on a second VM.
On hold (2026-10-21).
- [x] An outside tester follows only the admin guide and gets inbound and outbound calls working
on a fresh VM (2026-10-28, owner).
- [x] A backup restores on a second VM (2026-10-28, owner).
## A week on the owner's server
- [ ] 7 days handling real calls with no crash or restart of the api or Asterisk.
- [ ] Nightly backups restore cleanly.
In progress on the owner's server (v0.0.2, from 2026-10-21).
- [x] 7 days handling real calls with no crash or restart of the api or Asterisk (2026-10-28, owner).
- [x] Nightly backups restore cleanly (2026-10-28, owner).
## Updating
@@ -57,7 +55,8 @@ Docker only, never bare metal (owner, 2026-10-21).
restarts, and the script went back to v0.0.3 with that backup restored.
- [ ] A real compose install with the bundled Caddy updated with `scripts/update.sh`, with calls
and backups.
- [ ] The owner's Portainer stack updated to a release that changes the database.
- [x] The owner's Portainer stack updated to a release that changes the database, several times
(2026-10-28, owner).
## Security leftovers (08 §6)
@@ -73,10 +72,9 @@ Owner's decision (2026-10-21): fix all of them. Each is in 08 §6 with its test.
## The release
- [ ] Version 1.0.0 in every `package.json`, and a v1.0.0 entry in the [changelog](../CHANGELOG.md).
- [ ] The release builds and pushes images to both the Gitea registry and ghcr.io. (Gitea works.
ghcr.io: the api image pushes; the asterisk package exists, empty, and refuses the push.)
- [x] The release builds and pushes images to both the Gitea registry and ghcr.io (2026-10-28, owner).
## Not needed for v1
More SIP providers (Telnyx, Flowroute, Twilio), SMS, push notifications for phone apps, and the
M12 list in the roadmap.
The mobile app (owner, 2026-10-28), more SIP providers (Telnyx, Flowroute, Twilio), SMS, push
notifications for phone apps, and the M12 list in the roadmap.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "selfphoneng",
"version": "0.0.11",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -15,7 +15,8 @@
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs && node --conditions=source apps/api/test/asterisk/call-test.ts && node --conditions=source apps/api/test/asterisk/trunk-test.ts && node --conditions=source apps/api/test/asterisk/voicemail-test.ts && node --conditions=source apps/api/test/asterisk/moh-test.ts && node --conditions=source apps/api/test/asterisk/rec-test.ts"
"test:asterisk-upgrade": "node --conditions=source apps/api/test/asterisk/upgrade-test.ts",
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs && node --conditions=source apps/api/test/asterisk/call-test.ts && node --conditions=source apps/api/test/asterisk/trunk-test.ts && node --conditions=source apps/api/test/asterisk/voicemail-test.ts && node --conditions=source apps/api/test/asterisk/moh-test.ts && node --conditions=source apps/api/test/asterisk/rec-test.ts && node --conditions=source apps/api/test/asterisk/upgrade-test.ts"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/shared",
"version": "0.0.11",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+20 -1
View File
@@ -17,8 +17,27 @@ export const BlockedCreateSchema = z.object({
return z.NEVER;
}
}),
reason: z.string().trim().max(100).nullable().optional(),
reason: z.string().trim().max(200).nullable().optional(),
});
export type BlockedCreate = z.input<typeof BlockedCreateSchema>;
export type BlockedDto = { id: string; number: string; reason: string | null; createdAt: string };
/**
* A user's "Report spam" on a call that rang them (05 §4). The reason is the spam check's line
* when it flagged the call, else what the user typed. An admin blocks or dismisses it.
*/
export const SpamReportCreateSchema = z.object({
callId: z.uuid(),
reason: z.string().trim().min(1, 'Say why').max(200),
});
export type SpamReportCreate = z.input<typeof SpamReportCreateSchema>;
export type SpamReportDto = {
id: string;
number: string;
reason: string;
/** The reporter's display name; null once their account is gone. */
reportedBy: string | null;
createdAt: string;
};
+18 -1
View File
@@ -42,6 +42,11 @@ export const SettingsSchemas = {
.url({ protocol: /^https?$/ })
.nullable()
.default(null),
/**
* The probe's key (its PROBE_KEY), encrypted: made by POST /api/settings/network/probe-key,
* sent as a Bearer token, dropped when the address changes (08 §7 #9). Never in GET.
*/
'network.probeKeyEnc': z.string().nullable().default(null),
'network.stunServer': z
.string()
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
@@ -98,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),
@@ -327,10 +337,17 @@ export type SettingsDto = {
[
K in Exclude<
SettingKey,
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' | 'spam'
| 'smtp'
| 'sms.webhookToken'
| 'contacts.contrack'
| 'auth.oidc'
| 'auth.ldap'
| 'spam'
| 'network.probeKeyEnc'
>
]: SettingValue<K>;
} & {
'network.hasProbeKey': boolean;
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
spam: SpamSettingsDto;
+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(