Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b499e263 | ||
|
|
34f443385d | ||
|
|
f435b475ed | ||
|
|
1979a02827 | ||
|
|
08bc611dec | ||
|
|
63286a1954 | ||
|
|
6bd2aaec56 | ||
|
|
80e97b4118 | ||
|
|
0a18d5354d | ||
|
|
a0121d4975 | ||
|
|
678d45e4f9 | ||
|
|
60348cf0c5 | ||
|
|
958405651b | ||
|
|
db244b116a | ||
|
|
1db5aa22dc | ||
|
|
8a1f17099d | ||
|
|
a75113b6ac | ||
|
|
2bc7d5cde5 | ||
|
|
19ca4ffc91 | ||
|
|
1bc0fe8811 |
No files matched your search
@@ -39,3 +39,4 @@
|
|||||||
**/*.key
|
**/*.key
|
||||||
**/*.log
|
**/*.log
|
||||||
**/*.pid
|
**/*.pid
|
||||||
|
docs/voip.ms-api
|
||||||
@@ -8,3 +8,5 @@ coverage/
|
|||||||
*.pid
|
*.pid
|
||||||
# The mobile app is its own repo (13), checked out here beside the PBX.
|
# The mobile app is its own repo (13), checked out here beside the PBX.
|
||||||
mobile/
|
mobile/
|
||||||
|
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
|
||||||
|
docs/voip.ms-api/
|
||||||
@@ -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
|
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||||
Docker.
|
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
|
## v0.0.11 — 2026-10-28
|
||||||
|
|
||||||
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
|
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
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.
|
(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
|
## What it does today
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@selfphoneng/api",
|
"name": "@selfphoneng/api",
|
||||||
"version": "0.0.11",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { todayIn } from '../generator/load.ts';
|
import { todayIn } from '../generator/load.ts';
|
||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.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. */
|
/** A recorded call waits this long for its summary before it's logged without one. */
|
||||||
const SUMMARY_WAIT_MS = 30 * 60_000;
|
const SUMMARY_WAIT_MS = 30 * 60_000;
|
||||||
@@ -141,11 +142,13 @@ export async function logCallsToContrack(d: {
|
|||||||
.limit(500);
|
.limit(500);
|
||||||
|
|
||||||
// One entry per call and person (a person can be in a call as caller, rang and answered).
|
// 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) {
|
for (const r of rows) {
|
||||||
const k = `${r.call.id}|${r.userId}`;
|
const k = `${r.call.id}|${r.userId}`;
|
||||||
const out = r.role === 'caller' || todo.get(k)?.out === true;
|
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
|
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
|
||||||
let logged = 0;
|
let logged = 0;
|
||||||
@@ -169,7 +172,8 @@ export async function logCallsToContrack(d: {
|
|||||||
await record(null);
|
await record(null);
|
||||||
continue;
|
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)
|
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
|
||||||
continue; // the transcript job hasn't summarized it yet
|
continue; // the transcript job hasn't summarized it yet
|
||||||
const e = timelineEntry(
|
const e = timelineEntry(
|
||||||
@@ -180,7 +184,7 @@ export async function logCallsToContrack(d: {
|
|||||||
toNumber: r.call.toNumber,
|
toNumber: r.call.toNumber,
|
||||||
talkSeconds: r.call.talkSeconds,
|
talkSeconds: r.call.talkSeconds,
|
||||||
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
||||||
recorded: r.call.recordingId !== null,
|
recorded,
|
||||||
},
|
},
|
||||||
wantSummary ? r.summary : null,
|
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,
|
"when": 1793127210386,
|
||||||
"tag": "0014_spam_check",
|
"tag": "0014_spam_check",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793212840276,
|
||||||
|
"tag": "0015_spam_reports",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -670,6 +670,19 @@ export const ftcComplaints = pgTable(
|
|||||||
(t) => [index().on(t.number), index().on(t.reportedAt)],
|
(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(
|
export const callExtensions = pgTable(
|
||||||
'call_extensions',
|
'call_extensions',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
|
|||||||
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
||||||
type = aor
|
type = aor
|
||||||
contact = sip:chicago1.voip.ms:5061;transport=tls
|
contact = sip:chicago1.voip.ms:5061;transport=tls
|
||||||
qualify_frequency = 0
|
qualify_frequency = 60
|
||||||
|
|
||||||
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
||||||
type = endpoint
|
type = endpoint
|
||||||
|
|||||||
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
|
|||||||
section(`trunk-${id}`, [
|
section(`trunk-${id}`, [
|
||||||
'type = aor',
|
'type = aor',
|
||||||
`contact = sip:${target}`,
|
`contact = sip:${target}`,
|
||||||
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
|
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
|
||||||
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
|
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
|
||||||
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
|
'qualify_frequency = 60',
|
||||||
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
|
|
||||||
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
|
|
||||||
]),
|
]),
|
||||||
section(`trunk-${id}`, [
|
section(`trunk-${id}`, [
|
||||||
'type = endpoint',
|
'type = endpoint',
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
|
// 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
|
// `status` = "success" or an error code. The password is in the query string, so the URL is never
|
||||||
// logged or put in an error.
|
// logged or put in an error.
|
||||||
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
|
// TODO(spec): most 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.
|
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
|
||||||
import { normalizeNumber } from '@selfphoneng/shared';
|
import { normalizeNumber } from '@selfphoneng/shared';
|
||||||
|
|
||||||
export type VoipmsCreds = { username: string; password: string };
|
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).
|
* 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).
|
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
|
||||||
|
|||||||
+14
-1
@@ -31,6 +31,7 @@ import { logCallsToContrack } from './contacts/timeline.ts';
|
|||||||
import { checkDirectory } from './sso/directory.ts';
|
import { checkDirectory } from './sso/directory.ts';
|
||||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||||
import { logEvent } from './services/events.ts';
|
import { logEvent } from './services/events.ts';
|
||||||
|
import { createFraudWatch } from './services/fraud.ts';
|
||||||
import { spamQuotaCheck } from './spam/check.ts';
|
import { spamQuotaCheck } from './spam/check.ts';
|
||||||
import { ftcSync } from './spam/ftc.ts';
|
import { ftcSync } from './spam/ftc.ts';
|
||||||
import { deliverDue } from './services/webhooks.ts';
|
import { deliverDue } from './services/webhooks.ts';
|
||||||
@@ -130,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
|
|||||||
export function createJobs(d: JobDeps) {
|
export function createJobs(d: JobDeps) {
|
||||||
const now = d.now ?? (() => new Date());
|
const now = d.now ?? (() => new Date());
|
||||||
const tz = () => getSetting(d.db, 'general.timezone');
|
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.
|
// 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.
|
// 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}`,
|
`Public IP changed to ${ip}`,
|
||||||
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
|
`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' +
|
'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,
|
publicIpWatch,
|
||||||
trunkWatch,
|
trunkWatch,
|
||||||
diskWatch,
|
diskWatch,
|
||||||
|
riskyCallWatch: fraud.riskyCallWatch,
|
||||||
|
providerWatch: fraud.providerWatch,
|
||||||
callReports,
|
callReports,
|
||||||
contactsWarm,
|
contactsWarm,
|
||||||
contrackLog,
|
contrackLog,
|
||||||
@@ -321,6 +332,8 @@ export function startJobs(d: JobDeps): () => void {
|
|||||||
run('trunkWatch', j.trunkWatch);
|
run('trunkWatch', j.trunkWatch);
|
||||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||||
|
run('riskyCallWatch', j.riskyCallWatch);
|
||||||
|
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
|
||||||
run('callReports', j.callReports);
|
run('callReports', j.callReports);
|
||||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||||
run('contrackLog', j.contrackLog);
|
run('contrackLog', j.contrackLog);
|
||||||
|
|||||||
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
|||||||
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
|
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
|
||||||
async testTrunk(
|
async testTrunk(
|
||||||
id: string,
|
id: string,
|
||||||
{ waitMs = 1500, qualify = true } = {},
|
{ waitMs = 1500 } = {},
|
||||||
): Promise<{ live: TrunkLive; rttMs: number | null }> {
|
): Promise<{ live: TrunkLive; rttMs: number | null }> {
|
||||||
if (qualify) {
|
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
|
||||||
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
|
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
|
||||||
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
|
|
||||||
}
|
|
||||||
await this.refreshRegistrations();
|
await this.refreshRegistrations();
|
||||||
const rttMs = await this.refreshReachable(id);
|
const rttMs = await this.refreshReachable(id);
|
||||||
const live = this.trunks.get(id);
|
const live = this.trunks.get(id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
|
// /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 { desc, eq } from 'drizzle-orm';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
type BlockedDto,
|
type BlockedDto,
|
||||||
type ListResponse,
|
type ListResponse,
|
||||||
|
type SpamReportDto,
|
||||||
} from '@selfphoneng/shared';
|
} from '@selfphoneng/shared';
|
||||||
import { requireAdmin } from '../auth/guards.ts';
|
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 { conflict, notFound } from '../errors.ts';
|
||||||
import { setBlock } from '../services/astdb.ts';
|
import { setBlock } from '../services/astdb.ts';
|
||||||
import { audit } from '../services/audit.ts';
|
import { audit } from '../services/audit.ts';
|
||||||
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
await mirror(b.number, true);
|
await mirror(b.number, true);
|
||||||
|
await db.delete(spamReports).where(eq(spamReports.number, b.number));
|
||||||
await audit(db, {
|
await audit(db, {
|
||||||
actorUserId: req.user!.id,
|
actorUserId: req.user!.id,
|
||||||
actorIp: req.ip,
|
actorIp: req.ip,
|
||||||
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
return { ok: true };
|
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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
|
// /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 type { FastifyInstance } from 'fastify';
|
||||||
import { alias } from 'drizzle-orm/pg-core';
|
import { alias } from 'drizzle-orm/pg-core';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
CallFilterSchema,
|
CallFilterSchema,
|
||||||
|
SpamReportCreateSchema,
|
||||||
|
formatNumber,
|
||||||
|
normalizeNumber,
|
||||||
type CallDetailDto,
|
type CallDetailDto,
|
||||||
type CallDto,
|
type CallDto,
|
||||||
type ListResponse,
|
type ListResponse,
|
||||||
@@ -14,9 +18,18 @@ import {
|
|||||||
} from '@selfphoneng/shared';
|
} from '@selfphoneng/shared';
|
||||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||||
import type { Db } from '../db/client.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 { 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';
|
import { getSetting } from '../services/settings.ts';
|
||||||
|
|
||||||
const answerer = alias(extensions, 'answerer');
|
const answerer = alias(extensions, 'answerer');
|
||||||
@@ -216,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
|
|||||||
const out = mine.includes('caller');
|
const out = mine.includes('caller');
|
||||||
return {
|
return {
|
||||||
...c,
|
...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',
|
myRole: out ? 'out' : 'in',
|
||||||
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -270,8 +270,10 @@ export async function contactRoutes(app: FastifyInstance) {
|
|||||||
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
|
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
|
||||||
.parse(req.body);
|
.parse(req.body);
|
||||||
const saved = await getSetting(db, 'contacts.contrack');
|
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 =
|
const token =
|
||||||
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
|
b.companyToken || (same && saved.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
|
||||||
try {
|
try {
|
||||||
return { ok: true, detail: await contrackCheck(b.url, token) };
|
return { ok: true, detail: await contrackCheck(b.url, token) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
|
// 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 type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '../errors.ts';
|
} from '../errors.ts';
|
||||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||||
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.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 { audit } from '../services/audit.ts';
|
||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
import { decide, verifiedEmail } from '../sso/claims.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)));
|
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
|
||||||
|
|
||||||
if (connecting) {
|
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
|
const [sess] = await db
|
||||||
.select({ userId: sessions.userId, email: users.email })
|
.select({ userId: sessions.userId, email: users.email })
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
@@ -186,7 +192,8 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
let userId = owner?.userId;
|
let userId = owner?.userId;
|
||||||
if (!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 v = verifiedEmail(r.claims, s.trustEmail);
|
||||||
const [u] = v
|
const [u] = v
|
||||||
? await db
|
? await db
|
||||||
@@ -196,13 +203,12 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
and(
|
and(
|
||||||
eq(users.email, v),
|
eq(users.email, v),
|
||||||
isNull(users.passwordHash),
|
isNull(users.passwordHash),
|
||||||
|
ne(users.role, 'admin'),
|
||||||
notExists(
|
notExists(
|
||||||
db
|
db
|
||||||
.select({ id: userIdentities.id })
|
.select({ id: userIdentities.id })
|
||||||
.from(userIdentities)
|
.from(userIdentities)
|
||||||
.where(
|
.where(eq(userIdentities.userId, users.id)),
|
||||||
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -322,8 +328,15 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
recordLoginFailure(req.ip, login);
|
recordLoginFailure(req.ip, login);
|
||||||
throw badRequest('The directory refused that username or password');
|
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');
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
|
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
|
|||||||
import { badRequest, forbidden, notFound } from '../errors.ts';
|
import { badRequest, forbidden, notFound } from '../errors.ts';
|
||||||
import { audit } from '../services/audit.ts';
|
import { audit } from '../services/audit.ts';
|
||||||
import { sideFiles } from '../ai/calls.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. */
|
/** The recording and, when call transcripts left them, its two side files. */
|
||||||
const removeFiles = async (dir: string, filePath: string) => {
|
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) {
|
async function mayHear(user: { id: string; role: string }, recordingId: string) {
|
||||||
if (user.role === 'admin') return;
|
if (user.role === 'admin') return;
|
||||||
const [mine] = await db
|
const [mine] = await db
|
||||||
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
|
|||||||
.from(calls)
|
.from(calls)
|
||||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
.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);
|
.limit(1);
|
||||||
if (!mine) throw forbidden('Not your call');
|
if (!mine) throw forbidden('Not your call');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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.
|
// /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';
|
// 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 type { FastifyInstance } from 'fastify';
|
||||||
import {
|
import {
|
||||||
CALL_DIRECTIONS,
|
CALL_DIRECTIONS,
|
||||||
@@ -10,12 +11,12 @@ import {
|
|||||||
type ProviderBalanceDto,
|
type ProviderBalanceDto,
|
||||||
type ReportSummary,
|
type ReportSummary,
|
||||||
} from '@selfphoneng/shared';
|
} from '@selfphoneng/shared';
|
||||||
import { requireAdmin } from '../auth/guards.ts';
|
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { decrypt } from '../crypto/secrets.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 { 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 { todayIn } from '../generator/load.ts';
|
||||||
import { queueStats } from '../services/queuestats.ts';
|
import { queueStats } from '../services/queuestats.ts';
|
||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
|
|||||||
|
|
||||||
export async function reportRoutes(app: FastifyInstance) {
|
export async function reportRoutes(app: FastifyInstance) {
|
||||||
const { db } = app;
|
const { db } = app;
|
||||||
app.addHook('preHandler', requireAdmin);
|
const admin = { preHandler: requireAdmin };
|
||||||
|
|
||||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
|
||||||
const { tz, from, to } = await range(db, req.query);
|
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
|
||||||
|
const { tz, from, to } = await range(db, query);
|
||||||
const where = and(
|
const where = and(
|
||||||
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
||||||
sql`${calls.startedAt} < ((${to}::date + 1)::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 local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||||
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
calls: sql<number>`count(*)::int`,
|
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`,
|
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`,
|
lineIn: sql<number>`round(${lineIn})::int`,
|
||||||
lineOut: sql<number>`${lineOut}::int`,
|
lineOut: sql<number>`${lineOut}::int`,
|
||||||
avgTalk: sql<
|
avgTalk: sql<
|
||||||
number | null
|
number | null
|
||||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
|
||||||
})
|
})
|
||||||
.from(calls)
|
.from(calls)
|
||||||
.where(where),
|
.where(where),
|
||||||
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
hours,
|
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. */
|
/** 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
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(trunks)
|
.from(trunks)
|
||||||
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
return { items, nextCursor: null };
|
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 { from, to } = await range(db, req.query);
|
||||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
|
// /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 type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
type Warning,
|
type Warning,
|
||||||
} from '@selfphoneng/shared';
|
} from '@selfphoneng/shared';
|
||||||
import type { Db } from '../db/client.ts';
|
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 { HttpError, badRequest } from '../errors.ts';
|
||||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||||
@@ -63,6 +64,7 @@ const READ_ONLY = new Set<string>([
|
|||||||
'spam.state',
|
'spam.state',
|
||||||
'sms.webhookToken',
|
'sms.webhookToken',
|
||||||
'setup.completed',
|
'setup.completed',
|
||||||
|
'network.probeKeyEnc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
async function toDto(db: Db): Promise<SettingsDto> {
|
async function toDto(db: Db): Promise<SettingsDto> {
|
||||||
@@ -72,10 +74,12 @@ async function toDto(db: Db): Promise<SettingsDto> {
|
|||||||
'auth.oidc': oidc,
|
'auth.oidc': oidc,
|
||||||
'auth.ldap': ldap,
|
'auth.ldap': ldap,
|
||||||
spam,
|
spam,
|
||||||
|
'network.probeKeyEnc': probeKey,
|
||||||
...rest
|
...rest
|
||||||
} = await getAllSettings(db);
|
} = await getAllSettings(db);
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
'network.hasProbeKey': probeKey !== null,
|
||||||
spam: {
|
spam: {
|
||||||
...spam,
|
...spam,
|
||||||
ipqs: spam.ipqs && { hasKey: true },
|
ipqs: spam.ipqs && { hasKey: true },
|
||||||
@@ -257,6 +261,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
app.patch('/api/settings', async (req) => {
|
app.patch('/api/settings', async (req) => {
|
||||||
const body = z.record(z.string(), z.unknown()).parse(req.body);
|
const body = z.record(z.string(), z.unknown()).parse(req.body);
|
||||||
const values: Record<string, unknown> = {};
|
const values: Record<string, unknown> = {};
|
||||||
|
let contrackMoved = false;
|
||||||
for (const [key, raw] of Object.entries(body)) {
|
for (const [key, raw] of Object.entries(body)) {
|
||||||
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
|
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
|
||||||
throw badRequest(`${key} can't be changed here`);
|
throw badRequest(`${key} can't be changed here`);
|
||||||
@@ -287,8 +292,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw badRequest(`Contrack address: ${(err as Error).message}`);
|
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 && {
|
values[key] = input && {
|
||||||
url: input.url.replace(/\/+$/, ''),
|
url: url!,
|
||||||
publicUrl: input.publicUrl,
|
publicUrl: input.publicUrl,
|
||||||
companyTokenEnc: input.companyToken
|
companyTokenEnc: input.companyToken
|
||||||
? encrypt(input.companyToken)
|
? encrypt(input.companyToken)
|
||||||
@@ -296,6 +306,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
? ''
|
? ''
|
||||||
: (old?.companyTokenEnc ?? ''),
|
: (old?.companyTokenEnc ?? ''),
|
||||||
};
|
};
|
||||||
|
if (moved) contrackMoved = true;
|
||||||
forgetBook(null);
|
forgetBook(null);
|
||||||
} else if (key === 'auth.ldap') {
|
} else if (key === 'auth.ldap') {
|
||||||
values[key] = await ldapFromInput(raw);
|
values[key] = await ldapFromInput(raw);
|
||||||
@@ -349,8 +360,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const cert = await readTlsCert(app.config.TLS_DIR);
|
const cert = await readTlsCert(app.config.TLS_DIR);
|
||||||
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
|
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))
|
for (const [key, value] of Object.entries(values))
|
||||||
await setSetting(db, key as SettingKey, value as never);
|
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).
|
// 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');
|
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
|
||||||
const changed = Object.keys(values).filter(
|
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). */
|
/** 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 () => {
|
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
|
||||||
const ip = await detectPublicIp();
|
const ip = await detectPublicIp();
|
||||||
|
|||||||
@@ -208,21 +208,18 @@ export async function trunkRoutes(app: FastifyInstance) {
|
|||||||
if (!t.enabled) throw badRequest('Turn the trunk on first');
|
if (!t.enabled) throw badRequest('Turn the trunk on first');
|
||||||
if (!app.live.amiConnected)
|
if (!app.live.amiConnected)
|
||||||
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
|
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);
|
||||||
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
|
|
||||||
const reg = t.authMode === 'registration';
|
const reg = t.authMode === 'registration';
|
||||||
const detail =
|
const detail =
|
||||||
reg && live.registration === 'registered'
|
reg && live.registration === 'registered'
|
||||||
? `Registered with ${t.serverHost}`
|
? `Registered with ${t.serverHost}`
|
||||||
: reg && live.registration === 'rejected'
|
: reg && live.registration === 'rejected'
|
||||||
? `${t.serverHost} rejected the username or password`
|
? `${t.serverHost} rejected the username or password`
|
||||||
: !reg && t.transport === 'tls'
|
: !live.reachable
|
||||||
? `Can't check a TLS trunk without registration from here: make a test call.`
|
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
|
||||||
: !live.reachable
|
: reg
|
||||||
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
|
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
|
||||||
: reg
|
: `${t.serverHost} answers (IP authentication: no registration)`;
|
||||||
? `${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 };
|
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ export async function sendCallReports(d: {
|
|||||||
aiDoneAt: recordings.aiDoneAt,
|
aiDoneAt: recordings.aiDoneAt,
|
||||||
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
|
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)
|
.from(calls)
|
||||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||||
@@ -177,9 +180,9 @@ export async function sendCallReports(d: {
|
|||||||
toNumber: r.c.toNumber,
|
toNumber: r.c.toNumber,
|
||||||
talkSeconds: r.c.talkSeconds,
|
talkSeconds: r.c.talkSeconds,
|
||||||
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
||||||
recorded: r.c.recordingId !== null,
|
recorded: r.heard && r.c.recordingId !== null,
|
||||||
summary: r.summary,
|
summary: r.heard ? r.summary : null,
|
||||||
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
|
summaryPending: r.heard && r.c.recordingId !== null && r.aiDoneAt === null,
|
||||||
}));
|
}));
|
||||||
const withSummaries =
|
const withSummaries =
|
||||||
e.callReportSummaries &&
|
e.callReportSummaries &&
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/
|
|||||||
import { durationMs } from '../media/audio.ts';
|
import { durationMs } from '../media/audio.ts';
|
||||||
|
|
||||||
type CdrRow = typeof cdr.$inferSelect;
|
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];
|
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||||
|
|
||||||
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
|
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
|
|||||||
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
|
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
|
||||||
import type { AmiClient } from '../ami/client.ts';
|
import type { AmiClient } from '../ami/client.ts';
|
||||||
import type { Config } from '../config.ts';
|
import type { Config } from '../config.ts';
|
||||||
|
import { decrypt } from '../crypto/secrets.ts';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { detectPublicIp } from '../jobs.ts';
|
import { detectPublicIp } from '../jobs.ts';
|
||||||
import { getAllSettings } from './settings.ts';
|
import { getAllSettings } from './settings.ts';
|
||||||
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
out.push(
|
out.push(
|
||||||
s['network.probeUrl']
|
!s['network.probeUrl']
|
||||||
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
|
? check(
|
||||||
: check(
|
|
||||||
'probe',
|
'probe',
|
||||||
'Network test probe',
|
'Network test probe',
|
||||||
'skip',
|
'skip',
|
||||||
'None set, so nothing is tested from outside your network.',
|
'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.',
|
'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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -289,14 +298,20 @@ export async function networkTest(
|
|||||||
let ip: string | null = null;
|
let ip: string | null = null;
|
||||||
let results: ProbeResult[] = [];
|
let results: ProbeResult[] = [];
|
||||||
let probeError: string | null = null;
|
let probeError: string | null = null;
|
||||||
|
let keyRefused = false;
|
||||||
|
const key = s['network.probeKeyEnc'];
|
||||||
try {
|
try {
|
||||||
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
|
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(key && { authorization: `Bearer ${decrypt(key)}` }),
|
||||||
|
},
|
||||||
body: JSON.stringify({ host, tests }),
|
body: JSON.stringify({ host, tests }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
|
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}`;
|
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
|
||||||
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
|
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
|
||||||
// Datagrams sent last arrive just after the answer.
|
// Datagrams sent last arrive just after the answer.
|
||||||
@@ -316,7 +331,9 @@ export async function networkTest(
|
|||||||
'Network test probe',
|
'Network test probe',
|
||||||
'fail',
|
'fail',
|
||||||
`${probe} didn't answer: ${probeError}`,
|
`${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.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -121,11 +121,12 @@ async function transcribeMessage(d: VoicemailDeps, row: Row): Promise<string | n
|
|||||||
async function announce(d: VoicemailDeps, box: Box, found: Row) {
|
async function announce(d: VoicemailDeps, box: Box, found: Row) {
|
||||||
const transcript = await transcribeMessage(d, found);
|
const transcript = await transcribeMessage(d, found);
|
||||||
const row = { ...found, transcript };
|
const row = { ...found, transcript };
|
||||||
// Every mailbox, with or without a user (the live message is only for the user's own).
|
// Every mailbox, with or without a user (the live message is only for the user's own). Not
|
||||||
void enqueueWebhook(d.db, 'voicemail.new', {
|
// the transcript: webhooks go off this server, and transcripts never do (10 §1, 08 §7 #6).
|
||||||
...toVoicemailDto(row),
|
const { transcript: _words, ...hook } = toVoicemailDto(row);
|
||||||
extension: box.number,
|
void enqueueWebhook(d.db, 'voicemail.new', { ...hook, extension: box.number }).catch(
|
||||||
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
|
(err: unknown) => d.log.error({ err }, 'webhook enqueue failed'),
|
||||||
|
);
|
||||||
if (box.userId)
|
if (box.userId)
|
||||||
d.live.emit('message', {
|
d.live.emit('message', {
|
||||||
type: 'voicemail.new',
|
type: 'voicemail.new',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
|
// 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).
|
// 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 { deleteUserSessions } from '../auth/sessions.ts';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { userIdentities, users } from '../db/schema.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)));
|
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||||
if (byId) return byId.u;
|
if (byId) return byId.u;
|
||||||
if (!e.email) return null;
|
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;
|
return byEmail && byEmailOk(byEmail) ? byEmail : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,19 +49,27 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
|
|||||||
const byEmailOk = (u: UserRow) =>
|
const byEmailOk = (u: UserRow) =>
|
||||||
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
|
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(
|
export async function connectDirectory(
|
||||||
db: Db,
|
db: Db,
|
||||||
log: { info(o: object, m: string): void },
|
log: { info(o: object, m: string): void },
|
||||||
userId: string,
|
userId: string,
|
||||||
e: DirectoryEntry,
|
e: DirectoryEntry,
|
||||||
ip: string,
|
ip: string,
|
||||||
): Promise<'connected' | 'taken'> {
|
): Promise<'connected' | 'taken' | 'connected_other'> {
|
||||||
const [owner] = await db
|
const [owner] = await db
|
||||||
.select({ userId: userIdentities.userId })
|
.select({ userId: userIdentities.userId })
|
||||||
.from(userIdentities)
|
.from(userIdentities)
|
||||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||||
if (owner && owner.userId !== userId) return 'taken';
|
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 db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.delete(userIdentities)
|
.delete(userIdentities)
|
||||||
@@ -101,11 +123,8 @@ export async function directorySignIn(
|
|||||||
entityId: u.id,
|
entityId: u.id,
|
||||||
summary: `${u.email} connected directory account ${e.dn}`,
|
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
|
} else
|
||||||
|
// Found by this account's id (directoryUserFor never matches by email someone connected).
|
||||||
await db
|
await db
|
||||||
.update(userIdentities)
|
.update(userIdentities)
|
||||||
.set({ email: e.email, lastUsedAt: new Date() })
|
.set({ email: e.email, lastUsedAt: new Date() })
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
|||||||
import { AmiClient } from '../../src/ami/client.ts';
|
import { AmiClient } from '../../src/ami/client.ts';
|
||||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||||
import type { Snapshot } from '../../src/generator/load.ts';
|
import type { Snapshot } from '../../src/generator/load.ts';
|
||||||
|
import { testManagerConf } from './manager-conf.ts';
|
||||||
|
|
||||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||||
const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30);
|
const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30);
|
||||||
@@ -126,11 +127,8 @@ async function main() {
|
|||||||
spawnSync('mkdir', ['-p', conf]);
|
spawnSync('mkdir', ['-p', conf]);
|
||||||
const files = render(snapshot());
|
const files = render(snapshot());
|
||||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
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).
|
// The real manager.conf, reachable through the published port (test/asterisk/manager-conf.ts).
|
||||||
writeFileSync(
|
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||||
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',
|
|
||||||
);
|
|
||||||
phoneConfig(dir, '101', 5070, 440);
|
phoneConfig(dir, '101', 5070, 440);
|
||||||
phoneConfig(dir, '102', 5072, 880);
|
phoneConfig(dir, '102', 5072, 880);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { AmiMessage } from '../../src/ami/events.ts';
|
|||||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||||
import type { Snapshot, SnapExtension } from '../../src/generator/load.ts';
|
import type { Snapshot, SnapExtension } from '../../src/generator/load.ts';
|
||||||
import { providerDialplan, providerPjsip } from './provider.ts';
|
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||||
|
import { testManagerConf } from './manager-conf.ts';
|
||||||
|
|
||||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||||
const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url));
|
const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url));
|
||||||
@@ -243,10 +244,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const runPhones = phoneFiles(dir);
|
const runPhones = phoneFiles(dir);
|
||||||
writeFileSync(
|
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||||
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',
|
|
||||||
);
|
|
||||||
const ami = new AmiClient({
|
const ami = new AmiClient({
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: AMI_PORT,
|
port: AMI_PORT,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AmiClient } from '../../src/ami/client.ts';
|
|||||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||||
import type { Snapshot } from '../../src/generator/load.ts';
|
import type { Snapshot } from '../../src/generator/load.ts';
|
||||||
import { providerPjsip } from './provider.ts';
|
import { providerPjsip } from './provider.ts';
|
||||||
|
import { testManagerConf } from './manager-conf.ts';
|
||||||
|
|
||||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||||
const NET = 'sp-cnam';
|
const NET = 'sp-cnam';
|
||||||
@@ -223,10 +224,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
phoneFiles(dir);
|
phoneFiles(dir);
|
||||||
writeFileSync(
|
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||||
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',
|
|
||||||
);
|
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
const ami = new AmiClient({
|
const ami = new AmiClient({
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
|||||||
import type { Snapshot } from '../../src/generator/load.ts';
|
import type { Snapshot } from '../../src/generator/load.ts';
|
||||||
|
|
||||||
import { providerDialplan, providerPjsip } from './provider.ts';
|
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||||
|
import { testManagerConf } from './manager-conf.ts';
|
||||||
|
|
||||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||||
const RECORD = process.argv.includes('--record')
|
const RECORD = process.argv.includes('--record')
|
||||||
@@ -200,10 +201,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
phoneFiles(dir);
|
phoneFiles(dir);
|
||||||
writeFileSync(
|
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||||
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',
|
|
||||||
);
|
|
||||||
|
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
let recorder: Socket | undefined;
|
let recorder: Socket | undefined;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -11,6 +11,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
|||||||
import { AmiClient } from '../../src/ami/client.ts';
|
import { AmiClient } from '../../src/ami/client.ts';
|
||||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||||
import type { Snapshot } from '../../src/generator/load.ts';
|
import type { Snapshot } from '../../src/generator/load.ts';
|
||||||
|
import { testManagerConf } from './manager-conf.ts';
|
||||||
|
|
||||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||||
const NET = 'sp-vm';
|
const NET = 'sp-vm';
|
||||||
@@ -111,10 +112,7 @@ async function main() {
|
|||||||
spawnSync('mkdir', ['-p', conf]);
|
spawnSync('mkdir', ['-p', conf]);
|
||||||
const files = render(snapshot());
|
const files = render(snapshot());
|
||||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||||
writeFileSync(
|
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||||
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',
|
|
||||||
);
|
|
||||||
phoneConfig(dir, '101', 5070);
|
phoneConfig(dir, '101', 5070);
|
||||||
phoneConfig(dir, '102', 5072);
|
phoneConfig(dir, '102', 5072);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 type { LightMyRequestResponse } from 'fastify';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
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 { syncAstDb } from '../src/services/astdb.ts';
|
||||||
import { hasTestDb } from './db.ts';
|
import { hasTestDb } from './db.ts';
|
||||||
import { ADMIN, H, createTestApp } from './app.ts';
|
import { ADMIN, H, createTestApp } from './app.ts';
|
||||||
|
|
||||||
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
||||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
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 () => {
|
beforeAll(async () => {
|
||||||
t = await createTestApp();
|
t = await createTestApp();
|
||||||
await t.clearMustChange(ADMIN.email);
|
await t.clearMustChange(ADMIN.email);
|
||||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||||
req = (method, url, payload) =>
|
req = (method, url, payload, as = cookie) =>
|
||||||
t.app.inject({
|
t.app.inject({
|
||||||
method: method as 'GET',
|
method: method as 'GET',
|
||||||
url,
|
url,
|
||||||
payload: payload as object,
|
payload: payload as object,
|
||||||
headers: { ...H, cookie },
|
headers: { ...H, cookie: as },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
afterAll(() => t?.close());
|
afterAll(() => t?.close());
|
||||||
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
|||||||
});
|
});
|
||||||
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
|
|||||||
await run('2026-10-23T13:06:00Z');
|
await run('2026-10-23T13:06:00Z');
|
||||||
expect(t.mails[1]!.text).not.toContain('quote');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
|||||||
fake.state.readOnly.clear();
|
fake.state.readOnly.clear();
|
||||||
expect(await run()).toBe(0);
|
expect(await run()).toBe(0);
|
||||||
expect(jane).toHaveLength(8);
|
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 () => {
|
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();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
|
|||||||
401,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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
|
// 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 { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||||
import { userIdentities, users } from '../src/db/schema.ts';
|
import { userIdentities, users } from '../src/db/schema.ts';
|
||||||
import { setSetting } from '../src/services/settings.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 type { DirectoryEntry } from '../src/sso/ldap.ts';
|
||||||
import { hasTestDb } from './db.ts';
|
import { hasTestDb } from './db.ts';
|
||||||
import { ADMIN, H, createTestApp } from './app.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 () => {
|
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');
|
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' });
|
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -207,6 +207,56 @@ for (const alg of ['RS256', 'ES256'] as const)
|
|||||||
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
|
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 () => {
|
it('trustEmail matches an unverified email', async () => {
|
||||||
await t.db
|
await t.db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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
|
// 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { LightMyRequestResponse } from 'fastify';
|
import type { LightMyRequestResponse } from 'fastify';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import type { AmiMessage } from '../src/ami/events.ts';
|
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 { encrypt } from '../src/crypto/secrets.ts';
|
||||||
import { cleanupRecordings } from '../src/routes/recordings.ts';
|
import { cleanupRecordings } from '../src/routes/recordings.ts';
|
||||||
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
||||||
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
afterAll(() => t?.close());
|
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 () => {
|
it('the recording is linked to its call, with size and length', async () => {
|
||||||
const [c] = await t.db.select().from(calls);
|
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);
|
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 () => {
|
it('retention deletes recordings past their days; delete removes file and link', async () => {
|
||||||
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
|
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
|
||||||
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
|
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 type { LightMyRequestResponse } from 'fastify';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
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 { setSetting } from '../src/services/settings.ts';
|
||||||
import { hasTestDb } from './db.ts';
|
import { hasTestDb } from './db.ts';
|
||||||
import { ADMIN, H, createTestApp } from './app.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');
|
await t.addUser('pat@example.com', 'pat-password-123');
|
||||||
const pat = await t.login('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/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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -117,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
|||||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||||
afterAll(() => drop?.());
|
afterAll(() => drop?.());
|
||||||
|
|
||||||
it('creates all 47 tables', async () => {
|
it('creates all 48 tables', async () => {
|
||||||
const r = await db.execute(
|
const r = await db.execute(
|
||||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
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) => {
|
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
|
|||||||
expect(await warningCodes()).not.toContain('tls_cert');
|
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', {
|
const trunk = await req('POST', '/api/trunks', {
|
||||||
name: 'voip.ms TLS',
|
name: 'voip.ms TLS',
|
||||||
serverHost: '127.0.0.1',
|
serverHost: '127.0.0.1',
|
||||||
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
|
|||||||
await t.app.generator.apply({ trigger: 'test' });
|
await t.app.generator.apply({ trigger: 'test' });
|
||||||
const { readFile } = await import('node:fs/promises');
|
const { readFile } = await import('node:fs/promises');
|
||||||
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
|
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(/media_encryption = sdes/);
|
||||||
expect(conf).toMatch(/verify_server = yes/);
|
expect(conf).toMatch(/verify_server = yes/);
|
||||||
t.live.amiConnected = true;
|
t.live.amiConnected = true;
|
||||||
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
|
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
|
||||||
expect(test.statusCode).toBe(200);
|
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 });
|
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
|
||||||
expect(off.statusCode).toBe(400);
|
expect(off.statusCode).toBe(400);
|
||||||
expect(off.json().error.message).toMatch(/uses TLS/);
|
expect(off.json().error.message).toMatch(/uses TLS/);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import type { LiveMessage } from '@selfphoneng/shared';
|
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 { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
|
||||||
import { setSetting } from '../src/services/settings.ts';
|
import { setSetting } from '../src/services/settings.ts';
|
||||||
import { hasTestDb } from './db.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`;
|
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.stt', { url, model: 'whisper' });
|
||||||
await setSetting(t.db, 'ai.voicemailTranscripts', true);
|
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 {
|
try {
|
||||||
leave('INBOX', 3, '1792300700-00000004', '<104>');
|
leave('INBOX', 3, '1792300700-00000004', '<104>');
|
||||||
await w.scan('101');
|
await w.scan('101');
|
||||||
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
|
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
|
||||||
expect(r.transcript).toBe('Please call me.');
|
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(
|
expect(t.mails.at(-1)!.text).toContain(
|
||||||
'What they said (automatic, may have mistakes):\nPlease call me.',
|
'What they said (automatic, may have mistakes):\nPlease call me.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@selfphoneng/probe",
|
"name": "@selfphoneng/probe",
|
||||||
"version": "0.0.11",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+14
-3
@@ -1,10 +1,21 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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
|
// 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).
|
// 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';
|
import { createProbe } from './server.ts';
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 8080);
|
const port = Number(process.env.PORT ?? 8080);
|
||||||
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
|
const trustProxy = process.env.TRUST_PROXY === 'true';
|
||||||
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
|
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)));
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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
|
// 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 { 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 { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { runTest } from './checks.ts';
|
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 port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
|
||||||
const listen = <T extends Server | HttpServer>(s: T) =>
|
const listen = <T extends Server | HttpServer>(s: T) =>
|
||||||
@@ -165,3 +171,38 @@ describe('probe server', () => {
|
|||||||
target.close();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
|
// 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
|
// 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 { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
import { runTest, type Result, type Test } from './checks.ts';
|
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 = {
|
export type ProbeOptions = {
|
||||||
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
|
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
|
||||||
trustProxy?: boolean;
|
trustProxy?: boolean;
|
||||||
|
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
|
||||||
|
key?: string;
|
||||||
/** Checks per address per window. */
|
/** Checks per address per window. */
|
||||||
maxChecks?: number;
|
maxChecks?: number;
|
||||||
windowMs?: number;
|
windowMs?: number;
|
||||||
@@ -48,15 +52,27 @@ export function parseCheck(body: unknown): { host: string; tests: Test[] } | str
|
|||||||
return { host: b.host, tests };
|
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 xff = req.headers['x-forwarded-for'];
|
||||||
const raw =
|
const local = peer === '127.0.0.1' || peer === '::1';
|
||||||
trustProxy && typeof xff === 'string'
|
return trustProxy && local && typeof xff === 'string'
|
||||||
? xff.split(',').at(-1)!.trim()
|
? xff
|
||||||
: req.socket.remoteAddress!;
|
.split(',')
|
||||||
return raw.replace(/^::ffff:/, '');
|
.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> {
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let size = 0;
|
let size = 0;
|
||||||
@@ -78,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createProbe(opts: ProbeOptions = {}): Server {
|
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.
|
// ponytail: in-process memory; addresses past the window are swept on each check.
|
||||||
const seen = new Map<string, number[]>();
|
const seen = new Map<string, number[]>();
|
||||||
const allow = (ip: string, now = Date.now()) => {
|
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 === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
|
||||||
if (req.method !== 'POST' || req.url !== '/v1/check')
|
if (req.method !== 'POST' || req.url !== '/v1/check')
|
||||||
return send(res, 404, { error: 'not found' });
|
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);
|
const ip = clientIp(req, trustProxy);
|
||||||
void readJson(req)
|
void readJson(req)
|
||||||
.then(async (body) => {
|
.then(async (body) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@selfphoneng/web",
|
"name": "@selfphoneng/web",
|
||||||
"version": "0.0.11",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import type {
|
|||||||
AnnouncementUpdate,
|
AnnouncementUpdate,
|
||||||
BlockedCreate,
|
BlockedCreate,
|
||||||
BlockedDto,
|
BlockedDto,
|
||||||
|
SpamReportCreate,
|
||||||
|
SpamReportDto,
|
||||||
CallDetailDto,
|
CallDetailDto,
|
||||||
CallDto,
|
CallDto,
|
||||||
CallFilter,
|
CallFilter,
|
||||||
@@ -293,6 +295,9 @@ export const systemApi = {
|
|||||||
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
|
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
|
||||||
detectPublicIp: () =>
|
detectPublicIp: () =>
|
||||||
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
|
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'),
|
tls: () => api<TlsCert>('GET', '/api/system/tls'),
|
||||||
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
|
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
|
||||||
networkTest: (mode: 'quick' | 'complete') =>
|
networkTest: (mode: 'quick' | 'complete') =>
|
||||||
@@ -306,6 +311,9 @@ export const systemApi = {
|
|||||||
export const meApi = {
|
export const meApi = {
|
||||||
calls: (cursor?: string) =>
|
calls: (cursor?: string) =>
|
||||||
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
|
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'),
|
get: () => api<MeDto>('GET', '/api/me'),
|
||||||
/** 14 §5.1: only for a caller ringing me now. */
|
/** 14 §5.1: only for a caller ringing me now. */
|
||||||
callerCheck: (number: string) =>
|
callerCheck: (number: string) =>
|
||||||
@@ -505,6 +513,8 @@ export const blockedApi = {
|
|||||||
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
|
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
|
||||||
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
|
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
|
||||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`),
|
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). */
|
/** Filters as a query string (empty values left out). */
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { formatNumber } from '@selfphoneng/shared';
|
import { formatNumber } from '@selfphoneng/shared';
|
||||||
import { blockedApi } from '../../api/client.ts';
|
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() {
|
export function BlockedPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
|
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
|
||||||
|
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
|
||||||
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
|
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
|
||||||
const [number, setNumber] = useState('');
|
const [number, setNumber] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
@@ -24,6 +26,16 @@ export function BlockedPage() {
|
|||||||
mutationFn: (id: string) => blockedApi.remove(id),
|
mutationFn: (id: string) => blockedApi.remove(id),
|
||||||
onSuccess: refresh,
|
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 (
|
return (
|
||||||
<section className="flex flex-col gap-4">
|
<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
|
Calls from these numbers are rejected before any phone rings. Emergency calls you make are
|
||||||
never affected.
|
never affected.
|
||||||
</p>
|
</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
|
<form
|
||||||
className="flex flex-wrap items-end gap-2"
|
className="flex flex-wrap items-end gap-2"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -66,7 +110,7 @@ export function BlockedPage() {
|
|||||||
Block
|
Block
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
|
||||||
{list.data && list.data.length > 0 && (
|
{list.data && list.data.length > 0 && (
|
||||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
|
// 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
|
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
|
||||||
import { Link } from 'react-router';
|
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 { ConfirmDialog } from '../../components/Dialog.tsx';
|
||||||
import { Alert, Button, inputClass } from '../../components/ui.tsx';
|
import { Alert, Button, inputClass } from '../../components/ui.tsx';
|
||||||
import { fmtNumber } from './CallsPage.tsx';
|
import { fmtNumber } from './CallsPage.tsx';
|
||||||
@@ -31,6 +31,11 @@ export function DashboardPage() {
|
|||||||
const onCall = list.filter((e) =>
|
const onCall = list.filter((e) =>
|
||||||
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
|
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
|
||||||
).length;
|
).length;
|
||||||
|
const reports = useQuery({
|
||||||
|
queryKey: ['blocked', 'reports'],
|
||||||
|
queryFn: blockedApi.reports,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
@@ -40,7 +45,7 @@ export function DashboardPage() {
|
|||||||
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
|
(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">
|
<Card title="Extensions online">
|
||||||
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -61,6 +66,12 @@ export function DashboardPage() {
|
|||||||
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
|
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
|
||||||
</span>
|
</span>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
<QueueTiles />
|
<QueueTiles />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
|
// 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.
|
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
|
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 { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||||
import { fmtDuration } from './CallsPage.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 [draft, setDraft] = useState<ReportRange>({});
|
||||||
const [range, setRange] = useState<ReportRange>({});
|
const [range, setRange] = useState<ReportRange>({});
|
||||||
const summary = useQuery({
|
const summary = useQuery({
|
||||||
queryKey: ['reports', 'summary', range],
|
queryKey: [mine ? 'me' : 'reports', 'summary', range],
|
||||||
queryFn: () => reportsApi.summary(range),
|
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
|
||||||
});
|
});
|
||||||
const queues = useQuery({
|
const queues = useQuery({
|
||||||
queryKey: ['reports', 'queues', range],
|
queryKey: ['reports', 'queues', range],
|
||||||
queryFn: () => reportsApi.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 s = summary.data;
|
||||||
const date = (k: 'from' | 'to', label: string) => (
|
const date = (k: 'from' | 'to', label: string) => (
|
||||||
<Field label={label}>
|
<Field label={label}>
|
||||||
@@ -145,7 +151,8 @@ export function ReportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<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
|
<form
|
||||||
aria-label="Dates"
|
aria-label="Dates"
|
||||||
className="flex flex-wrap items-end gap-3"
|
className="flex flex-wrap items-end gap-3"
|
||||||
@@ -174,19 +181,33 @@ export function ReportsPage() {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['Calls', s.totals.calls, null],
|
['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'],
|
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
|
||||||
[
|
[
|
||||||
'Average talk time',
|
'Average talk time',
|
||||||
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
||||||
null,
|
null,
|
||||||
],
|
],
|
||||||
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
|
|
||||||
[
|
[
|
||||||
'Outside minutes',
|
'Talk time',
|
||||||
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
|
fmtMinutes(s.totals.talkSeconds),
|
||||||
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
|
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(
|
...(balance.data ?? []).map(
|
||||||
(b) =>
|
(b) =>
|
||||||
[
|
[
|
||||||
@@ -204,12 +225,18 @@ export function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<Bars label="Calls per day" days={s.days} segments={SERIES} />
|
|
||||||
<Bars
|
<Bars
|
||||||
label="Outside minutes per day"
|
label="Calls per day"
|
||||||
days={s.days}
|
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
|
<Bars
|
||||||
label="Missed per day"
|
label="Missed per day"
|
||||||
days={s.days}
|
days={s.days}
|
||||||
@@ -222,53 +249,55 @@ export function ReportsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{!mine && (
|
||||||
<h2 className="font-medium">Queues</h2>
|
<div className="flex flex-col gap-2">
|
||||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
<h2 className="font-medium">Queues</h2>
|
||||||
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
|
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||||
{!!queues.data?.length && (
|
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
|
||||||
<div className="overflow-x-auto">
|
{!!queues.data?.length && (
|
||||||
<table aria-label="Queue report" className="w-full text-left text-sm">
|
<div className="overflow-x-auto">
|
||||||
<thead className="text-muted">
|
<table aria-label="Queue report" className="w-full text-left text-sm">
|
||||||
<tr>
|
<thead className="text-muted">
|
||||||
{[
|
<tr>
|
||||||
'Queue',
|
{[
|
||||||
'Calls',
|
'Queue',
|
||||||
'Answered',
|
'Calls',
|
||||||
'Hung up waiting',
|
'Answered',
|
||||||
'Timed out',
|
'Hung up waiting',
|
||||||
'Average wait',
|
'Timed out',
|
||||||
'Answered in time',
|
'Average wait',
|
||||||
].map((h) => (
|
'Answered in time',
|
||||||
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
].map((h) => (
|
||||||
{h}
|
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
||||||
</th>
|
{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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-line">
|
||||||
</table>
|
{queues.data.map((q) => (
|
||||||
</div>
|
<tr key={q.id} className="tabular-nums">
|
||||||
)}
|
<td className="px-2 py-1">
|
||||||
</div>
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -505,6 +505,11 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
|||||||
const [watch, setWatch] = useState(s['network.publicIpWatch']);
|
const [watch, setWatch] = useState(s['network.publicIpWatch']);
|
||||||
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
|
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
|
||||||
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
|
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const makeKey = useMutation({
|
||||||
|
mutationFn: systemApi.probeKey,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
@@ -555,13 +560,61 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</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’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">
|
<div className="flex items-center gap-3">
|
||||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||||
</div>
|
</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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -743,6 +796,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
|||||||
const [provOn, setProvOn] = useState(s['provisioning.enabled']);
|
const [provOn, setProvOn] = useState(s['provisioning.enabled']);
|
||||||
const [perExt, setPerExt] = useState(String(s['calls.maxPerExtension']));
|
const [perExt, setPerExt] = useState(String(s['calls.maxPerExtension']));
|
||||||
const [total, setTotal] = useState(String(s['calls.maxTotal']));
|
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) => (
|
const box = (checked: boolean, set: (v: boolean) => void, label: string) => (
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
|
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
|
||||||
@@ -761,6 +817,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
|||||||
'provisioning.enabled': provOn,
|
'provisioning.enabled': provOn,
|
||||||
'calls.maxPerExtension': Number(perExt),
|
'calls.maxPerExtension': Number(perExt),
|
||||||
'calls.maxTotal': Number(total),
|
'calls.maxTotal': Number(total),
|
||||||
|
'alerts.riskyCalls': riskyCalls,
|
||||||
|
'alerts.providerCalls': providerCalls,
|
||||||
|
'alerts.balanceDropPerHour': Number(balanceDrop),
|
||||||
'sip.udpPort': Number(sipPort),
|
'sip.udpPort': Number(sipPort),
|
||||||
'sip.tcpEnabled': tcpOn,
|
'sip.tcpEnabled': tcpOn,
|
||||||
'sip.tlsEnabled': tlsOn,
|
'sip.tlsEnabled': tlsOn,
|
||||||
@@ -820,6 +879,36 @@ function SecurityTab({ s }: { s: SettingsDto }) {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="font-medium">Fraud alerts</h2>
|
||||||
|
<p className="-mt-2 text-xs text-muted">
|
||||||
|
Emails to every admin. The last two need the voip.ms API login on the trunk (Trunks).
|
||||||
|
</p>
|
||||||
|
{box(riskyCalls, setRiskyCalls, 'Calls to international, premium and Caribbean numbers')}
|
||||||
|
{box(
|
||||||
|
providerCalls,
|
||||||
|
setProviderCalls,
|
||||||
|
"Calls voip.ms billed that this phone system didn't make",
|
||||||
|
)}
|
||||||
|
<p className="-mt-2 text-xs text-muted">
|
||||||
|
Checked every 15 minutes. That's what a stolen trunk password looks like: the calls
|
||||||
|
never pass through this server, so no limit here can stop them.
|
||||||
|
</p>
|
||||||
|
<Field
|
||||||
|
label="voip.ms balance drop in an hour"
|
||||||
|
hint="In your account's currency. 0 = no email."
|
||||||
|
>
|
||||||
|
{(p) => (
|
||||||
|
<input
|
||||||
|
{...p}
|
||||||
|
className={`${inputClass} max-w-32`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={balanceDrop}
|
||||||
|
onChange={(e) => setBalanceDrop(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
<h2 className="font-medium">Phone setup files</h2>
|
<h2 className="font-medium">Phone setup files</h2>
|
||||||
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
|
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
|
||||||
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
|
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
|
||||||
|
|||||||
@@ -591,6 +591,11 @@ function VoipmsChecklist() {
|
|||||||
<em>G.711u</em>, DTMF <em>RFC 2833</em>, NAT <em>Yes</em>, CallerID number = your main
|
<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.
|
number, international calling <em>off</em> unless you need it.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Turn on IP restriction</strong> (edit the sub-account → Security) and allow only
|
||||||
|
this server's public IP. A stolen password then can't make calls from anywhere
|
||||||
|
else. If your public IP changes, update it there too.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
|
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
|
||||||
one below.
|
one below.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {
|
import {
|
||||||
BookUser,
|
BookUser,
|
||||||
|
ChartColumn,
|
||||||
House,
|
House,
|
||||||
Info,
|
Info,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -30,6 +32,7 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
|
|||||||
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||||
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
||||||
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
||||||
|
import { ReportsPage } from '../admin/ReportsPage.tsx';
|
||||||
|
|
||||||
const link = 'text-link underline';
|
const link = 'text-link underline';
|
||||||
|
|
||||||
@@ -124,6 +127,12 @@ export function MePage({ user }: { user: AuthUser }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{user.extension && (
|
||||||
|
<NavLink to="/me/reports" className={tab} onClick={close}>
|
||||||
|
<ChartColumn className={icon} />
|
||||||
|
Reports
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<NavLink to="/me/settings" className={tab} onClick={close}>
|
<NavLink to="/me/settings" className={tab} onClick={close}>
|
||||||
<Settings className={icon} />
|
<Settings className={icon} />
|
||||||
Settings
|
Settings
|
||||||
@@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
|||||||
<Route path="calls" element={<MyCallsPage />} />
|
<Route path="calls" element={<MyCallsPage />} />
|
||||||
<Route path="voicemail" element={<VoicemailPage />} />
|
<Route path="voicemail" element={<VoicemailPage />} />
|
||||||
<Route path="contacts" element={<ContactsPage />} />
|
<Route path="contacts" element={<ContactsPage />} />
|
||||||
|
<Route path="reports" element={<ReportsPage mine />} />
|
||||||
<Route path="settings" element={<MeSettingsPage />} />
|
<Route path="settings" element={<MeSettingsPage />} />
|
||||||
<Route path="people" element={<PresencePanel me={me} />} />
|
<Route path="people" element={<PresencePanel me={me} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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
|
// 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".
|
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts", "Report
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
// spam".
|
||||||
|
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { MyCallDto } from '@selfphoneng/shared';
|
import type { MyCallDto } from '@selfphoneng/shared';
|
||||||
import { meApi, recordingsApi } from '../../api/client.ts';
|
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 { softphone } from '../../softphone/SoftphoneEngine.ts';
|
||||||
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
|
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
|
||||||
import { AddToContacts, useContactName } from './ContactsPage.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. */
|
/** The other side of the call, from where I sit. */
|
||||||
export const otherParty = (c: MyCallDto) =>
|
export const otherParty = (c: MyCallDto) =>
|
||||||
@@ -30,6 +31,64 @@ export function useCallBack() {
|
|||||||
return { call, note };
|
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() {
|
export function MyCallsPage() {
|
||||||
const list = useInfiniteQuery({
|
const list = useInfiniteQuery({
|
||||||
queryKey: ['me', 'calls'],
|
queryKey: ['me', 'calls'],
|
||||||
@@ -98,6 +157,9 @@ export function MyCallsPage() {
|
|||||||
)}
|
)}
|
||||||
<StatusBadge status={c.status} />
|
<StatusBadge status={c.status} />
|
||||||
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
|
<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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
FROM debian:trixie-slim AS build
|
FROM debian:trixie-slim AS build
|
||||||
ARG ASTERISK_VERSION=24.0.0-rc2
|
ARG ASTERISK_VERSION=24.0.0-rc3
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \
|
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 \
|
&& mkdir -p /out/usr/share/selfphoneng \
|
||||||
&& mv /out/var/lib/asterisk/moh /out/usr/share/selfphoneng/moh-opsound
|
&& 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.
|
# 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 \
|
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 \
|
&& mkdir -p /out/etc/asterisk \
|
||||||
&& cp configs/samples/indications.conf.sample /out/etc/asterisk/indications.conf
|
&& cp configs/samples/indications.conf.sample /out/etc/asterisk/indications.conf
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ timestampevents = yes
|
|||||||
secret = ${AMI_SECRET}
|
secret = ${AMI_SECRET}
|
||||||
deny = 0.0.0.0/0.0.0.0
|
deny = 0.0.0.0/0.0.0.0
|
||||||
permit = 127.0.0.1/255.255.255.255
|
permit = 127.0.0.1/255.255.255.255
|
||||||
read = all
|
; Only the classes the api uses (08 §8). An action needs any one of its classes, so `system`
|
||||||
write = all
|
; 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
|
writetimeout = 5000
|
||||||
@@ -51,7 +51,8 @@ noload = chan_websocket.so
|
|||||||
noload = res_adsi.so
|
noload = res_adsi.so
|
||||||
noload = app_getcpeid.so
|
noload = app_getcpeid.so
|
||||||
noload = app_adsiprog.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 = app_system.so
|
||||||
noload = func_shell.so
|
noload = func_shell.so
|
||||||
noload = app_disa.so
|
noload = app_disa.so
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
# docker compose -f deploy/probe/compose.yml up -d → http://<this machine>:8080
|
# 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).
|
# 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
|
# Behind your own HTTPS reverse proxy on this machine instead: set PORT to a local port,
|
||||||
# the proxy set X-Forwarded-For. The SIP checks connect out from here, so no other ports are needed.
|
# 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:
|
services:
|
||||||
probe:
|
probe:
|
||||||
# Built by each release, like the api and asterisk images (pick your registry and version).
|
# Built by each release, like the api and asterisk images (pick your registry and version).
|
||||||
@@ -17,6 +18,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: '8080'
|
PORT: '8080'
|
||||||
TRUST_PROXY: 'false'
|
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:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: 10m, max-file: '3' }
|
options: { max-size: 10m, max-file: '3' }
|
||||||
@@ -12,7 +12,11 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/local/bin/selfphoneng-probe
|
ExecStart=/usr/local/bin/selfphoneng-probe
|
||||||
Environment=PORT=8080
|
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
|
#Environment=TRUST_PROXY=true
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -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.
|
- 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
|
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
|
finds calls by what was said. People who made or answered a call can read its transcript too, like
|
||||||
play its recording, and get its summary in their call report email (My settings → Call report).
|
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
|
- 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.
|
conference rooms set to record), and only those recorded while it's on.
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ backup.
|
|||||||
3. Run `./scripts/install.sh`. It keeps the existing `.env`.
|
3. Run `./scripts/install.sh`. It keeps the existing `.env`.
|
||||||
4. Run `./scripts/restore.sh <backup>`.
|
4. Run `./scripts/restore.sh <backup>`.
|
||||||
5. If the public IP changed: update it on **Settings → Network**, change your DNS record and any
|
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`.
|
`docker compose restart asterisk`.
|
||||||
6. Desk phones fetch their setup from the host name, so they follow the DNS change.
|
6. Desk phones fetch their setup from the host name, so they follow the DNS change.
|
||||||
|
|
||||||
|
|||||||
+110
-2
@@ -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.
|
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
|
||||||
|
|
||||||
```sh
|
```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/selfphoneng-probe-linux-x64
|
||||||
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
|
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
|
||||||
sha256sum --check --ignore-missing 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/…`.)
|
(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
|
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
|
`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:**
|
**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
|
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.
|
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
|
## Firewall
|
||||||
|
|
||||||
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the
|
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`.
|
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
|
## Web sign-in attacks
|
||||||
|
|
||||||
The web sign-in is rate limited. Failed attempts are written to the api's log as
|
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:
|
real money. Keep:
|
||||||
|
|
||||||
- international calling **off** at voip.ms and in **Outbound rules** unless you need it;
|
- 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 voip.ms **balance alerts and spending limits** on;
|
||||||
- the **desk phone network limit** above on if your phones are all in the office;
|
- 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
|
- the **outside calls at once** limits (Settings → Security) as low as your office needs: per
|
||||||
|
|||||||
@@ -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
|
- **International calling:** leave it **off** unless you need it. It's your best protection
|
||||||
against toll fraud if a password ever leaks.
|
against toll fraud if a password ever leaks.
|
||||||
- **Encrypted SIP traffic (TLS/SRTP):** optional; see [SIP over TLS](#sip-over-tls).
|
- **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
|
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.
|
number must use the same one.
|
||||||
3. **Point each number at the sub-account** (DID Numbers → Manage DIDs → edit the number):
|
3. **Point each number at the sub-account** (DID Numbers → Manage DIDs → edit the number):
|
||||||
|
|||||||
@@ -142,20 +142,29 @@ missing or invalid, it logs a clear message and exits with code 1.
|
|||||||
## 2. Asterisk image (`asterisk/Dockerfile`)
|
## 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
|
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
|
> **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
|
> 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
|
> 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
|
> 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
|
> 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).
|
> 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
|
```dockerfile
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
FROM debian:trixie-slim AS build
|
FROM debian:trixie-slim AS build
|
||||||
ARG ASTERISK_VERSION=24.0.0-rc2
|
ARG ASTERISK_VERSION=24.0.0-rc3
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \
|
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 \
|
&& mkdir -p /out/usr/share/selfphoneng \
|
||||||
&& mv /out/var/lib/asterisk/moh /out/usr/share/selfphoneng/moh-opsound
|
&& 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.
|
# 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 \
|
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 \
|
&& mkdir -p /out/etc/asterisk \
|
||||||
&& cp configs/samples/indications.conf.sample /out/etc/asterisk/indications.conf
|
&& 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 --from=build /out/ /
|
||||||
COPY conf/ /etc/asterisk/
|
COPY conf/ /etc/asterisk/
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
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
|
&& chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
```
|
```
|
||||||
@@ -359,10 +371,13 @@ timestampevents = yes
|
|||||||
secret = ${AMI_SECRET}
|
secret = ${AMI_SECRET}
|
||||||
deny = 0.0.0.0/0.0.0.0
|
deny = 0.0.0.0/0.0.0.0
|
||||||
permit = 127.0.0.1/255.255.255.255
|
permit = 127.0.0.1/255.255.255.255
|
||||||
read = all
|
read = system,call,agent,user,cdr,dialplan
|
||||||
write = all
|
write = system,call,command,originate,agent,user
|
||||||
writetimeout = 5000
|
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)
|
### 3.5 `http.conf` (WebSocket for the browser softphone)
|
||||||
```ini
|
```ini
|
||||||
|
|||||||
@@ -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 (number);
|
||||||
create index on ftc_complaints (reported_at);
|
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)
|
create table call_extensions ( -- which extensions were involved (for per-user history)
|
||||||
call_id uuid not null references calls(id) on delete cascade,
|
call_id uuid not null references calls(id) on delete cascade,
|
||||||
extension_id uuid not null references extensions(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 |
|
| `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.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 |
|
| `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.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
|
||||||
| `provisioning.enabled` | boolean | true | |
|
| `provisioning.enabled` | boolean | true | |
|
||||||
| `system.restartRequired` | boolean | false | set by generator |
|
| `system.restartRequired` | boolean | false | set by generator |
|
||||||
|
|||||||
@@ -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).
|
(and `from_user` when there is no username).
|
||||||
Skip disabled trunks entirely.
|
Skip disabled trunks entirely.
|
||||||
For TCP/TLS trunks, `server_uri`, `client_uri` and the AOR `contact` get `;transport=tcp|tls`.
|
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
|
TLS trunks are qualified like the others (`qualify_frequency = 60`) since 24.0.0-rc3. On rc2 they
|
||||||
against the IP, see 08 §2.1); their health comes from the registration.
|
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
|
`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
|
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.
|
that isn't turned on in Settings → SIP.
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ an admin (`requireAdmin` refuses them). See `13-mobile.md` §2 and §4.
|
|||||||
|
|
||||||
### 2.4 Guards
|
### 2.4 Guards
|
||||||
Fastify `preHandler` hooks in `auth/guards.ts`:
|
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`.
|
- `requireAdmin`.
|
||||||
- `requireSelfOrAdmin(paramName)`: for `/api/me/*` and per-user resources.
|
- `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).
|
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
|
- **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.
|
`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.
|
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
|
- 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).
|
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/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/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/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` | user | list |
|
||||||
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
|
| 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'}` |
|
| 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/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/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/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/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/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
|
||||||
| `/api/api-tokens` | POST returns the token once |
|
| `/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 |
|
| 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/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/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/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/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) |
|
| 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) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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`.
|
`sms.sent`, `emergency.call`, `trunk.registration_changed`, `extension.registration_changed`.
|
||||||
|
|
||||||
- Payload: `{ "id": "<uuid>", "event": "call.ended", "createdAt": "...", "data": {...} }`.
|
- 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`,
|
- Headers: `Content-Type: application/json`, `X-SelfPhoneNG-Event`,
|
||||||
`X-SelfPhoneNG-Timestamp` (unix seconds),
|
`X-SelfPhoneNG-Timestamp` (unix seconds),
|
||||||
`X-SelfPhoneNG-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.
|
`X-SelfPhoneNG-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.
|
||||||
|
|||||||
+11
-2
@@ -46,7 +46,7 @@ Left sidebar navigation, in this order:
|
|||||||
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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.
|
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.
|
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".
|
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.
|
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").
|
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.
|
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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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
@@ -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`).
|
- 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.
|
- CallerID Number: your main DID.
|
||||||
- International calling: leave **disabled** unless needed (toll-fraud protection on their side too).
|
- 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.
|
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):
|
3. **Point each DID at the sub-account** (DID Numbers → Manage DIDs → edit):
|
||||||
- Routing: **SIP/IAX → the sub-account**
|
- 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).
|
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,
|
- 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.
|
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
|
- **Checked (owner, 2026-10-21):** a mobile → DID call came in with the DID as the Request-URI
|
||||||
mobile phone, and confirm which header carries the DID. Record the finding here.
|
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.
|
- Inbound caller ID arrives as 10 digits for North American callers. The dialplan normalizes it to 11.
|
||||||
|
|
||||||
## 4. Outbound calls
|
## 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)**),
|
- Messages over 160 characters: use `method=sendMMS` (`message` up to 2048 chars **(verify limit)**),
|
||||||
plus `media1`…`media3` URLs for pictures. We split nothing ourselves.
|
plus `media1`…`media3` URLs for pictures. We split nothing ourselves.
|
||||||
- Numbers: voip.ms wants 10 digits for NANP, so strip the leading `1`.
|
- 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).
|
- Rate limit our sends to 1 per second per DID (queue in memory).
|
||||||
- Other methods used: `getDIDsInfo` (DID import), `getServersInfo` (server dropdown),
|
- Other methods used: `getDIDsInfo` (DID import), `getServersInfo` (server dropdown),
|
||||||
`getSMS` (optional polling fallback). All behind `voipmsApi(method, params)` with a 15 s timeout.
|
`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
|
- `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
|
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
|
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
|
### 5.2 Receiving
|
||||||
Endpoint: `GET|POST /hooks/voipms/sms/:token`. `:token` is a 32-char random value stored
|
Endpoint: `GET|POST /hooks/voipms/sms/:token`. `:token` is a 32-char random value stored
|
||||||
|
|||||||
@@ -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 |
|
| 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) |
|
| **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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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
|
## 2. Required controls
|
||||||
|
|
||||||
@@ -34,10 +35,12 @@
|
|||||||
cert is missing or expires within 14 days.
|
cert is missing or expires within 14 days.
|
||||||
- `transport-tls` has `verify_server = yes` + `ca_list_file = /etc/ssl/certs/ca-certificates.crt`:
|
- `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,
|
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
|
checked 2026-10-19). Asterisk 24.0.0-rc2 checked a **qualify** OPTIONS against the IP instead
|
||||||
the host name, which fails on voip.ms ("server identity does not match") and marks the contact
|
of the host name, which failed on voip.ms ("server identity does not match") and marked the
|
||||||
Unavailable, so TLS trunks get `qualify_frequency = 0` and Test skips the qualify; REGISTER and
|
contact Unavailable, so TLS trunks had `qualify_frequency = 0` and Test skipped the qualify.
|
||||||
INVITE verify the name correctly. Re-check on Asterisk upgrades.
|
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.
|
- Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later.
|
||||||
|
|
||||||
### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`)
|
### 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).
|
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
|
`deploy/probe/selfphoneng-probe.service` runs it under systemd with `DynamicUser` and a
|
||||||
read-only system. About 45 MB of memory.
|
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
|
- `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.
|
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
|
- 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`
|
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`).
|
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
|
- The login limiter forgets keys past every window, swept at most once a minute
|
||||||
(`login-limiter.test.ts`).
|
(`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.
|
||||||
@@ -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-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-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-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.
|
✅ An outside tester follows only the admin guide and gets inbound/outbound calls working on a fresh VM.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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
|
- API: `GET /api/recordings` has `summary`, `hasTranscript`, `aiError`, and `?q=` (ILIKE on the
|
||||||
transcript and summary, `%`/`_` taken literally). `GET /api/recordings/:id/transcript`: admins
|
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.
|
files too.
|
||||||
|
|
||||||
## 4. Next (in order)
|
## 4. Next (in order)
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ against the pinned version's `openapi.json`.
|
|||||||
checked by reading contacts).
|
checked by reading contacts).
|
||||||
- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
|
- 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).
|
"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.
|
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
|
- Contrack tokens expire in 90 days unless made with **Never**: the guide and the forms say to pick
|
||||||
Never (or a year).
|
Never (or a year).
|
||||||
|
|||||||
+14
-8
@@ -199,11 +199,12 @@ Failed callbacks count in the IP login limiter (04 §2.2).
|
|||||||
Signing in, in this order:
|
Signing in, in this order:
|
||||||
|
|
||||||
1. A `user_identities` row (`oidc`, `"<iss> <sub>"`) → its user.
|
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
|
2. Else, a user with that email (case-insensitive) who is **not an admin** and has **no password
|
||||||
yet** (made by an admin in `oidc` mode, or whose password an admin removed), **and the email is
|
and no connected account of any kind yet** (made by an admin in `oidc` mode, or whose password
|
||||||
verified** (`email_verified === true`, or `trustEmail`) → connect it now (step 3's row) and sign
|
an admin removed), **and the email is verified** (`email_verified === true`, or `trustEmail`)
|
||||||
in. Users who have a password are never matched by email: they connect from My account, so a
|
→ connect it now (step 3's row) and sign in. Users who have a password, an admin, or someone
|
||||||
provider where people can set their own email can't hand over an account.
|
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`.
|
3. Else `not_connected`.
|
||||||
|
|
||||||
Connecting (from My account, already signed in, any mode but `password`):
|
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
|
- 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
|
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`.
|
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,
|
- **Disconnect** (`DELETE /api/me/identities/oidc`; admins also on Users,
|
||||||
`DELETE /api/users/:id/identities/oidc`): ends that user's `oidc` sessions. Refused (409
|
`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
|
`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
|
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).
|
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
|
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`) → connect it (add the row) and sign in,
|
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
|
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
|
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)
|
2026-10-26: a directory where people can edit their own `mail` (common OpenLDAP access rules)
|
||||||
must not hand out the admin's account.
|
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
|
3. Else → 401 "Wrong email or password" (no account is made, §1), with `login_failed` audited as
|
||||||
|
|||||||
+13
-15
@@ -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] 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] 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)
|
## Real voip.ms account (M3)
|
||||||
|
|
||||||
- [x] The trunk shows **Registered** in the UI (2026-10-21: green, voip.ms, the sub-account at
|
- [x] The trunk shows **Registered** in the UI (2026-10-21: green, voip.ms, the sub-account at
|
||||||
its POP).
|
its POP).
|
||||||
- [x] A mobile → DID call rings a phone. 2026-10-21: DID → digital receptionist → browser
|
- [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).
|
- [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.
|
- [ ] 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)
|
## Someone else installs it (M11)
|
||||||
|
|
||||||
- [ ] An outside tester follows only the admin guide and gets inbound and outbound calls working
|
- [x] An outside tester follows only the admin guide and gets inbound and outbound calls working
|
||||||
on a fresh VM.
|
on a fresh VM (2026-10-28, owner).
|
||||||
- [ ] A backup restores on a second VM.
|
- [x] A backup restores on a second VM (2026-10-28, owner).
|
||||||
|
|
||||||
On hold (2026-10-21).
|
|
||||||
|
|
||||||
## A week on the owner's server
|
## A week on the owner's server
|
||||||
|
|
||||||
- [ ] 7 days handling real calls with no crash or restart of the api or Asterisk.
|
- [x] 7 days handling real calls with no crash or restart of the api or Asterisk (2026-10-28, owner).
|
||||||
- [ ] Nightly backups restore cleanly.
|
- [x] Nightly backups restore cleanly (2026-10-28, owner).
|
||||||
|
|
||||||
In progress on the owner's server (v0.0.2, from 2026-10-21).
|
|
||||||
|
|
||||||
## Updating
|
## 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.
|
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
|
- [ ] A real compose install with the bundled Caddy updated with `scripts/update.sh`, with calls
|
||||||
and backups.
|
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)
|
## 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
|
## The release
|
||||||
|
|
||||||
- [ ] Version 1.0.0 in every `package.json`, and a v1.0.0 entry in the [changelog](../CHANGELOG.md).
|
- [ ] 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.
|
- [x] The release builds and pushes images to both the Gitea registry and ghcr.io (2026-10-28, owner).
|
||||||
ghcr.io: the api image pushes; the asterisk package exists, empty, and refuses the push.)
|
|
||||||
|
|
||||||
## Not needed for v1
|
## Not needed for v1
|
||||||
|
|
||||||
More SIP providers (Telnyx, Flowroute, Twilio), SMS, push notifications for phone apps, and the
|
The mobile app (owner, 2026-10-28), more SIP providers (Telnyx, Flowroute, Twilio), SMS, push
|
||||||
M12 list in the roadmap.
|
notifications for phone apps, and the M12 list in the roadmap.
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "selfphoneng",
|
"name": "selfphoneng",
|
||||||
"version": "0.0.11",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@selfphoneng/shared",
|
"name": "@selfphoneng/shared",
|
||||||
"version": "0.0.11",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -17,8 +17,27 @@ export const BlockedCreateSchema = z.object({
|
|||||||
return z.NEVER;
|
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 BlockedCreate = z.input<typeof BlockedCreateSchema>;
|
||||||
|
|
||||||
export type BlockedDto = { id: string; number: string; reason: string | null; createdAt: string };
|
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;
|
||||||
|
};
|
||||||
@@ -42,6 +42,11 @@ export const SettingsSchemas = {
|
|||||||
.url({ protocol: /^https?$/ })
|
.url({ protocol: /^https?$/ })
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null),
|
.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
|
'network.stunServer': z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
|
.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.
|
// 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.maxPerExtension': z.number().int().min(0).max(100).default(3),
|
||||||
'calls.maxTotal': z.number().int().min(0).max(1000).default(10),
|
'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.lanOnly': z.boolean().default(false),
|
||||||
'provisioning.enabled': z.boolean().default(true),
|
'provisioning.enabled': z.boolean().default(true),
|
||||||
'system.restartRequired': z.boolean().default(false),
|
'system.restartRequired': z.boolean().default(false),
|
||||||
@@ -327,10 +337,17 @@ export type SettingsDto = {
|
|||||||
[
|
[
|
||||||
K in Exclude<
|
K in Exclude<
|
||||||
SettingKey,
|
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>;
|
]: SettingValue<K>;
|
||||||
} & {
|
} & {
|
||||||
|
'network.hasProbeKey': boolean;
|
||||||
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
|
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
|
||||||
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
|
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
|
||||||
spam: SpamSettingsDto;
|
spam: SpamSettingsDto;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { launch } from './cdp.mjs';
|
import { launch } from './cdp.mjs';
|
||||||
|
import { testManagerConf } from '../../apps/api/test/asterisk/manager-conf.ts';
|
||||||
|
|
||||||
const NET = 'sp-live';
|
const NET = 'sp-live';
|
||||||
const AST = 'sp-live-ast';
|
const AST = 'sp-live-ast';
|
||||||
@@ -33,10 +34,7 @@ if (cmd === 'up') {
|
|||||||
mkdirSync(conf, { recursive: true });
|
mkdirSync(conf, { recursive: true });
|
||||||
for (const d of [vm, sounds, recs, moh]) if (d) mkdirSync(d, { recursive: true });
|
for (const d of [vm, sounds, recs, moh]) if (d) mkdirSync(d, { recursive: true });
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'sp-live-'));
|
const dir = mkdtempSync(join(tmpdir(), 'sp-live-'));
|
||||||
writeFileSync(
|
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||||
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 browser softphone (m5-*) runs on the host: SIP-over-WebSocket and a small RTP range are
|
// 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.
|
// published on 127.0.0.1, and the api maps the container's ICE candidate to 127.0.0.1.
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
Reference in new issue
Block a user